From 306d324ba6d5c9baeb96f0f82a21521bf19619e1 Mon Sep 17 00:00:00 2001 From: mattatat25 Date: Tue, 27 Jan 2026 19:00:44 -0600 Subject: [PATCH 1/5] Implement Meshtastic reply bot module with ping and status features Adds a reply bot module that listens for /ping, /hello, and /test commands received via direct messages or broadcasts on the primary channel. The module always replies via direct message to the sender only, reporting hop count, RSSI, and SNR. Per-sender cooldowns are enforced to reduce network spam, and the module can be excluded at build time via a compile flag. Updates include the new module source files and required build configuration changes. --- platformio.ini | 1 + src/modules/ReplyBotModule.cpp | 183 +++++++++++++++++++++++++++++++++ src/modules/ReplyBotModule.h | 20 ++++ 3 files changed, 204 insertions(+) create mode 100644 src/modules/ReplyBotModule.cpp create mode 100644 src/modules/ReplyBotModule.h diff --git a/platformio.ini b/platformio.ini index ee51d24ecea..bf5ff579eb0 100644 --- a/platformio.ini +++ b/platformio.ini @@ -50,6 +50,7 @@ build_flags = -Wno-missing-field-initializers -DRADIOLIB_EXCLUDE_APRS=1 -DRADIOLIB_EXCLUDE_LORAWAN=1 -DMESHTASTIC_EXCLUDE_DROPZONE=1 + -DMESHTASTIC_EXCLUDE_REPLYBOT=1 -DMESHTASTIC_EXCLUDE_REMOTEHARDWARE=1 -DMESHTASTIC_EXCLUDE_HEALTH_TELEMETRY=1 -DMESHTASTIC_EXCLUDE_POWERSTRESS=1 ; exclude power stress test module from main firmware diff --git a/src/modules/ReplyBotModule.cpp b/src/modules/ReplyBotModule.cpp new file mode 100644 index 00000000000..bd04bad5e46 --- /dev/null +++ b/src/modules/ReplyBotModule.cpp @@ -0,0 +1,183 @@ +#if !DMESHTASTIC_EXCLUDE_REPLYBOT +/* + * ReplyBotModule.cpp + * + * This module implements a simple reply bot for the Meshtastic firmware. It listens for + * specific text commands ("/ping", "/hello" and "/test") delivered either via a direct + * message (DM) or a broadcast on the LongFast channel. When a supported command is + * received the bot responds with a short status message that includes the hop count + * (minimum number of relays), RSSI and SNR of the received packet. To avoid spamming + * the network it enforces a per‑sender cooldown between responses. By default the + * module is enabled; define MESHTASTIC_EXCLUDE_REPLYBOT at build time to exclude it + * entirely. See the official firmware documentation for guidance on adding modules. + */ + +#include "ReplyBotModule.h" +#include "Channels.h" +#include "MeshService.h" +#include "NodeDB.h" +#include "configuration.h" +#include "mesh/MeshTypes.h" + +#include +#include +#include + +// +// Rate limiting data structures +// +// Each sender is tracked in a small ring buffer. When a message arrives from a +// sender we check the last time we responded to them. If the difference is +// less than the configured cooldown (different values for DM vs broadcast) +// the message is ignored; otherwise we update the last response time and +// proceed with replying. + +struct ReplyBotCooldownEntry { + uint32_t from = 0; + uint32_t lastMs = 0; +}; + +static constexpr uint8_t REPLYBOT_COOLDOWN_SLOTS = 8; // ring buffer size +static constexpr uint32_t REPLYBOT_DM_COOLDOWN_MS = 15 * 1000; // 15 seconds for DMs +static constexpr uint32_t REPLYBOT_LF_COOLDOWN_MS = 60 * 1000; // 60 seconds for LongFast broadcasts + +static ReplyBotCooldownEntry replybotCooldown[REPLYBOT_COOLDOWN_SLOTS]; +static uint8_t replybotCooldownIdx = 0; + +// Return true if a reply should be rate‑limited for this sender, updating the +// entry table as needed. +static bool replybotRateLimited(uint32_t from, uint32_t cooldownMs) +{ + const uint32_t now = millis(); + for (auto &e : replybotCooldown) { + if (e.from == from) { + // Found existing entry; check if cooldown expired + if ((uint32_t)(now - e.lastMs) < cooldownMs) { + return true; + } + e.lastMs = now; + return false; + } + } + // No entry found – insert new sender into the ring + replybotCooldown[replybotCooldownIdx].from = from; + replybotCooldown[replybotCooldownIdx].lastMs = now; + replybotCooldownIdx = (replybotCooldownIdx + 1) % REPLYBOT_COOLDOWN_SLOTS; + return false; +} + +// Constructor – registers a single text port and marks the module promiscuous +// so that broadcast messages on the primary channel are visible. +ReplyBotModule::ReplyBotModule() : SinglePortModule("replybot", meshtastic_PortNum_TEXT_MESSAGE_APP) +{ + isPromiscuous = true; +} + +void ReplyBotModule::setup() +{ + // In future we may add a protobuf configuration; for now the module is + // always enabled when compiled in. +} + +// Determine whether we want to process this packet. We only care about +// plain text messages addressed to our port. +bool ReplyBotModule::wantPacket(const meshtastic_MeshPacket *p) +{ + return (p && p->decoded.portnum == ourPortNum); +} + +ProcessMessage ReplyBotModule::handleReceived(const meshtastic_MeshPacket &mp) +{ + // Accept only direct messages to us or broadcasts on the Primary channel + // (regardless of modem preset: LongFast, MediumFast, etc). + + const uint32_t ourNode = nodeDB->getNodeNum(); + const bool isDM = (mp.to == ourNode); + const bool isPrimaryChannel = (mp.channel == channels.getPrimaryIndex()) && isBroadcast(mp.to); + if (!isDM && !isPrimaryChannel) { + return ProcessMessage::CONTINUE; + } + + // Ignore empty payloads + if (mp.decoded.payload.size == 0) { + return ProcessMessage::CONTINUE; + } + + // Copy payload into a null‑terminated buffer + char buf[260]; + memset(buf, 0, sizeof(buf)); + size_t n = mp.decoded.payload.size; + if (n > sizeof(buf) - 1) + n = sizeof(buf) - 1; + memcpy(buf, mp.decoded.payload.bytes, n); + + // React only to supported slash commands + if (!isCommand(buf)) { + return ProcessMessage::CONTINUE; + } + + // Apply rate limiting per sender depending on DM/broadcast + const uint32_t cooldownMs = isDM ? REPLYBOT_DM_COOLDOWN_MS : REPLYBOT_LF_COOLDOWN_MS; + if (replybotRateLimited(mp.from, cooldownMs)) { + return ProcessMessage::CONTINUE; + } + + // Compute hop count indicator – if the relay_node is non‑zero we know + // there was at least one relay. Some firmware builds support a hop_start + // field which could be used for more accurate counts, but here we use + // the available relay_node flag only. + // int hopsAway = mp.hop_start - mp.hop_limit; + int hopsAway = getHopsAway(mp); + + // Normalize RSSI: if positive adjust down by 200 to align with typical values + int rssi = mp.rx_rssi; + if (rssi > 0) { + rssi -= 200; + } + float snr = mp.rx_snr; + + // Build the reply message and send it back via DM + char reply[96]; + snprintf(reply, sizeof(reply), "🎙️ Mic Check : %d Hops away | RSSI %d | SNR %.1f", hopsAway, rssi, snr); + sendDm(mp, reply); + return ProcessMessage::CONTINUE; +} + +// Check if the message starts with one of the supported commands. Leading +// whitespace is skipped and commands must be followed by end‑of‑string or +// whitespace. +bool ReplyBotModule::isCommand(const char *msg) const +{ + if (!msg) + return false; + while (*msg == ' ' || *msg == '\t') + msg++; + auto isEndOrSpace = [](char c) { return c == '\0' || std::isspace(static_cast(c)); }; + if (strncmp(msg, "/ping", 5) == 0 && isEndOrSpace(msg[5])) + return true; + if (strncmp(msg, "/hello", 6) == 0 && isEndOrSpace(msg[6])) + return true; + if (strncmp(msg, "/test", 5) == 0 && isEndOrSpace(msg[5])) + return true; + return false; +} + +// Send a direct message back to the originating node. +void ReplyBotModule::sendDm(const meshtastic_MeshPacket &rx, const char *text) +{ + if (!text) + return; + meshtastic_MeshPacket *p = allocDataPacket(); + p->to = rx.from; + p->channel = rx.channel; + p->want_ack = false; + p->decoded.want_response = false; + size_t len = strlen(text); + if (len > sizeof(p->decoded.payload.bytes)) { + len = sizeof(p->decoded.payload.bytes); + } + p->decoded.payload.size = len; + memcpy(p->decoded.payload.bytes, text, len); + service->sendToMesh(p); +} +#endif // MESHTASTIC_EXCLUDE_REPLYBOT \ No newline at end of file diff --git a/src/modules/ReplyBotModule.h b/src/modules/ReplyBotModule.h new file mode 100644 index 00000000000..000b1338981 --- /dev/null +++ b/src/modules/ReplyBotModule.h @@ -0,0 +1,20 @@ +#pragma once +#if !DMESHTASTIC_EXCLUDE_REPLYBOT +#include "SinglePortModule.h" +#include "mesh/generated/meshtastic/mesh.pb.h" + +// Forward declaration ONLY (correct for Meshtastic headers) + +class ReplyBotModule : public SinglePortModule +{ + public: + ReplyBotModule(); + void setup() override; + bool wantPacket(const meshtastic_MeshPacket *p) override; + ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) override; + + protected: + bool isCommand(const char *msg) const; + void sendDm(const meshtastic_MeshPacket &rx, const char *text); +}; +#endif // MESHTASTIC_EXCLUDE_REPLYBOT \ No newline at end of file From e9ba33c29581fcb5e6de21a3117652160578cbec Mon Sep 17 00:00:00 2001 From: Mattatat25 <108779801+mattatat25@users.noreply.github.com> Date: Tue, 27 Jan 2026 20:16:13 -0600 Subject: [PATCH 2/5] Update ReplyBotModule.cpp Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/modules/ReplyBotModule.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/ReplyBotModule.cpp b/src/modules/ReplyBotModule.cpp index bd04bad5e46..6dd05d9ae9e 100644 --- a/src/modules/ReplyBotModule.cpp +++ b/src/modules/ReplyBotModule.cpp @@ -4,7 +4,7 @@ * * This module implements a simple reply bot for the Meshtastic firmware. It listens for * specific text commands ("/ping", "/hello" and "/test") delivered either via a direct - * message (DM) or a broadcast on the LongFast channel. When a supported command is + * message (DM) or a broadcast on the primary channel. When a supported command is * received the bot responds with a short status message that includes the hop count * (minimum number of relays), RSSI and SNR of the received packet. To avoid spamming * the network it enforces a per‑sender cooldown between responses. By default the From 33813705749ee4b9a512404a4ba04debe6225d7d Mon Sep 17 00:00:00 2001 From: Mattatat25 <108779801+mattatat25@users.noreply.github.com> Date: Tue, 27 Jan 2026 20:36:06 -0600 Subject: [PATCH 3/5] Update src/modules/ReplyBotModule.h Match the existing MESHTASTIC_EXCLUDE_* guard pattern so the module is excluded by default. Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/modules/ReplyBotModule.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/ReplyBotModule.h b/src/modules/ReplyBotModule.h index 000b1338981..50c34909667 100644 --- a/src/modules/ReplyBotModule.h +++ b/src/modules/ReplyBotModule.h @@ -1,5 +1,5 @@ #pragma once -#if !DMESHTASTIC_EXCLUDE_REPLYBOT +#if !MESHTASTIC_EXCLUDE_REPLYBOT #include "SinglePortModule.h" #include "mesh/generated/meshtastic/mesh.pb.h" From 61846eb29f51a627c07e61032f824651272f5813 Mon Sep 17 00:00:00 2001 From: Mattatat25 <108779801+mattatat25@users.noreply.github.com> Date: Tue, 27 Jan 2026 20:37:04 -0600 Subject: [PATCH 4/5] Update src/modules/ReplyBotModule.cpp Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/modules/ReplyBotModule.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/ReplyBotModule.cpp b/src/modules/ReplyBotModule.cpp index 6dd05d9ae9e..90223cacb50 100644 --- a/src/modules/ReplyBotModule.cpp +++ b/src/modules/ReplyBotModule.cpp @@ -1,4 +1,4 @@ -#if !DMESHTASTIC_EXCLUDE_REPLYBOT +#if !MESHTASTIC_EXCLUDE_REPLYBOT /* * ReplyBotModule.cpp * From 932b55153a1ab1f1ac084728c7e041a1e7100299 Mon Sep 17 00:00:00 2001 From: mattatat25 Date: Tue, 27 Jan 2026 20:50:32 -0600 Subject: [PATCH 5/5] Tidying up --- src/modules/ReplyBotModule.h | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/modules/ReplyBotModule.h b/src/modules/ReplyBotModule.h index 50c34909667..7413667cad3 100644 --- a/src/modules/ReplyBotModule.h +++ b/src/modules/ReplyBotModule.h @@ -3,8 +3,6 @@ #include "SinglePortModule.h" #include "mesh/generated/meshtastic/mesh.pb.h" -// Forward declaration ONLY (correct for Meshtastic headers) - class ReplyBotModule : public SinglePortModule { public: