Bot: add DM contact target alongside channel

The bot can now reply to a specific DM contact instead of a channel.

UI (BotScreen): item 1 cycles through all channels followed by all DM
contacts (ADV_TYPE_CHAT). The label switches between "Channel" / "Contact"
and the value shows the channel or contact name. Left/right/enter navigate
the combined list.

Logic (MyMesh::onMessageRecv): when bot_target_type==1, match the trigger
against incoming DM text and reply via sendMessage(). A 4-byte pubkey
prefix identifies the target; all-zero prefix matches any DM sender.

Persistence: bot_target_type and bot_dm_pubkey[4] appended to prefs with
backward-compat read guard (old firmware defaults to channel mode).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-05-12 13:48:27 +02:00
parent 9756bb062c
commit e638955898
4 changed files with 109 additions and 19 deletions

View File

@@ -258,6 +258,10 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
file.read((uint8_t *)&_prefs.bot_channel_idx, sizeof(_prefs.bot_channel_idx));
file.read((uint8_t *)_prefs.bot_trigger, sizeof(_prefs.bot_trigger));
file.read((uint8_t *)_prefs.bot_reply, sizeof(_prefs.bot_reply));
if (file.available()) {
file.read((uint8_t *)&_prefs.bot_target_type, sizeof(_prefs.bot_target_type));
file.read((uint8_t *)_prefs.bot_dm_pubkey, sizeof(_prefs.bot_dm_pubkey));
}
}
}
}
@@ -322,6 +326,8 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_
file.write((uint8_t *)&_prefs.bot_channel_idx, sizeof(_prefs.bot_channel_idx));
file.write((uint8_t *)_prefs.bot_trigger, sizeof(_prefs.bot_trigger));
file.write((uint8_t *)_prefs.bot_reply, sizeof(_prefs.bot_reply));
file.write((uint8_t *)&_prefs.bot_target_type, sizeof(_prefs.bot_target_type));
file.write((uint8_t *)_prefs.bot_dm_pubkey, sizeof(_prefs.bot_dm_pubkey));
file.close();
}

View File

@@ -512,6 +512,33 @@ void MyMesh::onMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t
const char *text) {
markConnectionActive(from); // in case this is from a server, and we have a connection
queueMessage(from, TXT_TYPE_PLAIN, pkt, sender_timestamp, NULL, 0, text);
// DM auto-reply bot
if (_prefs.bot_enabled && _prefs.bot_target_type == 1 &&
_prefs.bot_trigger[0] && _prefs.bot_reply[0] &&
millis() - _bot_last_reply_ms > 10000UL) {
// all-zero pubkey = any sender; otherwise match first 4 bytes
bool key_ok = (_prefs.bot_dm_pubkey[0] == 0 && _prefs.bot_dm_pubkey[1] == 0 &&
_prefs.bot_dm_pubkey[2] == 0 && _prefs.bot_dm_pubkey[3] == 0) ||
memcmp(from.id.pub_key, _prefs.bot_dm_pubkey, 4) == 0;
if (key_ok) {
const char* t = text;
const char* tr = _prefs.bot_trigger;
int tlen = strlen(tr);
bool matched = false;
for (; *t && !matched; t++) {
int i = 0;
while (i < tlen && tolower((uint8_t)t[i]) == tolower((uint8_t)tr[i])) i++;
if (i == tlen) matched = true;
}
if (matched) {
uint32_t ts = getRTCClock()->getCurrentTime();
uint32_t expected_ack, est_timeout;
if (sendMessage(from, ts, 0, _prefs.bot_reply, expected_ack, est_timeout) != MSG_SEND_FAILED)
_bot_last_reply_ms = millis();
}
}
}
}
void MyMesh::onCommandDataRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp,
@@ -899,6 +926,8 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe
_prefs.bot_channel_idx = 0;
_prefs.bot_trigger[0] = '\0';
_prefs.bot_reply[0] = '\0';
_prefs.bot_target_type = 0;
memset(_prefs.bot_dm_pubkey, 0, sizeof(_prefs.bot_dm_pubkey));
_prefs.auto_off_secs = 15; // 15 seconds auto-off by default
_prefs.tz_offset_hours = 0; // UTC by default
_prefs.low_batt_mv = 3400; // auto-shutdown at 3.4V by default

View File

@@ -50,7 +50,9 @@ struct NodePrefs { // persisted to file
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=enabled
uint8_t bot_channel_idx; // channel index to monitor
uint8_t bot_channel_idx; // channel index to monitor (when bot_target_type==0)
char bot_trigger[64]; // trigger phrase (case-insensitive contains match)
char bot_reply[140]; // auto-reply text
uint8_t bot_target_type; // 0=channel (default), 1=DM contact
uint8_t bot_dm_pubkey[4]; // pubkey prefix of DM target; all-zero = any DM sender
};

View File

@@ -2108,10 +2108,16 @@ class BotScreen : public UIScreen {
static const char KB_CHARS[4][10];
static const int KB_ROWS = 4, KB_COLS = 10;
// channel count cache (refreshed on enter)
int _num_channels;
// channel + DM contact caches (refreshed on enter)
static const int MAX_BOT_DM = 16;
int _num_channels;
uint8_t _channel_indices[MAX_GROUP_CHANNELS];
int _num_dm;
uint8_t _dm_pubkeys[MAX_BOT_DM][4];
char _dm_names[MAX_BOT_DM][32];
void refreshChannels() {
_num_channels = 0;
ChannelDetails ch;
@@ -2121,11 +2127,32 @@ class BotScreen : public UIScreen {
}
}
// returns display index of current bot_channel_idx in _channel_indices
int currentChanDisplayIdx() const {
for (int i = 0; i < _num_channels; i++)
if (_channel_indices[i] == _prefs->bot_channel_idx) return i;
return 0;
void refreshContacts() {
_num_dm = 0;
ContactInfo ci;
int n = the_mesh.getNumContacts();
for (int i = 0; i < n && _num_dm < MAX_BOT_DM; i++) {
if (!the_mesh.getContactByIdx(i, ci)) continue;
if (ci.type != ADV_TYPE_CHAT) continue;
memcpy(_dm_pubkeys[_num_dm], ci.id.pub_key, 4);
strncpy(_dm_names[_num_dm], ci.name, sizeof(_dm_names[0]) - 1);
_dm_names[_num_dm][sizeof(_dm_names[0]) - 1] = '\0';
_num_dm++;
}
}
// returns combined index in [channels..., DM contacts...]
int currentTargetIdx() const {
if (_prefs->bot_target_type == 0) {
for (int i = 0; i < _num_channels; i++)
if (_channel_indices[i] == _prefs->bot_channel_idx) return i;
return 0;
} else {
for (int i = 0; i < _num_dm; i++)
if (memcmp(_dm_pubkeys[i], _prefs->bot_dm_pubkey, 4) == 0)
return _num_channels + i;
return _num_channels; // fallback: first DM
}
}
public:
@@ -2135,6 +2162,7 @@ public:
_sel = 0;
_kb_field = -1;
refreshChannels();
refreshContacts();
}
int render(DisplayDriver& display) override {
@@ -2194,7 +2222,7 @@ public:
display.drawTextCentered(display.width() / 2, 0, "AUTO-REPLY BOT");
display.fillRect(0, 10, display.width(), 1);
static const char* labels[] = { "Enable", "Channel", "Trigger", "Reply" };
static const char* labels[] = { "Enable", NULL, "Trigger", "Reply" };
for (int i = 0; i < ITEM_COUNT; i++) {
int y = START_Y + i * ITEM_H;
bool sel = (i == _sel);
@@ -2203,20 +2231,33 @@ public:
display.setColor(DisplayDriver::DARK);
}
display.setCursor(2, y);
display.print(labels[i]);
if (i == 1)
display.print(_prefs->bot_target_type == 0 ? "Channel" : "Contact");
else
display.print(labels[i]);
display.setCursor(VAL_X, y);
if (i == 0) {
display.print(_prefs->bot_enabled ? "ON" : "OFF");
} else if (i == 1) {
if (_num_channels == 0) {
int total = _num_channels + _num_dm;
if (total == 0) {
display.print("none");
} else {
} else if (_prefs->bot_target_type == 0) {
ChannelDetails ch;
if (the_mesh.getChannel(_prefs->bot_channel_idx, ch) && ch.name[0])
if (_num_channels > 0 && the_mesh.getChannel(_prefs->bot_channel_idx, ch) && ch.name[0])
display.drawTextEllipsized(VAL_X, y, display.width() - VAL_X - 1, ch.name);
else
display.print("Ch?");
display.print("?");
} else {
bool found = false;
for (int j = 0; j < _num_dm && !found; j++) {
if (memcmp(_dm_pubkeys[j], _prefs->bot_dm_pubkey, 4) == 0) {
display.drawTextEllipsized(VAL_X, y, display.width() - VAL_X - 1, _dm_names[j]);
found = true;
}
}
if (!found) display.print("?");
}
} else if (i == 2) {
const char* tr = _prefs->bot_trigger;
@@ -2297,11 +2338,23 @@ public:
_prefs->bot_enabled ^= 1;
return true;
}
if (_sel == 1 && _num_channels > 0) {
int idx = currentChanDisplayIdx();
if (right || enter) idx = (idx + 1) % _num_channels;
if (left) idx = (idx + _num_channels - 1) % _num_channels;
if (left || right || enter) { _prefs->bot_channel_idx = _channel_indices[idx]; return true; }
if (_sel == 1) {
int total = _num_channels + _num_dm;
if (total == 0) return false;
int idx = currentTargetIdx();
if (right || enter) idx = (idx + 1) % total;
if (left) idx = (idx + total - 1) % total;
if (left || right || enter) {
if (idx < _num_channels) {
_prefs->bot_target_type = 0;
_prefs->bot_channel_idx = _channel_indices[idx];
} else {
int di = idx - _num_channels;
_prefs->bot_target_type = 1;
memcpy(_prefs->bot_dm_pubkey, _dm_pubkeys[di], 4);
}
return true;
}
}
if ((_sel == 2 || _sel == 3) && enter) {
_kb_field = _sel;