diff --git a/docs/solo_features/tools_screen/tools_screen.md b/docs/solo_features/tools_screen/tools_screen.md index aacbec7e..387a1544 100644 --- a/docs/solo_features/tools_screen/tools_screen.md +++ b/docs/solo_features/tools_screen/tools_screen.md @@ -364,32 +364,68 @@ Melodies can be assigned in **Settings › Sound** (global default) or overridde | :-----------------------: | :-----------------------: | | ![](./autoreply_oled.png) | ![](./autoreply_eink.png) | -Automatically replies to incoming messages that contain a configured trigger word (case-insensitive, contains match). + -When the bot is enabled it listens to DMs by default. Channel monitoring is an optional addition — select a channel separately to activate it alongside DM mode. +Automatically replies to incoming messages that contain a configured trigger word (case-insensitive, contains match). The bot has three independent targets — **DM**, a monitored **Channel**, and a monitored **Room** — each with its own trigger/reply pair. + +The screen is a **circular tab carousel**, the same style as Tools › Nearby Nodes' filter tabs: **LEFT/RIGHT** switches between the **Direct** / **Channel** / **Room** / **Other** tabs, **UP/DOWN** moves between the rows within the active tab, and **Enter** acts on the selected row (LEFT/RIGHT is reserved entirely for tab-switching, so every row's value is changed via Enter, not by cycling it in place). + +Each target has its own **Enable** toggle on its own tab, and they're fully independent — you can run only a channel bot, only a room bot, only DM, or any combination, with no need to also switch on the others. + +Each target also has its own **Commands** toggle (see below) — DM, channel and room can each independently answer `!` queries or stay quiet, same as Enable. + +#### Direct tab + +| Setting | Description | +| ---------- | -------------------------------------------------------------------------------- | +| Enable | ON / OFF — **Enter** toggles. Enables DM listening. | +| DM allow | **All** / **Fav** — **Enter** toggles. Who the DM bot (trigger-reply and commands) responds to. All (default): any DM sender. Fav: only contacts you've starred (the same star Settings › Contacts filters on) — use this to keep a public bot from being spammed by strangers while it still answers people you trust. | +| Commands | ON / OFF — **Enter** toggles. Answer `!` query commands (see below) in DMs. | +| Trigger | Word or phrase that activates the DM reply (case-insensitive). A lone `*` means **reply to every DM** (away mode) and is shown as `(any msg)`. **Enter** opens the keyboard. | +| Reply | Reply text for DMs; supports `{time}`, `{loc}`, `{name}`, `{hops}` and sensor placeholders. **Enter** opens the keyboard. | + +#### Channel tab + +| Setting | Description | +| ------- | --------------------------------------------------------------------------------- | +| Enable | ON / OFF — **Enter** toggles. Independent of which channel is picked below, so switching it off and back on remembers the last channel. | +| Channel | Which channel the bot monitors — always shows the last-picked channel (or `(none)` if none exist yet), regardless of Enable. **Enter** opens the full channel picker (the same one Live Share's **To** row uses). | +| Commands | ON / OFF — **Enter** toggles. Answer `!` query commands on the monitored channel, independent of the DM tab's Commands setting. | +| Trigger | Independent trigger for the monitored channel. `*` means **reply to every channel message** — bounded by the per-channel cooldown, but use sparingly on a busy channel. | +| Reply | Reply text for channel messages; supports the same placeholders as Direct's Reply. | + +#### Room tab + +| Setting | Description | +| ------- | --------------------------------------------------------------------------------- | +| Enable | ON / OFF — **Enter** toggles. Independent of which room is picked below. | +| Room | Which room server the bot posts to — always shows the last-picked room (or `(none)` if you have none yet), regardless of Enable. **Enter** opens the full room picker. Picking a room you've never logged into prompts for its password right there — the bot can't post to a room it has no working login for, so this is the moment to set one up. | +| Commands | ON / OFF — **Enter** toggles. Answer `!` query commands on the monitored room, independent of the other two tabs' Commands settings. | +| Trigger | Independent trigger for the monitored room. `*` means **reply to every post in the room**. | +| Reply | Reply text for room posts; supports the same placeholders as Direct's Reply. | + +#### Other tab | Setting | Description | -| ------------- | -------------------------------------------------------------------------------- | -| Enable | ON / OFF — enables DM listening | -| Channel | OFF, or which channel to additionally monitor (cycles through your channels) | -| Trigger DM | Word or phrase that activates the DM reply (case-insensitive). A lone `*` means **reply to every DM** (away mode) and is shown as `(any msg)`. | -| Reply DM | Reply text for DMs; supports `{time}`, `{loc}` and sensor placeholders | -| Trigger Ch | Independent trigger for the monitored channel. `*` means **reply to every channel message** — bounded by the per-channel cooldown, but use sparingly on a busy channel. | -| Reply Ch | Reply text for channel messages; supports the same placeholders | -| Commands | ON / OFF — answer `!` query commands (see below) | -| Quiet from/to | Local-time window during which auto-replies stay silent; set both equal (`OFF`) to disable | +| ------------- | --------------------------------------------------------------------------------- | +| Quiet from | **Enter** opens a stepper (value shown bracketed, e.g. `[14:00]`) — **UP/DOWN** steps the hour, **Enter**/**Cancel** confirms. Local-time window start; set from = to (`OFF`) to disable quiet hours entirely. Applies to all three targets' trigger-replies. | +| Quiet to | Same stepper; window end. | -The DM and channel triggers are independent, so you can run e.g. an away-message (`*`) in DMs while the channel reacts only to a specific keyword (or vice-versa). +The DM, channel and room triggers are independent, so you can run e.g. an away-message (`*`) in DMs while the channel or room reacts only to a specific keyword (or vice-versa). -The header shows a running count of auto-replies sent since boot. +`{name}` (the triggering sender's name) and `{hops}` (`direct` or `N hops`) are only meaningful when replying to an actual incoming message, so — unlike `{time}`/`{loc}`/the sensor placeholders — they're offered only while editing a **Reply** field here, not on the general message-compose keyboard. -**Throttle.** Auto-replies are rate-limited **per contact** (10 s), so a second sender is never starved while one contact is on cooldown. The channel bot keeps a single 10 s cooldown and won't echo a message identical to its own reply (so two bots running the same reply text on one channel can't ping-pong); the cooldown caps any residual back-and-forth. +The header shows a running count of auto-replies sent since boot, alongside the tab bar. + +**Room posting requires a login.** The room bot reuses whatever session the device already has with that room server (Messages › Rooms › **Login…**, or a password saved from an earlier login/the phone app) — it has no way to prompt for a password itself in the background. If the saved password stops working, the room bot just silently stops posting there, the same as a manual post would; log back in from Messages to fix it. + +**Throttle.** DM auto-replies are rate-limited **per contact** (10 s), so a second sender is never starved while one contact is on cooldown. The channel and room bots each keep their own single 10 s cooldown and won't echo a message identical to their own reply (so two bots running the same reply text on one channel/room can't ping-pong); the cooldown caps any residual back-and-forth. **Quiet hours** suppress the push (trigger) replies between the configured local hours; a window where *from* is later than *to* wraps past midnight. Commands are a pull (explicitly requested), so they answer even during quiet hours. ### Commands -With **Commands** ON, a DM beginning with `!` is answered with live node data, independent of the trigger: +With a tab's **Commands** ON, a message beginning with `!` on that target is answered with live node data, independent of the trigger: | Command | Reply | | --------- | --------------------------------------- | @@ -404,7 +440,7 @@ With **Commands** ON, a DM beginning with `!` is answered with live node data, i Several commands can be combined in one message — `!batt !time !hops` is answered with a single `4.10V | 14:30 | 3 hops` reply (one transmission). A message with no recognised command falls through to the trigger bot. -Commands also work on the **monitored channel** (the one selected for channel mode) — anyone there can query the node. Channel replies are broadcast to everyone, so unlike DM commands they respect quiet hours and use the shared per-channel cooldown. DM commands use the per-contact throttle. +Each target's Commands toggle is independent — e.g. answer `!ping` in DMs but stay quiet on a busy public channel. Channel and room replies are broadcast/posted to everyone there, so unlike DM commands they respect quiet hours and use their own shared cooldown. DM commands use the per-contact throttle and the **DM allow** scope above. --- diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index d4874351..72960aa7 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -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 diff --git a/examples/companion_radio/MsgExpand.h b/examples/companion_radio/MsgExpand.h index 24e9d91e..f647ed85 100644 --- a/examples/companion_radio/MsgExpand.h +++ b/examples/companion_radio/MsgExpand.h @@ -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++; } diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 0a47fcdd..7add3134 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -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; diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index f3c74e18..7e712696 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -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 diff --git a/examples/companion_radio/MyMeshBot.h b/examples/companion_radio/MyMeshBot.h index 48e81c3d..9421e812 100644 --- a/examples/companion_radio/MyMeshBot.h +++ b/examples/companion_radio/MyMeshBot.h @@ -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; +} diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 85aaa9eb..3ecf3f92 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -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)."); diff --git a/examples/companion_radio/ui-new/BotScreen.h b/examples/companion_radio/ui-new/BotScreen.h index cbc9c975..78317e03 100644 --- a/examples/companion_radio/ui-new/BotScreen.h +++ b/examples/companion_radio/ui-new/BotScreen.h @@ -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 }; diff --git a/examples/companion_radio/ui-new/QuickMsgScreen.h b/examples/companion_radio/ui-new/QuickMsgScreen.h index 6fad2185..661ad878 100644 --- a/examples/companion_radio/ui-new/QuickMsgScreen.h +++ b/examples/companion_radio/ui-new/QuickMsgScreen.h @@ -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; diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 9441460e..46088dd3 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -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 diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 5b62f7b7..cd73e27c 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -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);