Files
MeshCore-Solo/examples/companion_radio/MyMeshBot.h
Jakub 5bfebc6559 feat(bot): Actions commands, multi-trigger, and user GPIO pins
- Auto-Reply Bot gains Actions (!buzz/!gps/!advert) behind a new per-target
  toggle nested under Commands (bot_actions_dm/ch/room); off by default.
- Bot Trigger fields accept comma-separated multiple phrases, matching any
  one fires the reply.
- New user-assignable GPIO feature (Wio Tracker L1): !gpio1..!gpio4 bot
  commands plus a Tools > GPIO screen. Each pin cycles Off/Input/Output;
  GPIO1/GPIO2 (P0.02/P0.29, the nRF52840's AIN0/AIN5) also offer a read-only
  Analog mode via direct SAADC access. GPIO3/GPIO4 (P0.09/P0.10) are the
  chip's NFC1/NFC2 pins, repurposed as plain GPIO via a one-time UICR
  NFCPINS bit-clear in initVariant() (adapted from Adafruit's own
  nfc_to_gpio example) -- confirmed working on real hardware.
- Fix: DM/room reply-prefix ("@[nick] ") stripping happened at the wrong
  layer, hiding the "To:" header on DM replies and leaking the raw prefix
  into room messages' list view; a related mismatch had the history
  scrollbar's sizing pass wrap room messages with the sender name still
  attached, disagreeing with the actual rendered text.

Build-verified: WioTrackerL1_companion_solo_dual and
WioTrackerL1Eink_companion_solo_dual both compile and link clean
(sizeof(NodePrefs) confirmed 2720 via real build, not guessed).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 20:30:03 +02:00

485 lines
22 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, 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")) {
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?"); return true; }
if (_ui) _ui->botSetGPS(on);
snprintf(out, out_len, "GPS: %s", on ? "on" : "off");
return true;
}
if (!strcmp(cmd, "buzz")) {
int secs = arg[0] ? atoi(arg) : 5; // UITask::botBuzz() clamps to [1,30]
if (_ui) _ui->botBuzz(secs);
snprintf(out, out_len, "Buzzing");
return true;
}
if (!strcmp(cmd, "advert")) {
snprintf(out, out_len, advert() ? "Advert sent" : "Advert failed");
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").
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; }
if (_ui && _ui->botSetGPIO(idx, on)) {
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) {
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;
// Optional argument: the next space-delimited token, unless it's itself
// the start of another command ("!"). E.g. "!gps on !batt" -- "on" is
// !gps's argument, "!batt" starts the next command. Query commands
// ignore arg; a stray word after e.g. "!ping" (no command reads it) is
// just consumed here instead of by the "not a command token" branch on
// the next iteration -- same net effect, no behaviour change.
while (*p == ' ') p++;
char arg[16] = "";
if (*p && *p != '!') {
int ai = 0;
while (*p && *p != ' ' && ai < (int)sizeof(arg) - 1) arg[ai++] = (char)tolower((uint8_t)*p++);
arg[ai] = '\0';
while (*p && *p != ' ') p++; // consume any overflow of this token
}
char seg[80];
if (!botCommandReply(cmd, arg, 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)) 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
}
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()) return true; // quiet hours
if (millis() - _bot_last_ch_reply_ms <= BOT_REPLY_COOLDOWN_MS) return true; // throttled
ChannelDetails ch;
if (!getChannel(channel_idx, ch)) 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
}
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()) 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;
}