mirror of
https://github.com/MarekZegare4/MeshCore-Solo.git
synced 2026-08-03 02:36:11 +00: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>
This commit is contained in:
@@ -2,9 +2,10 @@
|
||||
// Bot logic — not part of upstream MyMesh.cpp.
|
||||
// Included at the bottom of MyMesh.cpp after all class definitions.
|
||||
|
||||
// Minimum gap between two auto-replies to the same contact (DM) or on the bot
|
||||
// channel. Guards against floods and, with botTriggerMatches()'s loop check,
|
||||
// against bot↔bot ping-pong on a shared channel.
|
||||
// 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.
|
||||
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.
|
||||
@@ -12,7 +13,7 @@ static const int BOT_SCRATCH = 200;
|
||||
|
||||
// Case-insensitive (ASCII) substring test: does `body` contain `trigger`? A lone
|
||||
// "*" trigger means "match everything" (away / reply-to-all mode); honoured only
|
||||
// where allow_wildcard is set. DM and channel each pass their own trigger string.
|
||||
// where allow_wildcard is set. DM, channel and room each pass their own trigger string.
|
||||
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')
|
||||
@@ -32,6 +33,27 @@ bool MyMesh::botTriggerMatches(const char* trigger, const char* body, bool allow
|
||||
return strstr(low, low_trigger) != NULL;
|
||||
}
|
||||
|
||||
// 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");
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -80,9 +102,10 @@ bool MyMesh::botInQuietHours() const {
|
||||
: (h >= s || h < e); // overnight window
|
||||
}
|
||||
|
||||
void MyMesh::tryBotReplyDM(const ContactInfo& from, const char* text) {
|
||||
void MyMesh::tryBotReplyDM(const ContactInfo& from, const char* text, uint8_t hops) {
|
||||
if (from.type != ADV_TYPE_CHAT) return;
|
||||
if (!(_prefs.bot_enabled && _prefs.bot_reply_dm[0])) return;
|
||||
if (!botDmSenderAllowed(from)) return;
|
||||
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
|
||||
@@ -93,7 +116,8 @@ void MyMesh::tryBotReplyDM(const ContactInfo& from, const char* text) {
|
||||
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);
|
||||
&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) {
|
||||
botDmRecord(from.id.pub_key);
|
||||
@@ -104,17 +128,28 @@ void MyMesh::tryBotReplyDM(const ContactInfo& from, const char* text) {
|
||||
}
|
||||
}
|
||||
|
||||
void MyMesh::tryBotReplyChannel(uint8_t channel_idx, const char* text) {
|
||||
if (!(_prefs.bot_enabled && _prefs.bot_channel_enabled && _prefs.bot_reply_ch[0] &&
|
||||
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 &&
|
||||
millis() - _bot_last_ch_reply_ms > BOT_REPLY_COOLDOWN_MS))
|
||||
return;
|
||||
if (botInQuietHours()) return;
|
||||
|
||||
// Skip "sender_name: " prefix so the trigger is only matched against the message body.
|
||||
// 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 = text;
|
||||
char sender_name[32] = "someone";
|
||||
const char* sep = strstr(text, ": ");
|
||||
if (sep) msg = sep + 2;
|
||||
if (sep) {
|
||||
msg = sep + 2;
|
||||
int n = (int)(sep - text);
|
||||
if (n > 0 && n < (int)sizeof(sender_name)) { memcpy(sender_name, text, n); sender_name[n] = '\0'; }
|
||||
}
|
||||
|
||||
if (!botTriggerMatches(_prefs.bot_trigger_ch, msg, true)) return; // own trigger; "*" = any msg
|
||||
|
||||
@@ -127,7 +162,8 @@ void MyMesh::tryBotReplyChannel(uint8_t channel_idx, const char* text) {
|
||||
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);
|
||||
&sensors, (float)board.getBattMilliVolts() / 1000.0f,
|
||||
sender_name, hops);
|
||||
// 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.
|
||||
@@ -146,10 +182,57 @@ void MyMesh::tryBotReplyChannel(uint8_t channel_idx, const char* text) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// Builds the reply text for a single command word into out. Returns false for an
|
||||
// unknown command. !hops is dynamic (per received message); the rest expand a
|
||||
// static template via expandMsg's placeholders.
|
||||
bool MyMesh::botCommandReply(const char* cmd, uint8_t hops, uint32_t ts, char* out, int out_len) {
|
||||
// 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, uint8_t hops, uint32_t ts, char* out, int out_len, const char* sender_name) {
|
||||
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);
|
||||
@@ -170,15 +253,16 @@ bool MyMesh::botCommandReply(const char* cmd, uint8_t hops, uint32_t ts, char* o
|
||||
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);
|
||||
&sensors, (float)board.getBattMilliVolts() / 1000.0f,
|
||||
sender_name, hops);
|
||||
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
|
||||
// were recognised. Shared by the DM and channel command paths.
|
||||
int MyMesh::botScanCommands(const char* body, uint8_t hops, uint32_t ts, char* out, int out_len) {
|
||||
// were recognised. Shared by the DM, channel and room command paths.
|
||||
int MyMesh::botScanCommands(const char* body, uint8_t hops, uint32_t ts, char* out, int out_len, const char* sender_name) {
|
||||
int oi = 0;
|
||||
int matched = 0;
|
||||
for (const char* p = body; *p; ) {
|
||||
@@ -197,7 +281,7 @@ int MyMesh::botScanCommands(const char* body, uint8_t hops, uint32_t ts, char* o
|
||||
if (!cmd[0]) continue;
|
||||
|
||||
char seg[64];
|
||||
if (!botCommandReply(cmd, hops, ts, seg, sizeof(seg))) continue; // unknown
|
||||
if (!botCommandReply(cmd, hops, ts, seg, sizeof(seg), sender_name)) continue; // unknown
|
||||
|
||||
if (matched > 0 && oi + 3 < out_len) { // " | " separator
|
||||
out[oi++] = ' '; out[oi++] = '|'; out[oi++] = ' ';
|
||||
@@ -217,10 +301,11 @@ int MyMesh::botScanCommands(const char* body, uint8_t hops, uint32_t ts, char* o
|
||||
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;
|
||||
if (!botDmSenderAllowed(from)) return false;
|
||||
|
||||
uint32_t ts = getRTCClock()->getCurrentTime();
|
||||
char out[BOT_SCRATCH];
|
||||
if (botScanCommands(text, hops, ts, out, sizeof(out)) == 0) return false; // no commands
|
||||
if (botScanCommands(text, hops, ts, out, sizeof(out), from.name) == 0) return false; // no commands
|
||||
if (!botDmAllowed(from.id.pub_key)) return true; // throttled
|
||||
|
||||
uint32_t expected_ack, est_timeout;
|
||||
@@ -238,18 +323,23 @@ bool MyMesh::tryBotCommand(const ContactInfo& from, const char* text, uint8_t ho
|
||||
// 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) {
|
||||
if (!(_prefs.bot_commands_enabled && _prefs.bot_channel_enabled &&
|
||||
if (!(_prefs.bot_commands_ch && _prefs.bot_channel_enabled &&
|
||||
channel_idx == _prefs.bot_channel_idx))
|
||||
return false;
|
||||
|
||||
// Skip "sender_name: " prefix so commands are read from the message body only.
|
||||
const char* msg = text;
|
||||
char sender_name[32] = "someone";
|
||||
const char* sep = strstr(text, ": ");
|
||||
if (sep) msg = sep + 2;
|
||||
if (sep) {
|
||||
msg = sep + 2;
|
||||
int n = (int)(sep - text);
|
||||
if (n > 0 && n < (int)sizeof(sender_name)) { memcpy(sender_name, text, n); sender_name[n] = '\0'; }
|
||||
}
|
||||
|
||||
uint32_t ts = getRTCClock()->getCurrentTime();
|
||||
char out[BOT_SCRATCH];
|
||||
if (botScanCommands(msg, hops, ts, out, sizeof(out)) == 0) return false; // no commands
|
||||
if (botScanCommands(msg, hops, ts, out, sizeof(out), sender_name) == 0) return false; // no commands
|
||||
if (botInQuietHours()) return true; // quiet hours
|
||||
if (millis() - _bot_last_ch_reply_ms <= BOT_REPLY_COOLDOWN_MS) return true; // throttled
|
||||
|
||||
@@ -269,3 +359,32 @@ bool MyMesh::tryBotChannelCommand(uint8_t channel_idx, const char* text, uint8_t
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// 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) == 0) return false; // no commands
|
||||
if (botInQuietHours()) return true; // quiet hours
|
||||
if (millis() - _bot_last_room_reply_ms <= BOT_REPLY_COOLDOWN_MS) return true; // throttled
|
||||
|
||||
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
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user