mirror of
https://github.com/MarekZegare4/MeshCore-Solo.git
synced 2026-07-26 14:58:12 +00:00
- Bot Actions (!buzz/!gps/!advert/!gpio1-4) ran their side effect immediately during botScanCommands(), before quiet-hours/cooldown/ per-contact throttle were checked -- those gates only suppressed the reply text, not the actual buzz/GPS toggle/advert/pin write. botCommandReply() now only records what was requested; applyPendingBotActions() runs the deferred effects once a wrapper's throttle checks pass and the ack sent, mirroring the existing _locfix_requested pattern. resetPendingBotActions() clears everything on every throttled/aborted path. - CardKB's Fn+<letter> accent-popup shortcut bypassed the locked-input gate (it called into KeyboardWidget directly instead of through the enqueueKey()/dequeue path every other key uses, so it wasn't discarded while _locked). Now checks _locked itself. - Since a locked device now correctly ignores CardKB entirely, Fn+Esc (single press) is added as CardKB's own lock/unlock gesture -- otherwise a CardKB-only setup had no way to unlock. Esc rather than the adjacent Fn+Backspace, to avoid an accidental press. - botScanCommands() now parses up to two arguments per command instead of one. Used by "!gps fix [seconds]" to override the default 90s timeout (clamped 15-300s) for a poor sky view where 90s isn't always enough to reach isLocFixReady()'s HDOP/satellite bar. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
680 lines
31 KiB
C
680 lines
31 KiB
C
#pragma once
|
|
// 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.
|
|
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;
|
|
|
|
// 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-
|
|
// all mode); honoured only 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')
|
|
return allow_wildcard;
|
|
|
|
char low[BOT_SCRATCH];
|
|
size_t n = strlen(body);
|
|
if (n >= sizeof(low)) n = sizeof(low) - 1;
|
|
for (size_t i = 0; i < n; i++) low[i] = (char)tolower((uint8_t)body[i]);
|
|
low[n] = '\0';
|
|
|
|
// Multiple phrases can be packed into one Trigger field, comma-separated
|
|
// ("hi,hello there,yo") -- any single phrase matching is enough. Each
|
|
// phrase is trimmed of surrounding spaces so "foo, bar" and "foo,bar"
|
|
// behave the same; empty phrases (a stray/trailing comma) are skipped.
|
|
for (const char* p = trigger; *p; ) {
|
|
while (*p == ' ' || *p == ',') p++; // skip separators / leading space
|
|
if (!*p) break;
|
|
const char* start = p;
|
|
while (*p && *p != ',') p++; // phrase runs to the next comma (or end)
|
|
const char* end = p;
|
|
while (end > start && end[-1] == ' ') end--; // trim trailing space
|
|
size_t plen = (size_t)(end - start);
|
|
if (plen == 0) continue;
|
|
|
|
char low_trigger[64];
|
|
if (plen >= sizeof(low_trigger)) plen = sizeof(low_trigger) - 1;
|
|
for (size_t i = 0; i < plen; i++) low_trigger[i] = (char)tolower((uint8_t)start[i]);
|
|
low_trigger[plen] = '\0';
|
|
|
|
if (strstr(low, low_trigger)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// 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");
|
|
}
|
|
|
|
void MyMesh::botChannelSenderSplit(const char* text, char* sender_name, int sender_name_len, const char** msg_out) {
|
|
*msg_out = text;
|
|
snprintf(sender_name, sender_name_len, "someone");
|
|
const char* sep = strstr(text, ": ");
|
|
if (sep) {
|
|
*msg_out = sep + 2;
|
|
int n = (int)(sep - text);
|
|
if (n > 0 && n < sender_name_len) { memcpy(sender_name, text, n); sender_name[n] = '\0'; }
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
bool MyMesh::botDmAllowed(const uint8_t* pubkey) {
|
|
unsigned long now = millis();
|
|
for (int i = 0; i < BOT_DM_LOG_SIZE; i++) {
|
|
if (_bot_dm_log[i].used && memcmp(_bot_dm_log[i].key, pubkey, 4) == 0)
|
|
return now - _bot_dm_log[i].t_ms > BOT_REPLY_COOLDOWN_MS;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
void MyMesh::botDmRecord(const uint8_t* pubkey) {
|
|
unsigned long now = millis();
|
|
for (int i = 0; i < BOT_DM_LOG_SIZE; i++) { // refresh existing entry
|
|
if (_bot_dm_log[i].used && memcmp(_bot_dm_log[i].key, pubkey, 4) == 0) {
|
|
_bot_dm_log[i].t_ms = now;
|
|
return;
|
|
}
|
|
}
|
|
int target = -1; // else take a free slot...
|
|
for (int i = 0; i < BOT_DM_LOG_SIZE; i++)
|
|
if (!_bot_dm_log[i].used) { target = i; break; }
|
|
if (target < 0) { // ...or evict the oldest
|
|
target = 0;
|
|
for (int i = 1; i < BOT_DM_LOG_SIZE; i++)
|
|
if (_bot_dm_log[i].t_ms < _bot_dm_log[target].t_ms) target = i;
|
|
}
|
|
memcpy(_bot_dm_log[target].key, pubkey, 4);
|
|
_bot_dm_log[target].t_ms = now;
|
|
_bot_dm_log[target].used = true;
|
|
}
|
|
|
|
// Quiet hours: when the local hour is inside [start, end) the auto-reply paths
|
|
// stay silent (commands are explicitly requested, so they ignore this). A zero-
|
|
// width window (start == end) disables the feature; a window where start > end
|
|
// wraps past midnight.
|
|
bool MyMesh::botInQuietHours() const {
|
|
if (_prefs.bot_quiet_start == _prefs.bot_quiet_end) return false; // disabled
|
|
uint32_t utc = getRTCClock()->getCurrentTime();
|
|
if (utc < 1000000000UL) return false; // clock not set — don't suppress
|
|
uint32_t local = utc + (int32_t)_prefs.tz_offset_hours * 3600;
|
|
int h = (int)((local / 3600) % 24);
|
|
int s = _prefs.bot_quiet_start, e = _prefs.bot_quiet_end;
|
|
return (s < e) ? (h >= s && h < e) // same-day window
|
|
: (h >= s || h < e); // overnight window
|
|
}
|
|
|
|
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
|
|
|
|
uint32_t ts = getRTCClock()->getCurrentTime();
|
|
char expanded[BOT_SCRATCH];
|
|
expandMsg(_prefs.bot_reply_dm, 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,
|
|
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);
|
|
_bot_reply_count++;
|
|
#ifdef DISPLAY_CLASS
|
|
if (_ui) _ui->addDMMsg(from.id.pub_key, true, expanded);
|
|
#endif
|
|
}
|
|
}
|
|
|
|
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;
|
|
|
|
// 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;
|
|
char sender_name[32];
|
|
botChannelSenderSplit(text, sender_name, sizeof(sender_name), &msg);
|
|
|
|
if (!botTriggerMatches(_prefs.bot_trigger_ch, msg, true)) return; // own trigger; "*" = any msg
|
|
|
|
ChannelDetails ch;
|
|
if (!getChannel(channel_idx, ch)) return;
|
|
|
|
uint32_t ts = getRTCClock()->getCurrentTime();
|
|
char expanded[BOT_SCRATCH];
|
|
expandMsg(_prefs.bot_reply_ch, 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: 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.
|
|
if (strcmp(msg, expanded) == 0) return;
|
|
int rlen = strlen(expanded);
|
|
if (sendGroupMessage(ts, ch.channel, _prefs.node_name, expanded, rlen)) {
|
|
_bot_last_ch_reply_ms = millis();
|
|
_bot_reply_count++;
|
|
#ifdef DISPLAY_CLASS
|
|
if (_ui) {
|
|
char with_sender[240]; // node_name(32) + ": "(2) + expanded(200) + margin
|
|
snprintf(with_sender, sizeof(with_sender), "%s: %s", _prefs.node_name, expanded);
|
|
_ui->addChannelMsg(channel_idx, with_sender);
|
|
}
|
|
#endif
|
|
}
|
|
}
|
|
|
|
// 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; 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, const char* arg, const char* arg2, bool actions_allowed,
|
|
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);
|
|
out[out_len - 1] = '\0';
|
|
return true;
|
|
}
|
|
|
|
// Action commands change device state, unlike every query below — gated by
|
|
// this target's own bot_actions_* toggle (nested under Commands being on),
|
|
// not by the templating/expandMsg mechanism the queries share.
|
|
if (actions_allowed) {
|
|
if (!strcmp(cmd, "gps")) {
|
|
// "fix [seconds]" -- single-shot: turn GPS on (if not already), wait for
|
|
// a stabilised position, send it, then restore GPS to whatever it was
|
|
// before. This command can't reply synchronously like every other one
|
|
// here (a fix takes seconds-to-minutes), so it acks immediately and
|
|
// sets _locfix_requested; the tryBot*Command() wrapper that called us
|
|
// knows the destination and starts the actual wait (see MyMesh.h).
|
|
// Optional 2nd argument overrides the default LOCFIX_TIMEOUT_MS budget
|
|
// -- useful under poor sky view, where 90s isn't always enough to reach
|
|
// isLocFixReady()'s HDOP/satellite bar. Clamped to a sane range: floor
|
|
// covers LOCFIX_AVERAGE_MS (no point requesting less than one averaging
|
|
// window), ceiling keeps a forgotten/misfired request from parking GPS
|
|
// on indefinitely.
|
|
if (!strcmp(arg, "fix")) {
|
|
if (_loc_fix.active) { snprintf(out, out_len, "GPS: fix already pending"); return true; }
|
|
uint32_t timeout_ms = LOCFIX_TIMEOUT_MS;
|
|
if (arg2[0]) {
|
|
int secs = atoi(arg2);
|
|
if (secs < 15) secs = 15;
|
|
if (secs > 300) secs = 300;
|
|
timeout_ms = (uint32_t)secs * 1000;
|
|
}
|
|
_locfix_requested = true;
|
|
_locfix_requested_timeout_ms = timeout_ms;
|
|
snprintf(out, out_len, "GPS: acquiring fix...");
|
|
return true;
|
|
}
|
|
bool on = !strcmp(arg, "on"); // arg was lowercased while parsing (see botScanCommands)
|
|
bool off = !strcmp(arg, "off");
|
|
if (!on && !off) { snprintf(out, out_len, "gps: on|off|fix?"); return true; }
|
|
// Deferred: applyPendingBotActions() actually flips the GPS state, once
|
|
// quiet-hours/cooldown/throttle have passed (see MyMesh.h).
|
|
_bot_gps_action_pending = true;
|
|
_bot_gps_action_on = on;
|
|
snprintf(out, out_len, "GPS: %s", on ? "on" : "off");
|
|
return true;
|
|
}
|
|
if (!strcmp(cmd, "buzz")) {
|
|
int secs = arg[0] ? atoi(arg) : 5;
|
|
if (secs < 1) secs = 5;
|
|
if (secs > 30) secs = 30;
|
|
_bot_buzz_action_secs = secs; // deferred -- see applyPendingBotActions()
|
|
snprintf(out, out_len, "Buzzing");
|
|
return true;
|
|
}
|
|
if (!strcmp(cmd, "advert")) {
|
|
// Deferred -- can no longer report advert()'s own success/failure here
|
|
// (it doesn't run until applyPendingBotActions()), so the ack is just
|
|
// an acknowledgement, same spirit as !gps fix's "acquiring fix...".
|
|
_bot_advert_action_pending = true;
|
|
snprintf(out, out_len, "Advert requested");
|
|
return true;
|
|
}
|
|
// !gpio1..!gpio4 -- user-assignable pins (see UITask::botSetGPIO/
|
|
// botGetGPIO/botGetGPIOAnalog; no-op on boards without them). Bare (no
|
|
// arg) reports current mode+level (millivolts if in Analog mode, GPIO1/
|
|
// GPIO2 only); on/off only takes effect if that pin's Tools > GPIO mode
|
|
// is currently Output (Analog/Input/Off all reply "not output"). The
|
|
// Output check itself is read-only (botGetGPIO), so it's safe to do now
|
|
// for an accurate reply; only the actual pin write is deferred.
|
|
if (!strncmp(cmd, "gpio", 4) && cmd[4] >= '1' && cmd[4] <= '4' && cmd[5] == '\0') {
|
|
int idx = cmd[4] - '0';
|
|
if (arg[0]) {
|
|
bool on = !strcmp(arg, "on");
|
|
bool off = !strcmp(arg, "off");
|
|
if (!on && !off) { snprintf(out, out_len, "gpio%d: on|off?", idx); return true; }
|
|
bool is_out = false, val = false;
|
|
if (_ui && _ui->botGetGPIO(idx, is_out, val) && is_out) {
|
|
_bot_gpio_action[idx - 1] = on ? 1 : 0; // deferred -- see applyPendingBotActions()
|
|
snprintf(out, out_len, "gpio%d: %s", idx, on ? "on" : "off");
|
|
} else {
|
|
snprintf(out, out_len, "gpio%d: not output", idx);
|
|
}
|
|
return true;
|
|
}
|
|
int mv = 0;
|
|
bool is_out = false, val = false;
|
|
if (_ui && _ui->botGetGPIOAnalog(idx, mv)) {
|
|
snprintf(out, out_len, "gpio%d: %dmV", idx, mv);
|
|
} else if (_ui && _ui->botGetGPIO(idx, is_out, val)) {
|
|
snprintf(out, out_len, "gpio%d: %s %s", idx, is_out ? "out" : "in", val ? "on" : "off");
|
|
} else {
|
|
snprintf(out, out_len, "gpio%d: off", idx);
|
|
}
|
|
return true;
|
|
}
|
|
}
|
|
|
|
const char* tmpl = nullptr;
|
|
if (!strcmp(cmd, "ping")) tmpl = "pong";
|
|
else if (!strcmp(cmd, "batt")) tmpl = "{batt}";
|
|
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 !status !gps !buzz !advert !gpio1-4"
|
|
: "cmds: !ping !batt !loc !time !temp !hops !status";
|
|
else return false; // unknown — let the trigger bot try
|
|
|
|
expandMsg(tmpl, out, out_len,
|
|
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);
|
|
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, 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.
|
|
int MyMesh::botScanCommands(const char* body, uint8_t hops, uint32_t ts, char* out, int out_len,
|
|
const char* sender_name, bool actions_allowed) {
|
|
resetPendingBotActions(); // pending actions/flags below are set by tokens in this scan;
|
|
// caller applies or clears them once it knows if the reply sent
|
|
int oi = 0;
|
|
int matched = 0;
|
|
for (const char* p = body; *p; ) {
|
|
while (*p == ' ') p++; // skip whitespace
|
|
if (*p != '!') { // not a command token
|
|
while (*p && *p != ' ') p++;
|
|
continue;
|
|
}
|
|
p++; // step past '!'
|
|
char cmd[16];
|
|
int ci = 0;
|
|
while (*p && *p != ' ' && ci < (int)sizeof(cmd) - 1)
|
|
cmd[ci++] = (char)tolower((uint8_t)*p++);
|
|
cmd[ci] = '\0';
|
|
while (*p && *p != ' ') p++; // consume any overflow of this token
|
|
if (!cmd[0]) continue;
|
|
|
|
// Up to two optional arguments: the next one or two space-delimited
|
|
// tokens, unless a token is itself the start of another command ("!").
|
|
// E.g. "!gps fix 120 !batt" -- "fix"/"120" are !gps's arguments, "!batt"
|
|
// starts the next command. Almost every command only reads arg1 (arg2 is
|
|
// always "" for them) -- a stray second word after e.g. "!gpio1 on foo"
|
|
// just gets consumed as arg2 and ignored, same net effect as before this
|
|
// supported two tokens. Only "!gps fix" currently reads arg2 (an optional
|
|
// timeout override, see botCommandReply).
|
|
while (*p == ' ') p++;
|
|
char arg1[16] = "", arg2[16] = "";
|
|
if (*p && *p != '!') {
|
|
int ai = 0;
|
|
while (*p && *p != ' ' && ai < (int)sizeof(arg1) - 1) arg1[ai++] = (char)tolower((uint8_t)*p++);
|
|
arg1[ai] = '\0';
|
|
while (*p && *p != ' ') p++; // consume any overflow of this token
|
|
while (*p == ' ') p++;
|
|
if (*p && *p != '!') {
|
|
int bi = 0;
|
|
while (*p && *p != ' ' && bi < (int)sizeof(arg2) - 1) arg2[bi++] = (char)tolower((uint8_t)*p++);
|
|
arg2[bi] = '\0';
|
|
while (*p && *p != ' ') p++; // consume any overflow of this token
|
|
}
|
|
}
|
|
|
|
char seg[80];
|
|
if (!botCommandReply(cmd, arg1, arg2, actions_allowed, hops, ts, seg, sizeof(seg), sender_name)) continue; // unknown
|
|
|
|
if (matched > 0 && oi + 3 < out_len) { // " | " separator
|
|
out[oi++] = ' '; out[oi++] = '|'; out[oi++] = ' ';
|
|
}
|
|
int sl = strlen(seg);
|
|
if (oi + sl >= out_len) sl = out_len - 1 - oi;
|
|
if (sl > 0) { memcpy(out + oi, seg, sl); oi += sl; }
|
|
matched++;
|
|
}
|
|
out[oi] = '\0';
|
|
return matched;
|
|
}
|
|
|
|
// DM query commands. The reply is a pull, not a push, so it ignores quiet hours;
|
|
// the per-contact throttle still applies. Returns true when at least one command
|
|
// was found (replied or throttled), so the caller skips the trigger-reply path.
|
|
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), from.name, _prefs.bot_actions_dm != 0) == 0) return false; // no commands
|
|
if (!botDmAllowed(from.id.pub_key)) { resetPendingBotActions(); return true; } // throttled
|
|
|
|
uint32_t expected_ack, est_timeout;
|
|
if (sendMessage(from, ts, 0, out, expected_ack, est_timeout) != MSG_SEND_FAILED) {
|
|
botDmRecord(from.id.pub_key);
|
|
_bot_reply_count++;
|
|
#ifdef DISPLAY_CLASS
|
|
if (_ui) _ui->addDMMsg(from.id.pub_key, true, out);
|
|
#endif
|
|
if (_locfix_requested) startLocFix(LOCFIX_DEST_CONTACT, from.id.pub_key, 0);
|
|
applyPendingBotActions();
|
|
}
|
|
resetPendingBotActions();
|
|
return true;
|
|
}
|
|
|
|
// Channel query commands — only on the bot's monitored channel. The reply is
|
|
// 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_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;
|
|
char sender_name[32];
|
|
botChannelSenderSplit(text, sender_name, sizeof(sender_name), &msg);
|
|
|
|
uint32_t ts = getRTCClock()->getCurrentTime();
|
|
char out[BOT_SCRATCH];
|
|
if (botScanCommands(msg, hops, ts, out, sizeof(out), sender_name, _prefs.bot_actions_ch != 0) == 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
|
|
|
|
ChannelDetails ch;
|
|
if (!getChannel(channel_idx, ch)) { resetPendingBotActions(); return true; }
|
|
int rlen = strlen(out);
|
|
if (sendGroupMessage(ts, ch.channel, _prefs.node_name, out, rlen)) {
|
|
_bot_last_ch_reply_ms = millis();
|
|
_bot_reply_count++;
|
|
#ifdef DISPLAY_CLASS
|
|
if (_ui) {
|
|
char with_sender[240];
|
|
snprintf(with_sender, sizeof(with_sender), "%s: %s", _prefs.node_name, out);
|
|
_ui->addChannelMsg(channel_idx, with_sender);
|
|
}
|
|
#endif
|
|
if (_locfix_requested) startLocFix(LOCFIX_DEST_CHANNEL, nullptr, channel_idx);
|
|
applyPendingBotActions();
|
|
}
|
|
resetPendingBotActions();
|
|
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, _prefs.bot_actions_room != 0) == 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
|
|
|
|
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
|
|
if (_locfix_requested) startLocFix(LOCFIX_DEST_CONTACT, from.id.pub_key, 0);
|
|
applyPendingBotActions();
|
|
}
|
|
resetPendingBotActions();
|
|
return true;
|
|
}
|
|
|
|
// Actually runs the state-changing bot actions botCommandReply() recorded
|
|
// this scan (!gps on|off, !buzz, !advert, !gpio1..4 on|off) -- called by the
|
|
// tryBot*Command() wrappers only after quiet-hours/cooldown/per-contact
|
|
// throttle passed and the ack itself sent, so those gates genuinely block the
|
|
// action, not just the reply text (see the _bot_*_action_* fields in
|
|
// MyMesh.h). !gps fix's startLocFix() is armed separately by the caller (it
|
|
// needs the destination, which this function doesn't have).
|
|
void MyMesh::applyPendingBotActions() {
|
|
if (_bot_gps_action_pending && _ui) _ui->botSetGPS(_bot_gps_action_on);
|
|
if (_bot_buzz_action_secs > 0 && _ui) _ui->botBuzz(_bot_buzz_action_secs);
|
|
if (_bot_advert_action_pending) advert();
|
|
for (int i = 0; i < 4; i++) {
|
|
if (_bot_gpio_action[i] >= 0 && _ui) _ui->botSetGPIO(i + 1, _bot_gpio_action[i] != 0);
|
|
}
|
|
}
|
|
|
|
// Clears every pending-action flag botCommandReply() may have set this scan,
|
|
// without running them -- used both at scan start and on every throttled/
|
|
// aborted path, so a suppressed reply never leaves a stale action armed for
|
|
// next time.
|
|
void MyMesh::resetPendingBotActions() {
|
|
_locfix_requested = false;
|
|
_locfix_requested_timeout_ms = LOCFIX_TIMEOUT_MS;
|
|
_bot_gps_action_pending = false;
|
|
_bot_buzz_action_secs = 0;
|
|
_bot_advert_action_pending = false;
|
|
for (int i = 0; i < 4; i++) _bot_gpio_action[i] = -1;
|
|
}
|
|
|
|
// Arms the "!gps fix" wait: turns GPS on (remembering whether it already was,
|
|
// so tickLocFix() restores rather than assumes "off"), and resets the
|
|
// averaging accumulator. pub_key is only read for LOCFIX_DEST_CONTACT (DM or
|
|
// room -- both reply via sendMessage, see sendLocFixResult()); pass nullptr
|
|
// for LOCFIX_DEST_CHANNEL.
|
|
void MyMesh::startLocFix(uint8_t dest_type, const uint8_t* pub_key, uint8_t channel_idx) {
|
|
_loc_fix.active = true;
|
|
_loc_fix.dest_type = dest_type;
|
|
if (pub_key) memcpy(_loc_fix.pub_key, pub_key, PUB_KEY_SIZE);
|
|
_loc_fix.channel_idx = channel_idx;
|
|
_loc_fix.sum_lat = 0.0;
|
|
_loc_fix.sum_lon = 0.0;
|
|
_loc_fix.sample_count = 0;
|
|
_loc_fix.averaging_until_ms = 0;
|
|
_loc_fix.deadline_ms = futureMillis(_locfix_requested_timeout_ms);
|
|
_loc_fix.gps_was_on = (_prefs.gps_enabled != 0);
|
|
if (!_loc_fix.gps_was_on && _ui) _ui->botSetGPS(true);
|
|
}
|
|
|
|
// !gps fix state machine, ticked every MyMesh::loop() while _loc_fix.active.
|
|
// Phase 1 (acquire): wait for a valid fix good enough to trust -- HDOP (fix
|
|
// geometry) when the provider exposes it, satellite count as a cruder
|
|
// fallback when it doesn't (see isLocFixReady()). Phase 2 (average): once
|
|
// that's first met, keep summing node_lat/node_lon for LOCFIX_AVERAGE_MS more
|
|
// -- only on ticks where the threshold still holds, so a momentary dip just
|
|
// skips a sample instead of aborting the whole wait. A hard LOCFIX_TIMEOUT_MS
|
|
// deadline covers both phases; on timeout with zero samples collected the
|
|
// result is a plain failure (no stale-cache fallback -- a cached node_lat/lon
|
|
// from long before this request would be actively misleading here).
|
|
bool MyMesh::isLocFixReady(LocationProvider* loc) {
|
|
if (!loc || !loc->isValid()) return false;
|
|
long hdop = loc->getHDOP();
|
|
if (hdop >= 0) return hdop <= LOCFIX_MAX_HDOP; // preferred: real fix-quality signal
|
|
return loc->satellitesCount() >= LOCFIX_MIN_SATS; // provider has no HDOP -- cruder fallback
|
|
}
|
|
|
|
void MyMesh::tickLocFix() {
|
|
if (!_loc_fix.active) return;
|
|
|
|
LocationProvider* loc = sensors.getLocationProvider();
|
|
bool ready = isLocFixReady(loc);
|
|
|
|
if (_loc_fix.averaging_until_ms == 0 && ready) {
|
|
_loc_fix.averaging_until_ms = futureMillis(LOCFIX_AVERAGE_MS);
|
|
}
|
|
if (_loc_fix.averaging_until_ms != 0 && ready) {
|
|
_loc_fix.sum_lat += sensors.node_lat;
|
|
_loc_fix.sum_lon += sensors.node_lon;
|
|
_loc_fix.sample_count++;
|
|
}
|
|
|
|
bool averaging_done = _loc_fix.averaging_until_ms != 0 && millisHasNowPassed(_loc_fix.averaging_until_ms);
|
|
bool timed_out = millisHasNowPassed(_loc_fix.deadline_ms);
|
|
if (!averaging_done && !timed_out) return; // still waiting
|
|
|
|
char msg[BOT_SCRATCH];
|
|
if (_loc_fix.sample_count > 0) {
|
|
double avg_lat = _loc_fix.sum_lat / _loc_fix.sample_count;
|
|
double avg_lon = _loc_fix.sum_lon / _loc_fix.sample_count;
|
|
snprintf(msg, sizeof(msg), averaging_done ? "loc: %.5f,%.5f" : "loc: %.5f,%.5f (partial fix)", avg_lat, avg_lon);
|
|
} else {
|
|
snprintf(msg, sizeof(msg), "GPS: no fix (timeout)");
|
|
}
|
|
sendLocFixResult(msg);
|
|
|
|
if (!_loc_fix.gps_was_on && _ui) _ui->botSetGPS(false);
|
|
_loc_fix.active = false;
|
|
}
|
|
|
|
// Delivers tickLocFix()'s result to wherever the original "!gps fix" came
|
|
// from. Not gated by quiet hours/cooldown -- that gate already ran once, on
|
|
// the immediate ack (see the tryBot*Command() wrappers); this is the tail of
|
|
// an already-approved interaction, not a new proactive reply. Still updates
|
|
// the same cooldown timestamps/counters on success so subsequent trigger/
|
|
// command replies stay correctly throttled afterward.
|
|
void MyMesh::sendLocFixResult(const char* msg) {
|
|
uint32_t ts = getRTCClock()->getCurrentTime();
|
|
if (_loc_fix.dest_type == LOCFIX_DEST_CHANNEL) {
|
|
ChannelDetails ch;
|
|
if (!getChannel(_loc_fix.channel_idx, ch)) return;
|
|
int mlen = strlen(msg);
|
|
if (sendGroupMessage(ts, ch.channel, _prefs.node_name, msg, mlen)) {
|
|
_bot_last_ch_reply_ms = millis();
|
|
_bot_reply_count++;
|
|
#ifdef DISPLAY_CLASS
|
|
if (_ui) {
|
|
char with_sender[240];
|
|
snprintf(with_sender, sizeof(with_sender), "%s: %s", _prefs.node_name, msg);
|
|
_ui->addChannelMsg(_loc_fix.channel_idx, with_sender);
|
|
}
|
|
#endif
|
|
}
|
|
} else { // LOCFIX_DEST_CONTACT -- DM or room, both go through sendMessage
|
|
ContactInfo* c = lookupContactByPubKey(_loc_fix.pub_key, PUB_KEY_SIZE);
|
|
if (!c) return; // contact removed while we were waiting
|
|
uint32_t expected_ack, est_timeout;
|
|
if (sendMessage(*c, ts, 0, msg, expected_ack, est_timeout) != MSG_SEND_FAILED) {
|
|
if (c->type == ADV_TYPE_ROOM) _bot_last_room_reply_ms = millis();
|
|
else botDmRecord(c->id.pub_key);
|
|
_bot_reply_count++;
|
|
#ifdef DISPLAY_CLASS
|
|
if (_ui) _ui->addDMMsg(c->id.pub_key, true, msg);
|
|
#endif
|
|
}
|
|
}
|
|
}
|