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:
Jakub
2026-07-10 20:16:59 +02:00
parent dfb993de53
commit afd7c0ee78
11 changed files with 744 additions and 181 deletions

View File

@@ -495,6 +495,31 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
rd(&_prefs.keyboard_alt_alphabet, sizeof(_prefs.keyboard_alt_alphabet));
if (_prefs.keyboard_alt_alphabet >= NodePrefs::KB_ALPHABET_COUNT) _prefs.keyboard_alt_alphabet = 0;
// → 0xC0DE001E: append bot_dm_scope + the room-server bot fields at the
// tail. A pre-0x1E file has the old sentinel bytes / EOF here; rd() zero-
// inits when absent, so upgraders default to bot_dm_scope=0 (all DMs,
// matching the original bot_enabled behaviour) and the room bot fully
// disabled (bot_room_enabled clamps to 0; an empty trigger never matches
// even if somehow set).
rd(&_prefs.bot_dm_scope, sizeof(_prefs.bot_dm_scope));
if (_prefs.bot_dm_scope > 1) _prefs.bot_dm_scope = 0;
rd(&_prefs.bot_room_enabled, sizeof(_prefs.bot_room_enabled));
if (_prefs.bot_room_enabled > 1) _prefs.bot_room_enabled = 0;
rd(_prefs.bot_room_prefix, sizeof(_prefs.bot_room_prefix));
rd(_prefs.bot_trigger_room, sizeof(_prefs.bot_trigger_room));
rd(_prefs.bot_reply_room, sizeof(_prefs.bot_reply_room));
// → 0xC0DE001F: split the shared bot_commands_enabled into a per-target
// toggle for channel/room too (DM keeps the original field). A pre-0x1F
// file has neither new byte; both clamp to 0 here and are seeded from the
// old shared value in the sentinel-mismatch migration below, so upgraders
// don't silently lose channel/room commands they already had answering.
rd(&_prefs.bot_commands_ch, sizeof(_prefs.bot_commands_ch));
if (_prefs.bot_commands_ch > 1) _prefs.bot_commands_ch = 0;
rd(&_prefs.bot_commands_room, sizeof(_prefs.bot_commands_room));
if (_prefs.bot_commands_room > 1) _prefs.bot_commands_room = 0;
// Schema sentinel: bumped on layout changes. Mismatch means an older file
// (or a different schema); rd() already zero-inits any fields not present,
// so we just log it — next savePrefs writes the current sentinel.
@@ -518,6 +543,14 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
if (sentinel < 0xC0DE0018 && _prefs.home_pages_mask != 0) {
_prefs.home_pages_mask |= NodePrefs::HP_MAP;
}
// 0xC0DE001E → 0xC0DE001F: bot_commands_enabled split per target (see the
// rd() above). Seed the two new fields from the old shared one so a
// pre-0x1F upgrader's channel/room commands keep answering exactly as
// before; gated to this transition so a user who later splits them apart
// isn't overridden on a later update.
if (sentinel < 0xC0DE001F) {
_prefs.bot_commands_ch = _prefs.bot_commands_room = _prefs.bot_commands_enabled;
}
// 0xC0DE0003 → 0xC0DE0004: trail_units_idx added after trail_min_delta_idx.
// On a 0xC0DE0003 file the sentinel bytes sit where trail_units_idx is now,
// so rd() picks up 0x03 (low byte of the old sentinel) — reset just that
@@ -683,6 +716,13 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_
file.write((uint8_t *)&_prefs.trail_autosave_lowbatt, sizeof(_prefs.trail_autosave_lowbatt));
file.write((uint8_t *)&_prefs.alarm_repeat_mask, sizeof(_prefs.alarm_repeat_mask));
file.write((uint8_t *)&_prefs.keyboard_alt_alphabet, sizeof(_prefs.keyboard_alt_alphabet));
file.write((uint8_t *)&_prefs.bot_dm_scope, sizeof(_prefs.bot_dm_scope));
file.write((uint8_t *)&_prefs.bot_room_enabled, sizeof(_prefs.bot_room_enabled));
file.write((uint8_t *)_prefs.bot_room_prefix, sizeof(_prefs.bot_room_prefix));
file.write((uint8_t *)_prefs.bot_trigger_room, sizeof(_prefs.bot_trigger_room));
file.write((uint8_t *)_prefs.bot_reply_room, sizeof(_prefs.bot_reply_room));
file.write((uint8_t *)&_prefs.bot_commands_ch, sizeof(_prefs.bot_commands_ch));
file.write((uint8_t *)&_prefs.bot_commands_room, sizeof(_prefs.bot_commands_room));
// Tail sentinel — must be last. See NodePrefs::SCHEMA_SENTINEL. Its write is
// the one we check: once the flash fills, writes return 0, so a good

View File

@@ -17,11 +17,15 @@
// {lux} — luminosity lux (requires sm)
// {dist} — distance m (requires sm)
// {co2} — CO2 concentration ppm (requires sm)
// {name} — sender's name (bot replies only; requires sender_name, else left literal)
// {hops} — hop count: "direct" or "N hops" (bot replies only; requires hops>=0, else left literal)
inline void expandMsg(const char* tmpl, char* out, int out_len,
double lat, double lon, bool gps_valid,
uint32_t utc_ts, int8_t tz_hours,
SensorManager* sm = nullptr,
float batt_volts = -1.0f) {
float batt_volts = -1.0f,
const char* sender_name = nullptr,
int hops = -1) {
// sv indices: 0=temp 1=hum 2=pres 3=batt 4=alt 5=lux 6=dist 7=co2
float sv[8] = {};
bool sv_ok[8] = {};
@@ -122,6 +126,18 @@ inline void expandMsg(const char* tmpl, char* out, int out_len,
if (sv_ok[7]) { char b[12]; snprintf(b,sizeof(b),"%.0fppm",sv[7]); APPEND(b,strlen(b)); }
else { APPEND("{co2}", 5); }
p += 5;
} else if (strncmp(p, "{name}", 6) == 0) {
if (sender_name && sender_name[0]) { APPEND(sender_name, strlen(sender_name)); }
else { APPEND("{name}", 6); }
p += 6;
} else if (strncmp(p, "{hops}", 6) == 0) {
if (hops >= 0) {
char b[10];
if (hops == 0) strcpy(b, "direct");
else snprintf(b, sizeof(b), "%u hops", (unsigned)hops);
APPEND(b, strlen(b));
} else { APPEND("{hops}", 6); }
p += 6;
} else {
out[oi++] = *p++;
}

View File

@@ -683,7 +683,7 @@ void MyMesh::onMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t
// bitfield for transport packets, so it must not be used directly.)
uint8_t hops = pkt ? pkt->getPathHashCount() : 0;
if (!tryBotCommand(from, text, hops)) // commands take priority; fall through to trigger reply
tryBotReplyDM(from, text);
tryBotReplyDM(from, text, hops);
}
void MyMesh::onCommandDataRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp,
@@ -709,6 +709,15 @@ void MyMesh::onSignedMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uin
const char* who = (sc && sc->name[0]) ? sc->name : from.name;
_ui->onSharedLocation(nullptr, who, loc_lat, loc_lon, sender_timestamp, false);
}
// Room-server auto-reply bot — only ever fires for the room server contact
// itself (signed posts are how a room relays its members' messages back to
// us); a defensive type check lives in tryBotReplyRoom/tryBotRoomCommand too.
if (from.type == ADV_TYPE_ROOM) {
uint8_t hops = pkt ? pkt->getPathHashCount() : 0;
if (!tryBotRoomCommand(from, sender_prefix, text, hops)) // commands take priority
tryBotReplyRoom(from, sender_prefix, text, hops);
}
}
void MyMesh::onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packet *pkt, uint32_t timestamp,
@@ -782,8 +791,9 @@ void MyMesh::onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packe
#endif
// hop count for !hops (see onMessageRecv); not the wire path_len above.
if (!tryBotChannelCommand(channel_idx, text, pkt->getPathHashCount())) // commands take priority
tryBotReplyChannel(channel_idx, text);
uint8_t ch_hops = pkt->getPathHashCount();
if (!tryBotChannelCommand(channel_idx, text, ch_hops)) // commands take priority
tryBotReplyChannel(channel_idx, text, ch_hops);
}
void MyMesh::onChannelDataRecv(const mesh::GroupChannel &channel, mesh::Packet *pkt, uint16_t data_type,
@@ -1528,6 +1538,7 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe
offline_queue_len = 0;
app_target_ver = 0;
_bot_last_ch_reply_ms = 0;
_bot_last_room_reply_ms = 0;
memset(_bot_dm_log, 0, sizeof(_bot_dm_log));
_bot_reply_count = 0;
_next_auto_advert_ms = 0;

View File

@@ -294,16 +294,24 @@ public:
uint16_t botReplyCount() const { return _bot_reply_count; }
private:
void tryBotReplyDM(const ContactInfo& from, const char* text);
void tryBotReplyChannel(uint8_t channel_idx, const char* text);
void tryBotReplyDM(const ContactInfo& from, const char* text, uint8_t hops);
void tryBotReplyChannel(uint8_t channel_idx, const char* text, uint8_t hops);
void tryBotReplyRoom(const ContactInfo& from, const uint8_t* sender_prefix, const char* text, uint8_t hops);
bool tryBotCommand(const ContactInfo& from, const char* text, uint8_t hops); // DM commands
bool tryBotChannelCommand(uint8_t channel_idx, const char* text, uint8_t hops); // channel commands
bool botCommandReply(const char* cmd, uint8_t hops, uint32_t ts, char* out, int out_len); // one command → reply text
int botScanCommands(const char* body, uint8_t hops, uint32_t ts, char* out, int out_len); // scan "!word"s → combined reply, returns count
bool tryBotRoomCommand(const ContactInfo& from, const uint8_t* sender_prefix, const char* text, uint8_t hops); // room commands
bool botCommandReply(const char* cmd, uint8_t hops, uint32_t ts, char* out, int out_len, const char* sender_name); // one command → reply text
int botScanCommands(const char* body, uint8_t hops, uint32_t ts, char* out, int out_len, const char* sender_name); // scan "!word"s → combined reply, returns count
bool botTriggerMatches(const char* trigger, const char* body, bool allow_wildcard) const;
bool botInQuietHours() const; // true when auto-replies should stay silent
bool botDmAllowed(const uint8_t* pubkey); // per-contact DM throttle: ok to reply?
void botDmRecord(const uint8_t* pubkey); // remember we just replied to this contact
// DM allow-list gate (bot_dm_scope): true unless scope is favourites-only
// and `from` isn't starred. Shared by the DM trigger-reply and command paths.
bool botDmSenderAllowed(const ContactInfo& from) const;
// Resolves a room post's signed author prefix to a display name for {name};
// falls back to a generic label when the poster isn't a known contact.
void botRoomSenderName(const uint8_t* sender_prefix, char* out, int out_len);
void writeOKFrame();
void writeErrFrame(uint8_t err_code);
@@ -379,6 +387,7 @@ private:
uint32_t sign_data_len;
unsigned long dirty_contacts_expiry;
unsigned long _bot_last_ch_reply_ms;
unsigned long _bot_last_room_reply_ms;
unsigned long _next_auto_advert_ms;
// Per-contact DM reply throttle: a small ring of the most recent recipients so

View File

@@ -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;
}

View File

@@ -85,14 +85,14 @@ struct NodePrefs { // persisted to file
uint8_t ringtone_len; // number of notes in custom ringtone (0 = use default)
uint8_t ringtone_notes[32]; // packed: bits0-2=pitch, bits3-4=octave-4, bits5-6=dur_idx
uint16_t home_pages_mask; // bitmask of visible home pages (bit0=Clock..bit8=Shutdown); 0=all visible
uint8_t bot_enabled; // 0=disabled, 1=DM bot active (responds to all DMs)
uint8_t bot_enabled; // 0=disabled, 1=DM trigger-reply active — DM only; channel/room have their own bot_channel_enabled/bot_room_enabled and don't depend on this
uint8_t bot_channel_enabled; // 0=disabled, 1=channel bot active for bot_channel_idx
uint8_t bot_channel_idx; // channel index for channel bot [del→onChannelRemoved]
char bot_trigger[64]; // DM trigger phrase (case-insensitive contains; "*" = any DM)
char bot_reply_dm[140]; // auto-reply text for DM
char bot_reply_ch[140]; // auto-reply text for channel
char bot_trigger_ch[64]; // channel trigger phrase (independent of DM; "*" = any channel msg)
uint8_t bot_commands_enabled; // 0=off, 1=answer !ping/!batt/!loc/!time/!help DM commands
uint8_t bot_commands_enabled; // 0=off, 1=answer !ping/!batt/!loc/!time/!help DM commands — DM only, see bot_commands_ch/bot_commands_room below for the other two targets
uint8_t bot_quiet_start; // quiet-hours start hour, local 0-23 (start==end → disabled)
uint8_t bot_quiet_end; // quiet-hours end hour, local 0-23
uint8_t clock_hide_seconds; // 0=show HH:MM:SS/refresh 1s (default), 1=hide/refresh 60s
@@ -355,6 +355,34 @@ struct NodePrefs { // persisted to file
// Appended at the tail (see the keyboard_alt_alphabet doc comment above).
uint8_t keyboard_alt_alphabet;
// Bot DM allow-list: who is allowed to trigger a DM auto-reply or run a
// command. 0 = all (default, matches the original bot_enabled behaviour).
// 1 = favourites only — gate on ContactInfo::flags bit 0, the same
// "favourite"/starred bit the Messages screen's DM list filter (dm_show_all)
// already reads, so no separate allow-list storage is needed.
uint8_t bot_dm_scope;
// Room-server auto-reply bot — same trigger/reply/command shape as the DM
// and channel bots above, but targets a single room server (like the
// channel bot targets a single channel) since posting requires that room's
// own login session (see MyMesh::sendRoomLogin/logoutRoom). If the device
// has never logged into this room, replies silently fail to send — same
// as a manual post would — so log in at least once from Messages > Rooms
// before relying on the bot there.
uint8_t bot_room_enabled; // 0=disabled, 1=room bot active for bot_room_prefix
uint8_t bot_room_prefix[6]; // target room's pubkey prefix [del→onContactRemoved]
char bot_trigger_room[64]; // room trigger phrase (independent of DM/channel; "*" = any post)
char bot_reply_room[140]; // auto-reply text for the room
// Per-target Commands toggle for channel/room, splitting what used to be
// one bot_commands_enabled shared across all three (see that field's doc
// comment) — e.g. answer !ping in DM but stay quiet on a public channel.
// Upgraders: DataStore seeds both from the old shared bot_commands_enabled
// on first load past the schema bump, so existing behaviour is preserved
// until the user deliberately splits them apart.
uint8_t bot_commands_ch;
uint8_t bot_commands_room;
// Single source of truth for the live-share option tables (shared by the Map
// UI labels and the auto-send engine in UITask).
static const uint8_t LOC_SHARE_MOVE_COUNT = 4;
@@ -417,7 +445,7 @@ struct NodePrefs { // persisted to file
// adding/removing/reordering fields in DataStore::savePrefs/loadPrefsInt so
// older saves are detected on load and skipped (zero-init defaults kept).
// High 24 bits identify the file format; low byte is the schema revision.
static const uint32_t SCHEMA_SENTINEL = 0xC0DE001D;
static const uint32_t SCHEMA_SENTINEL = 0xC0DE001F;
// Bit-index for each home page. Used by page_order (entries store bit+1) and
// by home_pages_mask. Single source of truth — both HomeScreen::pageBit/bitToPage
@@ -514,7 +542,7 @@ struct NodePrefs { // persisted to file
// 3. clamp it on load (an upgrader's file lacks it → stray bytes)
// 4. bump SCHEMA_SENTINEL's low byte
// (Padding can also shift sizeof; a "false" trip just means re-check + rebump.)
static_assert(sizeof(NodePrefs) == 2504,
static_assert(sizeof(NodePrefs) == 2712,
"NodePrefs layout changed — sync DataStore save/load + clamp, bump "
"SCHEMA_SENTINEL, then update this size (see steps above).");

View File

@@ -6,112 +6,285 @@ class BotScreen : public UIScreen {
UITask* _task;
NodePrefs* _prefs;
// Items: 0=Enable(DM), 1=Channel, 2=Trigger DM, 3=Reply DM, 4=Trigger Ch,
// 5=Reply Ch, 6=Commands, 7=Quiet from, 8=Quiet to
static const int ITEM_COUNT = 9;
// Categories are a circular tab bar in the header (mirrors NearbyScreen's
// filter tabs — duplicated locally rather than shared, since this is the
// only other screen using the pattern so far). LEFT/RIGHT switches tabs;
// UP/DOWN moves within the active tab's rows. Because LEFT/RIGHT is fully
// owned by tab-switching, every row's value is edited via Enter only — no
// more inline LEFT/RIGHT cycling.
enum Tab : uint8_t { TAB_DIRECT, TAB_CHANNEL, TAB_ROOM, TAB_OTHER, TAB_COUNT };
static const char* TAB_LABELS[TAB_COUNT];
int _sel;
int _scroll; // index of first visible item (managed by drawList in render)
enum Kind : uint8_t { ENABLE_DM, DM_SCOPE, COMMANDS_DM, TRIGGER_DM, REPLY_DM,
ENABLE_CH, CHANNEL, COMMANDS_CH, TRIGGER_CH, REPLY_CH,
ENABLE_ROOM, ROOM, COMMANDS_ROOM, TRIGGER_ROOM, REPLY_ROOM,
QUIET_FROM, QUIET_TO };
struct Row { Kind kind; const char* label; };
static const int ROWS_PER_TAB[TAB_COUNT];
// Row labels drop the "DM"/"Ch"/"Room" suffix the old flat list needed —
// the active tab already gives that context, freeing up value-column width.
// Commands lives on each tab (not a shared "Other" toggle) so DM/channel/
// room can each independently answer !ping etc. or stay quiet.
static Row tabRow(int tab, int idx) {
static const Row DIRECT[] = {
{ ENABLE_DM, "Enable" },
{ DM_SCOPE, "DM allow" },
{ COMMANDS_DM, "Commands" },
{ TRIGGER_DM, "Trigger" },
{ REPLY_DM, "Reply" },
};
static const Row CHANNEL_ROWS[] = {
{ ENABLE_CH, "Enable" },
{ CHANNEL, "Channel" },
{ COMMANDS_CH, "Commands" },
{ TRIGGER_CH, "Trigger" },
{ REPLY_CH, "Reply" },
};
static const Row ROOM_ROWS[] = {
{ ENABLE_ROOM, "Enable" },
{ ROOM, "Room" },
{ COMMANDS_ROOM, "Commands" },
{ TRIGGER_ROOM, "Trigger" },
{ REPLY_ROOM, "Reply" },
};
static const Row OTHER[] = {
{ QUIET_FROM, "Quiet from" },
{ QUIET_TO, "Quiet to" },
};
switch (tab) {
case TAB_DIRECT: return DIRECT[idx];
case TAB_CHANNEL: return CHANNEL_ROWS[idx];
case TAB_ROOM: return ROOM_ROWS[idx];
default: return OTHER[idx];
}
}
uint8_t _tab; // persists across visits, like NearbyScreen's _filter
int _sel; // selected row index within the active tab
int _scroll; // index of first visible row (managed by drawList in render)
bool _dirty;
// keyboard state (reused for trigger and reply fields)
int _kb_field; // -1=off, else the item index being edited (2..5)
int _kb_row; // -1=off, else the row index (within the active tab) being edited
KeyboardWidget* _kb;
// channel cache (refreshed on enter)
int _num_channels;
uint8_t _channel_indices[MAX_GROUP_CHANNELS];
// Quiet-hours stepper: Enter on Quiet from/to enters this sub-mode (LEFT/
// RIGHT is unavailable for +/- now that it switches tabs), where UP/DOWN
// steps the hour and Enter/Cancel exits back to normal row browsing.
int _stepper_row; // -1=off, else the row index being stepped
// channel/room counts (refreshed on enter) — just gate whether Enter opens
// the (possibly empty) picker; the picker itself browses the real list, so
// no local index cache is needed now that there's no LEFT/RIGHT quick-cycle.
int _num_channels;
int _num_rooms;
void refreshChannels() {
_num_channels = 0;
ChannelDetails ch;
for (int i = 0; i < MAX_GROUP_CHANNELS; i++) {
if (the_mesh.getChannel(i, ch) && ch.name[0] != '\0')
_channel_indices[_num_channels++] = (uint8_t)i;
}
for (int i = 0; i < MAX_GROUP_CHANNELS; i++)
if (the_mesh.getChannel(i, ch) && ch.name[0] != '\0') _num_channels++;
}
// Returns index into _channel_indices[] for current bot_channel_idx, or -1 if not found/disabled.
int currentChannelListIdx() const {
if (!_prefs->bot_channel_enabled) return -1;
for (int i = 0; i < _num_channels; i++)
if (_channel_indices[i] == _prefs->bot_channel_idx) return i;
return -1;
void refreshRooms() {
_num_rooms = 0;
ContactInfo ci;
int total = the_mesh.getNumContacts();
for (int i = 0; i < total; i++)
if (the_mesh.getContactByIdx(i, ci) && ci.type == ADV_TYPE_ROOM) _num_rooms++;
}
// One tab: short label; the active one is an inverted pill (same look as a
// selected row) so it reads as "you are here" in the tab strip.
void drawTab(DisplayDriver& display, int x, int w, const char* label, bool active) {
int lh = display.getLineHeight();
if (active) {
display.setColor(DisplayDriver::LIGHT);
display.fillRect(x, 0, w, lh + 1);
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
}
display.drawTextCentered(x + w / 2, 0, label);
display.setColor(DisplayDriver::LIGHT);
}
// Header as a circular tab bar: the active tab sits centred as a filled
// pill, neighbours fan out either side and wrap around. Only whole tabs are
// drawn — one that would spill past a screen edge is skipped rather than
// clipped. `right_reserve` keeps the reply counter (drawn after this call)
// clear of the rightmost tab.
void drawTabBar(DisplayDriver& display, int right_reserve) {
const int pad = 3, gap = 2;
int w[TAB_COUNT];
for (int i = 0; i < TAB_COUNT; i++)
w[i] = display.getTextWidth(TAB_LABELS[i]) + pad * 2;
int ax = display.width() / 2 - w[_tab] / 2;
drawTab(display, ax, w[_tab], TAB_LABELS[_tab], true);
int lx = ax - gap;
int rx = ax + w[_tab] + gap;
int rx_limit = display.width() - right_reserve;
bool lfit = true, rfit = true;
for (int k = 1; k <= TAB_COUNT / 2 && (lfit || rfit); k++) {
int li = (_tab - k + TAB_COUNT) % TAB_COUNT;
int ri = (_tab + k) % TAB_COUNT;
if (lfit) {
int x = lx - w[li];
if (x >= 0) { drawTab(display, x, w[li], TAB_LABELS[li], false); lx = x - gap; }
else lfit = false;
}
// Skip the right side when it lands on the same tab as the left (the
// single opposite tab on an even count) so it isn't drawn twice.
if (rfit && ri != li) {
if (rx + w[ri] <= rx_limit) { drawTab(display, rx, w[ri], TAB_LABELS[ri], false); rx += w[ri] + gap; }
else rfit = false;
}
}
display.setColor(DisplayDriver::LIGHT);
display.fillRect(0, display.headerH() - display.sepH(), display.width(), display.sepH());
}
public:
BotScreen(UITask* task, NodePrefs* prefs, KeyboardWidget* kb) : _task(task), _prefs(prefs), _kb(kb) {}
BotScreen(UITask* task, NodePrefs* prefs, KeyboardWidget* kb)
: _task(task), _prefs(prefs), _tab(TAB_DIRECT), _kb(kb) {}
void onShow() override {
_sel = 0;
_scroll = 0;
_kb_field = -1;
_dirty = false;
// _tab persists across visits (like NearbyScreen's _filter) — only reset
// the in-tab cursor and any open sub-mode.
_sel = 0;
_scroll = 0;
_kb_row = -1;
_stepper_row = -1;
_dirty = false;
refreshChannels();
refreshRooms();
}
int render(DisplayDriver& display) override {
display.setTextSize(1);
display.setColor(DisplayDriver::LIGHT);
if (_kb_field >= 0) {
if (_kb_row >= 0) {
return _kb->render(display);
}
int val_x = display.valCol();
display.drawCenteredHeader("AUTO-REPLY BOT");
// reply counter, right-aligned in the header
// Reply counter, right-aligned in the header — reserve its width from the
// tab bar before drawing so a right-side tab can't run under it.
uint16_t sent = the_mesh.botReplyCount();
char cbuf[12];
int cw = 0;
if (sent > 0) {
char cbuf[12];
snprintf(cbuf, sizeof(cbuf), "%u", (unsigned)sent);
int cw = display.getTextWidth(cbuf);
cw = display.getTextWidth(cbuf);
}
drawTabBar(display, sent > 0 ? cw + 2 : 0);
if (sent > 0) {
display.setCursor(display.width() - cw - 1, 0);
display.print(cbuf);
display.setColor(DisplayDriver::LIGHT);
}
static const char* labels[] = { "Enable", "Channel", "Trigger DM", "Reply DM",
"Trigger Ch", "Reply Ch", "Commands",
"Quiet from", "Quiet to" };
drawList(display, ITEM_COUNT, _sel, _scroll, [&](int i, int y, bool sel, int reserve) {
int n = ROWS_PER_TAB[_tab];
drawList(display, n, _sel, _scroll, [&](int i, int y, bool sel, int reserve) {
Row r = tabRow(_tab, i);
drawRowSelection(display, y, sel, reserve);
display.setCursor(2, y);
display.print(labels[i]);
display.print(r.label);
display.setCursor(val_x, y);
if (i == 0) {
display.print(_prefs->bot_enabled ? "ON" : "OFF");
} else if (i == 1) {
if (!_prefs->bot_channel_enabled || _num_channels == 0) {
display.print("OFF");
} else {
switch (r.kind) {
case ENABLE_DM:
display.print(_prefs->bot_enabled ? "ON" : "OFF");
break;
case ENABLE_CH:
display.print(_prefs->bot_channel_enabled ? "ON" : "OFF");
break;
case ENABLE_ROOM:
display.print(_prefs->bot_room_enabled ? "ON" : "OFF");
break;
case DM_SCOPE:
display.print(_prefs->bot_dm_scope ? "Fav" : "All");
break;
case CHANNEL: {
ChannelDetails ch;
if (the_mesh.getChannel(_prefs->bot_channel_idx, ch) && ch.name[0])
display.drawTextEllipsized(val_x, y, display.width() - val_x - 1 - reserve,ch.name);
if (_num_channels == 0)
display.print("(none)");
else if (the_mesh.getChannel(_prefs->bot_channel_idx, ch) && ch.name[0])
display.drawTextEllipsized(val_x, y, display.width() - val_x - 1 - reserve, ch.name);
else
display.print("?");
break;
}
} else if (i == 2 || i == 4) {
const char* tr = (i == 2) ? _prefs->bot_trigger : _prefs->bot_trigger_ch;
const char* shown = !tr[0] ? "(none)"
: (tr[0] == '*' && !tr[1]) ? "(any msg)" // wildcard / away mode
: tr;
display.drawTextEllipsized(val_x, y, display.width() - val_x - 1 - reserve,shown);
} else if (i == 3 || i == 5) {
const char* rp = (i == 3) ? _prefs->bot_reply_dm : _prefs->bot_reply_ch;
display.drawTextEllipsized(val_x, y, display.width() - val_x - 1 - reserve,rp[0] ? rp : "(none)");
} else if (i == 6) {
display.print(_prefs->bot_commands_enabled ? "ON" : "OFF");
} else { // i == 7 (quiet from) or i == 8 (quiet to)
bool off = (_prefs->bot_quiet_start == _prefs->bot_quiet_end);
if (off) {
display.print("OFF");
} else {
char hb[8];
snprintf(hb, sizeof(hb), "%02d:00", i == 7 ? _prefs->bot_quiet_start : _prefs->bot_quiet_end);
display.print(hb);
case ROOM: {
if (_num_rooms == 0) {
display.print("(none)");
} else {
ContactInfo* c = the_mesh.lookupContactByPubKey(_prefs->bot_room_prefix, NodePrefs::FAVOURITE_PREFIX_LEN);
if (c && c->name[0])
display.drawTextEllipsized(val_x, y, display.width() - val_x - 1 - reserve, c->name);
else
display.print("?");
}
break;
}
case TRIGGER_DM:
case TRIGGER_CH:
case TRIGGER_ROOM: {
const char* tr = (r.kind == TRIGGER_DM) ? _prefs->bot_trigger
: (r.kind == TRIGGER_CH) ? _prefs->bot_trigger_ch
: _prefs->bot_trigger_room;
const char* shown = !tr[0] ? "(none)"
: (tr[0] == '*' && !tr[1]) ? "(any msg)" // wildcard / away mode
: tr;
display.drawTextEllipsized(val_x, y, display.width() - val_x - 1 - reserve, shown);
break;
}
case REPLY_DM:
case REPLY_CH:
case REPLY_ROOM: {
const char* rp = (r.kind == REPLY_DM) ? _prefs->bot_reply_dm
: (r.kind == REPLY_CH) ? _prefs->bot_reply_ch
: _prefs->bot_reply_room;
display.drawTextEllipsized(val_x, y, display.width() - val_x - 1 - reserve, rp[0] ? rp : "(none)");
break;
}
case COMMANDS_DM:
display.print(_prefs->bot_commands_enabled ? "ON" : "OFF");
break;
case COMMANDS_CH:
display.print(_prefs->bot_commands_ch ? "ON" : "OFF");
break;
case COMMANDS_ROOM:
display.print(_prefs->bot_commands_room ? "ON" : "OFF");
break;
case QUIET_FROM:
case QUIET_TO: {
bool off = (_prefs->bot_quiet_start == _prefs->bot_quiet_end);
char hb[10];
if (off) {
strcpy(hb, "OFF");
} else {
uint8_t h = (r.kind == QUIET_FROM) ? _prefs->bot_quiet_start : _prefs->bot_quiet_end;
snprintf(hb, sizeof(hb), "%02d:00", h);
}
// Bracket the value while the stepper sub-mode is open on this row,
// as a visual cue that UP/DOWN now steps it instead of moving rows.
if (_stepper_row == i) {
char bracketed[14];
snprintf(bracketed, sizeof(bracketed), "[%s]", hb);
display.print(bracketed);
} else {
display.print(hb);
}
break;
}
default: break;
}
display.setColor(DisplayDriver::LIGHT);
});
@@ -121,102 +294,123 @@ public:
bool handleInput(char c) override {
bool up = (c == KEY_UP);
bool down = (c == KEY_DOWN);
bool left = keyIsPrev(c);
bool right = keyIsNext(c);
bool enter = (c == KEY_ENTER);
bool cancel = (c == KEY_CANCEL || c == KEY_CONTEXT_MENU);
if (_kb_field >= 0) {
if (_kb_row >= 0) {
auto res = _kb->handleInput(c);
if (res == KeyboardWidget::DONE) {
char* dst = fieldBuf(_kb_field);
int cap = fieldCap(_kb_field);
Kind k = tabRow(_tab, _kb_row).kind;
char* dst = fieldBuf(k);
int cap = fieldCap(k);
if (dst) { strncpy(dst, _kb->buf, cap - 1); dst[cap - 1] = '\0'; }
_dirty = true;
_kb_field = -1;
_dirty = true;
_kb_row = -1;
} else if (res == KeyboardWidget::CANCELLED) {
_kb_field = -1;
_kb_row = -1;
}
return true;
}
if (_stepper_row >= 0) {
Kind k = tabRow(_tab, _stepper_row).kind;
uint8_t& h = (k == QUIET_FROM) ? _prefs->bot_quiet_start : _prefs->bot_quiet_end;
if (up) { h = (h + 1) % 24; _dirty = true; return true; }
if (down) { h = (h + 23) % 24; _dirty = true; return true; }
if (enter || cancel) { _stepper_row = -1; return true; }
return true; // swallow LEFT/RIGHT etc. while stepping
}
if (cancel) {
_task->savePrefsIfDirty(_dirty);
_task->gotoToolsScreen();
return true;
}
// drawList() reclamps _scroll from _sel every render.
if (up) { _sel = (_sel > 0) ? _sel - 1 : ITEM_COUNT - 1; return true; }
if (down) { _sel = (_sel < ITEM_COUNT - 1) ? _sel + 1 : 0; return true; }
if (keyIsPrev(c)) { _tab = (_tab + TAB_COUNT - 1) % TAB_COUNT; _sel = _scroll = 0; return true; }
if (keyIsNext(c)) { _tab = (_tab + 1) % TAB_COUNT; _sel = _scroll = 0; return true; }
if (_sel == 0 && (enter || left || right)) {
_prefs->bot_enabled ^= 1;
_dirty = true;
return true;
}
if (_sel == 1) {
if (_num_channels == 0) return false;
// Cycle: OFF → ch[0] → ch[1] → ... → OFF
int idx = currentChannelListIdx(); // -1 = OFF
if (right || enter) {
idx++;
if (idx >= _num_channels) idx = -1; // wrap to OFF
}
if (left) {
idx--;
if (idx < -1) idx = _num_channels - 1;
}
if (left || right || enter) {
if (idx < 0) {
_prefs->bot_channel_enabled = 0;
int n = ROWS_PER_TAB[_tab];
// drawList() reclamps _scroll from _sel every render.
if (up) { _sel = (_sel > 0) ? _sel - 1 : n - 1; return true; }
if (down) { _sel = (_sel < n - 1) ? _sel + 1 : 0; return true; }
if (!enter) return false;
Kind k = tabRow(_tab, _sel).kind;
switch (k) {
case ENABLE_DM: _prefs->bot_enabled ^= 1; _dirty = true; return true;
case ENABLE_CH: _prefs->bot_channel_enabled ^= 1; _dirty = true; return true;
case ENABLE_ROOM: _prefs->bot_room_enabled ^= 1; _dirty = true; return true;
case DM_SCOPE: _prefs->bot_dm_scope ^= 1; _dirty = true; return true;
case COMMANDS_DM: _prefs->bot_commands_enabled ^= 1; _dirty = true; return true;
case COMMANDS_CH: _prefs->bot_commands_ch ^= 1; _dirty = true; return true;
case COMMANDS_ROOM: _prefs->bot_commands_room ^= 1; _dirty = true; return true;
case CHANNEL:
// Full browsable channel picker (same experience as Live Share's "To"
// row); which channel is *active* is now the separate Enable row.
if (_num_channels == 0) return false;
_task->savePrefsIfDirty(_dirty);
_task->pickBotChannelTarget();
return true;
case ROOM:
if (_num_rooms == 0) return false;
_task->savePrefsIfDirty(_dirty);
_task->pickBotRoomTarget();
return true;
case QUIET_FROM:
case QUIET_TO:
_stepper_row = _sel;
return true;
case TRIGGER_DM:
case TRIGGER_CH:
case TRIGGER_ROOM:
case REPLY_DM:
case REPLY_CH:
case REPLY_ROOM: {
_kb_row = _sel;
_kb->begin(fieldBuf(k), fieldCap(k) - 1);
bool is_trigger = (k == TRIGGER_DM || k == TRIGGER_CH || k == TRIGGER_ROOM);
if (is_trigger) {
_kb->clearPlaceholders(); // trigger is literal — placeholders never match
} else {
_prefs->bot_channel_enabled = 1;
_prefs->bot_channel_idx = _channel_indices[idx];
kbAddSensorPlaceholders(*_kb, &sensors);
// Only meaningful in a bot reply (there's an actual triggering
// sender/hop-count to fill them with) — not offered on the general
// compose keyboard's own placeholder list.
_kb->addPlaceholder("{name}");
_kb->addPlaceholder("{hops}");
}
_dirty = true;
return true;
}
default: return false;
}
if (_sel == 6 && (enter || left || right)) {
_prefs->bot_commands_enabled ^= 1;
_dirty = true;
return true;
}
if ((_sel == 7 || _sel == 8) && (left || right)) {
uint8_t& h = (_sel == 7) ? _prefs->bot_quiet_start : _prefs->bot_quiet_end;
h = right ? (h + 1) % 24 : (h + 23) % 24;
_dirty = true;
return true;
}
if ((_sel == 2 || _sel == 3 || _sel == 4 || _sel == 5) && enter) {
_kb_field = _sel;
_kb->begin(fieldBuf(_sel), fieldCap(_sel) - 1);
bool is_trigger = (_sel == 2 || _sel == 4);
if (is_trigger) _kb->clearPlaceholders(); // trigger is literal — placeholders never match
else kbAddSensorPlaceholders(*_kb, &sensors);
return true;
}
return false;
}
private:
// Maps a keyboard-editable item index to its backing string + capacity.
char* fieldBuf(int item) {
switch (item) {
case 2: return _prefs->bot_trigger;
case 3: return _prefs->bot_reply_dm;
case 4: return _prefs->bot_trigger_ch;
case 5: return _prefs->bot_reply_ch;
// Maps a keyboard-editable row kind to its backing string + capacity.
char* fieldBuf(Kind k) {
switch (k) {
case TRIGGER_DM: return _prefs->bot_trigger;
case REPLY_DM: return _prefs->bot_reply_dm;
case TRIGGER_CH: return _prefs->bot_trigger_ch;
case REPLY_CH: return _prefs->bot_reply_ch;
case TRIGGER_ROOM: return _prefs->bot_trigger_room;
case REPLY_ROOM: return _prefs->bot_reply_room;
default: return nullptr;
}
}
int fieldCap(int item) {
switch (item) {
case 2: return sizeof(_prefs->bot_trigger);
case 3: return sizeof(_prefs->bot_reply_dm);
case 4: return sizeof(_prefs->bot_trigger_ch);
case 5: return sizeof(_prefs->bot_reply_ch);
int fieldCap(Kind k) {
switch (k) {
case TRIGGER_DM: return sizeof(_prefs->bot_trigger);
case REPLY_DM: return sizeof(_prefs->bot_reply_dm);
case TRIGGER_CH: return sizeof(_prefs->bot_trigger_ch);
case REPLY_CH: return sizeof(_prefs->bot_reply_ch);
case TRIGGER_ROOM: return sizeof(_prefs->bot_trigger_room);
case REPLY_ROOM: return sizeof(_prefs->bot_reply_room);
default: return 0;
}
}
};
const char* BotScreen::TAB_LABELS[BotScreen::TAB_COUNT] = { "Direct", "Channel", "Room", "Other" };
const int BotScreen::ROWS_PER_TAB[BotScreen::TAB_COUNT] = { 5, 5, 5, 2 };

View File

@@ -88,6 +88,12 @@ class QuickMsgScreen : public UIScreen {
// Live-share target picker: reuse the recipient chooser to set the persistent
// auto-share target (channel / DM) instead of composing a message.
bool _pick_target = false;
// Bot channel picker: same idea, but jumps straight to CHANNEL_PICK (the
// bot only ever targets a channel, never a DM) to set bot_channel_idx.
bool _pick_bot_channel = false;
// Bot room picker: jumps straight to CONTACT_PICK with room_mode on (the
// bot's room target is always a room server) to set bot_room_prefix.
bool _pick_bot_room = false;
// The message-history rings (channel + DM), their per-entry delivery state and
// per-channel unread counters live in this store (see MessageHistory.h). The
@@ -644,7 +650,7 @@ public:
// so only do it if they're still sitting on this same room in the picker
// (didn't navigate away, open a menu, or enter a share/pick sub-flow).
if (_phase == CONTACT_PICK && _room_mode && !_ctx_menu.active
&& !_share_mode && !_pick_target
&& !_share_mode && !_pick_target && !_pick_bot_room
&& memcmp(_sel_contact.id.pub_key, pub_key, 4) == 0) {
openDmHistory();
}
@@ -721,6 +727,8 @@ public:
_nav_active = false;
_share_mode = false;
_pick_target = false;
_pick_bot_channel = false;
_pick_bot_room = false;
_pin_picker_active = false;
_dm_direct_entry = false;
_unread_at_entry = 0;
@@ -767,6 +775,53 @@ public:
_task->gotoLiveShareScreen();
}
// Open the channel chooser to set the auto-reply bot's channel. Skips
// MODE_SELECT (the bot only ever targets a channel) and lands straight on
// CHANNEL_PICK, browsing real channel names instead of a bare index cycle.
void startPickBotChannel() {
reset();
_pick_bot_channel = true;
_phase = CHANNEL_PICK;
}
void commitPickBotChannel(int ch_idx) {
NodePrefs* p = _task->getNodePrefs();
if (p) {
p->bot_channel_enabled = 1;
p->bot_channel_idx = (uint8_t)ch_idx;
the_mesh.savePrefs();
}
_pick_bot_channel = false;
_task->showAlert("Bot channel set", 1200);
_task->gotoBotScreen();
}
// Open the room chooser to set the auto-reply bot's room. Enter routes
// through the same login handling the normal room-open flow uses (see the
// CONTACT_PICK/room_mode Enter handler below) — the bot can't ever post to
// a room it has no password for, so picking one is a good moment to prompt.
void startPickBotRoom() {
reset();
_pick_bot_room = true;
_room_mode = true;
buildContactList(); // rebuild now that _room_mode is on (reset() built the DM list)
_contact_sel = _contact_scroll = 0;
_phase = CONTACT_PICK;
}
void commitPickBotRoom(const ContactInfo& ci) {
NodePrefs* p = _task->getNodePrefs();
if (p) {
p->bot_room_enabled = 1;
memcpy(p->bot_room_prefix, ci.id.pub_key, NodePrefs::FAVOURITE_PREFIX_LEN);
the_mesh.savePrefs();
}
_pick_bot_room = false;
_room_mode = false;
_task->showAlert("Bot room set", 1200);
_task->gotoBotScreen();
}
void commitPickTargetDM(const ContactInfo& ci) {
// A room server can't be a live-share target — a [LOC] DM to it is never
// reposted to the room's members. Reject the pick and keep the chooser open.
@@ -1351,13 +1406,42 @@ public:
if (res != PopupMenu::NONE) _task->savePrefsIfDirty(_ctx_dirty);
return true;
}
if (c == KEY_CANCEL) { _room_mode = false; _phase = MODE_SELECT; return true; }
if (c == KEY_CANCEL) {
if (_pick_bot_room) { _pick_bot_room = false; _room_mode = false; _task->gotoBotScreen(); return true; }
_room_mode = false;
_phase = MODE_SELECT;
return true;
}
// drawList() reclamps _contact_scroll from _contact_sel every render.
if (c == KEY_UP && _num_contacts > 0) { _contact_sel = (_contact_sel > 0) ? _contact_sel - 1 : _num_contacts - 1; return true; }
if (c == KEY_DOWN && _num_contacts > 0) { _contact_sel = (_contact_sel < _num_contacts - 1) ? _contact_sel + 1 : 0; return true; }
if (c == KEY_ENTER && _num_contacts > 0) {
if (the_mesh.getContactByIdx(_sorted[_contact_sel], _sel_contact)) {
if (_pick_target) { commitPickTargetDM(_sel_contact); return true; }
if (_pick_bot_room) {
if (!isRoomLoggedIn(_sel_contact.id.pub_key)) {
char saved_pw[sizeof(_login_pw)];
if (the_mesh.getRoomPassword(_sel_contact.id.pub_key, saved_pw, sizeof(saved_pw))) {
// Known password, just not re-established this boot — retry
// silently in the background; the bot target is set below
// regardless of this attempt's outcome (self-heals like any
// other saved room password would on the next real open).
startRoomLogin(saved_pw);
} else {
// Never logged in — the bot could never post here without a
// password, so prompt for one now instead of picking an
// unusable target. Commits once submitted (see the KEYBOARD/
// _login_mode handler), whether or not login itself succeeds.
_login_mode = true;
_kb->begin("", 15); // room/repeater password: max 15 chars
_kb->clearPlaceholders();
_phase = KEYBOARD;
return true;
}
}
commitPickBotRoom(_sel_contact);
return true;
}
if (_room_mode && !isRoomLoggedIn(_sel_contact.id.pub_key)) {
// Posting to a room requires a login handshake first (even with a
// blank password) — go straight to the password prompt instead of
@@ -1468,13 +1552,18 @@ public:
}
return true;
}
if (c == KEY_CANCEL) { _phase = MODE_SELECT; return true; }
if (c == KEY_CANCEL) {
if (_pick_bot_channel) { _pick_bot_channel = false; _task->gotoBotScreen(); return true; }
_phase = MODE_SELECT;
return true;
}
// drawList() reclamps _channel_scroll from _channel_sel every render.
if (c == KEY_UP && _num_channels > 0) { _channel_sel = (_channel_sel > 0) ? _channel_sel - 1 : _num_channels - 1; return true; }
if (c == KEY_DOWN && _num_channels > 0) { _channel_sel = (_channel_sel < _num_channels - 1) ? _channel_sel + 1 : 0; return true; }
if (c == KEY_ENTER && _num_channels > 0) {
_sel_channel_idx = _channel_indices[_channel_sel];
if (_pick_target) { commitPickTargetChannel(_sel_channel_idx); return true; }
if (_pick_bot_channel) { commitPickBotChannel(_sel_channel_idx); return true; }
int hc = _history.histCountForChannel(_sel_channel_idx);
_unread_at_entry = (int)_history.chUnread(_sel_channel_idx);
_hist_scroll = 0;
@@ -1675,6 +1764,7 @@ public:
// password" attempt, so it isn't suppressed like an empty message is.
_login_mode = false;
startRoomLogin(_kb->buf);
if (_pick_bot_room) { commitPickBotRoom(_sel_contact); return true; }
_phase = CONTACT_PICK;
}
return true;

View File

@@ -1688,6 +1688,16 @@ void UITask::pickLocShareTarget() {
setCurrScreen(quick_msg);
}
void UITask::pickBotChannelTarget() {
((QuickMsgScreen*)quick_msg)->startPickBotChannel();
setCurrScreen(quick_msg);
}
void UITask::pickBotRoomTarget() {
((QuickMsgScreen*)quick_msg)->startPickBotRoom();
setCurrScreen(quick_msg);
}
int UITask::getRecentDMContacts(uint8_t out[][NodePrefs::FAVOURITE_PREFIX_LEN], int max) const {
return ((QuickMsgScreen*)quick_msg)->getRecentDMContacts(out, max);
}
@@ -2557,6 +2567,14 @@ void UITask::onContactRemoved(const uint8_t* pub_key) {
_node_prefs->loc_share_enabled = 0;
changed = true;
}
// Same fail-closed rule for the room bot's target: the room contact is
// gone, disable it rather than risk a re-added contact silently inheriting
// the old bot config.
if (_node_prefs->bot_room_enabled
&& memcmp(_node_prefs->bot_room_prefix, pub_key, NodePrefs::FAVOURITE_PREFIX_LEN) == 0) {
_node_prefs->bot_room_enabled = 0;
changed = true;
}
// Per-contact mute/melody overrides — only 16 slots shared across every
// contact, so an orphaned entry isn't just stale, it can eventually starve
// new overrides for contacts that still exist. Keyed by a 4-byte prefix

View File

@@ -223,6 +223,8 @@ public:
void shareToMessage(const char* text); // open Messages pre-loaded to share `text`
void quickShareMyLocation(); // Home Map Hold-Enter: one-shot position share
void pickLocShareTarget(); // open Messages to choose the live-share target
void pickBotChannelTarget(); // open Messages to choose the auto-reply bot's channel
void pickBotRoomTarget(); // open Messages to choose the auto-reply bot's room
int getRecentDMContacts(uint8_t out[][NodePrefs::FAVOURITE_PREFIX_LEN], int max) const;
void gotoToolsScreen();
void gotoRingtoneEditor(int slot = 0);