feat(bot): Allowed commands per channel, room and direct, Position = Fav, Reply gap, commands only at the start of a message

!loc off is the switch for the position: !status answers without it, !dist
is off and !gps fix answers "GPS: fix off". !help is built from the table.
ui-core/BotCommands.h, tested on the host.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-11-01 23:25:22 +01:00
co-authored by Claude Opus 5.5
parent 2fd2c596e4
commit 94a8f8fe72
13 changed files with 439 additions and 49 deletions
+2
View File
@@ -329,6 +329,8 @@ static void sanitize(NodePrefs& p) {
flag(p.bot_actions_ch, 0);
flag(p.bot_actions_room, 0);
flag(p.bot_dm_scope, 0);
flag(p.bot_pos_scope, 0);
if (p.bot_gap_idx >= NodePrefs::BOT_GAP_COUNT) p.bot_gap_idx = 0;
flag(p.bot_room_enabled, 0);
if (p.bot_quiet_start > 23) p.bot_quiet_start = 0;
if (p.bot_quiet_end > 23) p.bot_quiet_end = 0;
+1
View File
@@ -1,5 +1,6 @@
#include "MyMesh.h"
#include "MsgExpand.h"
#include "ui-core/BotCommands.h"
#include "GeoUtils.h"
#include "Features.h"
+4 -2
View File
@@ -185,6 +185,7 @@ public:
virtual void onChannelRemoved(uint8_t channel_idx) { }
// Remote bot device actions (!gps/!buzz/!gpioN), gated by bot_actions_* prefs.
virtual void botSetGPS(bool on) { }
virtual bool botGpsHeld() { return false; } // an SOS has the GPS: !gps is refused
virtual void botBuzz(int seconds) { }
virtual bool botSetGPIO(int idx, bool on) { return false; }
virtual bool botGetGPIO(int idx, bool& is_output, bool& value) { return false; }
@@ -614,8 +615,9 @@ private:
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 tryBotRoomCommand(const ContactInfo& from, const uint8_t* sender_prefix, const char* text, uint8_t hops); // room commands
bool botCommandReply(const char* cmd, const char* arg, const char* arg2, bool actions_allowed, uint8_t hops, uint32_t ts, char* out, int out_len, const char* sender_name); // one command → reply text
int botScanCommands(const char* body, uint8_t hops, uint32_t ts, char* out, int out_len, const char* sender_name, bool actions_allowed); // scan "!word"s → combined reply, returns count
bool botGapPassed(unsigned long last) const; // the Reply gap since a channel / room reply
bool botCommandReply(const char* cmd, const char* arg, const char* arg2, bool actions_allowed, uint16_t cmd_off, 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, bool actions_allowed, uint16_t cmd_off); // scan "!word"s → combined reply, returns count
// !gps fix -- single-shot "wait for a stabilised GPS fix, then push a follow-up
// message" action. botCommandReply() only sets _locfix_requested (it doesn't know
// the destination); the tryBot*Command() wrappers call startLocFix() with the
+34 -21
View File
@@ -2,15 +2,20 @@
// 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), 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.
// Minimum gap between two auto-replies to the same contact (DM). Guards against
// floods; the bot channel and room have their own setting, see botGapPassed().
static const unsigned long BOT_REPLY_COOLDOWN_MS = 10000UL;
// Scratch buffer size for trigger matching / reply expansion. Covers the full
// incoming message (MAX_TEXT_LEN ~160) plus the 140-byte reply templates.
static const int BOT_SCRATCH = 200;
// Reply gap on the bot channel / room: airtime for everyone in range, and with
// botTriggerMatches()'s loop check it guards against bot<->bot ping-pong. last
// is 0 until the first reply: the wait isn't owed from boot.
bool MyMesh::botGapPassed(unsigned long last) const {
return !last || millis() - last > (unsigned long)NodePrefs::botGapSecs(_prefs.bot_gap_idx) * 1000UL;
}
// Case-insensitive (ASCII) substring test: does `body` contain any one of
// `trigger`'s comma-separated phrases? A lone "*" trigger (the whole field,
// not one comma-separated phrase) means "match everything" (away / reply-to-
@@ -209,7 +214,7 @@ void MyMesh::tryBotReplyChannel(uint8_t channel_idx, const char* text, uint8_t h
// 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))
botGapPassed(_bot_last_ch_reply_ms)))
return;
if (botInQuietHours()) return;
@@ -263,7 +268,7 @@ void MyMesh::tryBotReplyRoom(const ContactInfo& from, const uint8_t* sender_pref
// 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))
botGapPassed(_bot_last_room_reply_ms)))
return;
if (botInQuietHours()) return;
if (!botTriggerMatches(_prefs.bot_trigger_room, text, true)) return; // "*" = any post
@@ -301,7 +306,7 @@ void MyMesh::tryBotReplyRoom(const ContactInfo& from, const uint8_t* sender_pref
// expandMsg's placeholders, including {name}/{hops} if the sender wrote them
// into a custom reply — not used by any of the built-in templates below today.
bool MyMesh::botCommandReply(const char* cmd, const char* arg, const char* arg2, bool actions_allowed,
uint8_t hops, uint32_t ts, char* out, int out_len, const char* sender_name) {
uint16_t cmd_off, uint8_t hops, uint32_t ts, char* out, int out_len, const char* sender_name) {
if (!strcmp(cmd, "path")) {
strncpy(out, s_bot_path, out_len); // "direct" or the hop hashes, oldest first
out[out_len - 1] = '\0';
@@ -348,7 +353,9 @@ bool MyMesh::botCommandReply(const char* cmd, const char* arg, const char* arg2,
// covers LOCFIX_AVERAGE_MS (no point requesting less than one averaging
// window), ceiling keeps a forgotten/misfired request from parking GPS
// on indefinitely.
if (_listener && _listener->botGpsHeld()) { snprintf(out, out_len, "GPS: on for SOS"); return true; }
if (!strcmp(arg, "fix")) {
if (!botcmd::position(cmd_off)) { snprintf(out, out_len, "GPS: fix off"); return true; }
if (_loc_fix.active) { snprintf(out, out_len, "GPS: fix already pending"); return true; }
uint32_t timeout_ms = LOCFIX_TIMEOUT_MS;
if (arg2[0]) {
@@ -429,10 +436,8 @@ bool MyMesh::botCommandReply(const char* cmd, const char* arg, const char* arg2,
else if (!strcmp(cmd, "loc")) tmpl = "{loc}";
else if (!strcmp(cmd, "time")) tmpl = "{time}";
else if (!strcmp(cmd, "temp")) tmpl = "{temp}";
else if (!strcmp(cmd, "status")) tmpl = "batt {batt} loc {loc} at {time}";
else if (!strcmp(cmd, "help")) tmpl = actions_allowed
? "cmds: !ping !batt !loc !time !temp !hops !path !snr !dist !status !gps !buzz !advert !gpio1-4"
: "cmds: !ping !batt !loc !time !temp !hops !path !snr !dist !status";
else if (!strcmp(cmd, "status")) tmpl = botcmd::position(cmd_off) ? "batt {batt} loc {loc} at {time}" : "batt {batt} at {time}";
else if (!strcmp(cmd, "help")) { botcmd::help(out, out_len, cmd_off, actions_allowed); return true; }
else return false; // unknown — let the trigger bot try
expandMsg(tmpl, out, out_len,
@@ -449,9 +454,10 @@ bool MyMesh::botCommandReply(const char* cmd, const char* arg, const char* arg2,
// combined reply (segments joined by " | ") into out. Returns how many commands
// were recognised. Shared by the DM, channel and room command paths.
// actions_allowed gates the state-changing commands (!gps/!buzz/!advert) --
// the read-only queries are always reachable once a target's Commands is on.
// the read-only queries are reachable once a target's Commands is on. cmd_off:
// the ones this target doesn't answer, treated like a word it doesn't know.
int MyMesh::botScanCommands(const char* body, uint8_t hops, uint32_t ts, char* out, int out_len,
const char* sender_name, bool actions_allowed) {
const char* sender_name, bool actions_allowed, uint16_t cmd_off) {
resetPendingBotActions(); // pending actions/flags below are set by tokens in this scan;
// caller applies or clears them once it knows if the reply sent
// Reads one space-delimited token at p into buf (lowercased, truncated to
@@ -465,7 +471,9 @@ int MyMesh::botScanCommands(const char* body, uint8_t hops, uint32_t ts, char* o
};
int oi = 0;
int matched = 0;
for (const char* p = body; *p; ) {
const char* p = botcmd::start(body);
if (!p) return 0;
while (*p) {
while (*p == ' ') p++; // skip whitespace
if (*p != '!') { // not a command token
while (*p && *p != ' ') p++;
@@ -492,8 +500,10 @@ int MyMesh::botScanCommands(const char* body, uint8_t hops, uint32_t ts, char* o
if (*p && *p != '!') readToken(p, arg2, sizeof(arg2));
}
char seg[80];
if (!botCommandReply(cmd, arg1, arg2, actions_allowed, hops, ts, seg, sizeof(seg), sender_name)) continue; // unknown
if (!botcmd::enabled(cmd, cmd_off)) continue;
char seg[100]; // the whole of !help
if (!botCommandReply(cmd, arg1, arg2, actions_allowed, cmd_off, hops, ts, seg, sizeof(seg), sender_name)) continue; // unknown
if (matched > 0 && oi + 3 < out_len) { // " | " separator
out[oi++] = ' '; out[oi++] = '|'; out[oi++] = ' ';
@@ -515,9 +525,12 @@ bool MyMesh::tryBotCommand(const ContactInfo& from, const char* text, uint8_t ho
if (from.type != ADV_TYPE_CHAT) return false;
if (!botDmSenderAllowed(from)) return false;
uint16_t cmd_off = _prefs.bot_cmd_off_dm;
if (_prefs.bot_pos_scope && !(from.flags & 0x01)) cmd_off |= botcmd::flag(botcmd::LOC); // a DM's sender is verified, unlike a channel's
uint32_t ts = getRTCClock()->getCurrentTime();
char out[BOT_SCRATCH];
if (botScanCommands(text, hops, ts, out, sizeof(out), from.name, _prefs.bot_actions_dm != 0 && botActionsFrom(&from)) == 0) return false; // no commands
if (botScanCommands(text, hops, ts, out, sizeof(out), from.name, _prefs.bot_actions_dm != 0 && botActionsFrom(&from), cmd_off) == 0) return false; // no commands
if (!botDmAllowed(from.id.pub_key)) { resetPendingBotActions(); return true; } // throttled
uint32_t expected_ack, est_timeout;
@@ -550,9 +563,9 @@ bool MyMesh::tryBotChannelCommand(uint8_t channel_idx, const char* text, uint8_t
uint32_t ts = getRTCClock()->getCurrentTime();
char out[BOT_SCRATCH];
if (botScanCommands(msg, hops, ts, out, sizeof(out), sender_name, _prefs.bot_actions_ch != 0 && botActionsFrom(s_bot_sender)) == 0) return false; // no commands
if (botScanCommands(msg, hops, ts, out, sizeof(out), sender_name, _prefs.bot_actions_ch != 0 && botActionsFrom(s_bot_sender), _prefs.bot_cmd_off_ch) == 0) return false; // no commands
if (botInQuietHours()) { resetPendingBotActions(); return true; } // quiet hours
if (millis() - _bot_last_ch_reply_ms <= BOT_REPLY_COOLDOWN_MS) { resetPendingBotActions(); return true; } // throttled
if (!botGapPassed(_bot_last_ch_reply_ms)) { resetPendingBotActions(); return true; } // throttled
ChannelDetails ch;
if (!getChannel(channel_idx, ch)) { resetPendingBotActions(); return true; }
@@ -585,9 +598,9 @@ bool MyMesh::tryBotRoomCommand(const ContactInfo& from, const uint8_t* sender_pr
uint32_t ts = getRTCClock()->getCurrentTime();
char out[BOT_SCRATCH];
if (botScanCommands(text, hops, ts, out, sizeof(out), sender_name, _prefs.bot_actions_room != 0 && botActionsFrom(s_bot_sender)) == 0) return false; // no commands
if (botScanCommands(text, hops, ts, out, sizeof(out), sender_name, _prefs.bot_actions_room != 0 && botActionsFrom(s_bot_sender), _prefs.bot_cmd_off_room) == 0) return false; // no commands
if (botInQuietHours()) { resetPendingBotActions(); return true; } // quiet hours
if (millis() - _bot_last_room_reply_ms <= BOT_REPLY_COOLDOWN_MS) { resetPendingBotActions(); return true; } // throttled
if (!botGapPassed(_bot_last_room_reply_ms)) { resetPendingBotActions(); return true; } // throttled
uint32_t expected_ack, est_timeout;
if (sendMessage(from, ts, 0, out, expected_ack, est_timeout) != MSG_SEND_FAILED) {
+14
View File
@@ -419,6 +419,13 @@ struct NodePrefsFields {
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 bot_actions_scope; // who may run the bot's actions (!gps !buzz !gpio ...): 0 = all, 1 = favourites
// Commands each target doesn't answer, bits of botcmd::Id (ui-core/BotCommands.h);
// 0 = all of them. Under Commands / Actions, which stay the on/off switches.
uint16_t bot_cmd_off_dm;
uint16_t bot_cmd_off_ch;
uint16_t bot_cmd_off_room;
uint8_t bot_pos_scope; // who gets the position in a DM: 0 = all, 1 = favourites (the others as if !loc were off)
uint8_t bot_gap_idx; // index into botGapSecs(): between replies on the channel / in the room
// ── GPS, trail & location sharing ─────────────────────────────────────
uint8_t gps_enabled; // GPS enabled flag (0=disabled, 1=enabled)
@@ -649,6 +656,11 @@ struct NodePrefsFields {
static const char* L[CONTACT_EXPIRY_COUNT] = { "Off", "7d", "30d", "90d" };
return L[idx < CONTACT_EXPIRY_COUNT ? idx : 0];
}
static const uint8_t BOT_GAP_COUNT = 4;
static uint16_t botGapSecs(uint8_t idx) {
static const uint16_t S[BOT_GAP_COUNT] = { 10, 30, 60, 300 };
return S[idx < BOT_GAP_COUNT ? idx : 0];
}
static const uint8_t MSG_KEEP_COUNT = 4;
static uint16_t msgKeepTotal(uint8_t idx) {
static const uint16_t N[MSG_KEEP_COUNT] = { 0, 500, 1000, 2500 };
@@ -879,6 +891,8 @@ class NodePrefs : public NodePrefsFields, public ConfigSerializer {
s.def("rm_reply", bot_reply_room, sizeof(bot_reply_room));
s.def("rm_cmd", bot_commands_room); s.def("rm_act", bot_actions_room);
s.def("q_start", bot_quiet_start); s.def("q_end", bot_quiet_end); s.def("act_scope", bot_actions_scope);
s.def("dm_off", bot_cmd_off_dm); s.def("ch_off", bot_cmd_off_ch); s.def("rm_off", bot_cmd_off_room);
s.def("pos_scope", bot_pos_scope); s.def("gap", bot_gap_idx);
}
void gpsSolo(Section& s) {
s.def("imperial", units_imperial);
+5 -2
View File
@@ -664,14 +664,17 @@ extern "C" EMSCRIPTEN_KEEPALIVE const char* sim_test_contact_hash(int type) {
// Turns on the DM bot's commands (Tools > Bot > Direct > Commands), with its
// actions and who may run them (Other > Actions by: 0 all, 1 favourites), so
// a host page can test the bot from a peer. In memory only.
extern "C" EMSCRIPTEN_KEEPALIVE int sim_test_bot(int actions, int actions_scope) {
// a host page can test the bot from a peer; cmd_off: the commands it doesn't
// answer (bot_cmd_off_dm), pos_scope: Direct > Position. In memory only.
extern "C" EMSCRIPTEN_KEEPALIVE int sim_test_bot(int actions, int actions_scope, int cmd_off, int pos_scope) {
if (!g_sim_ready) return 0;
NodePrefs* prefs = the_mesh.getNodePrefs();
if (!prefs) return 0;
prefs->bot_commands_enabled = 1;
prefs->bot_actions_dm = actions ? 1 : 0;
prefs->bot_actions_scope = actions_scope ? 1 : 0;
prefs->bot_cmd_off_dm = (uint16_t)cmd_off;
prefs->bot_pos_scope = pos_scope ? 1 : 0;
return 1;
}
@@ -0,0 +1,99 @@
#pragma once
// The bot's ! commands as a table: which ones a target (channel, room, direct)
// answers. A target keeps a mask of the ones switched off, so 0 is all of them
// and a command added later starts on. No radio and no prefs here, so it's
// tested on the host (test/test_bot_commands). The commands themselves are in
// MyMeshBot.h, the mask's rows in BotConfig.h.
#include <stdint.h>
#include <stdio.h>
#include <string.h>
namespace botcmd {
enum Id : uint8_t { PING, BATT, TEMP, TIME, LOC, HOPS, PATH, SNR, DIST, STATUS, HELP,
GPS, BUZZ, ADVERT, GPIO, COUNT };
// name: as !help lists it. hint: a one-line explanation for frontends with room.
struct Cmd { const char* name; const char* label; const char* hint; };
static const Cmd CMDS[COUNT] = {
{ "ping", "!ping", "Answers pong" },
{ "batt", "!batt", "Battery" },
{ "temp", "!temp", "Temperature" },
{ "time", "!time", "Clock" },
{ "loc", "!loc position", "Off: hidden in !status, !dist, !gps fix too" },
{ "hops", "!hops", "Hops the command took" },
{ "path", "!path", "The route it came by" },
{ "snr", "!snr", "How its last hop was heard" },
{ "dist", "!dist", "How far the sender is, and which way" },
{ "status", "!status", "Battery, position and time" },
{ "help", "!help", "The list of commands" },
{ "gps", "!gps", "Action: on, off, fix" },
{ "buzz", "!buzz", "Action: sounds the buzzer" },
{ "advert", "!advert", "Action: sends an advert" },
{ "gpio1-4", "!gpio1-4", "Action: reads and sets the pins" },
};
static inline uint16_t flag(int id) { return (uint16_t)(1u << id); }
// How many of the table a target's list shows: the actions, last in it, only
// with its Actions on. Their switches keep their state while hidden.
static inline int count(bool actions) { return actions ? (int)COUNT : (int)GPS; }
// What `off` switches off, by the word after the "!" (lowercase). -1: not a
// command of the table.
static inline int find(const char* cmd) {
if (!strncmp(cmd, "gpio", 4) && cmd[4] >= '1' && cmd[4] <= '4' && !cmd[5]) return GPIO;
for (int i = 0; i < GPIO; i++) if (!strcmp(cmd, CMDS[i].name)) return i;
return -1;
}
// Where a message's commands begin, nullptr if it has none: only at its start,
// past a reply's "@[name] ", so text that merely mentions one doesn't run it,
// nor does another bot's !help answer ("cmds: !ping !buzz ...").
static inline const char* start(const char* body) {
const char* p = body;
while (*p == ' ') p++;
if (p[0] == '@' && p[1] == '[') {
p = strchr(p, ']');
if (!p) return nullptr;
p++;
while (*p == ' ') p++;
}
return *p == '!' ? p : nullptr;
}
// The position may leave: !loc is the switch for all of it. Anything else with
// !loc off would give away what the user has just hidden: !dist from any spot
// the sender claims, !gps fix outright.
static inline bool position(uint16_t off) { return !(off & flag(LOC)); }
// Unknown words count as enabled: they're the caller's to reject.
static inline bool enabled(const char* cmd, uint16_t off) {
int id = find(cmd);
if (id < 0) return true;
if (id == DIST && !position(off)) return false;
return !(off & flag(id));
}
// "cmds: !ping !batt ...": only what would answer. Actions only when allowed.
static inline void help(char* out, int n, uint16_t off, bool actions) {
int o = snprintf(out, n, "cmds:");
for (int i = 0; i < COUNT && o < n; i++) {
if (i == HELP || (i >= GPS && !actions)) continue;
if ((off & flag(i)) || (i == DIST && !position(off))) continue;
o += snprintf(out + o, n - o, " !%s", CMDS[i].name);
}
}
// A row's value: "All", or how many of the listed ones are on.
static inline void shown(uint16_t off, bool actions, char* out, int n) {
const int total = count(actions);
int on = 0;
for (int i = 0; i < total; i++) if (!(off & flag(i))) on++;
if (on == total) snprintf(out, n, "All");
else snprintf(out, n, "%d of %d", on, total);
}
} // namespace botcmd
+33 -6
View File
@@ -6,7 +6,10 @@
//
// Row kinds: switches (a uint8_t flag), text (trigger / reply, with a
// capacity), a target (channel / room: a picker in the frontend), an hour
// (quiet from / to; equal hours = quiet hours off).
// (quiet from / to; equal hours = quiet hours off), a list of the commands a
// target answers (a mask over botcmd::, a list popup in the frontend).
#include "BotCommands.h"
namespace botcfg {
@@ -22,10 +25,10 @@ static const char* const TAB_LABELS[TAB_COUNT] = { "Channel", "Room", "Direct",
static bool isLog(int) { return false; }
#endif
enum Kind : uint8_t { ENABLE_DM, DM_SCOPE, COMMANDS_DM, ACTIONS_DM, TRIGGER_DM, REPLY_DM,
ENABLE_CH, CHANNEL, COMMANDS_CH, ACTIONS_CH, TRIGGER_CH, REPLY_CH,
ENABLE_ROOM, ROOM, COMMANDS_ROOM, ACTIONS_ROOM, TRIGGER_ROOM, REPLY_ROOM,
QUIET_FROM, QUIET_TO, ACTIONS_SCOPE };
enum Kind : uint8_t { ENABLE_DM, DM_SCOPE, COMMANDS_DM, ALLOWED_DM, ACTIONS_DM, TRIGGER_DM, REPLY_DM,
ENABLE_CH, CHANNEL, COMMANDS_CH, ALLOWED_CH, ACTIONS_CH, TRIGGER_CH, REPLY_CH,
ENABLE_ROOM, ROOM, COMMANDS_ROOM, ALLOWED_ROOM, ACTIONS_ROOM, TRIGGER_ROOM, REPLY_ROOM,
QUIET_FROM, QUIET_TO, ACTIONS_SCOPE, POS_SCOPE, GAP };
// hint: a one-line explanation for frontends with room for it.
struct Row { Kind kind; const char* label; const char* hint; };
@@ -34,6 +37,7 @@ static const Row CHANNEL_ROWS[] = {
{ ENABLE_CH, "Enable", "Answer on one channel" },
{ CHANNEL, "Channel", nullptr },
{ COMMANDS_CH, "Commands", "!ping, !help, ..." },
{ ALLOWED_CH, "Allowed", "Which ones it answers" },
{ ACTIONS_CH, "Actions", "!gps, !gpio (this device)" },
{ TRIGGER_CH, "Trigger", "Reply trigger; * = any" },
{ REPLY_CH, "Reply", "{name} {loc} {time} ..." },
@@ -42,6 +46,7 @@ static const Row ROOM_ROWS[] = {
{ ENABLE_ROOM, "Enable", "Answer in one room server" },
{ ROOM, "Room", nullptr },
{ COMMANDS_ROOM, "Commands", "!ping, !help, ..." },
{ ALLOWED_ROOM, "Allowed", "Which ones it answers" },
{ ACTIONS_ROOM, "Actions", "!gps, !gpio (this device)" },
{ TRIGGER_ROOM, "Trigger", "Reply trigger; * = any" },
{ REPLY_ROOM, "Reply", "{name} {loc} {time} ..." },
@@ -49,7 +54,9 @@ static const Row ROOM_ROWS[] = {
static const Row DIRECT_ROWS[] = {
{ ENABLE_DM, "Enable", "Answer direct messages" },
{ DM_SCOPE, "DM allow", "Everyone, or favourites only" },
{ POS_SCOPE, "Position", "Fav: only favourites get it" },
{ COMMANDS_DM, "Commands", "!ping, !help, ..." },
{ ALLOWED_DM, "Allowed", "Which ones it answers" },
{ ACTIONS_DM, "Actions", "!gps, !gpio (this device)" },
{ TRIGGER_DM, "Trigger", "Reply trigger; * = any" },
{ REPLY_DM, "Reply", "{name} {loc} {time} ..." },
@@ -58,6 +65,7 @@ static const Row OTHER_ROWS[] = {
{ QUIET_FROM, "Quiet from", "No replies in these hours" },
{ QUIET_TO, "Quiet to", "Same hour = always on" },
{ ACTIONS_SCOPE, "Actions by", "Fav: favourites only (a channel name can be faked)" },
{ GAP, "Reply gap", "On the channel and in the room" },
};
static int rowCount(int tab) {
@@ -93,10 +101,26 @@ static uint8_t* flag(NodePrefs* p, Kind k) {
case ACTIONS_CH: return &p->bot_actions_ch;
case ACTIONS_ROOM: return &p->bot_actions_room;
case ACTIONS_SCOPE: return &p->bot_actions_scope; // 1 = favourites only
case POS_SCOPE: return &p->bot_pos_scope; // 1 = favourites only
default: return nullptr;
}
}
// Allowed rows: the target's mask of commands switched off; nullptr for other kinds.
static uint16_t* cmdOff(NodePrefs* p, Kind k) {
switch (k) {
case ALLOWED_DM: return &p->bot_cmd_off_dm;
case ALLOWED_CH: return &p->bot_cmd_off_ch;
case ALLOWED_ROOM: return &p->bot_cmd_off_room;
default: return nullptr;
}
}
// That Allowed row's target has its Actions on: the list shows them.
static bool actionsOn(const NodePrefs* p, Kind k) {
return (k == ALLOWED_DM ? p->bot_actions_dm : k == ALLOWED_CH ? p->bot_actions_ch : p->bot_actions_room) != 0;
}
// Text rows: the buffer and its size (incl. NUL); nullptr for other kinds.
static char* text(NodePrefs* p, Kind k, int& cap) {
switch (k) {
@@ -111,13 +135,16 @@ static char* text(NodePrefs* p, Kind k, int& cap) {
}
// All / Fav rows (a flag shown as who, not on / off).
static bool isScope(Kind k) { return k == DM_SCOPE || k == ACTIONS_SCOPE; }
static bool isScope(Kind k) { return k == DM_SCOPE || k == ACTIONS_SCOPE || k == POS_SCOPE; }
static bool isTrigger(Kind k) { return k == TRIGGER_DM || k == TRIGGER_CH || k == TRIGGER_ROOM; }
static bool isHour(Kind k) { return k == QUIET_FROM || k == QUIET_TO; }
static uint8_t& hour(NodePrefs* p, Kind k) { return k == QUIET_FROM ? p->bot_quiet_start : p->bot_quiet_end; }
static bool quietOff(const NodePrefs* p) { return p->bot_quiet_start == p->bot_quiet_end; }
// The Reply gap's choices, by NodePrefs::botGapSecs() index; "" ends the list.
static const char* GAP_LABELS[NodePrefs::BOT_GAP_COUNT + 1] = { "10s", "30s", "1m", "5m", "" };
// Placeholders a reply can use: the bot fills {name} / {hops} / {path} from the
// message it answers; {loc} / {time} and the sensor ones come from here.
// Most useful first (a frontend short of room shows a prefix).
+70 -18
View File
@@ -3,7 +3,8 @@
// their NodePrefs fields are ui-core/BotConfig.h; the bot runs in MyMesh.
// Tabs Channel / Room / Direct / Other; switches inline, trigger / reply in a
// popup with the keyboard (reply: placeholder buttons above it), channel /
// room from a list popup, quiet hours with - / +.
// room from a list popup, quiet hours with - / +, the commands a target
// answers as a list of switches in a popup.
//
// Single-TU fragment: included by ui-lvgl/UITask.cpp after AdminScreen.h.
@@ -12,6 +13,7 @@ namespace botview {
static uint8_t s_tab = botcfg::TAB_CHANNEL;
static int s_row = -1; // row a popup is editing
static lv_obj_t* s_hour_lbl = nullptr;
static lv_obj_t* s_allowed_lbl = nullptr; // the Allowed row's value, behind the popup
static uint8_t s_rooms[32][NodePrefs::FAVOURITE_PREFIX_LEN];
enum : uint8_t { B_MINUS, B_PLUS, B_DONE };
@@ -30,6 +32,12 @@ static void onBotScope(lv_event_t* e) {
s_ui->botToggle((int)(uintptr_t)lv_event_get_user_data(e),
lv_buttonmatrix_get_selected_button((lv_obj_t*)lv_event_get_target(e)) == 1);
}
static void onBotCmd(lv_event_t* e) {
s_ui->botCmd((int)(uintptr_t)lv_event_get_user_data(e), lv_obj_has_state((lv_obj_t*)lv_event_get_target(e), LV_STATE_CHECKED));
}
static void onBotGap(lv_event_t* e) {
s_ui->botGap((int)lv_buttonmatrix_get_selected_button((lv_obj_t*)lv_event_get_target(e)));
}
static void onBotPick(lv_event_t* e) { s_ui->botPick((int)(uintptr_t)lv_event_get_user_data(e)); }
static void onBotHour(lv_event_t* e) { s_ui->botHour((uint8_t)(uintptr_t)lv_event_get_user_data(e)); }
static void onBotTextKb(lv_event_t* e) { s_ui->botTextDone(lv_event_get_code(e) == LV_EVENT_READY); }
@@ -50,6 +58,12 @@ static void botChip(const char* ph, void* parent) {
lv_obj_add_event_cb(b, onBotPlaceholder, LV_EVENT_CLICKED, NULL);
lv_obj_center(label(b, ph, THEME_FONT_SMALL, theme::TEXT));
}
// A switchRow() for something that isn't a plain pref: `cb` gets `idx`.
static void switchRowIdx(lv_obj_t* parent, const char* text, const char* hint, bool on, lv_event_cb_t cb, int idx) {
lv_obj_t* sw = switchRow(parent, text, hint, nullptr);
if (on) lv_obj_add_state(sw, LV_STATE_CHECKED);
lv_obj_add_event_cb(sw, cb, LV_EVENT_VALUE_CHANGED, (void*)(uintptr_t)idx);
}
static void onOpenBot(lv_event_t* e) { (void)e; s_ui->showBot(); }
static lv_obj_t* botHourButton(lv_obj_t* parent, const char* text, uint8_t act, bool accent) {
@@ -65,6 +79,18 @@ static lv_obj_t* botHourButton(lv_obj_t* parent, const char* text, uint8_t act,
return b;
}
// The open tab's Allowed value: it follows that tab's Actions too.
static void botShowAllowed(NodePrefs* p) {
for (int i = 0; i < botcfg::rowCount(botview::s_tab); i++) {
botcfg::Kind k = botcfg::row(botview::s_tab, i).kind;
uint16_t* off = botcfg::cmdOff(p, k);
if (!off || !botview::s_allowed_lbl) continue;
char val[16];
botcmd::shown(*off, botcfg::actionsOn(p, k), val, sizeof(val));
lv_label_set_text(botview::s_allowed_lbl, val);
}
}
void UITask::showBot() {
_screen = SCR_BOT;
buildBot();
@@ -121,37 +147,37 @@ void UITask::buildBot() {
for (int i = 0; i < botcfg::rowCount(s_tab); i++) {
const botcfg::Row& r = botcfg::row(s_tab, i);
uint8_t* fl = botcfg::flag(p, r.kind);
lv_obj_t* row = settingRow(g, r.label, fl || botcfg::isHour(r.kind) ? r.hint : nullptr);
if (fl && botcfg::isScope(r.kind)) {
if (fl && !botcfg::isScope(r.kind)) {
switchRowIdx(g, r.label, r.hint, *fl, onBotSwitch, i);
continue;
}
uint16_t* cmd_off = botcfg::cmdOff(p, r.kind);
const bool gap = r.kind == botcfg::GAP;
lv_obj_t* row = settingRow(g, r.label, fl || cmd_off || gap || botcfg::isHour(r.kind) ? r.hint : nullptr);
if (gap) {
lv_obj_t* seg = segmented(row, botcfg::GAP_LABELS, p->bot_gap_idx, 160, 32);
lv_obj_align(seg, LV_ALIGN_RIGHT_MID, -4, 0);
lv_obj_add_event_cb(seg, onBotGap, LV_EVENT_VALUE_CHANGED, NULL);
continue;
}
if (fl) {
static const char* SCOPE[] = { "All", "Fav", "" };
lv_obj_t* seg = segmented(row, SCOPE, *fl ? 1 : 0, 110, 32);
lv_obj_align(seg, LV_ALIGN_RIGHT_MID, -4, 0);
lv_obj_add_event_cb(seg, onBotScope, LV_EVENT_VALUE_CHANGED, (void*)(uintptr_t)i);
continue;
}
if (fl) {
lv_obj_t* sw = lv_switch_create(row);
lv_obj_set_size(sw, 46, 24);
lv_obj_align(sw, LV_ALIGN_RIGHT_MID, -theme::PAD, 0);
if (*fl) lv_obj_add_state(sw, LV_STATE_CHECKED);
lv_obj_add_event_cb(sw, onBotSwitch, LV_EVENT_VALUE_CHANGED, (void*)(uintptr_t)i);
continue;
}
// Value on the right; tap the row to change it.
char val[48];
int cap;
if (const char* t = botcfg::text(p, r.kind, cap)) snprintf(val, sizeof(val), "%s", botcfg::shownText(t, botcfg::isTrigger(r.kind)));
else if (r.kind == botcfg::CHANNEL) { if (!botcfg::channelName(p, val, sizeof(val))) snprintf(val, sizeof(val), "(pick one)"); }
else if (r.kind == botcfg::ROOM) { if (!botcfg::roomName(p, val, sizeof(val))) snprintf(val, sizeof(val), "(pick one)"); }
else if (cmd_off) botcmd::shown(*cmd_off, botcfg::actionsOn(p, r.kind), val, sizeof(val));
else if (botcfg::quietOff(p)) snprintf(val, sizeof(val), "Off");
else snprintf(val, sizeof(val), "%02d:00", botcfg::hour(p, r.kind));
lv_obj_t* v = label(row, val, THEME_FONT_BODY, theme::ACCENT);
lv_label_set_long_mode(v, LV_LABEL_LONG_DOT);
lv_obj_set_width(v, botcfg::isHour(r.kind) ? 60 : 150); // fixed: LONG_DOT needs a width
lv_obj_set_style_text_align(v, LV_TEXT_ALIGN_RIGHT, 0);
label(row, LV_SYMBOL_RIGHT, THEME_FONT_SMALL, theme::TEXT_MUTED);
rowPressable(row);
lv_obj_add_event_cb(row, onBotRow, LV_EVENT_CLICKED, (void*)(uintptr_t)i);
lv_obj_t* v = rowPick(row, val, botcfg::isHour(r.kind) ? 60 : cmd_off ? 80 : 150, onBotRow, (void*)(uintptr_t)i);
if (cmd_off) s_allowed_lbl = v;
}
}
@@ -167,6 +193,25 @@ void UITask::botToggle(int row, bool on) {
if (!fl) return;
*fl = on ? 1 : 0;
prefsSave();
botShowAllowed(_prefs);
}
void UITask::botGap(int idx) {
if (!_prefs || idx < 0 || idx >= NodePrefs::BOT_GAP_COUNT) return;
_prefs->bot_gap_idx = (uint8_t)idx;
prefsSave();
}
// A command's switch in the Allowed popup: the mask is the row's the popup opened on.
void UITask::botCmd(int id, bool on) {
using namespace botview;
if (!_prefs || s_row < 0 || s_row >= botcfg::rowCount(s_tab)) return;
uint16_t* off = botcfg::cmdOff(_prefs, botcfg::row(s_tab, s_row).kind);
if (!off || id < 0 || id >= botcmd::COUNT) return;
if (on) *off &= ~botcmd::flag(id);
else *off |= botcmd::flag(id);
prefsSave();
botShowAllowed(_prefs);
}
// Tap on a value row: its editor.
@@ -207,6 +252,13 @@ void UITask::botRow(int row) {
return;
}
if (uint16_t* off = botcfg::cmdOff(p, r.kind)) {
lv_obj_t* g = group(scrollList(navPopupPanel("Allowed commands", true)), nullptr);
for (int i = 0; i < botcmd::count(botcfg::actionsOn(p, r.kind)); i++)
switchRowIdx(g, botcmd::CMDS[i].label, botcmd::CMDS[i].hint, !(*off & botcmd::flag(i)), onBotCmd, i);
return;
}
if (r.kind == botcfg::CHANNEL || r.kind == botcfg::ROOM) {
lv_obj_t* panel = navPopupPanel(r.kind == botcfg::CHANNEL ? "Answer on channel" : "Answer in room", true);
lv_obj_t* list = lv_obj_create(panel);
@@ -313,6 +313,8 @@ public:
void botTab(int tab);
void botRow(int row);
void botToggle(int row, bool on);
void botCmd(int id, bool on);
void botGap(int idx);
void botPick(int idx);
void botHour(uint8_t act);
void botTextDone(bool ok);
@@ -908,6 +908,19 @@ static lv_obj_t* choiceRow(lv_obj_t* parent, const char* text, const char* hint,
return c;
}
// A value picked or typed behind the row: cut to `w`, with a chevron, the whole
// row is the tap. Returns the value's label.
static lv_obj_t* rowPick(lv_obj_t* row, const char* text, int w, lv_event_cb_t cb, void* user = nullptr) {
lv_obj_t* v = label(row, text, THEME_FONT_BODY, theme::ACCENT);
lv_label_set_long_mode(v, LV_LABEL_LONG_DOT);
lv_obj_set_width(v, w); // fixed: LONG_DOT needs a width
lv_obj_set_style_text_align(v, LV_TEXT_ALIGN_RIGHT, 0);
label(row, LV_SYMBOL_RIGHT, THEME_FONT_SMALL, theme::TEXT_MUTED);
rowPressable(row);
lv_obj_add_event_cb(row, cb, LV_EVENT_CLICKED, user);
return v;
}
// A value typed in a popup (frequency, name ...): shown as a choice is, with
// a pencil; the tap is the caller's.
static lv_obj_t* rowValue(lv_obj_t* row, const char* text, lv_event_cb_t cb, void* user = nullptr) {
@@ -33,6 +33,10 @@ class BotScreen : public UIScreen {
// steps the hour and Enter/Cancel exits back to normal row browsing.
int _stepper_row; // -1=off, else the row index being stepped
// Allowed rows: the commands list instead of the rows, over this target's mask.
uint16_t* _cmd_off;
int _cmd_sel, _cmd_scroll, _cmd_count;
// 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.
@@ -66,6 +70,27 @@ class BotScreen : public UIScreen {
tabbar::draw(display, botcfg::TAB_LABELS, botcfg::TAB_COUNT, _tab, right_reserve);
}
int renderCommands(DisplayDriver& display) {
drawScreenHeader(display, "Bot commands");
drawList(display, _cmd_count, _cmd_sel, _cmd_scroll, [&](int i, int y, bool sel, int reserve) {
drawRowSelection(display, y, sel, reserve);
info::listRow(display, y, botcmd::CMDS[i].label, (*_cmd_off & botcmd::flag(i)) ? "OFF" : "ON", sel, reserve);
display.setColor(UIColor::primary_txt);
});
return 2000;
}
bool commandsInput(char c) {
if (c == KEY_CANCEL) { _cmd_off = nullptr; return true; }
if (c == KEY_UP) { _cmd_sel = (_cmd_sel + _cmd_count - 1) % _cmd_count; return true; }
if (c == KEY_DOWN) { _cmd_sel = (_cmd_sel + 1) % _cmd_count; return true; }
if (c == KEY_ENTER || keyIsPrev(c) || keyIsNext(c)) {
*_cmd_off ^= botcmd::flag(_cmd_sel);
_dirty = true;
}
return true;
}
public:
BotScreen(UITask* task, NodePrefs* prefs, KeyboardWidget* kb)
: _task(task), _prefs(prefs), _tab(botcfg::TAB_CHANNEL), _kb(kb) {}
@@ -77,6 +102,7 @@ public:
_scroll = 0;
_kb_row = -1;
_stepper_row = -1;
_cmd_off = nullptr;
_dirty = false;
refreshChannels();
refreshRooms();
@@ -89,6 +115,7 @@ public:
if (_kb_row >= 0) {
return _kb->render(display);
}
if (_cmd_off) return renderCommands(display);
// 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.
@@ -123,6 +150,11 @@ public:
shown = botcfg::isScope(r.kind) ? (*fl ? "Fav" : "All") : (*fl ? "ON" : "OFF");
} else if (txt) {
shown = botcfg::shownText(txt, botcfg::isTrigger(r.kind));
} else if (const uint16_t* off = botcfg::cmdOff(_prefs, r.kind)) {
botcmd::shown(*off, botcfg::actionsOn(_prefs, r.kind), name, sizeof(name));
shown = name;
} else if (r.kind == botcfg::GAP) {
shown = botcfg::GAP_LABELS[_prefs->bot_gap_idx];
} else if (r.kind == botcfg::CHANNEL || r.kind == botcfg::ROOM) {
bool any = r.kind == botcfg::CHANNEL ? _num_channels > 0 : _num_rooms > 0;
bool ok = r.kind == botcfg::CHANNEL ? botcfg::channelName(_prefs, name, sizeof(name))
@@ -188,6 +220,8 @@ public:
return true;
}
if (_cmd_off) return commandsInput(c);
if (_stepper_row >= 0) {
uint8_t& h = botcfg::hour(_prefs, tabRow(_tab, _stepper_row).kind);
if (up) { h = (h + 1) % 24; _dirty = true; return true; }
@@ -215,6 +249,12 @@ public:
Kind k = tabRow(_tab, _sel).kind;
if (uint8_t* fl = botcfg::flag(_prefs, k)) { *fl ^= 1; _dirty = true; return true; }
if (uint16_t* off = botcfg::cmdOff(_prefs, k)) {
_cmd_off = off;
_cmd_count = botcmd::count(botcfg::actionsOn(_prefs, k));
_cmd_sel = _cmd_scroll = 0;
return true;
}
int cap = 0;
if (char* txt = botcfg::text(_prefs, k, cap)) {
_kb_row = _sel;
@@ -252,6 +292,10 @@ public:
case botcfg::QUIET_TO:
_stepper_row = _sel;
return true;
case botcfg::GAP:
_prefs->bot_gap_idx = (_prefs->bot_gap_idx + 1) % NodePrefs::BOT_GAP_COUNT;
_dirty = true;
return true;
default: return false;
}
}
@@ -0,0 +1,118 @@
#include <gtest/gtest.h>
#include <cstring>
// Which ! commands a target answers (ui-core/BotCommands.h).
#include "../../examples/companion_radio/ui-core/BotCommands.h"
using namespace botcmd;
TEST(BotCommands, CommandsOnlyAtTheStart) {
EXPECT_STREQ(start("!batt !time"), "!batt !time");
EXPECT_STREQ(start(" !batt"), "!batt");
EXPECT_EQ(start("don't write !loc here"), nullptr);
EXPECT_EQ(start("cmds: !ping !buzz !advert"), nullptr); // another bot's !help
EXPECT_EQ(start("hello"), nullptr);
EXPECT_EQ(start(""), nullptr);
}
TEST(BotCommands, PastAReplyPrefix) {
EXPECT_STREQ(start("@[Bob] !batt"), "!batt");
EXPECT_STREQ(start("@[Bob]!batt"), "!batt");
EXPECT_EQ(start("@[Bob] thanks !batt"), nullptr);
EXPECT_EQ(start("@[Bob !batt"), nullptr);
}
TEST(BotCommands, EverythingOnByDefault) {
for (int i = 0; i < COUNT; i++) EXPECT_TRUE(enabled(i == GPIO ? "gpio1" : CMDS[i].name, 0)) << CMDS[i].name;
}
TEST(BotCommands, FindsTheTableNames) {
EXPECT_EQ(find("ping"), PING);
EXPECT_EQ(find("status"), STATUS);
EXPECT_EQ(find("advert"), ADVERT);
}
TEST(BotCommands, TheFourPinsAreOneSwitch) {
for (const char* c : { "gpio1", "gpio2", "gpio3", "gpio4" }) EXPECT_EQ(find(c), GPIO);
EXPECT_EQ(find("gpio5"), -1);
EXPECT_EQ(find("gpio12"), -1);
EXPECT_EQ(find("gpio1-4"), -1); // the row's name, not something to type
EXPECT_FALSE(enabled("gpio3", flag(GPIO)));
}
TEST(BotCommands, UnknownWordsAreNotBlocked) {
EXPECT_EQ(find("hello"), -1);
EXPECT_TRUE(enabled("hello", 0xFFFF));
}
TEST(BotCommands, OneOffLeavesTheOthers) {
EXPECT_FALSE(enabled("batt", flag(BATT)));
EXPECT_TRUE(enabled("ping", flag(BATT)));
EXPECT_TRUE(enabled("temp", flag(BATT)));
}
TEST(BotCommands, LocOffHidesThePositionEverywhere) {
const uint16_t off = flag(LOC);
EXPECT_FALSE(position(off));
EXPECT_FALSE(enabled("loc", off));
EXPECT_FALSE(enabled("dist", off));
EXPECT_TRUE(enabled("status", off)); // answers, without the position
EXPECT_TRUE(enabled("gps", off)); // on / off still work, the fix doesn't
}
TEST(BotCommands, DistOffKeepsLoc) {
EXPECT_TRUE(position(flag(DIST)));
EXPECT_TRUE(enabled("loc", flag(DIST)));
EXPECT_FALSE(enabled("dist", flag(DIST)));
}
TEST(BotCommands, HelpListsAllWithoutItself) {
char b[128];
help(b, sizeof(b), 0, true);
EXPECT_STREQ(b, "cmds: !ping !batt !temp !time !loc !hops !path !snr !dist !status !gps !buzz !advert !gpio1-4");
}
TEST(BotCommands, HelpLeavesOutActionsWhenNotAllowed) {
char b[128];
help(b, sizeof(b), 0, false);
EXPECT_STREQ(b, "cmds: !ping !batt !temp !time !loc !hops !path !snr !dist !status");
}
TEST(BotCommands, HelpLeavesOutWhatIsOff) {
char b[128];
help(b, sizeof(b), flag(LOC) | flag(BATT) | flag(GPS), true);
EXPECT_STREQ(b, "cmds: !ping !temp !time !hops !path !snr !status !buzz !advert !gpio1-4");
}
TEST(BotCommands, HelpStopsAtTheBuffer) {
char b[12];
memset(b, 'x', sizeof(b));
help(b, sizeof(b), 0, true);
EXPECT_LT(strlen(b), sizeof(b));
}
TEST(BotCommands, RowValue) {
char b[16];
shown(0, true, b, sizeof(b));
EXPECT_STREQ(b, "All");
shown(flag(LOC) | flag(DIST), true, b, sizeof(b));
EXPECT_STREQ(b, "13 of 15");
shown(0xFFFF, true, b, sizeof(b));
EXPECT_STREQ(b, "0 of 15");
}
TEST(BotCommands, RowValueWithoutActionsCountsWhatIsListed) {
char b[16];
EXPECT_EQ(count(false), (int)GPS);
EXPECT_EQ(count(true), (int)COUNT);
shown(flag(GPS) | flag(GPIO), false, b, sizeof(b)); // hidden: not counted
EXPECT_STREQ(b, "All");
shown(flag(LOC) | flag(GPS), false, b, sizeof(b));
EXPECT_STREQ(b, "10 of 11");
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}