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
+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) {