Files
MeshCore-Solo/examples/companion_radio/MyMeshBot.h

677 lines
31 KiB
C
Raw Normal View History

#pragma once
// Bot logic — not part of upstream MyMesh.cpp.
// Included at the bottom of MyMesh.cpp after all class definitions.
feat(bot): room-server target, tab-carousel UI, per-target independence Auto-Reply Bot gains a third target (room servers, alongside DM and channel), new {name}/{hops} reply placeholders, and a DM all/favourites allow-list. BotScreen is redesigned as a circular tab carousel (Direct / Channel / Room / Other, same interaction as Nearby Nodes' filter tabs) instead of one long scrolling list, which also exposed and fixed a leftover coupling where Channel/Room trigger-replies and their !command handling secretly depended on the DM tab's Enable/Commands toggles — each target's Enable and Commands are now fully independent. - NodePrefs: bot_room_enabled/prefix/trigger/reply, bot_dm_scope, bot_commands_ch/bot_commands_room (bot_commands_enabled repurposed as DM-only). SCHEMA_SENTINEL 0xC0DE001D -> 0xC0DE001F; sizeof unchanged at 2712 (new bytes absorbed existing padding, verified via a standalone host compile + offsetof check). - DataStore: persists all new fields; seeds bot_commands_ch/room from the old shared bot_commands_enabled on upgrade so existing channel/room command behaviour isn't silently lost. - MyMeshBot: tryBotReplyRoom/tryBotRoomCommand mirror the channel bot's shape but post via sendMessage (room relays to members itself); requires an existing login session with that room, same as a manual post would. botDmSenderAllowed() gates DM trigger-reply/commands on the favourites bit when bot_dm_scope=Fav. - MsgExpand: {name}/{hops} as optional trailing params (default nullptr/-1, no existing caller affected) — deliberately not exposed on the general compose keyboard, only on bot Reply fields. - QuickMsgScreen/UITask: room-target picker (mirrors the channel picker), routing through the existing room-login prompt when there's no saved password yet. - BotScreen: tab carousel (LEFT/RIGHT switches tabs, UP/DOWN moves rows, Enter is now the only way to change a value); Enable split out of Channel/Room's combo row; Quiet Hours gained a stepper sub-mode; Commands moved from a shared toggle into each tab. - docs/tools_screen.md updated for the new tab layout and behaviour. Not build-verified — no PlatformIO toolchain in this environment. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 20:16:59 +02:00
// Minimum gap between two auto-replies to the same contact (DM), on the bot
// channel, or on the bot room. Guards against floods and, with
// botTriggerMatches()'s loop check, against bot↔bot ping-pong on a shared
// channel/room.
feat(bot): hardening + auto-responder, query commands, quiet hours Reply-bot overhaul on top of the trigger/reply auto-reply: Robustness - single botTriggerMatches() shared by DM/channel paths; named constants (BOT_REPLY_COOLDOWN_MS / BOT_SCRATCH) replace magic numbers. - per-contact DM throttle (8-entry ring) so a second sender isn't starved while one contact is on cooldown. - channel anti-loop: skip only when an incoming message equals our own reply (was: any reply containing the trigger word — which silently killed channel replies for the common "trigger word in reply" setup). Features - away / reply-to-all: a lone "*" trigger matches every message. - independent DM vs channel triggers (bot_trigger / bot_trigger_ch); "*" works on the channel too, bounded by cooldown + echo guard. - query commands: a DM or monitored-channel message is scanned for "!word" tokens — !ping/!batt/!loc/!time/!temp/!hops/!status/!help — merged into one combined reply. DM = per-contact throttle, ignores quiet hours; channel = broadcast, per-channel cooldown, respects quiet hours. !hops uses getPathHashCount() (0 = direct). - quiet hours (bot_quiet_start/end, local, wraps midnight) silence push replies. - reply counter shown in the BotScreen header. UI / storage - BotScreen: 9 rows, scrolling list (no more cramming), field I/O via fieldBuf()/fieldCap(). - prefs bot_commands_enabled / bot_quiet_start / bot_quiet_end / bot_trigger_ch persisted; schema 0xC0DE000A → 0xC0DE000C with clamping + migration (bot_trigger_ch seeded from bot_trigger on upgrade). - docs: tools_screen bot section rewritten; FEATURES.md refreshed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 11:57:27 +02:00
static const unsigned long BOT_REPLY_COOLDOWN_MS = 10000UL;
// Scratch buffer size for trigger matching / reply expansion. Covers the full
// incoming message (MAX_TEXT_LEN ~160) plus the 140-byte reply templates.
static const int BOT_SCRATCH = 200;
// Case-insensitive (ASCII) substring test: does `body` contain any one of
// `trigger`'s comma-separated phrases? A lone "*" trigger (the whole field,
// not one comma-separated phrase) means "match everything" (away / reply-to-
// all mode); honoured only where allow_wildcard is set. DM, channel and room
// each pass their own trigger string.
feat(bot): hardening + auto-responder, query commands, quiet hours Reply-bot overhaul on top of the trigger/reply auto-reply: Robustness - single botTriggerMatches() shared by DM/channel paths; named constants (BOT_REPLY_COOLDOWN_MS / BOT_SCRATCH) replace magic numbers. - per-contact DM throttle (8-entry ring) so a second sender isn't starved while one contact is on cooldown. - channel anti-loop: skip only when an incoming message equals our own reply (was: any reply containing the trigger word — which silently killed channel replies for the common "trigger word in reply" setup). Features - away / reply-to-all: a lone "*" trigger matches every message. - independent DM vs channel triggers (bot_trigger / bot_trigger_ch); "*" works on the channel too, bounded by cooldown + echo guard. - query commands: a DM or monitored-channel message is scanned for "!word" tokens — !ping/!batt/!loc/!time/!temp/!hops/!status/!help — merged into one combined reply. DM = per-contact throttle, ignores quiet hours; channel = broadcast, per-channel cooldown, respects quiet hours. !hops uses getPathHashCount() (0 = direct). - quiet hours (bot_quiet_start/end, local, wraps midnight) silence push replies. - reply counter shown in the BotScreen header. UI / storage - BotScreen: 9 rows, scrolling list (no more cramming), field I/O via fieldBuf()/fieldCap(). - prefs bot_commands_enabled / bot_quiet_start / bot_quiet_end / bot_trigger_ch persisted; schema 0xC0DE000A → 0xC0DE000C with clamping + migration (bot_trigger_ch seeded from bot_trigger on upgrade). - docs: tools_screen bot section rewritten; FEATURES.md refreshed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 11:57:27 +02:00
bool MyMesh::botTriggerMatches(const char* trigger, const char* body, bool allow_wildcard) const {
if (!trigger[0]) return false; // empty trigger would match everything
if (trigger[0] == '*' && trigger[1] == '\0')
return allow_wildcard;
feat(bot): hardening + auto-responder, query commands, quiet hours Reply-bot overhaul on top of the trigger/reply auto-reply: Robustness - single botTriggerMatches() shared by DM/channel paths; named constants (BOT_REPLY_COOLDOWN_MS / BOT_SCRATCH) replace magic numbers. - per-contact DM throttle (8-entry ring) so a second sender isn't starved while one contact is on cooldown. - channel anti-loop: skip only when an incoming message equals our own reply (was: any reply containing the trigger word — which silently killed channel replies for the common "trigger word in reply" setup). Features - away / reply-to-all: a lone "*" trigger matches every message. - independent DM vs channel triggers (bot_trigger / bot_trigger_ch); "*" works on the channel too, bounded by cooldown + echo guard. - query commands: a DM or monitored-channel message is scanned for "!word" tokens — !ping/!batt/!loc/!time/!temp/!hops/!status/!help — merged into one combined reply. DM = per-contact throttle, ignores quiet hours; channel = broadcast, per-channel cooldown, respects quiet hours. !hops uses getPathHashCount() (0 = direct). - quiet hours (bot_quiet_start/end, local, wraps midnight) silence push replies. - reply counter shown in the BotScreen header. UI / storage - BotScreen: 9 rows, scrolling list (no more cramming), field I/O via fieldBuf()/fieldCap(). - prefs bot_commands_enabled / bot_quiet_start / bot_quiet_end / bot_trigger_ch persisted; schema 0xC0DE000A → 0xC0DE000C with clamping + migration (bot_trigger_ch seeded from bot_trigger on upgrade). - docs: tools_screen bot section rewritten; FEATURES.md refreshed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 11:57:27 +02:00
char low[BOT_SCRATCH];
size_t n = strlen(body);
if (n >= sizeof(low)) n = sizeof(low) - 1;
feat(bot): hardening + auto-responder, query commands, quiet hours Reply-bot overhaul on top of the trigger/reply auto-reply: Robustness - single botTriggerMatches() shared by DM/channel paths; named constants (BOT_REPLY_COOLDOWN_MS / BOT_SCRATCH) replace magic numbers. - per-contact DM throttle (8-entry ring) so a second sender isn't starved while one contact is on cooldown. - channel anti-loop: skip only when an incoming message equals our own reply (was: any reply containing the trigger word — which silently killed channel replies for the common "trigger word in reply" setup). Features - away / reply-to-all: a lone "*" trigger matches every message. - independent DM vs channel triggers (bot_trigger / bot_trigger_ch); "*" works on the channel too, bounded by cooldown + echo guard. - query commands: a DM or monitored-channel message is scanned for "!word" tokens — !ping/!batt/!loc/!time/!temp/!hops/!status/!help — merged into one combined reply. DM = per-contact throttle, ignores quiet hours; channel = broadcast, per-channel cooldown, respects quiet hours. !hops uses getPathHashCount() (0 = direct). - quiet hours (bot_quiet_start/end, local, wraps midnight) silence push replies. - reply counter shown in the BotScreen header. UI / storage - BotScreen: 9 rows, scrolling list (no more cramming), field I/O via fieldBuf()/fieldCap(). - prefs bot_commands_enabled / bot_quiet_start / bot_quiet_end / bot_trigger_ch persisted; schema 0xC0DE000A → 0xC0DE000C with clamping + migration (bot_trigger_ch seeded from bot_trigger on upgrade). - docs: tools_screen bot section rewritten; FEATURES.md refreshed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 11:57:27 +02:00
for (size_t i = 0; i < n; i++) low[i] = (char)tolower((uint8_t)body[i]);
low[n] = '\0';
feat(bot): hardening + auto-responder, query commands, quiet hours Reply-bot overhaul on top of the trigger/reply auto-reply: Robustness - single botTriggerMatches() shared by DM/channel paths; named constants (BOT_REPLY_COOLDOWN_MS / BOT_SCRATCH) replace magic numbers. - per-contact DM throttle (8-entry ring) so a second sender isn't starved while one contact is on cooldown. - channel anti-loop: skip only when an incoming message equals our own reply (was: any reply containing the trigger word — which silently killed channel replies for the common "trigger word in reply" setup). Features - away / reply-to-all: a lone "*" trigger matches every message. - independent DM vs channel triggers (bot_trigger / bot_trigger_ch); "*" works on the channel too, bounded by cooldown + echo guard. - query commands: a DM or monitored-channel message is scanned for "!word" tokens — !ping/!batt/!loc/!time/!temp/!hops/!status/!help — merged into one combined reply. DM = per-contact throttle, ignores quiet hours; channel = broadcast, per-channel cooldown, respects quiet hours. !hops uses getPathHashCount() (0 = direct). - quiet hours (bot_quiet_start/end, local, wraps midnight) silence push replies. - reply counter shown in the BotScreen header. UI / storage - BotScreen: 9 rows, scrolling list (no more cramming), field I/O via fieldBuf()/fieldCap(). - prefs bot_commands_enabled / bot_quiet_start / bot_quiet_end / bot_trigger_ch persisted; schema 0xC0DE000A → 0xC0DE000C with clamping + migration (bot_trigger_ch seeded from bot_trigger on upgrade). - docs: tools_screen bot section rewritten; FEATURES.md refreshed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 11:57:27 +02:00
// Multiple phrases can be packed into one Trigger field, comma-separated
// ("hi,hello there,yo") -- any single phrase matching is enough. Each
// phrase is trimmed of surrounding spaces so "foo, bar" and "foo,bar"
// behave the same; empty phrases (a stray/trailing comma) are skipped.
for (const char* p = trigger; *p; ) {
while (*p == ' ' || *p == ',') p++; // skip separators / leading space
if (!*p) break;
const char* start = p;
while (*p && *p != ',') p++; // phrase runs to the next comma (or end)
const char* end = p;
while (end > start && end[-1] == ' ') end--; // trim trailing space
size_t plen = (size_t)(end - start);
if (plen == 0) continue;
char low_trigger[64];
if (plen >= sizeof(low_trigger)) plen = sizeof(low_trigger) - 1;
for (size_t i = 0; i < plen; i++) low_trigger[i] = (char)tolower((uint8_t)start[i]);
low_trigger[plen] = '\0';
if (strstr(low, low_trigger)) return true;
}
return false;
feat(bot): hardening + auto-responder, query commands, quiet hours Reply-bot overhaul on top of the trigger/reply auto-reply: Robustness - single botTriggerMatches() shared by DM/channel paths; named constants (BOT_REPLY_COOLDOWN_MS / BOT_SCRATCH) replace magic numbers. - per-contact DM throttle (8-entry ring) so a second sender isn't starved while one contact is on cooldown. - channel anti-loop: skip only when an incoming message equals our own reply (was: any reply containing the trigger word — which silently killed channel replies for the common "trigger word in reply" setup). Features - away / reply-to-all: a lone "*" trigger matches every message. - independent DM vs channel triggers (bot_trigger / bot_trigger_ch); "*" works on the channel too, bounded by cooldown + echo guard. - query commands: a DM or monitored-channel message is scanned for "!word" tokens — !ping/!batt/!loc/!time/!temp/!hops/!status/!help — merged into one combined reply. DM = per-contact throttle, ignores quiet hours; channel = broadcast, per-channel cooldown, respects quiet hours. !hops uses getPathHashCount() (0 = direct). - quiet hours (bot_quiet_start/end, local, wraps midnight) silence push replies. - reply counter shown in the BotScreen header. UI / storage - BotScreen: 9 rows, scrolling list (no more cramming), field I/O via fieldBuf()/fieldCap(). - prefs bot_commands_enabled / bot_quiet_start / bot_quiet_end / bot_trigger_ch persisted; schema 0xC0DE000A → 0xC0DE000C with clamping + migration (bot_trigger_ch seeded from bot_trigger on upgrade). - docs: tools_screen bot section rewritten; FEATURES.md refreshed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 11:57:27 +02:00
}
feat(bot): room-server target, tab-carousel UI, per-target independence Auto-Reply Bot gains a third target (room servers, alongside DM and channel), new {name}/{hops} reply placeholders, and a DM all/favourites allow-list. BotScreen is redesigned as a circular tab carousel (Direct / Channel / Room / Other, same interaction as Nearby Nodes' filter tabs) instead of one long scrolling list, which also exposed and fixed a leftover coupling where Channel/Room trigger-replies and their !command handling secretly depended on the DM tab's Enable/Commands toggles — each target's Enable and Commands are now fully independent. - NodePrefs: bot_room_enabled/prefix/trigger/reply, bot_dm_scope, bot_commands_ch/bot_commands_room (bot_commands_enabled repurposed as DM-only). SCHEMA_SENTINEL 0xC0DE001D -> 0xC0DE001F; sizeof unchanged at 2712 (new bytes absorbed existing padding, verified via a standalone host compile + offsetof check). - DataStore: persists all new fields; seeds bot_commands_ch/room from the old shared bot_commands_enabled on upgrade so existing channel/room command behaviour isn't silently lost. - MyMeshBot: tryBotReplyRoom/tryBotRoomCommand mirror the channel bot's shape but post via sendMessage (room relays to members itself); requires an existing login session with that room, same as a manual post would. botDmSenderAllowed() gates DM trigger-reply/commands on the favourites bit when bot_dm_scope=Fav. - MsgExpand: {name}/{hops} as optional trailing params (default nullptr/-1, no existing caller affected) — deliberately not exposed on the general compose keyboard, only on bot Reply fields. - QuickMsgScreen/UITask: room-target picker (mirrors the channel picker), routing through the existing room-login prompt when there's no saved password yet. - BotScreen: tab carousel (LEFT/RIGHT switches tabs, UP/DOWN moves rows, Enter is now the only way to change a value); Enable split out of Channel/Room's combo row; Quiet Hours gained a stepper sub-mode; Commands moved from a shared toggle into each tab. - docs/tools_screen.md updated for the new tab layout and behaviour. Not build-verified — no PlatformIO toolchain in this environment. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 20:16:59 +02:00
// DM allow-list gate: when bot_dm_scope is favourites-only, ignore triggers/
// commands from any contact that isn't starred (ContactInfo::flags bit 0 —
// the same "favourite" bit the Messages screen's DM list filter reads), so a
// bot left on can't be spammed by strangers while still answering people the
// user has starred. Channel and room bots have no equivalent — they're
// inherently public to whoever's already on that channel/room.
bool MyMesh::botDmSenderAllowed(const ContactInfo& from) const {
if (_prefs.bot_dm_scope == 0) return true; // all (default)
return (from.flags & 0x01) != 0; // favourites only
}
// Resolves a room post's signed author (a 4-byte pubkey prefix carried
// alongside the post — `from` is the room server itself, not the poster) to a
// display name for the {name} placeholder. Falls back to a generic label when
// the poster isn't a known contact (unverified beyond the 4-byte prefix).
void MyMesh::botRoomSenderName(const uint8_t* sender_prefix, char* out, int out_len) {
ContactInfo* sc = sender_prefix ? lookupContactByPubKey(sender_prefix, 4) : nullptr;
if (sc && sc->name[0]) snprintf(out, out_len, "%s", sc->name);
else snprintf(out, out_len, "someone");
}
void MyMesh::botChannelSenderSplit(const char* text, char* sender_name, int sender_name_len, const char** msg_out) {
*msg_out = text;
snprintf(sender_name, sender_name_len, "someone");
const char* sep = strstr(text, ": ");
if (sep) {
*msg_out = sep + 2;
int n = (int)(sep - text);
if (n > 0 && n < sender_name_len) { memcpy(sender_name, text, n); sender_name[n] = '\0'; }
}
}
feat(bot): hardening + auto-responder, query commands, quiet hours Reply-bot overhaul on top of the trigger/reply auto-reply: Robustness - single botTriggerMatches() shared by DM/channel paths; named constants (BOT_REPLY_COOLDOWN_MS / BOT_SCRATCH) replace magic numbers. - per-contact DM throttle (8-entry ring) so a second sender isn't starved while one contact is on cooldown. - channel anti-loop: skip only when an incoming message equals our own reply (was: any reply containing the trigger word — which silently killed channel replies for the common "trigger word in reply" setup). Features - away / reply-to-all: a lone "*" trigger matches every message. - independent DM vs channel triggers (bot_trigger / bot_trigger_ch); "*" works on the channel too, bounded by cooldown + echo guard. - query commands: a DM or monitored-channel message is scanned for "!word" tokens — !ping/!batt/!loc/!time/!temp/!hops/!status/!help — merged into one combined reply. DM = per-contact throttle, ignores quiet hours; channel = broadcast, per-channel cooldown, respects quiet hours. !hops uses getPathHashCount() (0 = direct). - quiet hours (bot_quiet_start/end, local, wraps midnight) silence push replies. - reply counter shown in the BotScreen header. UI / storage - BotScreen: 9 rows, scrolling list (no more cramming), field I/O via fieldBuf()/fieldCap(). - prefs bot_commands_enabled / bot_quiet_start / bot_quiet_end / bot_trigger_ch persisted; schema 0xC0DE000A → 0xC0DE000C with clamping + migration (bot_trigger_ch seeded from bot_trigger on upgrade). - docs: tools_screen bot section rewritten; FEATURES.md refreshed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 11:57:27 +02:00
// Per-contact DM throttle: true if enough time has passed (or we've never
// replied) to this contact. Only the first 4 key bytes are compared — ample to
// tell local contacts apart.
bool MyMesh::botDmAllowed(const uint8_t* pubkey) {
unsigned long now = millis();
for (int i = 0; i < BOT_DM_LOG_SIZE; i++) {
if (_bot_dm_log[i].used && memcmp(_bot_dm_log[i].key, pubkey, 4) == 0)
return now - _bot_dm_log[i].t_ms > BOT_REPLY_COOLDOWN_MS;
}
return true;
}
void MyMesh::botDmRecord(const uint8_t* pubkey) {
unsigned long now = millis();
for (int i = 0; i < BOT_DM_LOG_SIZE; i++) { // refresh existing entry
if (_bot_dm_log[i].used && memcmp(_bot_dm_log[i].key, pubkey, 4) == 0) {
_bot_dm_log[i].t_ms = now;
return;
}
}
int target = -1; // else take a free slot...
for (int i = 0; i < BOT_DM_LOG_SIZE; i++)
if (!_bot_dm_log[i].used) { target = i; break; }
if (target < 0) { // ...or evict the oldest
target = 0;
for (int i = 1; i < BOT_DM_LOG_SIZE; i++)
if (_bot_dm_log[i].t_ms < _bot_dm_log[target].t_ms) target = i;
}
memcpy(_bot_dm_log[target].key, pubkey, 4);
_bot_dm_log[target].t_ms = now;
_bot_dm_log[target].used = true;
}
// Quiet hours: when the local hour is inside [start, end) the auto-reply paths
// stay silent (commands are explicitly requested, so they ignore this). A zero-
// width window (start == end) disables the feature; a window where start > end
// wraps past midnight.
bool MyMesh::botInQuietHours() const {
if (_prefs.bot_quiet_start == _prefs.bot_quiet_end) return false; // disabled
uint32_t utc = getRTCClock()->getCurrentTime();
if (utc < 1000000000UL) return false; // clock not set — don't suppress
uint32_t local = utc + (int32_t)_prefs.tz_offset_hours * 3600;
int h = (int)((local / 3600) % 24);
int s = _prefs.bot_quiet_start, e = _prefs.bot_quiet_end;
return (s < e) ? (h >= s && h < e) // same-day window
: (h >= s || h < e); // overnight window
}
feat(bot): room-server target, tab-carousel UI, per-target independence Auto-Reply Bot gains a third target (room servers, alongside DM and channel), new {name}/{hops} reply placeholders, and a DM all/favourites allow-list. BotScreen is redesigned as a circular tab carousel (Direct / Channel / Room / Other, same interaction as Nearby Nodes' filter tabs) instead of one long scrolling list, which also exposed and fixed a leftover coupling where Channel/Room trigger-replies and their !command handling secretly depended on the DM tab's Enable/Commands toggles — each target's Enable and Commands are now fully independent. - NodePrefs: bot_room_enabled/prefix/trigger/reply, bot_dm_scope, bot_commands_ch/bot_commands_room (bot_commands_enabled repurposed as DM-only). SCHEMA_SENTINEL 0xC0DE001D -> 0xC0DE001F; sizeof unchanged at 2712 (new bytes absorbed existing padding, verified via a standalone host compile + offsetof check). - DataStore: persists all new fields; seeds bot_commands_ch/room from the old shared bot_commands_enabled on upgrade so existing channel/room command behaviour isn't silently lost. - MyMeshBot: tryBotReplyRoom/tryBotRoomCommand mirror the channel bot's shape but post via sendMessage (room relays to members itself); requires an existing login session with that room, same as a manual post would. botDmSenderAllowed() gates DM trigger-reply/commands on the favourites bit when bot_dm_scope=Fav. - MsgExpand: {name}/{hops} as optional trailing params (default nullptr/-1, no existing caller affected) — deliberately not exposed on the general compose keyboard, only on bot Reply fields. - QuickMsgScreen/UITask: room-target picker (mirrors the channel picker), routing through the existing room-login prompt when there's no saved password yet. - BotScreen: tab carousel (LEFT/RIGHT switches tabs, UP/DOWN moves rows, Enter is now the only way to change a value); Enable split out of Channel/Room's combo row; Quiet Hours gained a stepper sub-mode; Commands moved from a shared toggle into each tab. - docs/tools_screen.md updated for the new tab layout and behaviour. Not build-verified — no PlatformIO toolchain in this environment. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 20:16:59 +02:00
void MyMesh::tryBotReplyDM(const ContactInfo& from, const char* text, uint8_t hops) {
feat(bot): hardening + auto-responder, query commands, quiet hours Reply-bot overhaul on top of the trigger/reply auto-reply: Robustness - single botTriggerMatches() shared by DM/channel paths; named constants (BOT_REPLY_COOLDOWN_MS / BOT_SCRATCH) replace magic numbers. - per-contact DM throttle (8-entry ring) so a second sender isn't starved while one contact is on cooldown. - channel anti-loop: skip only when an incoming message equals our own reply (was: any reply containing the trigger word — which silently killed channel replies for the common "trigger word in reply" setup). Features - away / reply-to-all: a lone "*" trigger matches every message. - independent DM vs channel triggers (bot_trigger / bot_trigger_ch); "*" works on the channel too, bounded by cooldown + echo guard. - query commands: a DM or monitored-channel message is scanned for "!word" tokens — !ping/!batt/!loc/!time/!temp/!hops/!status/!help — merged into one combined reply. DM = per-contact throttle, ignores quiet hours; channel = broadcast, per-channel cooldown, respects quiet hours. !hops uses getPathHashCount() (0 = direct). - quiet hours (bot_quiet_start/end, local, wraps midnight) silence push replies. - reply counter shown in the BotScreen header. UI / storage - BotScreen: 9 rows, scrolling list (no more cramming), field I/O via fieldBuf()/fieldCap(). - prefs bot_commands_enabled / bot_quiet_start / bot_quiet_end / bot_trigger_ch persisted; schema 0xC0DE000A → 0xC0DE000C with clamping + migration (bot_trigger_ch seeded from bot_trigger on upgrade). - docs: tools_screen bot section rewritten; FEATURES.md refreshed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 11:57:27 +02:00
if (from.type != ADV_TYPE_CHAT) return;
if (!(_prefs.bot_enabled && _prefs.bot_reply_dm[0])) return;
feat(bot): room-server target, tab-carousel UI, per-target independence Auto-Reply Bot gains a third target (room servers, alongside DM and channel), new {name}/{hops} reply placeholders, and a DM all/favourites allow-list. BotScreen is redesigned as a circular tab carousel (Direct / Channel / Room / Other, same interaction as Nearby Nodes' filter tabs) instead of one long scrolling list, which also exposed and fixed a leftover coupling where Channel/Room trigger-replies and their !command handling secretly depended on the DM tab's Enable/Commands toggles — each target's Enable and Commands are now fully independent. - NodePrefs: bot_room_enabled/prefix/trigger/reply, bot_dm_scope, bot_commands_ch/bot_commands_room (bot_commands_enabled repurposed as DM-only). SCHEMA_SENTINEL 0xC0DE001D -> 0xC0DE001F; sizeof unchanged at 2712 (new bytes absorbed existing padding, verified via a standalone host compile + offsetof check). - DataStore: persists all new fields; seeds bot_commands_ch/room from the old shared bot_commands_enabled on upgrade so existing channel/room command behaviour isn't silently lost. - MyMeshBot: tryBotReplyRoom/tryBotRoomCommand mirror the channel bot's shape but post via sendMessage (room relays to members itself); requires an existing login session with that room, same as a manual post would. botDmSenderAllowed() gates DM trigger-reply/commands on the favourites bit when bot_dm_scope=Fav. - MsgExpand: {name}/{hops} as optional trailing params (default nullptr/-1, no existing caller affected) — deliberately not exposed on the general compose keyboard, only on bot Reply fields. - QuickMsgScreen/UITask: room-target picker (mirrors the channel picker), routing through the existing room-login prompt when there's no saved password yet. - BotScreen: tab carousel (LEFT/RIGHT switches tabs, UP/DOWN moves rows, Enter is now the only way to change a value); Enable split out of Channel/Room's combo row; Quiet Hours gained a stepper sub-mode; Commands moved from a shared toggle into each tab. - docs/tools_screen.md updated for the new tab layout and behaviour. Not build-verified — no PlatformIO toolchain in this environment. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 20:16:59 +02:00
if (!botDmSenderAllowed(from)) return;
feat(bot): hardening + auto-responder, query commands, quiet hours Reply-bot overhaul on top of the trigger/reply auto-reply: Robustness - single botTriggerMatches() shared by DM/channel paths; named constants (BOT_REPLY_COOLDOWN_MS / BOT_SCRATCH) replace magic numbers. - per-contact DM throttle (8-entry ring) so a second sender isn't starved while one contact is on cooldown. - channel anti-loop: skip only when an incoming message equals our own reply (was: any reply containing the trigger word — which silently killed channel replies for the common "trigger word in reply" setup). Features - away / reply-to-all: a lone "*" trigger matches every message. - independent DM vs channel triggers (bot_trigger / bot_trigger_ch); "*" works on the channel too, bounded by cooldown + echo guard. - query commands: a DM or monitored-channel message is scanned for "!word" tokens — !ping/!batt/!loc/!time/!temp/!hops/!status/!help — merged into one combined reply. DM = per-contact throttle, ignores quiet hours; channel = broadcast, per-channel cooldown, respects quiet hours. !hops uses getPathHashCount() (0 = direct). - quiet hours (bot_quiet_start/end, local, wraps midnight) silence push replies. - reply counter shown in the BotScreen header. UI / storage - BotScreen: 9 rows, scrolling list (no more cramming), field I/O via fieldBuf()/fieldCap(). - prefs bot_commands_enabled / bot_quiet_start / bot_quiet_end / bot_trigger_ch persisted; schema 0xC0DE000A → 0xC0DE000C with clamping + migration (bot_trigger_ch seeded from bot_trigger on upgrade). - docs: tools_screen bot section rewritten; FEATURES.md refreshed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 11:57:27 +02:00
if (botInQuietHours()) return;
if (!botTriggerMatches(_prefs.bot_trigger, text, true)) return; // wildcard "*" allowed
if (!botDmAllowed(from.id.pub_key)) return; // already answered this contact recently
uint32_t ts = getRTCClock()->getCurrentTime();
feat(bot): hardening + auto-responder, query commands, quiet hours Reply-bot overhaul on top of the trigger/reply auto-reply: Robustness - single botTriggerMatches() shared by DM/channel paths; named constants (BOT_REPLY_COOLDOWN_MS / BOT_SCRATCH) replace magic numbers. - per-contact DM throttle (8-entry ring) so a second sender isn't starved while one contact is on cooldown. - channel anti-loop: skip only when an incoming message equals our own reply (was: any reply containing the trigger word — which silently killed channel replies for the common "trigger word in reply" setup). Features - away / reply-to-all: a lone "*" trigger matches every message. - independent DM vs channel triggers (bot_trigger / bot_trigger_ch); "*" works on the channel too, bounded by cooldown + echo guard. - query commands: a DM or monitored-channel message is scanned for "!word" tokens — !ping/!batt/!loc/!time/!temp/!hops/!status/!help — merged into one combined reply. DM = per-contact throttle, ignores quiet hours; channel = broadcast, per-channel cooldown, respects quiet hours. !hops uses getPathHashCount() (0 = direct). - quiet hours (bot_quiet_start/end, local, wraps midnight) silence push replies. - reply counter shown in the BotScreen header. UI / storage - BotScreen: 9 rows, scrolling list (no more cramming), field I/O via fieldBuf()/fieldCap(). - prefs bot_commands_enabled / bot_quiet_start / bot_quiet_end / bot_trigger_ch persisted; schema 0xC0DE000A → 0xC0DE000C with clamping + migration (bot_trigger_ch seeded from bot_trigger on upgrade). - docs: tools_screen bot section rewritten; FEATURES.md refreshed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 11:57:27 +02:00
char expanded[BOT_SCRATCH];
expandMsg(_prefs.bot_reply_dm, expanded, sizeof(expanded),
sensors.node_lat, sensors.node_lon,
sensors.node_lat != 0.0 || sensors.node_lon != 0.0,
ts, _prefs.tz_offset_hours,
feat(bot): room-server target, tab-carousel UI, per-target independence Auto-Reply Bot gains a third target (room servers, alongside DM and channel), new {name}/{hops} reply placeholders, and a DM all/favourites allow-list. BotScreen is redesigned as a circular tab carousel (Direct / Channel / Room / Other, same interaction as Nearby Nodes' filter tabs) instead of one long scrolling list, which also exposed and fixed a leftover coupling where Channel/Room trigger-replies and their !command handling secretly depended on the DM tab's Enable/Commands toggles — each target's Enable and Commands are now fully independent. - NodePrefs: bot_room_enabled/prefix/trigger/reply, bot_dm_scope, bot_commands_ch/bot_commands_room (bot_commands_enabled repurposed as DM-only). SCHEMA_SENTINEL 0xC0DE001D -> 0xC0DE001F; sizeof unchanged at 2712 (new bytes absorbed existing padding, verified via a standalone host compile + offsetof check). - DataStore: persists all new fields; seeds bot_commands_ch/room from the old shared bot_commands_enabled on upgrade so existing channel/room command behaviour isn't silently lost. - MyMeshBot: tryBotReplyRoom/tryBotRoomCommand mirror the channel bot's shape but post via sendMessage (room relays to members itself); requires an existing login session with that room, same as a manual post would. botDmSenderAllowed() gates DM trigger-reply/commands on the favourites bit when bot_dm_scope=Fav. - MsgExpand: {name}/{hops} as optional trailing params (default nullptr/-1, no existing caller affected) — deliberately not exposed on the general compose keyboard, only on bot Reply fields. - QuickMsgScreen/UITask: room-target picker (mirrors the channel picker), routing through the existing room-login prompt when there's no saved password yet. - BotScreen: tab carousel (LEFT/RIGHT switches tabs, UP/DOWN moves rows, Enter is now the only way to change a value); Enable split out of Channel/Room's combo row; Quiet Hours gained a stepper sub-mode; Commands moved from a shared toggle into each tab. - docs/tools_screen.md updated for the new tab layout and behaviour. Not build-verified — no PlatformIO toolchain in this environment. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 20:16:59 +02:00
&sensors, (float)board.getBattMilliVolts() / 1000.0f,
from.name, hops);
uint32_t expected_ack, est_timeout;
if (sendMessage(from, ts, 0, expanded, expected_ack, est_timeout) != MSG_SEND_FAILED) {
feat(bot): hardening + auto-responder, query commands, quiet hours Reply-bot overhaul on top of the trigger/reply auto-reply: Robustness - single botTriggerMatches() shared by DM/channel paths; named constants (BOT_REPLY_COOLDOWN_MS / BOT_SCRATCH) replace magic numbers. - per-contact DM throttle (8-entry ring) so a second sender isn't starved while one contact is on cooldown. - channel anti-loop: skip only when an incoming message equals our own reply (was: any reply containing the trigger word — which silently killed channel replies for the common "trigger word in reply" setup). Features - away / reply-to-all: a lone "*" trigger matches every message. - independent DM vs channel triggers (bot_trigger / bot_trigger_ch); "*" works on the channel too, bounded by cooldown + echo guard. - query commands: a DM or monitored-channel message is scanned for "!word" tokens — !ping/!batt/!loc/!time/!temp/!hops/!status/!help — merged into one combined reply. DM = per-contact throttle, ignores quiet hours; channel = broadcast, per-channel cooldown, respects quiet hours. !hops uses getPathHashCount() (0 = direct). - quiet hours (bot_quiet_start/end, local, wraps midnight) silence push replies. - reply counter shown in the BotScreen header. UI / storage - BotScreen: 9 rows, scrolling list (no more cramming), field I/O via fieldBuf()/fieldCap(). - prefs bot_commands_enabled / bot_quiet_start / bot_quiet_end / bot_trigger_ch persisted; schema 0xC0DE000A → 0xC0DE000C with clamping + migration (bot_trigger_ch seeded from bot_trigger on upgrade). - docs: tools_screen bot section rewritten; FEATURES.md refreshed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 11:57:27 +02:00
botDmRecord(from.id.pub_key);
_bot_reply_count++;
#ifdef DISPLAY_CLASS
if (_ui) _ui->addDMMsg(from.id.pub_key, true, expanded);
#endif
}
}
feat(bot): room-server target, tab-carousel UI, per-target independence Auto-Reply Bot gains a third target (room servers, alongside DM and channel), new {name}/{hops} reply placeholders, and a DM all/favourites allow-list. BotScreen is redesigned as a circular tab carousel (Direct / Channel / Room / Other, same interaction as Nearby Nodes' filter tabs) instead of one long scrolling list, which also exposed and fixed a leftover coupling where Channel/Room trigger-replies and their !command handling secretly depended on the DM tab's Enable/Commands toggles — each target's Enable and Commands are now fully independent. - NodePrefs: bot_room_enabled/prefix/trigger/reply, bot_dm_scope, bot_commands_ch/bot_commands_room (bot_commands_enabled repurposed as DM-only). SCHEMA_SENTINEL 0xC0DE001D -> 0xC0DE001F; sizeof unchanged at 2712 (new bytes absorbed existing padding, verified via a standalone host compile + offsetof check). - DataStore: persists all new fields; seeds bot_commands_ch/room from the old shared bot_commands_enabled on upgrade so existing channel/room command behaviour isn't silently lost. - MyMeshBot: tryBotReplyRoom/tryBotRoomCommand mirror the channel bot's shape but post via sendMessage (room relays to members itself); requires an existing login session with that room, same as a manual post would. botDmSenderAllowed() gates DM trigger-reply/commands on the favourites bit when bot_dm_scope=Fav. - MsgExpand: {name}/{hops} as optional trailing params (default nullptr/-1, no existing caller affected) — deliberately not exposed on the general compose keyboard, only on bot Reply fields. - QuickMsgScreen/UITask: room-target picker (mirrors the channel picker), routing through the existing room-login prompt when there's no saved password yet. - BotScreen: tab carousel (LEFT/RIGHT switches tabs, UP/DOWN moves rows, Enter is now the only way to change a value); Enable split out of Channel/Room's combo row; Quiet Hours gained a stepper sub-mode; Commands moved from a shared toggle into each tab. - docs/tools_screen.md updated for the new tab layout and behaviour. Not build-verified — no PlatformIO toolchain in this environment. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 20:16:59 +02:00
void MyMesh::tryBotReplyChannel(uint8_t channel_idx, const char* text, uint8_t hops) {
// bot_channel_enabled is this target's own on/off switch — independent of
// bot_enabled (the DM tab's Enable), matching how the commands paths and
// the tabbed BotScreen UI already treat DM/channel/room as separate targets.
if (!(_prefs.bot_channel_enabled && _prefs.bot_reply_ch[0] &&
channel_idx == _prefs.bot_channel_idx &&
feat(bot): hardening + auto-responder, query commands, quiet hours Reply-bot overhaul on top of the trigger/reply auto-reply: Robustness - single botTriggerMatches() shared by DM/channel paths; named constants (BOT_REPLY_COOLDOWN_MS / BOT_SCRATCH) replace magic numbers. - per-contact DM throttle (8-entry ring) so a second sender isn't starved while one contact is on cooldown. - channel anti-loop: skip only when an incoming message equals our own reply (was: any reply containing the trigger word — which silently killed channel replies for the common "trigger word in reply" setup). Features - away / reply-to-all: a lone "*" trigger matches every message. - independent DM vs channel triggers (bot_trigger / bot_trigger_ch); "*" works on the channel too, bounded by cooldown + echo guard. - query commands: a DM or monitored-channel message is scanned for "!word" tokens — !ping/!batt/!loc/!time/!temp/!hops/!status/!help — merged into one combined reply. DM = per-contact throttle, ignores quiet hours; channel = broadcast, per-channel cooldown, respects quiet hours. !hops uses getPathHashCount() (0 = direct). - quiet hours (bot_quiet_start/end, local, wraps midnight) silence push replies. - reply counter shown in the BotScreen header. UI / storage - BotScreen: 9 rows, scrolling list (no more cramming), field I/O via fieldBuf()/fieldCap(). - prefs bot_commands_enabled / bot_quiet_start / bot_quiet_end / bot_trigger_ch persisted; schema 0xC0DE000A → 0xC0DE000C with clamping + migration (bot_trigger_ch seeded from bot_trigger on upgrade). - docs: tools_screen bot section rewritten; FEATURES.md refreshed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 11:57:27 +02:00
millis() - _bot_last_ch_reply_ms > BOT_REPLY_COOLDOWN_MS))
return;
feat(bot): hardening + auto-responder, query commands, quiet hours Reply-bot overhaul on top of the trigger/reply auto-reply: Robustness - single botTriggerMatches() shared by DM/channel paths; named constants (BOT_REPLY_COOLDOWN_MS / BOT_SCRATCH) replace magic numbers. - per-contact DM throttle (8-entry ring) so a second sender isn't starved while one contact is on cooldown. - channel anti-loop: skip only when an incoming message equals our own reply (was: any reply containing the trigger word — which silently killed channel replies for the common "trigger word in reply" setup). Features - away / reply-to-all: a lone "*" trigger matches every message. - independent DM vs channel triggers (bot_trigger / bot_trigger_ch); "*" works on the channel too, bounded by cooldown + echo guard. - query commands: a DM or monitored-channel message is scanned for "!word" tokens — !ping/!batt/!loc/!time/!temp/!hops/!status/!help — merged into one combined reply. DM = per-contact throttle, ignores quiet hours; channel = broadcast, per-channel cooldown, respects quiet hours. !hops uses getPathHashCount() (0 = direct). - quiet hours (bot_quiet_start/end, local, wraps midnight) silence push replies. - reply counter shown in the BotScreen header. UI / storage - BotScreen: 9 rows, scrolling list (no more cramming), field I/O via fieldBuf()/fieldCap(). - prefs bot_commands_enabled / bot_quiet_start / bot_quiet_end / bot_trigger_ch persisted; schema 0xC0DE000A → 0xC0DE000C with clamping + migration (bot_trigger_ch seeded from bot_trigger on upgrade). - docs: tools_screen bot section rewritten; FEATURES.md refreshed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 11:57:27 +02:00
if (botInQuietHours()) return;
feat(bot): room-server target, tab-carousel UI, per-target independence Auto-Reply Bot gains a third target (room servers, alongside DM and channel), new {name}/{hops} reply placeholders, and a DM all/favourites allow-list. BotScreen is redesigned as a circular tab carousel (Direct / Channel / Room / Other, same interaction as Nearby Nodes' filter tabs) instead of one long scrolling list, which also exposed and fixed a leftover coupling where Channel/Room trigger-replies and their !command handling secretly depended on the DM tab's Enable/Commands toggles — each target's Enable and Commands are now fully independent. - NodePrefs: bot_room_enabled/prefix/trigger/reply, bot_dm_scope, bot_commands_ch/bot_commands_room (bot_commands_enabled repurposed as DM-only). SCHEMA_SENTINEL 0xC0DE001D -> 0xC0DE001F; sizeof unchanged at 2712 (new bytes absorbed existing padding, verified via a standalone host compile + offsetof check). - DataStore: persists all new fields; seeds bot_commands_ch/room from the old shared bot_commands_enabled on upgrade so existing channel/room command behaviour isn't silently lost. - MyMeshBot: tryBotReplyRoom/tryBotRoomCommand mirror the channel bot's shape but post via sendMessage (room relays to members itself); requires an existing login session with that room, same as a manual post would. botDmSenderAllowed() gates DM trigger-reply/commands on the favourites bit when bot_dm_scope=Fav. - MsgExpand: {name}/{hops} as optional trailing params (default nullptr/-1, no existing caller affected) — deliberately not exposed on the general compose keyboard, only on bot Reply fields. - QuickMsgScreen/UITask: room-target picker (mirrors the channel picker), routing through the existing room-login prompt when there's no saved password yet. - BotScreen: tab carousel (LEFT/RIGHT switches tabs, UP/DOWN moves rows, Enter is now the only way to change a value); Enable split out of Channel/Room's combo row; Quiet Hours gained a stepper sub-mode; Commands moved from a shared toggle into each tab. - docs/tools_screen.md updated for the new tab layout and behaviour. Not build-verified — no PlatformIO toolchain in this environment. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 20:16:59 +02:00
// Split off the "SenderName: " prefix so the trigger is matched against the
// message body only, and so the name is available for the {name}
// placeholder. Unsigned/unverified — channel messages carry no identity
// beyond this text convention.
const char* msg;
char sender_name[32];
botChannelSenderSplit(text, sender_name, sizeof(sender_name), &msg);
feat(bot): hardening + auto-responder, query commands, quiet hours Reply-bot overhaul on top of the trigger/reply auto-reply: Robustness - single botTriggerMatches() shared by DM/channel paths; named constants (BOT_REPLY_COOLDOWN_MS / BOT_SCRATCH) replace magic numbers. - per-contact DM throttle (8-entry ring) so a second sender isn't starved while one contact is on cooldown. - channel anti-loop: skip only when an incoming message equals our own reply (was: any reply containing the trigger word — which silently killed channel replies for the common "trigger word in reply" setup). Features - away / reply-to-all: a lone "*" trigger matches every message. - independent DM vs channel triggers (bot_trigger / bot_trigger_ch); "*" works on the channel too, bounded by cooldown + echo guard. - query commands: a DM or monitored-channel message is scanned for "!word" tokens — !ping/!batt/!loc/!time/!temp/!hops/!status/!help — merged into one combined reply. DM = per-contact throttle, ignores quiet hours; channel = broadcast, per-channel cooldown, respects quiet hours. !hops uses getPathHashCount() (0 = direct). - quiet hours (bot_quiet_start/end, local, wraps midnight) silence push replies. - reply counter shown in the BotScreen header. UI / storage - BotScreen: 9 rows, scrolling list (no more cramming), field I/O via fieldBuf()/fieldCap(). - prefs bot_commands_enabled / bot_quiet_start / bot_quiet_end / bot_trigger_ch persisted; schema 0xC0DE000A → 0xC0DE000C with clamping + migration (bot_trigger_ch seeded from bot_trigger on upgrade). - docs: tools_screen bot section rewritten; FEATURES.md refreshed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 11:57:27 +02:00
if (!botTriggerMatches(_prefs.bot_trigger_ch, msg, true)) return; // own trigger; "*" = any msg
ChannelDetails ch;
if (!getChannel(channel_idx, ch)) return;
uint32_t ts = getRTCClock()->getCurrentTime();
feat(bot): hardening + auto-responder, query commands, quiet hours Reply-bot overhaul on top of the trigger/reply auto-reply: Robustness - single botTriggerMatches() shared by DM/channel paths; named constants (BOT_REPLY_COOLDOWN_MS / BOT_SCRATCH) replace magic numbers. - per-contact DM throttle (8-entry ring) so a second sender isn't starved while one contact is on cooldown. - channel anti-loop: skip only when an incoming message equals our own reply (was: any reply containing the trigger word — which silently killed channel replies for the common "trigger word in reply" setup). Features - away / reply-to-all: a lone "*" trigger matches every message. - independent DM vs channel triggers (bot_trigger / bot_trigger_ch); "*" works on the channel too, bounded by cooldown + echo guard. - query commands: a DM or monitored-channel message is scanned for "!word" tokens — !ping/!batt/!loc/!time/!temp/!hops/!status/!help — merged into one combined reply. DM = per-contact throttle, ignores quiet hours; channel = broadcast, per-channel cooldown, respects quiet hours. !hops uses getPathHashCount() (0 = direct). - quiet hours (bot_quiet_start/end, local, wraps midnight) silence push replies. - reply counter shown in the BotScreen header. UI / storage - BotScreen: 9 rows, scrolling list (no more cramming), field I/O via fieldBuf()/fieldCap(). - prefs bot_commands_enabled / bot_quiet_start / bot_quiet_end / bot_trigger_ch persisted; schema 0xC0DE000A → 0xC0DE000C with clamping + migration (bot_trigger_ch seeded from bot_trigger on upgrade). - docs: tools_screen bot section rewritten; FEATURES.md refreshed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 11:57:27 +02:00
char expanded[BOT_SCRATCH];
expandMsg(_prefs.bot_reply_ch, expanded, sizeof(expanded),
sensors.node_lat, sensors.node_lon,
sensors.node_lat != 0.0 || sensors.node_lon != 0.0,
ts, _prefs.tz_offset_hours,
feat(bot): room-server target, tab-carousel UI, per-target independence Auto-Reply Bot gains a third target (room servers, alongside DM and channel), new {name}/{hops} reply placeholders, and a DM all/favourites allow-list. BotScreen is redesigned as a circular tab carousel (Direct / Channel / Room / Other, same interaction as Nearby Nodes' filter tabs) instead of one long scrolling list, which also exposed and fixed a leftover coupling where Channel/Room trigger-replies and their !command handling secretly depended on the DM tab's Enable/Commands toggles — each target's Enable and Commands are now fully independent. - NodePrefs: bot_room_enabled/prefix/trigger/reply, bot_dm_scope, bot_commands_ch/bot_commands_room (bot_commands_enabled repurposed as DM-only). SCHEMA_SENTINEL 0xC0DE001D -> 0xC0DE001F; sizeof unchanged at 2712 (new bytes absorbed existing padding, verified via a standalone host compile + offsetof check). - DataStore: persists all new fields; seeds bot_commands_ch/room from the old shared bot_commands_enabled on upgrade so existing channel/room command behaviour isn't silently lost. - MyMeshBot: tryBotReplyRoom/tryBotRoomCommand mirror the channel bot's shape but post via sendMessage (room relays to members itself); requires an existing login session with that room, same as a manual post would. botDmSenderAllowed() gates DM trigger-reply/commands on the favourites bit when bot_dm_scope=Fav. - MsgExpand: {name}/{hops} as optional trailing params (default nullptr/-1, no existing caller affected) — deliberately not exposed on the general compose keyboard, only on bot Reply fields. - QuickMsgScreen/UITask: room-target picker (mirrors the channel picker), routing through the existing room-login prompt when there's no saved password yet. - BotScreen: tab carousel (LEFT/RIGHT switches tabs, UP/DOWN moves rows, Enter is now the only way to change a value); Enable split out of Channel/Room's combo row; Quiet Hours gained a stepper sub-mode; Commands moved from a shared toggle into each tab. - docs/tools_screen.md updated for the new tab layout and behaviour. Not build-verified — no PlatformIO toolchain in this environment. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 20:16:59 +02:00
&sensors, (float)board.getBattMilliVolts() / 1000.0f,
sender_name, hops);
feat(bot): hardening + auto-responder, query commands, quiet hours Reply-bot overhaul on top of the trigger/reply auto-reply: Robustness - single botTriggerMatches() shared by DM/channel paths; named constants (BOT_REPLY_COOLDOWN_MS / BOT_SCRATCH) replace magic numbers. - per-contact DM throttle (8-entry ring) so a second sender isn't starved while one contact is on cooldown. - channel anti-loop: skip only when an incoming message equals our own reply (was: any reply containing the trigger word — which silently killed channel replies for the common "trigger word in reply" setup). Features - away / reply-to-all: a lone "*" trigger matches every message. - independent DM vs channel triggers (bot_trigger / bot_trigger_ch); "*" works on the channel too, bounded by cooldown + echo guard. - query commands: a DM or monitored-channel message is scanned for "!word" tokens — !ping/!batt/!loc/!time/!temp/!hops/!status/!help — merged into one combined reply. DM = per-contact throttle, ignores quiet hours; channel = broadcast, per-channel cooldown, respects quiet hours. !hops uses getPathHashCount() (0 = direct). - quiet hours (bot_quiet_start/end, local, wraps midnight) silence push replies. - reply counter shown in the BotScreen header. UI / storage - BotScreen: 9 rows, scrolling list (no more cramming), field I/O via fieldBuf()/fieldCap(). - prefs bot_commands_enabled / bot_quiet_start / bot_quiet_end / bot_trigger_ch persisted; schema 0xC0DE000A → 0xC0DE000C with clamping + migration (bot_trigger_ch seeded from bot_trigger on upgrade). - docs: tools_screen bot section rewritten; FEATURES.md refreshed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 11:57:27 +02:00
// Anti-loop: don't echo a message identical to our own reply (e.g. another bot
// on the channel sending the same text), which would ping-pong forever. The
// per-channel cooldown caps any residual back-and-forth between mismatched bots.
if (strcmp(msg, expanded) == 0) return;
int rlen = strlen(expanded);
if (sendGroupMessage(ts, ch.channel, _prefs.node_name, expanded, rlen)) {
_bot_last_ch_reply_ms = millis();
feat(bot): hardening + auto-responder, query commands, quiet hours Reply-bot overhaul on top of the trigger/reply auto-reply: Robustness - single botTriggerMatches() shared by DM/channel paths; named constants (BOT_REPLY_COOLDOWN_MS / BOT_SCRATCH) replace magic numbers. - per-contact DM throttle (8-entry ring) so a second sender isn't starved while one contact is on cooldown. - channel anti-loop: skip only when an incoming message equals our own reply (was: any reply containing the trigger word — which silently killed channel replies for the common "trigger word in reply" setup). Features - away / reply-to-all: a lone "*" trigger matches every message. - independent DM vs channel triggers (bot_trigger / bot_trigger_ch); "*" works on the channel too, bounded by cooldown + echo guard. - query commands: a DM or monitored-channel message is scanned for "!word" tokens — !ping/!batt/!loc/!time/!temp/!hops/!status/!help — merged into one combined reply. DM = per-contact throttle, ignores quiet hours; channel = broadcast, per-channel cooldown, respects quiet hours. !hops uses getPathHashCount() (0 = direct). - quiet hours (bot_quiet_start/end, local, wraps midnight) silence push replies. - reply counter shown in the BotScreen header. UI / storage - BotScreen: 9 rows, scrolling list (no more cramming), field I/O via fieldBuf()/fieldCap(). - prefs bot_commands_enabled / bot_quiet_start / bot_quiet_end / bot_trigger_ch persisted; schema 0xC0DE000A → 0xC0DE000C with clamping + migration (bot_trigger_ch seeded from bot_trigger on upgrade). - docs: tools_screen bot section rewritten; FEATURES.md refreshed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 11:57:27 +02:00
_bot_reply_count++;
#ifdef DISPLAY_CLASS
if (_ui) {
char with_sender[240]; // node_name(32) + ": "(2) + expanded(200) + margin
snprintf(with_sender, sizeof(with_sender), "%s: %s", _prefs.node_name, expanded);
_ui->addChannelMsg(channel_idx, with_sender);
}
#endif
}
}
feat(bot): hardening + auto-responder, query commands, quiet hours Reply-bot overhaul on top of the trigger/reply auto-reply: Robustness - single botTriggerMatches() shared by DM/channel paths; named constants (BOT_REPLY_COOLDOWN_MS / BOT_SCRATCH) replace magic numbers. - per-contact DM throttle (8-entry ring) so a second sender isn't starved while one contact is on cooldown. - channel anti-loop: skip only when an incoming message equals our own reply (was: any reply containing the trigger word — which silently killed channel replies for the common "trigger word in reply" setup). Features - away / reply-to-all: a lone "*" trigger matches every message. - independent DM vs channel triggers (bot_trigger / bot_trigger_ch); "*" works on the channel too, bounded by cooldown + echo guard. - query commands: a DM or monitored-channel message is scanned for "!word" tokens — !ping/!batt/!loc/!time/!temp/!hops/!status/!help — merged into one combined reply. DM = per-contact throttle, ignores quiet hours; channel = broadcast, per-channel cooldown, respects quiet hours. !hops uses getPathHashCount() (0 = direct). - quiet hours (bot_quiet_start/end, local, wraps midnight) silence push replies. - reply counter shown in the BotScreen header. UI / storage - BotScreen: 9 rows, scrolling list (no more cramming), field I/O via fieldBuf()/fieldCap(). - prefs bot_commands_enabled / bot_quiet_start / bot_quiet_end / bot_trigger_ch persisted; schema 0xC0DE000A → 0xC0DE000C with clamping + migration (bot_trigger_ch seeded from bot_trigger on upgrade). - docs: tools_screen bot section rewritten; FEATURES.md refreshed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 11:57:27 +02:00
feat(bot): room-server target, tab-carousel UI, per-target independence Auto-Reply Bot gains a third target (room servers, alongside DM and channel), new {name}/{hops} reply placeholders, and a DM all/favourites allow-list. BotScreen is redesigned as a circular tab carousel (Direct / Channel / Room / Other, same interaction as Nearby Nodes' filter tabs) instead of one long scrolling list, which also exposed and fixed a leftover coupling where Channel/Room trigger-replies and their !command handling secretly depended on the DM tab's Enable/Commands toggles — each target's Enable and Commands are now fully independent. - NodePrefs: bot_room_enabled/prefix/trigger/reply, bot_dm_scope, bot_commands_ch/bot_commands_room (bot_commands_enabled repurposed as DM-only). SCHEMA_SENTINEL 0xC0DE001D -> 0xC0DE001F; sizeof unchanged at 2712 (new bytes absorbed existing padding, verified via a standalone host compile + offsetof check). - DataStore: persists all new fields; seeds bot_commands_ch/room from the old shared bot_commands_enabled on upgrade so existing channel/room command behaviour isn't silently lost. - MyMeshBot: tryBotReplyRoom/tryBotRoomCommand mirror the channel bot's shape but post via sendMessage (room relays to members itself); requires an existing login session with that room, same as a manual post would. botDmSenderAllowed() gates DM trigger-reply/commands on the favourites bit when bot_dm_scope=Fav. - MsgExpand: {name}/{hops} as optional trailing params (default nullptr/-1, no existing caller affected) — deliberately not exposed on the general compose keyboard, only on bot Reply fields. - QuickMsgScreen/UITask: room-target picker (mirrors the channel picker), routing through the existing room-login prompt when there's no saved password yet. - BotScreen: tab carousel (LEFT/RIGHT switches tabs, UP/DOWN moves rows, Enter is now the only way to change a value); Enable split out of Channel/Room's combo row; Quiet Hours gained a stepper sub-mode; Commands moved from a shared toggle into each tab. - docs/tools_screen.md updated for the new tab layout and behaviour. Not build-verified — no PlatformIO toolchain in this environment. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 20:16:59 +02:00
// Room-server counterpart to tryBotReplyChannel: a room also carries many
// posters, so it shares that function's "public reply, respects quiet hours +
// a single shared cooldown" shape, but the reply is a direct sendMessage() to
// the room server (same call used for a room's normal chat compose), like a
// DM, since the room server relays it to its members itself. Requires the
// device to already have an active login session with this room server (see
// NodePrefs::bot_room_prefix's doc comment) — otherwise sendMessage() still
// "succeeds" locally but the server silently drops the unauthorized post.
void MyMesh::tryBotReplyRoom(const ContactInfo& from, const uint8_t* sender_prefix, const char* text, uint8_t hops) {
if (from.type != ADV_TYPE_ROOM) return;
// bot_room_enabled is this target's own on/off switch — independent of
// bot_enabled, same reasoning as tryBotReplyChannel above.
if (!(_prefs.bot_room_enabled && _prefs.bot_reply_room[0] &&
memcmp(from.id.pub_key, _prefs.bot_room_prefix, NodePrefs::FAVOURITE_PREFIX_LEN) == 0 &&
millis() - _bot_last_room_reply_ms > BOT_REPLY_COOLDOWN_MS))
return;
if (botInQuietHours()) return;
if (!botTriggerMatches(_prefs.bot_trigger_room, text, true)) return; // "*" = any post
char sender_name[32];
botRoomSenderName(sender_prefix, sender_name, sizeof(sender_name));
uint32_t ts = getRTCClock()->getCurrentTime();
char expanded[BOT_SCRATCH];
expandMsg(_prefs.bot_reply_room, expanded, sizeof(expanded),
sensors.node_lat, sensors.node_lon,
sensors.node_lat != 0.0 || sensors.node_lon != 0.0,
ts, _prefs.tz_offset_hours,
&sensors, (float)board.getBattMilliVolts() / 1000.0f,
sender_name, hops);
// Anti-loop, mirrors the channel path: don't echo a message identical to
// our own reply (e.g. re-synced back from the room server).
if (strcmp(text, expanded) == 0) return;
uint32_t expected_ack, est_timeout;
if (sendMessage(from, ts, 0, expanded, expected_ack, est_timeout) != MSG_SEND_FAILED) {
_bot_last_room_reply_ms = millis();
_bot_reply_count++;
#ifdef DISPLAY_CLASS
if (_ui) _ui->addDMMsg(from.id.pub_key, true, expanded);
#endif
}
}
feat(bot): hardening + auto-responder, query commands, quiet hours Reply-bot overhaul on top of the trigger/reply auto-reply: Robustness - single botTriggerMatches() shared by DM/channel paths; named constants (BOT_REPLY_COOLDOWN_MS / BOT_SCRATCH) replace magic numbers. - per-contact DM throttle (8-entry ring) so a second sender isn't starved while one contact is on cooldown. - channel anti-loop: skip only when an incoming message equals our own reply (was: any reply containing the trigger word — which silently killed channel replies for the common "trigger word in reply" setup). Features - away / reply-to-all: a lone "*" trigger matches every message. - independent DM vs channel triggers (bot_trigger / bot_trigger_ch); "*" works on the channel too, bounded by cooldown + echo guard. - query commands: a DM or monitored-channel message is scanned for "!word" tokens — !ping/!batt/!loc/!time/!temp/!hops/!status/!help — merged into one combined reply. DM = per-contact throttle, ignores quiet hours; channel = broadcast, per-channel cooldown, respects quiet hours. !hops uses getPathHashCount() (0 = direct). - quiet hours (bot_quiet_start/end, local, wraps midnight) silence push replies. - reply counter shown in the BotScreen header. UI / storage - BotScreen: 9 rows, scrolling list (no more cramming), field I/O via fieldBuf()/fieldCap(). - prefs bot_commands_enabled / bot_quiet_start / bot_quiet_end / bot_trigger_ch persisted; schema 0xC0DE000A → 0xC0DE000C with clamping + migration (bot_trigger_ch seeded from bot_trigger on upgrade). - docs: tools_screen bot section rewritten; FEATURES.md refreshed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 11:57:27 +02:00
// Builds the reply text for a single command word into out. Returns false for an
feat(bot): room-server target, tab-carousel UI, per-target independence Auto-Reply Bot gains a third target (room servers, alongside DM and channel), new {name}/{hops} reply placeholders, and a DM all/favourites allow-list. BotScreen is redesigned as a circular tab carousel (Direct / Channel / Room / Other, same interaction as Nearby Nodes' filter tabs) instead of one long scrolling list, which also exposed and fixed a leftover coupling where Channel/Room trigger-replies and their !command handling secretly depended on the DM tab's Enable/Commands toggles — each target's Enable and Commands are now fully independent. - NodePrefs: bot_room_enabled/prefix/trigger/reply, bot_dm_scope, bot_commands_ch/bot_commands_room (bot_commands_enabled repurposed as DM-only). SCHEMA_SENTINEL 0xC0DE001D -> 0xC0DE001F; sizeof unchanged at 2712 (new bytes absorbed existing padding, verified via a standalone host compile + offsetof check). - DataStore: persists all new fields; seeds bot_commands_ch/room from the old shared bot_commands_enabled on upgrade so existing channel/room command behaviour isn't silently lost. - MyMeshBot: tryBotReplyRoom/tryBotRoomCommand mirror the channel bot's shape but post via sendMessage (room relays to members itself); requires an existing login session with that room, same as a manual post would. botDmSenderAllowed() gates DM trigger-reply/commands on the favourites bit when bot_dm_scope=Fav. - MsgExpand: {name}/{hops} as optional trailing params (default nullptr/-1, no existing caller affected) — deliberately not exposed on the general compose keyboard, only on bot Reply fields. - QuickMsgScreen/UITask: room-target picker (mirrors the channel picker), routing through the existing room-login prompt when there's no saved password yet. - BotScreen: tab carousel (LEFT/RIGHT switches tabs, UP/DOWN moves rows, Enter is now the only way to change a value); Enable split out of Channel/Room's combo row; Quiet Hours gained a stepper sub-mode; Commands moved from a shared toggle into each tab. - docs/tools_screen.md updated for the new tab layout and behaviour. Not build-verified — no PlatformIO toolchain in this environment. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 20:16:59 +02:00
// unknown command. !hops is dynamic (per received message; separate from the
// general {hops} placeholder so plain "!hops" keeps its terse "direct"/"N hops"
// reply without needing a template); the rest expand a static template via
// expandMsg's placeholders, including {name}/{hops} if the sender wrote them
// into a custom reply — not used by any of the built-in templates below today.
bool MyMesh::botCommandReply(const char* cmd, const char* arg, const char* arg2, bool actions_allowed,
uint8_t hops, uint32_t ts, char* out, int out_len, const char* sender_name) {
feat(bot): hardening + auto-responder, query commands, quiet hours Reply-bot overhaul on top of the trigger/reply auto-reply: Robustness - single botTriggerMatches() shared by DM/channel paths; named constants (BOT_REPLY_COOLDOWN_MS / BOT_SCRATCH) replace magic numbers. - per-contact DM throttle (8-entry ring) so a second sender isn't starved while one contact is on cooldown. - channel anti-loop: skip only when an incoming message equals our own reply (was: any reply containing the trigger word — which silently killed channel replies for the common "trigger word in reply" setup). Features - away / reply-to-all: a lone "*" trigger matches every message. - independent DM vs channel triggers (bot_trigger / bot_trigger_ch); "*" works on the channel too, bounded by cooldown + echo guard. - query commands: a DM or monitored-channel message is scanned for "!word" tokens — !ping/!batt/!loc/!time/!temp/!hops/!status/!help — merged into one combined reply. DM = per-contact throttle, ignores quiet hours; channel = broadcast, per-channel cooldown, respects quiet hours. !hops uses getPathHashCount() (0 = direct). - quiet hours (bot_quiet_start/end, local, wraps midnight) silence push replies. - reply counter shown in the BotScreen header. UI / storage - BotScreen: 9 rows, scrolling list (no more cramming), field I/O via fieldBuf()/fieldCap(). - prefs bot_commands_enabled / bot_quiet_start / bot_quiet_end / bot_trigger_ch persisted; schema 0xC0DE000A → 0xC0DE000C with clamping + migration (bot_trigger_ch seeded from bot_trigger on upgrade). - docs: tools_screen bot section rewritten; FEATURES.md refreshed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 11:57:27 +02:00
if (!strcmp(cmd, "hops")) {
if (hops == 0) strncpy(out, "direct", out_len); // heard directly (0 repeaters)
else snprintf(out, out_len, "%u hops", (unsigned)hops);
out[out_len - 1] = '\0';
return true;
}
// Action commands change device state, unlike every query below — gated by
// this target's own bot_actions_* toggle (nested under Commands being on),
// not by the templating/expandMsg mechanism the queries share.
if (actions_allowed) {
if (!strcmp(cmd, "gps")) {
// "fix [seconds]" -- single-shot: turn GPS on (if not already), wait for
// a stabilised position, send it, then restore GPS to whatever it was
// before. This command can't reply synchronously like every other one
// here (a fix takes seconds-to-minutes), so it acks immediately and
// sets _locfix_requested; the tryBot*Command() wrapper that called us
// knows the destination and starts the actual wait (see MyMesh.h).
// Optional 2nd argument overrides the default LOCFIX_TIMEOUT_MS budget
// -- useful under poor sky view, where 90s isn't always enough to reach
// isLocFixReady()'s HDOP/satellite bar. Clamped to a sane range: floor
// covers LOCFIX_AVERAGE_MS (no point requesting less than one averaging
// window), ceiling keeps a forgotten/misfired request from parking GPS
// on indefinitely.
if (!strcmp(arg, "fix")) {
if (_loc_fix.active) { snprintf(out, out_len, "GPS: fix already pending"); return true; }
uint32_t timeout_ms = LOCFIX_TIMEOUT_MS;
if (arg2[0]) {
int secs = atoi(arg2);
if (secs < 15) secs = 15;
if (secs > 300) secs = 300;
timeout_ms = (uint32_t)secs * 1000;
}
_locfix_requested = true;
_locfix_requested_timeout_ms = timeout_ms;
snprintf(out, out_len, "GPS: acquiring fix...");
return true;
}
bool on = !strcmp(arg, "on"); // arg was lowercased while parsing (see botScanCommands)
bool off = !strcmp(arg, "off");
if (!on && !off) { snprintf(out, out_len, "gps: on|off|fix?"); return true; }
// Deferred: applyPendingBotActions() actually flips the GPS state, once
// quiet-hours/cooldown/throttle have passed (see MyMesh.h).
_bot_gps_action_pending = true;
_bot_gps_action_on = on;
snprintf(out, out_len, "GPS: %s", on ? "on" : "off");
return true;
}
if (!strcmp(cmd, "buzz")) {
int secs = arg[0] ? atoi(arg) : 5;
if (secs < 1) secs = 5;
if (secs > 30) secs = 30;
_bot_buzz_action_secs = secs; // deferred -- see applyPendingBotActions()
snprintf(out, out_len, "Buzzing");
return true;
}
if (!strcmp(cmd, "advert")) {
// Deferred -- can no longer report advert()'s own success/failure here
// (it doesn't run until applyPendingBotActions()), so the ack is just
// an acknowledgement, same spirit as !gps fix's "acquiring fix...".
_bot_advert_action_pending = true;
snprintf(out, out_len, "Advert requested");
return true;
}
// !gpio1..!gpio4 -- user-assignable pins (see UITask::botSetGPIO/
// botGetGPIO/botGetGPIOAnalog; no-op on boards without them). Bare (no
// arg) reports current mode+level (millivolts if in Analog mode, GPIO1/
// GPIO2 only); on/off only takes effect if that pin's Tools > GPIO mode
// is currently Output (Analog/Input/Off all reply "not output"). The
// Output check itself is read-only (botGetGPIO), so it's safe to do now
// for an accurate reply; only the actual pin write is deferred.
if (!strncmp(cmd, "gpio", 4) && cmd[4] >= '1' && cmd[4] <= '4' && cmd[5] == '\0') {
int idx = cmd[4] - '0';
if (arg[0]) {
bool on = !strcmp(arg, "on");
bool off = !strcmp(arg, "off");
if (!on && !off) { snprintf(out, out_len, "gpio%d: on|off?", idx); return true; }
bool is_out = false, val = false;
if (_ui && _ui->botGetGPIO(idx, is_out, val) && is_out) {
_bot_gpio_action[idx - 1] = on ? 1 : 0; // deferred -- see applyPendingBotActions()
snprintf(out, out_len, "gpio%d: %s", idx, on ? "on" : "off");
} else {
snprintf(out, out_len, "gpio%d: not output", idx);
}
return true;
}
int mv = 0;
bool is_out = false, val = false;
if (_ui && _ui->botGetGPIOAnalog(idx, mv)) {
snprintf(out, out_len, "gpio%d: %dmV", idx, mv);
} else if (_ui && _ui->botGetGPIO(idx, is_out, val)) {
snprintf(out, out_len, "gpio%d: %s %s", idx, is_out ? "out" : "in", val ? "on" : "off");
} else {
snprintf(out, out_len, "gpio%d: off", idx);
}
return true;
}
}
feat(bot): hardening + auto-responder, query commands, quiet hours Reply-bot overhaul on top of the trigger/reply auto-reply: Robustness - single botTriggerMatches() shared by DM/channel paths; named constants (BOT_REPLY_COOLDOWN_MS / BOT_SCRATCH) replace magic numbers. - per-contact DM throttle (8-entry ring) so a second sender isn't starved while one contact is on cooldown. - channel anti-loop: skip only when an incoming message equals our own reply (was: any reply containing the trigger word — which silently killed channel replies for the common "trigger word in reply" setup). Features - away / reply-to-all: a lone "*" trigger matches every message. - independent DM vs channel triggers (bot_trigger / bot_trigger_ch); "*" works on the channel too, bounded by cooldown + echo guard. - query commands: a DM or monitored-channel message is scanned for "!word" tokens — !ping/!batt/!loc/!time/!temp/!hops/!status/!help — merged into one combined reply. DM = per-contact throttle, ignores quiet hours; channel = broadcast, per-channel cooldown, respects quiet hours. !hops uses getPathHashCount() (0 = direct). - quiet hours (bot_quiet_start/end, local, wraps midnight) silence push replies. - reply counter shown in the BotScreen header. UI / storage - BotScreen: 9 rows, scrolling list (no more cramming), field I/O via fieldBuf()/fieldCap(). - prefs bot_commands_enabled / bot_quiet_start / bot_quiet_end / bot_trigger_ch persisted; schema 0xC0DE000A → 0xC0DE000C with clamping + migration (bot_trigger_ch seeded from bot_trigger on upgrade). - docs: tools_screen bot section rewritten; FEATURES.md refreshed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 11:57:27 +02:00
const char* tmpl = nullptr;
if (!strcmp(cmd, "ping")) tmpl = "pong";
else if (!strcmp(cmd, "batt")) tmpl = "{batt}";
else if (!strcmp(cmd, "loc")) tmpl = "{loc}";
else if (!strcmp(cmd, "time")) tmpl = "{time}";
else if (!strcmp(cmd, "temp")) tmpl = "{temp}";
else if (!strcmp(cmd, "status")) tmpl = "batt {batt} loc {loc} at {time}";
else if (!strcmp(cmd, "help")) tmpl = actions_allowed
? "cmds: !ping !batt !loc !time !temp !hops !status !gps !buzz !advert !gpio1-4"
: "cmds: !ping !batt !loc !time !temp !hops !status";
feat(bot): hardening + auto-responder, query commands, quiet hours Reply-bot overhaul on top of the trigger/reply auto-reply: Robustness - single botTriggerMatches() shared by DM/channel paths; named constants (BOT_REPLY_COOLDOWN_MS / BOT_SCRATCH) replace magic numbers. - per-contact DM throttle (8-entry ring) so a second sender isn't starved while one contact is on cooldown. - channel anti-loop: skip only when an incoming message equals our own reply (was: any reply containing the trigger word — which silently killed channel replies for the common "trigger word in reply" setup). Features - away / reply-to-all: a lone "*" trigger matches every message. - independent DM vs channel triggers (bot_trigger / bot_trigger_ch); "*" works on the channel too, bounded by cooldown + echo guard. - query commands: a DM or monitored-channel message is scanned for "!word" tokens — !ping/!batt/!loc/!time/!temp/!hops/!status/!help — merged into one combined reply. DM = per-contact throttle, ignores quiet hours; channel = broadcast, per-channel cooldown, respects quiet hours. !hops uses getPathHashCount() (0 = direct). - quiet hours (bot_quiet_start/end, local, wraps midnight) silence push replies. - reply counter shown in the BotScreen header. UI / storage - BotScreen: 9 rows, scrolling list (no more cramming), field I/O via fieldBuf()/fieldCap(). - prefs bot_commands_enabled / bot_quiet_start / bot_quiet_end / bot_trigger_ch persisted; schema 0xC0DE000A → 0xC0DE000C with clamping + migration (bot_trigger_ch seeded from bot_trigger on upgrade). - docs: tools_screen bot section rewritten; FEATURES.md refreshed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 11:57:27 +02:00
else return false; // unknown — let the trigger bot try
expandMsg(tmpl, out, out_len,
sensors.node_lat, sensors.node_lon,
sensors.node_lat != 0.0 || sensors.node_lon != 0.0,
ts, _prefs.tz_offset_hours,
feat(bot): room-server target, tab-carousel UI, per-target independence Auto-Reply Bot gains a third target (room servers, alongside DM and channel), new {name}/{hops} reply placeholders, and a DM all/favourites allow-list. BotScreen is redesigned as a circular tab carousel (Direct / Channel / Room / Other, same interaction as Nearby Nodes' filter tabs) instead of one long scrolling list, which also exposed and fixed a leftover coupling where Channel/Room trigger-replies and their !command handling secretly depended on the DM tab's Enable/Commands toggles — each target's Enable and Commands are now fully independent. - NodePrefs: bot_room_enabled/prefix/trigger/reply, bot_dm_scope, bot_commands_ch/bot_commands_room (bot_commands_enabled repurposed as DM-only). SCHEMA_SENTINEL 0xC0DE001D -> 0xC0DE001F; sizeof unchanged at 2712 (new bytes absorbed existing padding, verified via a standalone host compile + offsetof check). - DataStore: persists all new fields; seeds bot_commands_ch/room from the old shared bot_commands_enabled on upgrade so existing channel/room command behaviour isn't silently lost. - MyMeshBot: tryBotReplyRoom/tryBotRoomCommand mirror the channel bot's shape but post via sendMessage (room relays to members itself); requires an existing login session with that room, same as a manual post would. botDmSenderAllowed() gates DM trigger-reply/commands on the favourites bit when bot_dm_scope=Fav. - MsgExpand: {name}/{hops} as optional trailing params (default nullptr/-1, no existing caller affected) — deliberately not exposed on the general compose keyboard, only on bot Reply fields. - QuickMsgScreen/UITask: room-target picker (mirrors the channel picker), routing through the existing room-login prompt when there's no saved password yet. - BotScreen: tab carousel (LEFT/RIGHT switches tabs, UP/DOWN moves rows, Enter is now the only way to change a value); Enable split out of Channel/Room's combo row; Quiet Hours gained a stepper sub-mode; Commands moved from a shared toggle into each tab. - docs/tools_screen.md updated for the new tab layout and behaviour. Not build-verified — no PlatformIO toolchain in this environment. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 20:16:59 +02:00
&sensors, (float)board.getBattMilliVolts() / 1000.0f,
sender_name, hops);
feat(bot): hardening + auto-responder, query commands, quiet hours Reply-bot overhaul on top of the trigger/reply auto-reply: Robustness - single botTriggerMatches() shared by DM/channel paths; named constants (BOT_REPLY_COOLDOWN_MS / BOT_SCRATCH) replace magic numbers. - per-contact DM throttle (8-entry ring) so a second sender isn't starved while one contact is on cooldown. - channel anti-loop: skip only when an incoming message equals our own reply (was: any reply containing the trigger word — which silently killed channel replies for the common "trigger word in reply" setup). Features - away / reply-to-all: a lone "*" trigger matches every message. - independent DM vs channel triggers (bot_trigger / bot_trigger_ch); "*" works on the channel too, bounded by cooldown + echo guard. - query commands: a DM or monitored-channel message is scanned for "!word" tokens — !ping/!batt/!loc/!time/!temp/!hops/!status/!help — merged into one combined reply. DM = per-contact throttle, ignores quiet hours; channel = broadcast, per-channel cooldown, respects quiet hours. !hops uses getPathHashCount() (0 = direct). - quiet hours (bot_quiet_start/end, local, wraps midnight) silence push replies. - reply counter shown in the BotScreen header. UI / storage - BotScreen: 9 rows, scrolling list (no more cramming), field I/O via fieldBuf()/fieldCap(). - prefs bot_commands_enabled / bot_quiet_start / bot_quiet_end / bot_trigger_ch persisted; schema 0xC0DE000A → 0xC0DE000C with clamping + migration (bot_trigger_ch seeded from bot_trigger on upgrade). - docs: tools_screen bot section rewritten; FEATURES.md refreshed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 11:57:27 +02:00
if (strchr(out, '{')) strcpy(out, "n/a"); // requested sensor not present
return true;
}
// Scans body for any number of space-separated "!word" tokens, building a single
// combined reply (segments joined by " | ") into out. Returns how many commands
feat(bot): room-server target, tab-carousel UI, per-target independence Auto-Reply Bot gains a third target (room servers, alongside DM and channel), new {name}/{hops} reply placeholders, and a DM all/favourites allow-list. BotScreen is redesigned as a circular tab carousel (Direct / Channel / Room / Other, same interaction as Nearby Nodes' filter tabs) instead of one long scrolling list, which also exposed and fixed a leftover coupling where Channel/Room trigger-replies and their !command handling secretly depended on the DM tab's Enable/Commands toggles — each target's Enable and Commands are now fully independent. - NodePrefs: bot_room_enabled/prefix/trigger/reply, bot_dm_scope, bot_commands_ch/bot_commands_room (bot_commands_enabled repurposed as DM-only). SCHEMA_SENTINEL 0xC0DE001D -> 0xC0DE001F; sizeof unchanged at 2712 (new bytes absorbed existing padding, verified via a standalone host compile + offsetof check). - DataStore: persists all new fields; seeds bot_commands_ch/room from the old shared bot_commands_enabled on upgrade so existing channel/room command behaviour isn't silently lost. - MyMeshBot: tryBotReplyRoom/tryBotRoomCommand mirror the channel bot's shape but post via sendMessage (room relays to members itself); requires an existing login session with that room, same as a manual post would. botDmSenderAllowed() gates DM trigger-reply/commands on the favourites bit when bot_dm_scope=Fav. - MsgExpand: {name}/{hops} as optional trailing params (default nullptr/-1, no existing caller affected) — deliberately not exposed on the general compose keyboard, only on bot Reply fields. - QuickMsgScreen/UITask: room-target picker (mirrors the channel picker), routing through the existing room-login prompt when there's no saved password yet. - BotScreen: tab carousel (LEFT/RIGHT switches tabs, UP/DOWN moves rows, Enter is now the only way to change a value); Enable split out of Channel/Room's combo row; Quiet Hours gained a stepper sub-mode; Commands moved from a shared toggle into each tab. - docs/tools_screen.md updated for the new tab layout and behaviour. Not build-verified — no PlatformIO toolchain in this environment. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 20:16:59 +02:00
// were recognised. Shared by the DM, channel and room command paths.
// actions_allowed gates the state-changing commands (!gps/!buzz/!advert) --
// the read-only queries are always reachable once a target's Commands is on.
int MyMesh::botScanCommands(const char* body, uint8_t hops, uint32_t ts, char* out, int out_len,
const char* sender_name, bool actions_allowed) {
resetPendingBotActions(); // pending actions/flags below are set by tokens in this scan;
// caller applies or clears them once it knows if the reply sent
// Reads one space-delimited token at p into buf (lowercased, truncated to
// cap-1 chars with any overflow consumed too), leaving p on the space/NUL
// that ended it. Shared by the command name and its two optional args.
auto readToken = [](const char*& p, char* buf, int cap) {
int i = 0;
while (*p && *p != ' ' && i < cap - 1) buf[i++] = (char)tolower((uint8_t)*p++);
buf[i] = '\0';
while (*p && *p != ' ') p++;
};
feat(bot): hardening + auto-responder, query commands, quiet hours Reply-bot overhaul on top of the trigger/reply auto-reply: Robustness - single botTriggerMatches() shared by DM/channel paths; named constants (BOT_REPLY_COOLDOWN_MS / BOT_SCRATCH) replace magic numbers. - per-contact DM throttle (8-entry ring) so a second sender isn't starved while one contact is on cooldown. - channel anti-loop: skip only when an incoming message equals our own reply (was: any reply containing the trigger word — which silently killed channel replies for the common "trigger word in reply" setup). Features - away / reply-to-all: a lone "*" trigger matches every message. - independent DM vs channel triggers (bot_trigger / bot_trigger_ch); "*" works on the channel too, bounded by cooldown + echo guard. - query commands: a DM or monitored-channel message is scanned for "!word" tokens — !ping/!batt/!loc/!time/!temp/!hops/!status/!help — merged into one combined reply. DM = per-contact throttle, ignores quiet hours; channel = broadcast, per-channel cooldown, respects quiet hours. !hops uses getPathHashCount() (0 = direct). - quiet hours (bot_quiet_start/end, local, wraps midnight) silence push replies. - reply counter shown in the BotScreen header. UI / storage - BotScreen: 9 rows, scrolling list (no more cramming), field I/O via fieldBuf()/fieldCap(). - prefs bot_commands_enabled / bot_quiet_start / bot_quiet_end / bot_trigger_ch persisted; schema 0xC0DE000A → 0xC0DE000C with clamping + migration (bot_trigger_ch seeded from bot_trigger on upgrade). - docs: tools_screen bot section rewritten; FEATURES.md refreshed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 11:57:27 +02:00
int oi = 0;
int matched = 0;
for (const char* p = body; *p; ) {
while (*p == ' ') p++; // skip whitespace
if (*p != '!') { // not a command token
while (*p && *p != ' ') p++;
continue;
}
p++; // step past '!'
char cmd[16];
readToken(p, cmd, sizeof(cmd));
feat(bot): hardening + auto-responder, query commands, quiet hours Reply-bot overhaul on top of the trigger/reply auto-reply: Robustness - single botTriggerMatches() shared by DM/channel paths; named constants (BOT_REPLY_COOLDOWN_MS / BOT_SCRATCH) replace magic numbers. - per-contact DM throttle (8-entry ring) so a second sender isn't starved while one contact is on cooldown. - channel anti-loop: skip only when an incoming message equals our own reply (was: any reply containing the trigger word — which silently killed channel replies for the common "trigger word in reply" setup). Features - away / reply-to-all: a lone "*" trigger matches every message. - independent DM vs channel triggers (bot_trigger / bot_trigger_ch); "*" works on the channel too, bounded by cooldown + echo guard. - query commands: a DM or monitored-channel message is scanned for "!word" tokens — !ping/!batt/!loc/!time/!temp/!hops/!status/!help — merged into one combined reply. DM = per-contact throttle, ignores quiet hours; channel = broadcast, per-channel cooldown, respects quiet hours. !hops uses getPathHashCount() (0 = direct). - quiet hours (bot_quiet_start/end, local, wraps midnight) silence push replies. - reply counter shown in the BotScreen header. UI / storage - BotScreen: 9 rows, scrolling list (no more cramming), field I/O via fieldBuf()/fieldCap(). - prefs bot_commands_enabled / bot_quiet_start / bot_quiet_end / bot_trigger_ch persisted; schema 0xC0DE000A → 0xC0DE000C with clamping + migration (bot_trigger_ch seeded from bot_trigger on upgrade). - docs: tools_screen bot section rewritten; FEATURES.md refreshed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 11:57:27 +02:00
if (!cmd[0]) continue;
// Up to two optional arguments: the next one or two space-delimited
// tokens, unless a token is itself the start of another command ("!").
// E.g. "!gps fix 120 !batt" -- "fix"/"120" are !gps's arguments, "!batt"
// starts the next command. Almost every command only reads arg1 (arg2 is
// always "" for them) -- a stray second word after e.g. "!gpio1 on foo"
// just gets consumed as arg2 and ignored, same net effect as before this
// supported two tokens. Only "!gps fix" currently reads arg2 (an optional
// timeout override, see botCommandReply).
while (*p == ' ') p++;
char arg1[16] = "", arg2[16] = "";
if (*p && *p != '!') {
readToken(p, arg1, sizeof(arg1));
while (*p == ' ') p++;
if (*p && *p != '!') readToken(p, arg2, sizeof(arg2));
}
char seg[80];
if (!botCommandReply(cmd, arg1, arg2, actions_allowed, hops, ts, seg, sizeof(seg), sender_name)) continue; // unknown
feat(bot): hardening + auto-responder, query commands, quiet hours Reply-bot overhaul on top of the trigger/reply auto-reply: Robustness - single botTriggerMatches() shared by DM/channel paths; named constants (BOT_REPLY_COOLDOWN_MS / BOT_SCRATCH) replace magic numbers. - per-contact DM throttle (8-entry ring) so a second sender isn't starved while one contact is on cooldown. - channel anti-loop: skip only when an incoming message equals our own reply (was: any reply containing the trigger word — which silently killed channel replies for the common "trigger word in reply" setup). Features - away / reply-to-all: a lone "*" trigger matches every message. - independent DM vs channel triggers (bot_trigger / bot_trigger_ch); "*" works on the channel too, bounded by cooldown + echo guard. - query commands: a DM or monitored-channel message is scanned for "!word" tokens — !ping/!batt/!loc/!time/!temp/!hops/!status/!help — merged into one combined reply. DM = per-contact throttle, ignores quiet hours; channel = broadcast, per-channel cooldown, respects quiet hours. !hops uses getPathHashCount() (0 = direct). - quiet hours (bot_quiet_start/end, local, wraps midnight) silence push replies. - reply counter shown in the BotScreen header. UI / storage - BotScreen: 9 rows, scrolling list (no more cramming), field I/O via fieldBuf()/fieldCap(). - prefs bot_commands_enabled / bot_quiet_start / bot_quiet_end / bot_trigger_ch persisted; schema 0xC0DE000A → 0xC0DE000C with clamping + migration (bot_trigger_ch seeded from bot_trigger on upgrade). - docs: tools_screen bot section rewritten; FEATURES.md refreshed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 11:57:27 +02:00
if (matched > 0 && oi + 3 < out_len) { // " | " separator
out[oi++] = ' '; out[oi++] = '|'; out[oi++] = ' ';
}
int sl = strlen(seg);
if (oi + sl >= out_len) sl = out_len - 1 - oi;
if (sl > 0) { memcpy(out + oi, seg, sl); oi += sl; }
matched++;
}
out[oi] = '\0';
return matched;
}
// DM query commands. The reply is a pull, not a push, so it ignores quiet hours;
// the per-contact throttle still applies. Returns true when at least one command
// was found (replied or throttled), so the caller skips the trigger-reply path.
bool MyMesh::tryBotCommand(const ContactInfo& from, const char* text, uint8_t hops) {
if (!_prefs.bot_commands_enabled) return false;
if (from.type != ADV_TYPE_CHAT) return false;
feat(bot): room-server target, tab-carousel UI, per-target independence Auto-Reply Bot gains a third target (room servers, alongside DM and channel), new {name}/{hops} reply placeholders, and a DM all/favourites allow-list. BotScreen is redesigned as a circular tab carousel (Direct / Channel / Room / Other, same interaction as Nearby Nodes' filter tabs) instead of one long scrolling list, which also exposed and fixed a leftover coupling where Channel/Room trigger-replies and their !command handling secretly depended on the DM tab's Enable/Commands toggles — each target's Enable and Commands are now fully independent. - NodePrefs: bot_room_enabled/prefix/trigger/reply, bot_dm_scope, bot_commands_ch/bot_commands_room (bot_commands_enabled repurposed as DM-only). SCHEMA_SENTINEL 0xC0DE001D -> 0xC0DE001F; sizeof unchanged at 2712 (new bytes absorbed existing padding, verified via a standalone host compile + offsetof check). - DataStore: persists all new fields; seeds bot_commands_ch/room from the old shared bot_commands_enabled on upgrade so existing channel/room command behaviour isn't silently lost. - MyMeshBot: tryBotReplyRoom/tryBotRoomCommand mirror the channel bot's shape but post via sendMessage (room relays to members itself); requires an existing login session with that room, same as a manual post would. botDmSenderAllowed() gates DM trigger-reply/commands on the favourites bit when bot_dm_scope=Fav. - MsgExpand: {name}/{hops} as optional trailing params (default nullptr/-1, no existing caller affected) — deliberately not exposed on the general compose keyboard, only on bot Reply fields. - QuickMsgScreen/UITask: room-target picker (mirrors the channel picker), routing through the existing room-login prompt when there's no saved password yet. - BotScreen: tab carousel (LEFT/RIGHT switches tabs, UP/DOWN moves rows, Enter is now the only way to change a value); Enable split out of Channel/Room's combo row; Quiet Hours gained a stepper sub-mode; Commands moved from a shared toggle into each tab. - docs/tools_screen.md updated for the new tab layout and behaviour. Not build-verified — no PlatformIO toolchain in this environment. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 20:16:59 +02:00
if (!botDmSenderAllowed(from)) return false;
feat(bot): hardening + auto-responder, query commands, quiet hours Reply-bot overhaul on top of the trigger/reply auto-reply: Robustness - single botTriggerMatches() shared by DM/channel paths; named constants (BOT_REPLY_COOLDOWN_MS / BOT_SCRATCH) replace magic numbers. - per-contact DM throttle (8-entry ring) so a second sender isn't starved while one contact is on cooldown. - channel anti-loop: skip only when an incoming message equals our own reply (was: any reply containing the trigger word — which silently killed channel replies for the common "trigger word in reply" setup). Features - away / reply-to-all: a lone "*" trigger matches every message. - independent DM vs channel triggers (bot_trigger / bot_trigger_ch); "*" works on the channel too, bounded by cooldown + echo guard. - query commands: a DM or monitored-channel message is scanned for "!word" tokens — !ping/!batt/!loc/!time/!temp/!hops/!status/!help — merged into one combined reply. DM = per-contact throttle, ignores quiet hours; channel = broadcast, per-channel cooldown, respects quiet hours. !hops uses getPathHashCount() (0 = direct). - quiet hours (bot_quiet_start/end, local, wraps midnight) silence push replies. - reply counter shown in the BotScreen header. UI / storage - BotScreen: 9 rows, scrolling list (no more cramming), field I/O via fieldBuf()/fieldCap(). - prefs bot_commands_enabled / bot_quiet_start / bot_quiet_end / bot_trigger_ch persisted; schema 0xC0DE000A → 0xC0DE000C with clamping + migration (bot_trigger_ch seeded from bot_trigger on upgrade). - docs: tools_screen bot section rewritten; FEATURES.md refreshed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 11:57:27 +02:00
uint32_t ts = getRTCClock()->getCurrentTime();
char out[BOT_SCRATCH];
if (botScanCommands(text, hops, ts, out, sizeof(out), from.name, _prefs.bot_actions_dm != 0) == 0) return false; // no commands
if (!botDmAllowed(from.id.pub_key)) { resetPendingBotActions(); return true; } // throttled
feat(bot): hardening + auto-responder, query commands, quiet hours Reply-bot overhaul on top of the trigger/reply auto-reply: Robustness - single botTriggerMatches() shared by DM/channel paths; named constants (BOT_REPLY_COOLDOWN_MS / BOT_SCRATCH) replace magic numbers. - per-contact DM throttle (8-entry ring) so a second sender isn't starved while one contact is on cooldown. - channel anti-loop: skip only when an incoming message equals our own reply (was: any reply containing the trigger word — which silently killed channel replies for the common "trigger word in reply" setup). Features - away / reply-to-all: a lone "*" trigger matches every message. - independent DM vs channel triggers (bot_trigger / bot_trigger_ch); "*" works on the channel too, bounded by cooldown + echo guard. - query commands: a DM or monitored-channel message is scanned for "!word" tokens — !ping/!batt/!loc/!time/!temp/!hops/!status/!help — merged into one combined reply. DM = per-contact throttle, ignores quiet hours; channel = broadcast, per-channel cooldown, respects quiet hours. !hops uses getPathHashCount() (0 = direct). - quiet hours (bot_quiet_start/end, local, wraps midnight) silence push replies. - reply counter shown in the BotScreen header. UI / storage - BotScreen: 9 rows, scrolling list (no more cramming), field I/O via fieldBuf()/fieldCap(). - prefs bot_commands_enabled / bot_quiet_start / bot_quiet_end / bot_trigger_ch persisted; schema 0xC0DE000A → 0xC0DE000C with clamping + migration (bot_trigger_ch seeded from bot_trigger on upgrade). - docs: tools_screen bot section rewritten; FEATURES.md refreshed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 11:57:27 +02:00
uint32_t expected_ack, est_timeout;
if (sendMessage(from, ts, 0, out, expected_ack, est_timeout) != MSG_SEND_FAILED) {
botDmRecord(from.id.pub_key);
_bot_reply_count++;
#ifdef DISPLAY_CLASS
if (_ui) _ui->addDMMsg(from.id.pub_key, true, out);
#endif
if (_locfix_requested) startLocFix(LOCFIX_DEST_CONTACT, from.id.pub_key, 0);
applyPendingBotActions();
feat(bot): hardening + auto-responder, query commands, quiet hours Reply-bot overhaul on top of the trigger/reply auto-reply: Robustness - single botTriggerMatches() shared by DM/channel paths; named constants (BOT_REPLY_COOLDOWN_MS / BOT_SCRATCH) replace magic numbers. - per-contact DM throttle (8-entry ring) so a second sender isn't starved while one contact is on cooldown. - channel anti-loop: skip only when an incoming message equals our own reply (was: any reply containing the trigger word — which silently killed channel replies for the common "trigger word in reply" setup). Features - away / reply-to-all: a lone "*" trigger matches every message. - independent DM vs channel triggers (bot_trigger / bot_trigger_ch); "*" works on the channel too, bounded by cooldown + echo guard. - query commands: a DM or monitored-channel message is scanned for "!word" tokens — !ping/!batt/!loc/!time/!temp/!hops/!status/!help — merged into one combined reply. DM = per-contact throttle, ignores quiet hours; channel = broadcast, per-channel cooldown, respects quiet hours. !hops uses getPathHashCount() (0 = direct). - quiet hours (bot_quiet_start/end, local, wraps midnight) silence push replies. - reply counter shown in the BotScreen header. UI / storage - BotScreen: 9 rows, scrolling list (no more cramming), field I/O via fieldBuf()/fieldCap(). - prefs bot_commands_enabled / bot_quiet_start / bot_quiet_end / bot_trigger_ch persisted; schema 0xC0DE000A → 0xC0DE000C with clamping + migration (bot_trigger_ch seeded from bot_trigger on upgrade). - docs: tools_screen bot section rewritten; FEATURES.md refreshed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 11:57:27 +02:00
}
resetPendingBotActions();
feat(bot): hardening + auto-responder, query commands, quiet hours Reply-bot overhaul on top of the trigger/reply auto-reply: Robustness - single botTriggerMatches() shared by DM/channel paths; named constants (BOT_REPLY_COOLDOWN_MS / BOT_SCRATCH) replace magic numbers. - per-contact DM throttle (8-entry ring) so a second sender isn't starved while one contact is on cooldown. - channel anti-loop: skip only when an incoming message equals our own reply (was: any reply containing the trigger word — which silently killed channel replies for the common "trigger word in reply" setup). Features - away / reply-to-all: a lone "*" trigger matches every message. - independent DM vs channel triggers (bot_trigger / bot_trigger_ch); "*" works on the channel too, bounded by cooldown + echo guard. - query commands: a DM or monitored-channel message is scanned for "!word" tokens — !ping/!batt/!loc/!time/!temp/!hops/!status/!help — merged into one combined reply. DM = per-contact throttle, ignores quiet hours; channel = broadcast, per-channel cooldown, respects quiet hours. !hops uses getPathHashCount() (0 = direct). - quiet hours (bot_quiet_start/end, local, wraps midnight) silence push replies. - reply counter shown in the BotScreen header. UI / storage - BotScreen: 9 rows, scrolling list (no more cramming), field I/O via fieldBuf()/fieldCap(). - prefs bot_commands_enabled / bot_quiet_start / bot_quiet_end / bot_trigger_ch persisted; schema 0xC0DE000A → 0xC0DE000C with clamping + migration (bot_trigger_ch seeded from bot_trigger on upgrade). - docs: tools_screen bot section rewritten; FEATURES.md refreshed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 11:57:27 +02:00
return true;
}
// Channel query commands — only on the bot's monitored channel. The reply is
// broadcast to everyone on the channel, so unlike DM commands it respects quiet
// hours and uses the global per-channel cooldown (shared with the trigger reply).
bool MyMesh::tryBotChannelCommand(uint8_t channel_idx, const char* text, uint8_t hops) {
feat(bot): room-server target, tab-carousel UI, per-target independence Auto-Reply Bot gains a third target (room servers, alongside DM and channel), new {name}/{hops} reply placeholders, and a DM all/favourites allow-list. BotScreen is redesigned as a circular tab carousel (Direct / Channel / Room / Other, same interaction as Nearby Nodes' filter tabs) instead of one long scrolling list, which also exposed and fixed a leftover coupling where Channel/Room trigger-replies and their !command handling secretly depended on the DM tab's Enable/Commands toggles — each target's Enable and Commands are now fully independent. - NodePrefs: bot_room_enabled/prefix/trigger/reply, bot_dm_scope, bot_commands_ch/bot_commands_room (bot_commands_enabled repurposed as DM-only). SCHEMA_SENTINEL 0xC0DE001D -> 0xC0DE001F; sizeof unchanged at 2712 (new bytes absorbed existing padding, verified via a standalone host compile + offsetof check). - DataStore: persists all new fields; seeds bot_commands_ch/room from the old shared bot_commands_enabled on upgrade so existing channel/room command behaviour isn't silently lost. - MyMeshBot: tryBotReplyRoom/tryBotRoomCommand mirror the channel bot's shape but post via sendMessage (room relays to members itself); requires an existing login session with that room, same as a manual post would. botDmSenderAllowed() gates DM trigger-reply/commands on the favourites bit when bot_dm_scope=Fav. - MsgExpand: {name}/{hops} as optional trailing params (default nullptr/-1, no existing caller affected) — deliberately not exposed on the general compose keyboard, only on bot Reply fields. - QuickMsgScreen/UITask: room-target picker (mirrors the channel picker), routing through the existing room-login prompt when there's no saved password yet. - BotScreen: tab carousel (LEFT/RIGHT switches tabs, UP/DOWN moves rows, Enter is now the only way to change a value); Enable split out of Channel/Room's combo row; Quiet Hours gained a stepper sub-mode; Commands moved from a shared toggle into each tab. - docs/tools_screen.md updated for the new tab layout and behaviour. Not build-verified — no PlatformIO toolchain in this environment. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 20:16:59 +02:00
if (!(_prefs.bot_commands_ch && _prefs.bot_channel_enabled &&
feat(bot): hardening + auto-responder, query commands, quiet hours Reply-bot overhaul on top of the trigger/reply auto-reply: Robustness - single botTriggerMatches() shared by DM/channel paths; named constants (BOT_REPLY_COOLDOWN_MS / BOT_SCRATCH) replace magic numbers. - per-contact DM throttle (8-entry ring) so a second sender isn't starved while one contact is on cooldown. - channel anti-loop: skip only when an incoming message equals our own reply (was: any reply containing the trigger word — which silently killed channel replies for the common "trigger word in reply" setup). Features - away / reply-to-all: a lone "*" trigger matches every message. - independent DM vs channel triggers (bot_trigger / bot_trigger_ch); "*" works on the channel too, bounded by cooldown + echo guard. - query commands: a DM or monitored-channel message is scanned for "!word" tokens — !ping/!batt/!loc/!time/!temp/!hops/!status/!help — merged into one combined reply. DM = per-contact throttle, ignores quiet hours; channel = broadcast, per-channel cooldown, respects quiet hours. !hops uses getPathHashCount() (0 = direct). - quiet hours (bot_quiet_start/end, local, wraps midnight) silence push replies. - reply counter shown in the BotScreen header. UI / storage - BotScreen: 9 rows, scrolling list (no more cramming), field I/O via fieldBuf()/fieldCap(). - prefs bot_commands_enabled / bot_quiet_start / bot_quiet_end / bot_trigger_ch persisted; schema 0xC0DE000A → 0xC0DE000C with clamping + migration (bot_trigger_ch seeded from bot_trigger on upgrade). - docs: tools_screen bot section rewritten; FEATURES.md refreshed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 11:57:27 +02:00
channel_idx == _prefs.bot_channel_idx))
return false;
// Skip "sender_name: " prefix so commands are read from the message body only.
const char* msg;
char sender_name[32];
botChannelSenderSplit(text, sender_name, sizeof(sender_name), &msg);
feat(bot): hardening + auto-responder, query commands, quiet hours Reply-bot overhaul on top of the trigger/reply auto-reply: Robustness - single botTriggerMatches() shared by DM/channel paths; named constants (BOT_REPLY_COOLDOWN_MS / BOT_SCRATCH) replace magic numbers. - per-contact DM throttle (8-entry ring) so a second sender isn't starved while one contact is on cooldown. - channel anti-loop: skip only when an incoming message equals our own reply (was: any reply containing the trigger word — which silently killed channel replies for the common "trigger word in reply" setup). Features - away / reply-to-all: a lone "*" trigger matches every message. - independent DM vs channel triggers (bot_trigger / bot_trigger_ch); "*" works on the channel too, bounded by cooldown + echo guard. - query commands: a DM or monitored-channel message is scanned for "!word" tokens — !ping/!batt/!loc/!time/!temp/!hops/!status/!help — merged into one combined reply. DM = per-contact throttle, ignores quiet hours; channel = broadcast, per-channel cooldown, respects quiet hours. !hops uses getPathHashCount() (0 = direct). - quiet hours (bot_quiet_start/end, local, wraps midnight) silence push replies. - reply counter shown in the BotScreen header. UI / storage - BotScreen: 9 rows, scrolling list (no more cramming), field I/O via fieldBuf()/fieldCap(). - prefs bot_commands_enabled / bot_quiet_start / bot_quiet_end / bot_trigger_ch persisted; schema 0xC0DE000A → 0xC0DE000C with clamping + migration (bot_trigger_ch seeded from bot_trigger on upgrade). - docs: tools_screen bot section rewritten; FEATURES.md refreshed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 11:57:27 +02:00
uint32_t ts = getRTCClock()->getCurrentTime();
char out[BOT_SCRATCH];
if (botScanCommands(msg, hops, ts, out, sizeof(out), sender_name, _prefs.bot_actions_ch != 0) == 0) return false; // no commands
if (botInQuietHours()) { resetPendingBotActions(); return true; } // quiet hours
if (millis() - _bot_last_ch_reply_ms <= BOT_REPLY_COOLDOWN_MS) { resetPendingBotActions(); return true; } // throttled
feat(bot): hardening + auto-responder, query commands, quiet hours Reply-bot overhaul on top of the trigger/reply auto-reply: Robustness - single botTriggerMatches() shared by DM/channel paths; named constants (BOT_REPLY_COOLDOWN_MS / BOT_SCRATCH) replace magic numbers. - per-contact DM throttle (8-entry ring) so a second sender isn't starved while one contact is on cooldown. - channel anti-loop: skip only when an incoming message equals our own reply (was: any reply containing the trigger word — which silently killed channel replies for the common "trigger word in reply" setup). Features - away / reply-to-all: a lone "*" trigger matches every message. - independent DM vs channel triggers (bot_trigger / bot_trigger_ch); "*" works on the channel too, bounded by cooldown + echo guard. - query commands: a DM or monitored-channel message is scanned for "!word" tokens — !ping/!batt/!loc/!time/!temp/!hops/!status/!help — merged into one combined reply. DM = per-contact throttle, ignores quiet hours; channel = broadcast, per-channel cooldown, respects quiet hours. !hops uses getPathHashCount() (0 = direct). - quiet hours (bot_quiet_start/end, local, wraps midnight) silence push replies. - reply counter shown in the BotScreen header. UI / storage - BotScreen: 9 rows, scrolling list (no more cramming), field I/O via fieldBuf()/fieldCap(). - prefs bot_commands_enabled / bot_quiet_start / bot_quiet_end / bot_trigger_ch persisted; schema 0xC0DE000A → 0xC0DE000C with clamping + migration (bot_trigger_ch seeded from bot_trigger on upgrade). - docs: tools_screen bot section rewritten; FEATURES.md refreshed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 11:57:27 +02:00
ChannelDetails ch;
if (!getChannel(channel_idx, ch)) { resetPendingBotActions(); return true; }
feat(bot): hardening + auto-responder, query commands, quiet hours Reply-bot overhaul on top of the trigger/reply auto-reply: Robustness - single botTriggerMatches() shared by DM/channel paths; named constants (BOT_REPLY_COOLDOWN_MS / BOT_SCRATCH) replace magic numbers. - per-contact DM throttle (8-entry ring) so a second sender isn't starved while one contact is on cooldown. - channel anti-loop: skip only when an incoming message equals our own reply (was: any reply containing the trigger word — which silently killed channel replies for the common "trigger word in reply" setup). Features - away / reply-to-all: a lone "*" trigger matches every message. - independent DM vs channel triggers (bot_trigger / bot_trigger_ch); "*" works on the channel too, bounded by cooldown + echo guard. - query commands: a DM or monitored-channel message is scanned for "!word" tokens — !ping/!batt/!loc/!time/!temp/!hops/!status/!help — merged into one combined reply. DM = per-contact throttle, ignores quiet hours; channel = broadcast, per-channel cooldown, respects quiet hours. !hops uses getPathHashCount() (0 = direct). - quiet hours (bot_quiet_start/end, local, wraps midnight) silence push replies. - reply counter shown in the BotScreen header. UI / storage - BotScreen: 9 rows, scrolling list (no more cramming), field I/O via fieldBuf()/fieldCap(). - prefs bot_commands_enabled / bot_quiet_start / bot_quiet_end / bot_trigger_ch persisted; schema 0xC0DE000A → 0xC0DE000C with clamping + migration (bot_trigger_ch seeded from bot_trigger on upgrade). - docs: tools_screen bot section rewritten; FEATURES.md refreshed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 11:57:27 +02:00
int rlen = strlen(out);
if (sendGroupMessage(ts, ch.channel, _prefs.node_name, out, rlen)) {
_bot_last_ch_reply_ms = millis();
_bot_reply_count++;
#ifdef DISPLAY_CLASS
if (_ui) {
char with_sender[240];
snprintf(with_sender, sizeof(with_sender), "%s: %s", _prefs.node_name, out);
_ui->addChannelMsg(channel_idx, with_sender);
}
#endif
if (_locfix_requested) startLocFix(LOCFIX_DEST_CHANNEL, nullptr, channel_idx);
applyPendingBotActions();
feat(bot): hardening + auto-responder, query commands, quiet hours Reply-bot overhaul on top of the trigger/reply auto-reply: Robustness - single botTriggerMatches() shared by DM/channel paths; named constants (BOT_REPLY_COOLDOWN_MS / BOT_SCRATCH) replace magic numbers. - per-contact DM throttle (8-entry ring) so a second sender isn't starved while one contact is on cooldown. - channel anti-loop: skip only when an incoming message equals our own reply (was: any reply containing the trigger word — which silently killed channel replies for the common "trigger word in reply" setup). Features - away / reply-to-all: a lone "*" trigger matches every message. - independent DM vs channel triggers (bot_trigger / bot_trigger_ch); "*" works on the channel too, bounded by cooldown + echo guard. - query commands: a DM or monitored-channel message is scanned for "!word" tokens — !ping/!batt/!loc/!time/!temp/!hops/!status/!help — merged into one combined reply. DM = per-contact throttle, ignores quiet hours; channel = broadcast, per-channel cooldown, respects quiet hours. !hops uses getPathHashCount() (0 = direct). - quiet hours (bot_quiet_start/end, local, wraps midnight) silence push replies. - reply counter shown in the BotScreen header. UI / storage - BotScreen: 9 rows, scrolling list (no more cramming), field I/O via fieldBuf()/fieldCap(). - prefs bot_commands_enabled / bot_quiet_start / bot_quiet_end / bot_trigger_ch persisted; schema 0xC0DE000A → 0xC0DE000C with clamping + migration (bot_trigger_ch seeded from bot_trigger on upgrade). - docs: tools_screen bot section rewritten; FEATURES.md refreshed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 11:57:27 +02:00
}
resetPendingBotActions();
feat(bot): hardening + auto-responder, query commands, quiet hours Reply-bot overhaul on top of the trigger/reply auto-reply: Robustness - single botTriggerMatches() shared by DM/channel paths; named constants (BOT_REPLY_COOLDOWN_MS / BOT_SCRATCH) replace magic numbers. - per-contact DM throttle (8-entry ring) so a second sender isn't starved while one contact is on cooldown. - channel anti-loop: skip only when an incoming message equals our own reply (was: any reply containing the trigger word — which silently killed channel replies for the common "trigger word in reply" setup). Features - away / reply-to-all: a lone "*" trigger matches every message. - independent DM vs channel triggers (bot_trigger / bot_trigger_ch); "*" works on the channel too, bounded by cooldown + echo guard. - query commands: a DM or monitored-channel message is scanned for "!word" tokens — !ping/!batt/!loc/!time/!temp/!hops/!status/!help — merged into one combined reply. DM = per-contact throttle, ignores quiet hours; channel = broadcast, per-channel cooldown, respects quiet hours. !hops uses getPathHashCount() (0 = direct). - quiet hours (bot_quiet_start/end, local, wraps midnight) silence push replies. - reply counter shown in the BotScreen header. UI / storage - BotScreen: 9 rows, scrolling list (no more cramming), field I/O via fieldBuf()/fieldCap(). - prefs bot_commands_enabled / bot_quiet_start / bot_quiet_end / bot_trigger_ch persisted; schema 0xC0DE000A → 0xC0DE000C with clamping + migration (bot_trigger_ch seeded from bot_trigger on upgrade). - docs: tools_screen bot section rewritten; FEATURES.md refreshed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 11:57:27 +02:00
return true;
}
feat(bot): room-server target, tab-carousel UI, per-target independence Auto-Reply Bot gains a third target (room servers, alongside DM and channel), new {name}/{hops} reply placeholders, and a DM all/favourites allow-list. BotScreen is redesigned as a circular tab carousel (Direct / Channel / Room / Other, same interaction as Nearby Nodes' filter tabs) instead of one long scrolling list, which also exposed and fixed a leftover coupling where Channel/Room trigger-replies and their !command handling secretly depended on the DM tab's Enable/Commands toggles — each target's Enable and Commands are now fully independent. - NodePrefs: bot_room_enabled/prefix/trigger/reply, bot_dm_scope, bot_commands_ch/bot_commands_room (bot_commands_enabled repurposed as DM-only). SCHEMA_SENTINEL 0xC0DE001D -> 0xC0DE001F; sizeof unchanged at 2712 (new bytes absorbed existing padding, verified via a standalone host compile + offsetof check). - DataStore: persists all new fields; seeds bot_commands_ch/room from the old shared bot_commands_enabled on upgrade so existing channel/room command behaviour isn't silently lost. - MyMeshBot: tryBotReplyRoom/tryBotRoomCommand mirror the channel bot's shape but post via sendMessage (room relays to members itself); requires an existing login session with that room, same as a manual post would. botDmSenderAllowed() gates DM trigger-reply/commands on the favourites bit when bot_dm_scope=Fav. - MsgExpand: {name}/{hops} as optional trailing params (default nullptr/-1, no existing caller affected) — deliberately not exposed on the general compose keyboard, only on bot Reply fields. - QuickMsgScreen/UITask: room-target picker (mirrors the channel picker), routing through the existing room-login prompt when there's no saved password yet. - BotScreen: tab carousel (LEFT/RIGHT switches tabs, UP/DOWN moves rows, Enter is now the only way to change a value); Enable split out of Channel/Room's combo row; Quiet Hours gained a stepper sub-mode; Commands moved from a shared toggle into each tab. - docs/tools_screen.md updated for the new tab layout and behaviour. Not build-verified — no PlatformIO toolchain in this environment. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 20:16:59 +02:00
// Room query commands — mirrors tryBotChannelCommand: the reply posts to the
// room (visible to every member), so it respects quiet hours and the shared
// per-room cooldown, unlike the DM command path's private-pull exemption.
bool MyMesh::tryBotRoomCommand(const ContactInfo& from, const uint8_t* sender_prefix, const char* text, uint8_t hops) {
if (from.type != ADV_TYPE_ROOM) return false;
if (!(_prefs.bot_commands_room && _prefs.bot_room_enabled &&
memcmp(from.id.pub_key, _prefs.bot_room_prefix, NodePrefs::FAVOURITE_PREFIX_LEN) == 0))
return false;
char sender_name[32];
botRoomSenderName(sender_prefix, sender_name, sizeof(sender_name));
uint32_t ts = getRTCClock()->getCurrentTime();
char out[BOT_SCRATCH];
if (botScanCommands(text, hops, ts, out, sizeof(out), sender_name, _prefs.bot_actions_room != 0) == 0) return false; // no commands
if (botInQuietHours()) { resetPendingBotActions(); return true; } // quiet hours
if (millis() - _bot_last_room_reply_ms <= BOT_REPLY_COOLDOWN_MS) { resetPendingBotActions(); return true; } // throttled
feat(bot): room-server target, tab-carousel UI, per-target independence Auto-Reply Bot gains a third target (room servers, alongside DM and channel), new {name}/{hops} reply placeholders, and a DM all/favourites allow-list. BotScreen is redesigned as a circular tab carousel (Direct / Channel / Room / Other, same interaction as Nearby Nodes' filter tabs) instead of one long scrolling list, which also exposed and fixed a leftover coupling where Channel/Room trigger-replies and their !command handling secretly depended on the DM tab's Enable/Commands toggles — each target's Enable and Commands are now fully independent. - NodePrefs: bot_room_enabled/prefix/trigger/reply, bot_dm_scope, bot_commands_ch/bot_commands_room (bot_commands_enabled repurposed as DM-only). SCHEMA_SENTINEL 0xC0DE001D -> 0xC0DE001F; sizeof unchanged at 2712 (new bytes absorbed existing padding, verified via a standalone host compile + offsetof check). - DataStore: persists all new fields; seeds bot_commands_ch/room from the old shared bot_commands_enabled on upgrade so existing channel/room command behaviour isn't silently lost. - MyMeshBot: tryBotReplyRoom/tryBotRoomCommand mirror the channel bot's shape but post via sendMessage (room relays to members itself); requires an existing login session with that room, same as a manual post would. botDmSenderAllowed() gates DM trigger-reply/commands on the favourites bit when bot_dm_scope=Fav. - MsgExpand: {name}/{hops} as optional trailing params (default nullptr/-1, no existing caller affected) — deliberately not exposed on the general compose keyboard, only on bot Reply fields. - QuickMsgScreen/UITask: room-target picker (mirrors the channel picker), routing through the existing room-login prompt when there's no saved password yet. - BotScreen: tab carousel (LEFT/RIGHT switches tabs, UP/DOWN moves rows, Enter is now the only way to change a value); Enable split out of Channel/Room's combo row; Quiet Hours gained a stepper sub-mode; Commands moved from a shared toggle into each tab. - docs/tools_screen.md updated for the new tab layout and behaviour. Not build-verified — no PlatformIO toolchain in this environment. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 20:16:59 +02:00
uint32_t expected_ack, est_timeout;
if (sendMessage(from, ts, 0, out, expected_ack, est_timeout) != MSG_SEND_FAILED) {
_bot_last_room_reply_ms = millis();
_bot_reply_count++;
#ifdef DISPLAY_CLASS
if (_ui) _ui->addDMMsg(from.id.pub_key, true, out);
#endif
if (_locfix_requested) startLocFix(LOCFIX_DEST_CONTACT, from.id.pub_key, 0);
applyPendingBotActions();
feat(bot): room-server target, tab-carousel UI, per-target independence Auto-Reply Bot gains a third target (room servers, alongside DM and channel), new {name}/{hops} reply placeholders, and a DM all/favourites allow-list. BotScreen is redesigned as a circular tab carousel (Direct / Channel / Room / Other, same interaction as Nearby Nodes' filter tabs) instead of one long scrolling list, which also exposed and fixed a leftover coupling where Channel/Room trigger-replies and their !command handling secretly depended on the DM tab's Enable/Commands toggles — each target's Enable and Commands are now fully independent. - NodePrefs: bot_room_enabled/prefix/trigger/reply, bot_dm_scope, bot_commands_ch/bot_commands_room (bot_commands_enabled repurposed as DM-only). SCHEMA_SENTINEL 0xC0DE001D -> 0xC0DE001F; sizeof unchanged at 2712 (new bytes absorbed existing padding, verified via a standalone host compile + offsetof check). - DataStore: persists all new fields; seeds bot_commands_ch/room from the old shared bot_commands_enabled on upgrade so existing channel/room command behaviour isn't silently lost. - MyMeshBot: tryBotReplyRoom/tryBotRoomCommand mirror the channel bot's shape but post via sendMessage (room relays to members itself); requires an existing login session with that room, same as a manual post would. botDmSenderAllowed() gates DM trigger-reply/commands on the favourites bit when bot_dm_scope=Fav. - MsgExpand: {name}/{hops} as optional trailing params (default nullptr/-1, no existing caller affected) — deliberately not exposed on the general compose keyboard, only on bot Reply fields. - QuickMsgScreen/UITask: room-target picker (mirrors the channel picker), routing through the existing room-login prompt when there's no saved password yet. - BotScreen: tab carousel (LEFT/RIGHT switches tabs, UP/DOWN moves rows, Enter is now the only way to change a value); Enable split out of Channel/Room's combo row; Quiet Hours gained a stepper sub-mode; Commands moved from a shared toggle into each tab. - docs/tools_screen.md updated for the new tab layout and behaviour. Not build-verified — no PlatformIO toolchain in this environment. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 20:16:59 +02:00
}
resetPendingBotActions();
feat(bot): room-server target, tab-carousel UI, per-target independence Auto-Reply Bot gains a third target (room servers, alongside DM and channel), new {name}/{hops} reply placeholders, and a DM all/favourites allow-list. BotScreen is redesigned as a circular tab carousel (Direct / Channel / Room / Other, same interaction as Nearby Nodes' filter tabs) instead of one long scrolling list, which also exposed and fixed a leftover coupling where Channel/Room trigger-replies and their !command handling secretly depended on the DM tab's Enable/Commands toggles — each target's Enable and Commands are now fully independent. - NodePrefs: bot_room_enabled/prefix/trigger/reply, bot_dm_scope, bot_commands_ch/bot_commands_room (bot_commands_enabled repurposed as DM-only). SCHEMA_SENTINEL 0xC0DE001D -> 0xC0DE001F; sizeof unchanged at 2712 (new bytes absorbed existing padding, verified via a standalone host compile + offsetof check). - DataStore: persists all new fields; seeds bot_commands_ch/room from the old shared bot_commands_enabled on upgrade so existing channel/room command behaviour isn't silently lost. - MyMeshBot: tryBotReplyRoom/tryBotRoomCommand mirror the channel bot's shape but post via sendMessage (room relays to members itself); requires an existing login session with that room, same as a manual post would. botDmSenderAllowed() gates DM trigger-reply/commands on the favourites bit when bot_dm_scope=Fav. - MsgExpand: {name}/{hops} as optional trailing params (default nullptr/-1, no existing caller affected) — deliberately not exposed on the general compose keyboard, only on bot Reply fields. - QuickMsgScreen/UITask: room-target picker (mirrors the channel picker), routing through the existing room-login prompt when there's no saved password yet. - BotScreen: tab carousel (LEFT/RIGHT switches tabs, UP/DOWN moves rows, Enter is now the only way to change a value); Enable split out of Channel/Room's combo row; Quiet Hours gained a stepper sub-mode; Commands moved from a shared toggle into each tab. - docs/tools_screen.md updated for the new tab layout and behaviour. Not build-verified — no PlatformIO toolchain in this environment. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 20:16:59 +02:00
return true;
}
// Actually runs the state-changing bot actions botCommandReply() recorded
// this scan (!gps on|off, !buzz, !advert, !gpio1..4 on|off) -- called by the
// tryBot*Command() wrappers only after quiet-hours/cooldown/per-contact
// throttle passed and the ack itself sent, so those gates genuinely block the
// action, not just the reply text (see the _bot_*_action_* fields in
// MyMesh.h). !gps fix's startLocFix() is armed separately by the caller (it
// needs the destination, which this function doesn't have).
void MyMesh::applyPendingBotActions() {
if (_bot_gps_action_pending && _ui) _ui->botSetGPS(_bot_gps_action_on);
if (_bot_buzz_action_secs > 0 && _ui) _ui->botBuzz(_bot_buzz_action_secs);
if (_bot_advert_action_pending) advert();
for (int i = 0; i < 4; i++) {
if (_bot_gpio_action[i] >= 0 && _ui) _ui->botSetGPIO(i + 1, _bot_gpio_action[i] != 0);
}
}
// Clears every pending-action flag botCommandReply() may have set this scan,
// without running them -- used both at scan start and on every throttled/
// aborted path, so a suppressed reply never leaves a stale action armed for
// next time.
void MyMesh::resetPendingBotActions() {
_locfix_requested = false;
_locfix_requested_timeout_ms = LOCFIX_TIMEOUT_MS;
_bot_gps_action_pending = false;
_bot_buzz_action_secs = 0;
_bot_advert_action_pending = false;
for (int i = 0; i < 4; i++) _bot_gpio_action[i] = -1;
}
// Arms the "!gps fix" wait: turns GPS on (remembering whether it already was,
// so tickLocFix() restores rather than assumes "off"), and resets the
// averaging accumulator. pub_key is only read for LOCFIX_DEST_CONTACT (DM or
// room -- both reply via sendMessage, see sendLocFixResult()); pass nullptr
// for LOCFIX_DEST_CHANNEL.
void MyMesh::startLocFix(uint8_t dest_type, const uint8_t* pub_key, uint8_t channel_idx) {
_loc_fix.active = true;
_loc_fix.dest_type = dest_type;
if (pub_key) memcpy(_loc_fix.pub_key, pub_key, PUB_KEY_SIZE);
_loc_fix.channel_idx = channel_idx;
_loc_fix.sum_lat = 0.0;
_loc_fix.sum_lon = 0.0;
_loc_fix.sample_count = 0;
_loc_fix.averaging_until_ms = 0;
_loc_fix.deadline_ms = futureMillis(_locfix_requested_timeout_ms);
_loc_fix.gps_was_on = (_prefs.gps_enabled != 0);
if (!_loc_fix.gps_was_on && _ui) _ui->botSetGPS(true);
}
// !gps fix state machine, ticked every MyMesh::loop() while _loc_fix.active.
// Phase 1 (acquire): wait for a valid fix good enough to trust -- HDOP (fix
// geometry) when the provider exposes it, satellite count as a cruder
// fallback when it doesn't (see isLocFixReady()). Phase 2 (average): once
// that's first met, keep summing node_lat/node_lon for LOCFIX_AVERAGE_MS more
// -- only on ticks where the threshold still holds, so a momentary dip just
// skips a sample instead of aborting the whole wait. A hard LOCFIX_TIMEOUT_MS
// deadline covers both phases; on timeout with zero samples collected the
// result is a plain failure (no stale-cache fallback -- a cached node_lat/lon
// from long before this request would be actively misleading here).
bool MyMesh::isLocFixReady(LocationProvider* loc) {
if (!loc || !loc->isValid()) return false;
long hdop = loc->getHDOP();
if (hdop >= 0) return hdop <= LOCFIX_MAX_HDOP; // preferred: real fix-quality signal
return loc->satellitesCount() >= LOCFIX_MIN_SATS; // provider has no HDOP -- cruder fallback
}
void MyMesh::tickLocFix() {
if (!_loc_fix.active) return;
LocationProvider* loc = sensors.getLocationProvider();
bool ready = isLocFixReady(loc);
if (_loc_fix.averaging_until_ms == 0 && ready) {
_loc_fix.averaging_until_ms = futureMillis(LOCFIX_AVERAGE_MS);
}
if (_loc_fix.averaging_until_ms != 0 && ready) {
_loc_fix.sum_lat += sensors.node_lat;
_loc_fix.sum_lon += sensors.node_lon;
_loc_fix.sample_count++;
}
bool averaging_done = _loc_fix.averaging_until_ms != 0 && millisHasNowPassed(_loc_fix.averaging_until_ms);
bool timed_out = millisHasNowPassed(_loc_fix.deadline_ms);
if (!averaging_done && !timed_out) return; // still waiting
char msg[BOT_SCRATCH];
if (_loc_fix.sample_count > 0) {
double avg_lat = _loc_fix.sum_lat / _loc_fix.sample_count;
double avg_lon = _loc_fix.sum_lon / _loc_fix.sample_count;
snprintf(msg, sizeof(msg), averaging_done ? "loc: %.5f,%.5f" : "loc: %.5f,%.5f (partial fix)", avg_lat, avg_lon);
} else {
snprintf(msg, sizeof(msg), "GPS: no fix (timeout)");
}
sendLocFixResult(msg);
if (!_loc_fix.gps_was_on && _ui) _ui->botSetGPS(false);
_loc_fix.active = false;
}
// Delivers tickLocFix()'s result to wherever the original "!gps fix" came
// from. Not gated by quiet hours/cooldown -- that gate already ran once, on
// the immediate ack (see the tryBot*Command() wrappers); this is the tail of
// an already-approved interaction, not a new proactive reply. Still updates
// the same cooldown timestamps/counters on success so subsequent trigger/
// command replies stay correctly throttled afterward.
void MyMesh::sendLocFixResult(const char* msg) {
uint32_t ts = getRTCClock()->getCurrentTime();
if (_loc_fix.dest_type == LOCFIX_DEST_CHANNEL) {
ChannelDetails ch;
if (!getChannel(_loc_fix.channel_idx, ch)) return;
int mlen = strlen(msg);
if (sendGroupMessage(ts, ch.channel, _prefs.node_name, msg, mlen)) {
_bot_last_ch_reply_ms = millis();
_bot_reply_count++;
#ifdef DISPLAY_CLASS
if (_ui) {
char with_sender[240];
snprintf(with_sender, sizeof(with_sender), "%s: %s", _prefs.node_name, msg);
_ui->addChannelMsg(_loc_fix.channel_idx, with_sender);
}
#endif
}
} else { // LOCFIX_DEST_CONTACT -- DM or room, both go through sendMessage
ContactInfo* c = lookupContactByPubKey(_loc_fix.pub_key, PUB_KEY_SIZE);
if (!c) return; // contact removed while we were waiting
uint32_t expected_ack, est_timeout;
if (sendMessage(*c, ts, 0, msg, expected_ack, est_timeout) != MSG_SEND_FAILED) {
if (c->type == ADV_TYPE_ROOM) _bot_last_room_reply_ms = millis();
else botDmRecord(c->id.pub_key);
_bot_reply_count++;
#ifdef DISPLAY_CLASS
if (_ui) _ui->addDMMsg(c->id.pub_key, true, msg);
#endif
}
}
}