diff --git a/patches/meshcore/0001-Add-companion-firmware-bot-core.patch b/patches/meshcore/0001-Add-companion-firmware-bot-core.patch deleted file mode 100644 index a028111..0000000 --- a/patches/meshcore/0001-Add-companion-firmware-bot-core.patch +++ /dev/null @@ -1,423 +0,0 @@ -From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 -From: cj-vana -Date: Thu, 14 May 2026 14:03:07 -0600 -Subject: [PATCH 1/2] Add companion firmware bot core - ---- - examples/companion_radio/BotPolicy.cpp | 58 +++++++ - examples/companion_radio/BotPolicy.h | 12 ++ - examples/companion_radio/BotTypes.h | 99 ++++++++++++ - examples/companion_radio/FirmwareBot.cpp | 193 +++++++++++++++++++++++ - examples/companion_radio/FirmwareBot.h | 13 ++ - 5 files changed, 375 insertions(+) - create mode 100644 examples/companion_radio/BotPolicy.cpp - create mode 100644 examples/companion_radio/BotPolicy.h - create mode 100644 examples/companion_radio/BotTypes.h - create mode 100644 examples/companion_radio/FirmwareBot.cpp - create mode 100644 examples/companion_radio/FirmwareBot.h - -diff --git a/examples/companion_radio/BotPolicy.cpp b/examples/companion_radio/BotPolicy.cpp -new file mode 100644 -index 00000000..bd3fc6c7 ---- /dev/null -+++ b/examples/companion_radio/BotPolicy.cpp -@@ -0,0 +1,58 @@ -+#include "BotPolicy.h" -+ -+#include -+#include -+ -+namespace { -+ -+bool equalsIgnoreCase(const char* value, size_t len, const char* expected) { -+ if (!value || !expected) return false; -+ -+ if (len > 0 && value[0] == '#') { -+ value++; -+ len--; -+ } -+ -+ size_t expected_len = strlen(expected); -+ if (expected_len > 0 && expected[0] == '#') { -+ expected++; -+ expected_len--; -+ } -+ if (len != expected_len) return false; -+ -+ for (size_t i = 0; i < len; i++) { -+ if (tolower((unsigned char)value[i]) != tolower((unsigned char)expected[i])) return false; -+ } -+ return true; -+} -+ -+} -+ -+namespace BotPolicy { -+ -+BotChannelKind classifyChannel(const char* name, size_t len, bool direct_message) { -+ if (direct_message) return BOT_CHANNEL_DM; -+ if (equalsIgnoreCase(name, len, "Public")) return BOT_CHANNEL_PUBLIC; -+ if (equalsIgnoreCase(name, len, "#bot")) return BOT_CHANNEL_BOT; -+ if (equalsIgnoreCase(name, len, "#testing")) return BOT_CHANNEL_TESTING; -+ if (equalsIgnoreCase(name, len, "#emergency")) return BOT_CHANNEL_EMERGENCY; -+ return BOT_CHANNEL_OTHER; -+} -+ -+BotPolicyDecision decide(BotChannelKind kind) { -+ if (kind == BOT_CHANNEL_DM || kind == BOT_CHANNEL_BOT || kind == BOT_CHANNEL_TESTING) { -+ return BOT_POLICY_ALLOW_NORMAL; -+ } -+ if (kind == BOT_CHANNEL_EMERGENCY) return BOT_POLICY_EMERGENCY_FORWARD; -+ return BOT_POLICY_IGNORE; -+} -+ -+bool isNormalAllowed(BotChannelKind kind) { -+ return decide(kind) == BOT_POLICY_ALLOW_NORMAL; -+} -+ -+bool isEmergency(BotChannelKind kind) { -+ return decide(kind) == BOT_POLICY_EMERGENCY_FORWARD; -+} -+ -+} -diff --git a/examples/companion_radio/BotPolicy.h b/examples/companion_radio/BotPolicy.h -new file mode 100644 -index 00000000..e074e3eb ---- /dev/null -+++ b/examples/companion_radio/BotPolicy.h -@@ -0,0 +1,12 @@ -+#pragma once -+ -+#include "BotTypes.h" -+ -+namespace BotPolicy { -+ -+BotChannelKind classifyChannel(const char* name, size_t len, bool direct_message); -+BotPolicyDecision decide(BotChannelKind kind); -+bool isNormalAllowed(BotChannelKind kind); -+bool isEmergency(BotChannelKind kind); -+ -+} -diff --git a/examples/companion_radio/BotTypes.h b/examples/companion_radio/BotTypes.h -new file mode 100644 -index 00000000..869ae3ae ---- /dev/null -+++ b/examples/companion_radio/BotTypes.h -@@ -0,0 +1,99 @@ -+#pragma once -+ -+#include -+#include -+ -+#define BOT_MAX_TEXT_LEN 160 -+#define BOT_MAX_RESPONSE_LEN 144 -+#define BOT_MAX_COMMAND_NAME_LEN 15 -+#define BOT_MAX_COMMAND_ARGS_LEN 79 -+#define BOT_MAX_CHANNEL_NAME_LEN 23 -+#define BOT_MAX_SENDER_NAME_LEN 31 -+#define BOT_SENDER_KEY_PREFIX_LEN 6 -+ -+enum BotChannelKind : uint8_t { -+ BOT_CHANNEL_DM = 0, -+ BOT_CHANNEL_PUBLIC, -+ BOT_CHANNEL_BOT, -+ BOT_CHANNEL_TESTING, -+ BOT_CHANNEL_EMERGENCY, -+ BOT_CHANNEL_OTHER -+}; -+ -+enum BotPolicyDecision : uint8_t { -+ BOT_POLICY_IGNORE = 0, -+ BOT_POLICY_ALLOW_NORMAL, -+ BOT_POLICY_EMERGENCY_FORWARD -+}; -+ -+enum BotCommandId : uint8_t { -+ BOT_COMMAND_NONE = 0, -+ BOT_COMMAND_HELP, -+ BOT_COMMAND_PING, -+ BOT_COMMAND_TEST, -+ BOT_COMMAND_HELLO, -+ BOT_COMMAND_ABOUT, -+ BOT_COMMAND_DICE, -+ BOT_COMMAND_STATUS, -+ BOT_COMMAND_UNKNOWN -+}; -+ -+enum BotWriteResult : uint8_t { -+ BOT_WRITE_OK = 0, -+ BOT_WRITE_TRUNCATED, -+ BOT_WRITE_NO_SPACE -+}; -+ -+struct BotFingerprint { -+ uint64_t value; -+}; -+ -+struct BotMessage { -+ BotChannelKind channel_kind; -+ char channel_name[BOT_MAX_CHANNEL_NAME_LEN + 1]; -+ char sender_name[BOT_MAX_SENDER_NAME_LEN + 1]; -+ uint8_t sender_key_prefix[BOT_SENDER_KEY_PREFIX_LEN]; -+ uint32_t sender_timestamp; -+ char text[BOT_MAX_TEXT_LEN + 1]; -+ size_t text_len; -+}; -+ -+struct BotCommand { -+ BotCommandId id; -+ char name[BOT_MAX_COMMAND_NAME_LEN + 1]; -+ char args[BOT_MAX_COMMAND_ARGS_LEN + 1]; -+ size_t args_len; -+}; -+ -+struct BotResponse { -+ BotPolicyDecision decision; -+ BotFingerprint fingerprint; -+ char text[BOT_MAX_RESPONSE_LEN + 1]; -+ size_t text_len; -+ bool truncated; -+}; -+ -+struct BotPrefs { -+ bool enabled; -+ uint16_t normal_delay_ms; -+ uint16_t normal_jitter_ms; -+ uint8_t max_response_parts; -+ char bot_channel[BOT_MAX_CHANNEL_NAME_LEN + 1]; -+ char testing_channel[BOT_MAX_CHANNEL_NAME_LEN + 1]; -+ char emergency_channel[BOT_MAX_CHANNEL_NAME_LEN + 1]; -+ char public_channel[BOT_MAX_CHANNEL_NAME_LEN + 1]; -+}; -+ -+struct BotStats { -+ uint32_t observed_messages; -+ uint32_t ignored_messages; -+ uint32_t eligible_messages; -+ uint32_t emergency_messages; -+ uint32_t parse_errors; -+}; -+ -+static_assert(sizeof(BotMessage) <= 240, "BotMessage RAM budget exceeded"); -+static_assert(sizeof(BotCommand) <= 120, "BotCommand RAM budget exceeded"); -+static_assert(sizeof(BotResponse) <= 184, "BotResponse RAM budget exceeded"); -+static_assert(sizeof(BotPrefs) <= 128, "BotPrefs RAM budget exceeded"); -+static_assert(sizeof(BotStats) <= 32, "BotStats RAM budget exceeded"); -diff --git a/examples/companion_radio/FirmwareBot.cpp b/examples/companion_radio/FirmwareBot.cpp -new file mode 100644 -index 00000000..c9447282 ---- /dev/null -+++ b/examples/companion_radio/FirmwareBot.cpp -@@ -0,0 +1,193 @@ -+#include "FirmwareBot.h" -+ -+#include -+#include -+ -+namespace { -+ -+uint64_t fnv1aUpdate(uint64_t hash, uint8_t value) { -+ hash ^= value; -+ hash *= 1099511628211ULL; -+ return hash; -+} -+ -+uint64_t fnv1aUpdateBytes(uint64_t hash, const uint8_t* data, size_t len) { -+ for (size_t i = 0; i < len; i++) { -+ hash = fnv1aUpdate(hash, data[i]); -+ } -+ return hash; -+} -+ -+uint64_t fnv1aUpdateTextLower(uint64_t hash, const char* value, size_t len) { -+ for (size_t i = 0; i < len; i++) { -+ hash = fnv1aUpdate(hash, (uint8_t)tolower((unsigned char)value[i])); -+ } -+ return hash; -+} -+ -+bool isSpaceByte(char ch) { -+ return ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n'; -+} -+ -+bool isControlByte(char ch) { -+ unsigned char value = (unsigned char)ch; -+ return value < 0x20 || value == 0x7F; -+} -+ -+bool namesEqual(const char* name, size_t len, const char* expected) { -+ size_t expected_len = strlen(expected); -+ if (len != expected_len) return false; -+ for (size_t i = 0; i < len; i++) { -+ if (tolower((unsigned char)name[i]) != expected[i]) return false; -+ } -+ return true; -+} -+ -+bool isCommandDelimiter(char ch) { -+ return ch == ' ' || ch == ':' || ch == ',' || ch == '.' || ch == ';' || ch == '?' || ch == '!'; -+} -+ -+uint64_t fnv1aUpdateU32(uint64_t hash, uint32_t value) { -+ hash = fnv1aUpdate(hash, (uint8_t)(value & 0xFF)); -+ hash = fnv1aUpdate(hash, (uint8_t)((value >> 8) & 0xFF)); -+ hash = fnv1aUpdate(hash, (uint8_t)((value >> 16) & 0xFF)); -+ hash = fnv1aUpdate(hash, (uint8_t)((value >> 24) & 0xFF)); -+ return hash; -+} -+ -+size_t boundedStrLen(const char* value, size_t max_len) { -+ size_t len = 0; -+ while (len < max_len && value[len] != 0) len++; -+ return len; -+} -+ -+} -+ -+namespace FirmwareBot { -+ -+BotWriteResult normalizeText(const char* input, size_t input_len, char* output, size_t output_len, size_t* written) { -+ if (written) *written = 0; -+ if (!output || output_len == 0) return BOT_WRITE_NO_SPACE; -+ -+ size_t out = 0; -+ bool pending_space = false; -+ bool truncated = false; -+ -+ for (size_t i = 0; i < input_len; i++) { -+ char ch = input ? input[i] : 0; -+ if (ch == 0) break; -+ -+ if (isSpaceByte(ch) || isControlByte(ch)) { -+ pending_space = out > 0; -+ continue; -+ } -+ -+ if (pending_space) { -+ if (out + 1 >= output_len) { -+ truncated = true; -+ break; -+ } -+ output[out++] = ' '; -+ pending_space = false; -+ } -+ -+ if (out + 1 >= output_len) { -+ truncated = true; -+ break; -+ } -+ output[out++] = ch; -+ } -+ -+ output[out] = 0; -+ if (written) *written = out; -+ return truncated ? BOT_WRITE_TRUNCATED : BOT_WRITE_OK; -+} -+ -+BotCommandId commandIdForName(const char* name, size_t len) { -+ if (namesEqual(name, len, "help") || namesEqual(name, len, "cmd") || namesEqual(name, len, "commands")) return BOT_COMMAND_HELP; -+ if (namesEqual(name, len, "ping")) return BOT_COMMAND_PING; -+ if (namesEqual(name, len, "test")) return BOT_COMMAND_TEST; -+ if (namesEqual(name, len, "hello") || namesEqual(name, len, "hi")) return BOT_COMMAND_HELLO; -+ if (namesEqual(name, len, "about")) return BOT_COMMAND_ABOUT; -+ if (namesEqual(name, len, "dice") || namesEqual(name, len, "roll")) return BOT_COMMAND_DICE; -+ if (namesEqual(name, len, "status")) return BOT_COMMAND_STATUS; -+ return BOT_COMMAND_UNKNOWN; -+} -+ -+bool parseCommand(const char* text, size_t text_len, BotCommand* command) { -+ if (!command) return false; -+ memset(command, 0, sizeof(*command)); -+ command->id = BOT_COMMAND_NONE; -+ -+ char normalized[BOT_MAX_TEXT_LEN + 1]; -+ size_t normalized_len = 0; -+ normalizeText(text, text_len, normalized, sizeof(normalized), &normalized_len); -+ -+ if (normalized_len < 2 || (normalized[0] != '!' && normalized[0] != '/')) return false; -+ -+ size_t pos = 1; -+ while (pos < normalized_len && normalized[pos] == ' ') pos++; -+ size_t name_start = pos; -+ while (pos < normalized_len && !isCommandDelimiter(normalized[pos])) pos++; -+ size_t name_len = pos - name_start; -+ if (name_len == 0) return false; -+ -+ size_t copy_name_len = name_len; -+ if (copy_name_len > BOT_MAX_COMMAND_NAME_LEN) copy_name_len = BOT_MAX_COMMAND_NAME_LEN; -+ for (size_t i = 0; i < copy_name_len; i++) { -+ command->name[i] = (char)tolower((unsigned char)normalized[name_start + i]); -+ } -+ command->name[copy_name_len] = 0; -+ command->id = name_len > BOT_MAX_COMMAND_NAME_LEN ? BOT_COMMAND_UNKNOWN : commandIdForName(command->name, copy_name_len); -+ -+ while (pos < normalized_len && isCommandDelimiter(normalized[pos])) pos++; -+ size_t args_len = normalized_len - pos; -+ if (args_len > BOT_MAX_COMMAND_ARGS_LEN) args_len = BOT_MAX_COMMAND_ARGS_LEN; -+ if (args_len > 0) memcpy(command->args, &normalized[pos], args_len); -+ command->args[args_len] = 0; -+ command->args_len = args_len; -+ -+ return true; -+} -+ -+BotWriteResult writeResponse(char* output, size_t output_len, const char* text, size_t text_len, size_t* written) { -+ if (written) *written = 0; -+ if (!output || output_len == 0) return BOT_WRITE_NO_SPACE; -+ -+ if (!text && text_len > 0) { -+ output[0] = 0; -+ return BOT_WRITE_NO_SPACE; -+ } -+ -+ size_t copy_len = text_len; -+ if (copy_len + 1 > output_len) copy_len = output_len - 1; -+ if (copy_len > 0) memcpy(output, text, copy_len); -+ output[copy_len] = 0; -+ if (written) *written = copy_len; -+ -+ return copy_len < text_len ? BOT_WRITE_TRUNCATED : BOT_WRITE_OK; -+} -+ -+BotFingerprint fingerprintFor(const BotMessage& message) { -+ uint64_t hash = 1469598103934665603ULL; -+ hash = fnv1aUpdate(hash, (uint8_t)message.channel_kind); -+ const char* channel_name = message.channel_name; -+ size_t channel_name_len = boundedStrLen(message.channel_name, sizeof(message.channel_name)); -+ if (channel_name_len > 0 && channel_name[0] == '#') { -+ channel_name++; -+ channel_name_len--; -+ } -+ hash = fnv1aUpdateTextLower(hash, channel_name, channel_name_len); -+ hash = fnv1aUpdateBytes(hash, message.sender_key_prefix, sizeof(message.sender_key_prefix)); -+ hash = fnv1aUpdateU32(hash, message.sender_timestamp); -+ -+ char normalized[BOT_MAX_TEXT_LEN + 1]; -+ size_t normalized_len = 0; -+ normalizeText(message.text, message.text_len, normalized, sizeof(normalized), &normalized_len); -+ hash = fnv1aUpdateTextLower(hash, normalized, normalized_len); -+ -+ BotFingerprint fingerprint = { hash }; -+ return fingerprint; -+} -+ -+} -diff --git a/examples/companion_radio/FirmwareBot.h b/examples/companion_radio/FirmwareBot.h -new file mode 100644 -index 00000000..8fce16ab ---- /dev/null -+++ b/examples/companion_radio/FirmwareBot.h -@@ -0,0 +1,13 @@ -+#pragma once -+ -+#include "BotTypes.h" -+ -+namespace FirmwareBot { -+ -+BotWriteResult normalizeText(const char* input, size_t input_len, char* output, size_t output_len, size_t* written); -+bool parseCommand(const char* text, size_t text_len, BotCommand* command); -+BotWriteResult writeResponse(char* output, size_t output_len, const char* text, size_t text_len, size_t* written); -+BotFingerprint fingerprintFor(const BotMessage& message); -+BotCommandId commandIdForName(const char* name, size_t len); -+ -+} diff --git a/patches/meshcore/0001-Add-companion-radio-firmware-bot-command-parity.patch b/patches/meshcore/0001-Add-companion-radio-firmware-bot-command-parity.patch new file mode 100644 index 0000000..7f557cd --- /dev/null +++ b/patches/meshcore/0001-Add-companion-radio-firmware-bot-command-parity.patch @@ -0,0 +1,3739 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: cj-vana +Date: Fri, 15 May 2026 11:44:21 -0600 +Subject: [PATCH] Add companion radio firmware bot command parity + +--- + .../companion_radio/BotCommandRegistry.cpp | 121 ++ + examples/companion_radio/BotCommandRegistry.h | 17 + + examples/companion_radio/BotCommands.cpp | 357 ++++++ + examples/companion_radio/BotCommands.h | 10 + + examples/companion_radio/BotPolicy.cpp | 81 ++ + examples/companion_radio/BotPolicy.h | 14 + + examples/companion_radio/BotPrefs.cpp | 364 ++++++ + examples/companion_radio/BotPrefs.h | 31 + + examples/companion_radio/BotTypes.h | 331 +++++ + examples/companion_radio/DataStore.cpp | 36 + + examples/companion_radio/DataStore.h | 11 + + .../companion_radio/EmergencyForwarder.cpp | 118 ++ + examples/companion_radio/EmergencyForwarder.h | 10 + + examples/companion_radio/FirmwareBot.cpp | 272 ++++ + examples/companion_radio/FirmwareBot.h | 22 + + examples/companion_radio/KnownBotRegistry.cpp | 87 ++ + examples/companion_radio/KnownBotRegistry.h | 16 + + examples/companion_radio/MyMesh.cpp | 1142 ++++++++++++++++- + examples/companion_radio/MyMesh.h | 93 ++ + .../companion_radio/ResponseCoordinator.cpp | 210 +++ + .../companion_radio/ResponseCoordinator.h | 33 + + platformio.ini | 8 + + variants/heltec_v3/platformio.ini | 2 + + variants/rak4631/platformio.ini | 2 + + 24 files changed, 3385 insertions(+), 3 deletions(-) + create mode 100644 examples/companion_radio/BotCommandRegistry.cpp + create mode 100644 examples/companion_radio/BotCommandRegistry.h + create mode 100644 examples/companion_radio/BotCommands.cpp + create mode 100644 examples/companion_radio/BotCommands.h + create mode 100644 examples/companion_radio/BotPolicy.cpp + create mode 100644 examples/companion_radio/BotPolicy.h + create mode 100644 examples/companion_radio/BotPrefs.cpp + create mode 100644 examples/companion_radio/BotPrefs.h + create mode 100644 examples/companion_radio/BotTypes.h + create mode 100644 examples/companion_radio/EmergencyForwarder.cpp + create mode 100644 examples/companion_radio/EmergencyForwarder.h + create mode 100644 examples/companion_radio/FirmwareBot.cpp + create mode 100644 examples/companion_radio/FirmwareBot.h + create mode 100644 examples/companion_radio/KnownBotRegistry.cpp + create mode 100644 examples/companion_radio/KnownBotRegistry.h + create mode 100644 examples/companion_radio/ResponseCoordinator.cpp + create mode 100644 examples/companion_radio/ResponseCoordinator.h + +diff --git a/examples/companion_radio/BotCommandRegistry.cpp b/examples/companion_radio/BotCommandRegistry.cpp +new file mode 100644 +index 00000000..4fcf07a2 +--- /dev/null ++++ b/examples/companion_radio/BotCommandRegistry.cpp +@@ -0,0 +1,121 @@ ++#include "BotCommandRegistry.h" ++ ++#include ++#include ++ ++namespace { ++ ++const char* const kCmdAliases[] = { "commands" }; ++const char* const kTestAliases[] = { "t" }; ++const char* const kHelloAliases[] = { "hi" }; ++const char* const kVersionAliases[] = { "ver" }; ++const char* const kMagic8Aliases[] = { "8ball", "eightball" }; ++const char* const kPathAliases[] = { "p", "decode", "route" }; ++const char* const kUnsupportedAliases[] = { ++ "ack", "bbs", "blacklist", "delete", "download", "email", "heard", "history", "ignore", "info", ++ "last", "lheard", "location", "map", "mqtt", "nodes", "reboot", "set", "sms", "telemetry", ++ "upload", "weather", "whereami" ++}; ++ ++size_t boundedStrLen(const char* value, size_t max_len) { ++ size_t len = 0; ++ while (value && len < max_len && value[len] != 0) len++; ++ return len; ++} ++ ++bool namesEqual(const char* name, size_t len, const char* expected) { ++ size_t expected_len = boundedStrLen(expected, BOT_MAX_COMMAND_NAME_LEN + 1); ++ if (len != expected_len) return false; ++ for (size_t i = 0; i < len; i++) { ++ if (tolower((unsigned char)name[i]) != tolower((unsigned char)expected[i])) return false; ++ } ++ return true; ++} ++ ++const BotCommandMetadata kCommands[] = { ++ { BOT_COMMAND_HELP, "help", NULL, 0, BOT_COMMAND_MASK_HELP, BOT_COMMAND_VISIBILITY_DISCOVERABLE, ++ BOT_COMMAND_CONTEXT_NORMAL, "Show bot help", "help [command]", "Show available commands or details for one command." }, ++ { BOT_COMMAND_CMD, "cmd", kCmdAliases, 1, BOT_COMMAND_MASK_CMD, BOT_COMMAND_VISIBILITY_DISCOVERABLE, ++ BOT_COMMAND_CONTEXT_NORMAL, "List commands", "cmd", "List compact command names supported by this firmware bot." }, ++ { BOT_COMMAND_PING, "ping", NULL, 0, BOT_COMMAND_MASK_PING, BOT_COMMAND_VISIBILITY_DISCOVERABLE, ++ BOT_COMMAND_CONTEXT_NORMAL, "Check bot response", "ping", "Reply with Pong when the bot is alive." }, ++ { BOT_COMMAND_TEST, "test", kTestAliases, 1, BOT_COMMAND_MASK_TEST, BOT_COMMAND_VISIBILITY_DISCOVERABLE, ++ BOT_COMMAND_CONTEXT_NORMAL, "Run a bot test", "test", "Return a short firmware bot self-test response." }, ++ { BOT_COMMAND_HELLO, "hello", kHelloAliases, 1, BOT_COMMAND_MASK_HELLO, BOT_COMMAND_VISIBILITY_DISCOVERABLE, ++ BOT_COMMAND_CONTEXT_NORMAL, "Greet from the node", "hello", "Reply with the local bot node name." }, ++ { BOT_COMMAND_ABOUT, "about", NULL, 0, BOT_COMMAND_MASK_ABOUT, BOT_COMMAND_VISIBILITY_DISCOVERABLE, ++ BOT_COMMAND_CONTEXT_NORMAL, "Describe this bot", "about", "Describe the local firmware bot." }, ++ { BOT_COMMAND_ROLL, "roll", NULL, 0, BOT_COMMAND_MASK_ROLL, BOT_COMMAND_VISIBILITY_DISCOVERABLE, ++ BOT_COMMAND_CONTEXT_NORMAL, "Roll a numeric range", "roll [max|low high]", "Roll within a numeric range." }, ++ { BOT_COMMAND_DICE, "dice", NULL, 0, BOT_COMMAND_MASK_DICE, BOT_COMMAND_VISIBILITY_DISCOVERABLE, ++ BOT_COMMAND_CONTEXT_NORMAL, "Roll dice notation", "dice [dN|NdN]", "Roll dice notation with bounded dice and sides." }, ++ { BOT_COMMAND_STATUS, "status", NULL, 0, BOT_COMMAND_MASK_STATUS, BOT_COMMAND_VISIBILITY_DISCOVERABLE, ++ BOT_COMMAND_CONTEXT_DIAGNOSTIC, "Show node status", "status", "Show local uptime, battery, storage, and bot send counters." }, ++ { BOT_COMMAND_CHANNELS, "channels", NULL, 0, BOT_COMMAND_MASK_CHANNELS, BOT_COMMAND_VISIBILITY_DISCOVERABLE, ++ BOT_COMMAND_CONTEXT_DIAGNOSTIC, "Show configured channels", "channels", "Show local bot, testing, emergency, and public channel names." }, ++ { BOT_COMMAND_VERSION, "version", kVersionAliases, 1, BOT_COMMAND_MASK_VERSION, BOT_COMMAND_VISIBILITY_DISCOVERABLE, ++ BOT_COMMAND_CONTEXT_NORMAL, "Show firmware version", "version", "Show local firmware version and build date." }, ++ { BOT_COMMAND_STATS, "stats", NULL, 0, BOT_COMMAND_MASK_STATS, BOT_COMMAND_VISIBILITY_DISCOVERABLE, ++ BOT_COMMAND_CONTEXT_DIAGNOSTIC, "Show bot counters", "stats", "Show local bot and RF counters." }, ++ { BOT_COMMAND_MAGIC8, "magic8", kMagic8Aliases, 2, BOT_COMMAND_MASK_MAGIC8, BOT_COMMAND_VISIBILITY_DISCOVERABLE, ++ BOT_COMMAND_CONTEXT_NORMAL, "Ask the magic 8-ball", "magic8 ", "Return a short pseudo-random magic 8-ball answer." }, ++ { BOT_COMMAND_PATH, "path", kPathAliases, 3, BOT_COMMAND_MASK_PATH, BOT_COMMAND_VISIBILITY_DISCOVERABLE, ++ BOT_COMMAND_CONTEXT_TRACE, "Show last path", "path", "Show the latest local packet path hashes, hash width, and SNR if available." }, ++ { BOT_COMMAND_TRACE, "trace", NULL, 0, BOT_COMMAND_MASK_TRACE, BOT_COMMAND_VISIBILITY_DISCOVERABLE, ++ BOT_COMMAND_CONTEXT_TRACE, "Send active trace", "trace [hex-path]", "Send a bounded active trace request using local MeshCore state." }, ++ { BOT_COMMAND_TRACER, "tracer", NULL, 0, BOT_COMMAND_MASK_TRACER, BOT_COMMAND_VISIBILITY_DISCOVERABLE, ++ BOT_COMMAND_CONTEXT_TRACE, "Show route trace", "tracer", "Show the current packet route hashes without sending an active trace." }, ++ { BOT_COMMAND_PREFIX, "prefix", NULL, 0, BOT_COMMAND_MASK_PREFIX, BOT_COMMAND_VISIBILITY_DISCOVERABLE, ++ BOT_COMMAND_CONTEXT_LOCAL_CONTACT, "Look up local prefix", "prefix ", "Look up a local contact by public-key prefix using local firmware contacts only." }, ++ { BOT_COMMAND_UNSUPPORTED, "unsupported", kUnsupportedAliases, 23, 0, BOT_COMMAND_VISIBILITY_HIDDEN, ++ BOT_COMMAND_CONTEXT_UNSUPPORTED, "Unavailable in firmware", "", "This upstream host command is unavailable in firmware." }, ++ { BOT_COMMAND_UNKNOWN, "unknown", NULL, 0, 0, BOT_COMMAND_VISIBILITY_INTERNAL, ++ BOT_COMMAND_CONTEXT_INTERNAL, "Unknown command", "unknown", "Internal unknown-command handler." } ++}; ++ ++} ++ ++namespace BotCommandRegistry { ++ ++size_t commandCount() { ++ return sizeof(kCommands) / sizeof(kCommands[0]); ++} ++ ++const BotCommandMetadata* commandAt(size_t index) { ++ return index < commandCount() ? &kCommands[index] : NULL; ++} ++ ++const BotCommandMetadata* findById(BotCommandId id) { ++ for (size_t i = 0; i < commandCount(); i++) { ++ if (kCommands[i].id == id) return &kCommands[i]; ++ } ++ return NULL; ++} ++ ++const BotCommandMetadata* findByName(const char* name, size_t len) { ++ if (!name || len == 0 || len > BOT_MAX_COMMAND_NAME_LEN) return NULL; ++ for (size_t i = 0; i < commandCount(); i++) { ++ if (namesEqual(name, len, kCommands[i].name)) return &kCommands[i]; ++ for (uint8_t j = 0; j < kCommands[i].alias_count; j++) { ++ if (namesEqual(name, len, kCommands[i].aliases[j])) return &kCommands[i]; ++ } ++ } ++ return NULL; ++} ++ ++const char* commandName(BotCommandId id) { ++ const BotCommandMetadata* command = findById(id); ++ return command ? command->name : ""; ++} ++ ++uint32_t commandMask(BotCommandId id) { ++ const BotCommandMetadata* command = findById(id); ++ return command ? command->mask : 0; ++} ++ ++bool isDiscoverable(BotCommandId id) { ++ const BotCommandMetadata* command = findById(id); ++ return command && command->visibility == BOT_COMMAND_VISIBILITY_DISCOVERABLE; ++} ++ ++} +diff --git a/examples/companion_radio/BotCommandRegistry.h b/examples/companion_radio/BotCommandRegistry.h +new file mode 100644 +index 00000000..e5aae4dd +--- /dev/null ++++ b/examples/companion_radio/BotCommandRegistry.h +@@ -0,0 +1,17 @@ ++#pragma once ++ ++#include "BotTypes.h" ++ ++#include ++ ++namespace BotCommandRegistry { ++ ++size_t commandCount(); ++const BotCommandMetadata* commandAt(size_t index); ++const BotCommandMetadata* findById(BotCommandId id); ++const BotCommandMetadata* findByName(const char* name, size_t len); ++const char* commandName(BotCommandId id); ++uint32_t commandMask(BotCommandId id); ++bool isDiscoverable(BotCommandId id); ++ ++} +diff --git a/examples/companion_radio/BotCommands.cpp b/examples/companion_radio/BotCommands.cpp +new file mode 100644 +index 00000000..a7d3637f +--- /dev/null ++++ b/examples/companion_radio/BotCommands.cpp +@@ -0,0 +1,357 @@ ++#include "BotCommands.h" ++ ++#include "BotCommandRegistry.h" ++ ++#include ++#include ++#include ++#include ++ ++namespace { ++ ++BotCommandResult makeResult(BotCommandResultCode code, size_t text_len) { ++ BotCommandResult result = { code, text_len }; ++ return result; ++} ++ ++size_t boundedStrLen(const char* value, size_t max_len) { ++ size_t len = 0; ++ while (value && len < max_len && value[len] != 0) len++; ++ return len; ++} ++ ++BotCommandResult writeText(char* output, size_t output_len, const char* text) { ++ if (!output || output_len == 0) return makeResult(BOT_COMMAND_RESULT_NO_SPACE, 0); ++ ++ size_t text_len = boundedStrLen(text, BOT_MAX_RESPONSE_LEN + 1); ++ size_t copy_len = text_len; ++ if (copy_len + 1 > output_len) copy_len = output_len - 1; ++ if (copy_len > 0) memcpy(output, text, copy_len); ++ output[copy_len] = 0; ++ ++ return makeResult(copy_len < text_len ? BOT_COMMAND_RESULT_TRUNCATED : BOT_COMMAND_RESULT_OK, copy_len); ++} ++ ++BotCommandResult writeFormatted(char* output, size_t output_len, const char* format, ...) { ++ if (!output || output_len == 0) return makeResult(BOT_COMMAND_RESULT_NO_SPACE, 0); ++ ++ va_list args; ++ va_start(args, format); ++ int n = vsnprintf(output, output_len, format, args); ++ va_end(args); ++ ++ if (n < 0) { ++ output[0] = 0; ++ return makeResult(BOT_COMMAND_RESULT_NO_SPACE, 0); ++ } ++ ++ size_t written = (size_t)n; ++ if (written >= output_len) written = output_len - 1; ++ return makeResult((size_t)n >= output_len ? BOT_COMMAND_RESULT_TRUNCATED : BOT_COMMAND_RESULT_OK, written); ++} ++ ++void appendText(char* output, size_t output_len, size_t* pos, const char* text) { ++ if (!output || output_len == 0 || !pos || !text) return; ++ for (size_t i = 0; text[i] != 0; i++) { ++ if (*pos + 1 < output_len) output[*pos] = text[i]; ++ (*pos)++; ++ } ++ output[*pos < output_len ? *pos : output_len - 1] = 0; ++} ++ ++BotCommandResult resultForAppend(char* output, size_t output_len, size_t pos) { ++ if (!output || output_len == 0) return makeResult(BOT_COMMAND_RESULT_NO_SPACE, 0); ++ size_t actual = boundedStrLen(output, output_len); ++ return makeResult(pos >= output_len ? BOT_COMMAND_RESULT_TRUNCATED : BOT_COMMAND_RESULT_OK, actual); ++} ++ ++bool parseUInt(const char* text, size_t len, size_t* pos, uint16_t* value) { ++ uint32_t parsed = 0; ++ size_t start = *pos; ++ while (*pos < len && isdigit((unsigned char)text[*pos])) { ++ parsed = parsed * 10 + (uint32_t)(text[*pos] - '0'); ++ if (parsed > 1000) return false; ++ (*pos)++; ++ } ++ if (*pos == start) return false; ++ *value = (uint16_t)parsed; ++ return true; ++} ++ ++bool isSupportedSides(uint16_t sides) { ++ return sides >= 2 && sides <= 1000; ++} ++ ++void skipSpaces(const char* text, size_t len, size_t* pos) { ++ while (*pos < len && text[*pos] == ' ') (*pos)++; ++} ++ ++bool parseRoll(const BotCommand& command, uint16_t* low, uint16_t* high) { ++ *low = 1; ++ *high = 100; ++ if (command.args_len == 0) return true; ++ ++ const char* text = command.args; ++ size_t len = command.args_len; ++ size_t pos = 0; ++ uint16_t first = 0; ++ if (!parseUInt(text, len, &pos, &first) || first == 0) return false; ++ ++ skipSpaces(text, len, &pos); ++ if (pos == len) { ++ *high = first; ++ return true; ++ } ++ ++ uint16_t second = 0; ++ if (!parseUInt(text, len, &pos, &second) || second == 0) return false; ++ skipSpaces(text, len, &pos); ++ if (pos != len || first > second) return false; ++ ++ *low = first; ++ *high = second; ++ return true; ++} ++ ++bool parseDice(const BotCommand& command, uint16_t* count, uint16_t* sides) { ++ *count = 1; ++ *sides = 6; ++ if (command.args_len == 0) return true; ++ ++ const char* text = command.args; ++ size_t len = command.args_len; ++ size_t pos = 0; ++ ++ if (text[pos] == 'd' || text[pos] == 'D') { ++ pos++; ++ if (!parseUInt(text, len, &pos, sides)) return false; ++ } else { ++ if (!parseUInt(text, len, &pos, count)) return false; ++ if (pos >= len || (text[pos] != 'd' && text[pos] != 'D')) return false; ++ pos++; ++ if (!parseUInt(text, len, &pos, sides)) return false; ++ } ++ ++ return pos == len && *count >= 1 && *count <= 10 && isSupportedSides(*sides); ++} ++ ++uint16_t rollOnce(uint32_t* state, uint16_t sides) { ++ *state = (*state * 1664525UL) + 1013904223UL; ++ return (uint16_t)((*state >> 16) % sides) + 1; ++} ++ ++void appendPathHex(char* output, size_t output_len, size_t* pos, const uint8_t* path, size_t path_len) { ++ static const char hex[] = "0123456789abcdef"; ++ for (size_t i = 0; i < path_len; i++) { ++ if (*pos + 2 < output_len) { ++ output[*pos] = hex[path[i] >> 4]; ++ output[*pos + 1] = hex[path[i] & 0x0F]; ++ } ++ *pos += 2; ++ } ++ if (output_len > 0) output[*pos < output_len ? *pos : output_len - 1] = 0; ++} ++ ++void formatQuarters(int8_t quarters, char* output, size_t output_len) { ++ if (!output || output_len == 0) return; ++ int value = quarters; ++ const char* sign = value < 0 ? "-" : ""; ++ if (value < 0) value = -value; ++ snprintf(output, output_len, "%s%d.%02d", sign, value / 4, (value % 4) * 25); ++} ++ ++BotCommandResult executeCmd(char* output, size_t output_len) { ++ if (!output || output_len == 0) return makeResult(BOT_COMMAND_RESULT_NO_SPACE, 0); ++ output[0] = 0; ++ size_t pos = 0; ++ bool first = true; ++ for (size_t i = 0; i < BotCommandRegistry::commandCount(); i++) { ++ const BotCommandMetadata* command = BotCommandRegistry::commandAt(i); ++ if (!command || command->visibility != BOT_COMMAND_VISIBILITY_DISCOVERABLE) continue; ++ if (!first) appendText(output, output_len, &pos, " "); ++ appendText(output, output_len, &pos, command->name); ++ first = false; ++ } ++ return resultForAppend(output, output_len, pos); ++} ++ ++BotCommandResult executeHelp(const BotCommand& command, char* output, size_t output_len) { ++ if (command.args_len == 0) { ++ if (!output || output_len == 0) return makeResult(BOT_COMMAND_RESULT_NO_SPACE, 0); ++ output[0] = 0; ++ size_t pos = 0; ++ appendText(output, output_len, &pos, "Commands: "); ++ bool first = true; ++ for (size_t i = 0; i < BotCommandRegistry::commandCount(); i++) { ++ const BotCommandMetadata* metadata = BotCommandRegistry::commandAt(i); ++ if (!metadata || metadata->visibility != BOT_COMMAND_VISIBILITY_DISCOVERABLE) continue; ++ if (!first) appendText(output, output_len, &pos, " "); ++ appendText(output, output_len, &pos, metadata->name); ++ first = false; ++ } ++ appendText(output, output_len, &pos, "; help "); ++ return resultForAppend(output, output_len, pos); ++ } ++ ++ const BotCommandMetadata* metadata = BotCommandRegistry::findByName(command.args, command.args_len); ++ if (!metadata) return writeFormatted(output, output_len, "No help for %s", command.args); ++ if (metadata->id == BOT_COMMAND_UNSUPPORTED) { ++ return writeFormatted(output, output_len, "%s is unavailable in firmware", command.args); ++ } ++ if (metadata->visibility != BOT_COMMAND_VISIBILITY_DISCOVERABLE) { ++ return writeFormatted(output, output_len, "%s is not available", command.args); ++ } ++ return writeFormatted(output, output_len, "%s: %s. Usage: %s", metadata->name, metadata->details, metadata->usage); ++} ++ ++BotCommandResult executeMagic8(const BotCommandContext& context, char* output, size_t output_len) { ++ static const char* responses[] = { ++ "It is certain", "Looks good", "Ask again later", "Cannot predict now", "Doubtful", "Very likely", ++ "Signs point yes", "No", "Reply hazy", "Absolutely" ++ }; ++ uint32_t seed = context.random_seed ? context.random_seed : 1; ++ seed = seed * 1664525UL + 1013904223UL; ++ return writeFormatted(output, output_len, "Magic 8-ball: %s", responses[(seed >> 16) % (sizeof(responses) / sizeof(responses[0]))]); ++} ++ ++BotCommandResult executePathLike(const BotCommandContext& context, char* output, size_t output_len, const char* label) { ++ if (!context.path || context.path_len == 0 || context.path_hash_count == 0 || context.path_hash_size == 0) { ++ return writeFormatted(output, output_len, "%s unavailable", label); ++ } ++ if (!output || output_len == 0) return makeResult(BOT_COMMAND_RESULT_NO_SPACE, 0); ++ ++ size_t byte_len = (size_t)context.path_hash_size * context.path_hash_count; ++ char snr[8]; ++ formatQuarters(context.path_snr_quarters, snr, sizeof(snr)); ++ int written = snprintf(output, output_len, "%s %uh x %uB snr %s: ", label, (unsigned)context.path_hash_count, ++ (unsigned)context.path_hash_size, snr); ++ if (written < 0) return makeResult(BOT_COMMAND_RESULT_NO_SPACE, 0); ++ size_t pos = (size_t)written; ++ appendPathHex(output, output_len, &pos, context.path, byte_len); ++ size_t actual = boundedStrLen(output, output_len); ++ return makeResult(pos >= output_len ? BOT_COMMAND_RESULT_TRUNCATED : BOT_COMMAND_RESULT_OK, actual); ++} ++ ++BotCommandResult executePath(const BotCommandContext& context, char* output, size_t output_len) { ++ return executePathLike(context, output, output_len, "Path"); ++} ++ ++BotCommandResult executeTracer(const BotCommand& command, const BotCommandContext& context, char* output, size_t output_len) { ++ if (command.args_len != 0) return writeText(output, output_len, "Usage: tracer"); ++ return executePathLike(context, output, output_len, "Tracer"); ++} ++ ++BotCommandResult executeRoll(const BotCommand& command, const BotCommandContext& context, char* output, size_t output_len) { ++ uint16_t low = 1; ++ uint16_t high = 100; ++ if (!parseRoll(command, &low, &high)) return writeText(output, output_len, "Usage: roll [max|low high], range 1-1000"); ++ ++ uint32_t state = context.random_seed ^ ((uint32_t)low << 16) ^ high; ++ if (state == 0) state = 1; ++ uint16_t span = (uint16_t)(high - low + 1); ++ uint16_t value = (uint16_t)(low + rollOnce(&state, span) - 1); ++ return writeFormatted(output, output_len, "Rolled %u-%u: %u", (unsigned)low, (unsigned)high, (unsigned)value); ++} ++ ++BotCommandResult executeDice(const BotCommand& command, const BotCommandContext& context, char* output, size_t output_len) { ++ uint16_t count = 1; ++ uint16_t sides = 6; ++ if (!parseDice(command, &count, &sides)) { ++ return writeText(output, output_len, "Usage: dice [dN|NdN], max 10 dice, sides 2-1000"); ++ } ++ ++ uint32_t state = context.random_seed ^ ((uint32_t)count << 16) ^ sides; ++ if (state == 0) state = 1; ++ ++ if (!output || output_len == 0) return makeResult(BOT_COMMAND_RESULT_NO_SPACE, 0); ++ int written = count == 1 ? snprintf(output, output_len, "Dice d%u: ", (unsigned)sides) ++ : snprintf(output, output_len, "Dice %ud%u: ", (unsigned)count, (unsigned)sides); ++ if (written < 0) return makeResult(BOT_COMMAND_RESULT_NO_SPACE, 0); ++ ++ size_t pos = (size_t)written; ++ uint16_t total = 0; ++ for (uint16_t i = 0; i < count; i++) { ++ uint16_t roll = rollOnce(&state, sides); ++ total += roll; ++ if (pos < output_len) { ++ int n = snprintf(&output[pos], output_len - pos, i == 0 ? "%u" : "+%u", (unsigned)roll); ++ if (n < 0) return makeResult(BOT_COMMAND_RESULT_NO_SPACE, 0); ++ pos += (size_t)n; ++ } ++ } ++ if (count > 1 && pos < output_len) { ++ int n = snprintf(&output[pos], output_len - pos, "=%u", (unsigned)total); ++ if (n < 0) return makeResult(BOT_COMMAND_RESULT_NO_SPACE, 0); ++ pos += (size_t)n; ++ } ++ ++ size_t actual = boundedStrLen(output, output_len); ++ return makeResult(pos >= output_len ? BOT_COMMAND_RESULT_TRUNCATED : BOT_COMMAND_RESULT_OK, actual); ++} ++ ++} ++ ++namespace BotCommands { ++ ++BotCommandResult executeCommand(const BotCommand& command, const BotCommandContext& context, char* output, ++ size_t output_len) { ++ switch (command.id) { ++ case BOT_COMMAND_HELP: ++ return executeHelp(command, output, output_len); ++ case BOT_COMMAND_CMD: ++ return executeCmd(output, output_len); ++ case BOT_COMMAND_PING: ++ return writeText(output, output_len, "Pong!"); ++ case BOT_COMMAND_TEST: ++ return writeText(output, output_len, "Bot test OK"); ++ case BOT_COMMAND_HELLO: ++ return writeFormatted(output, output_len, "Hello from %s", context.node_name[0] ? context.node_name : "MeshCore bot"); ++ case BOT_COMMAND_ABOUT: ++ return writeText(output, output_len, "Colorado Mesh firmware bot: local commands only, no internet required."); ++ case BOT_COMMAND_ROLL: ++ return executeRoll(command, context, output, output_len); ++ case BOT_COMMAND_DICE: ++ return executeDice(command, context, output, output_len); ++ case BOT_COMMAND_STATUS: ++ return writeFormatted(output, output_len, "%s up %lus batt %umV storage %lu/%luKB seen %lu sent %lu fail %lu", ++ context.node_name[0] ? context.node_name : "bot", (unsigned long)context.uptime_seconds, ++ (unsigned)context.battery_millivolts, (unsigned long)context.storage_used_kb, ++ (unsigned long)context.storage_total_kb, (unsigned long)context.observed_messages, ++ (unsigned long)context.sent_messages, (unsigned long)context.send_failures); ++ case BOT_COMMAND_CHANNELS: ++ return writeFormatted(output, output_len, "Channels: %s %s emergency=%s public=%s (%u configured)", ++ context.bot_channel[0] ? context.bot_channel : "#bot", ++ context.testing_channel[0] ? context.testing_channel : "#testing", ++ context.emergency_channel[0] ? context.emergency_channel : "#emergency", ++ context.public_channel[0] ? context.public_channel : "Public", (unsigned)context.channel_count); ++ case BOT_COMMAND_VERSION: ++ return writeFormatted(output, output_len, "Firmware %s built %s", context.firmware_version[0] ? context.firmware_version : "unknown", ++ context.firmware_build_date[0] ? context.firmware_build_date : "unknown"); ++ case BOT_COMMAND_STATS: ++ return writeFormatted(output, output_len, "Bot seen %lu ok %lu sent %lu fail %lu sup %lu pend %lu rf rx/tx %lu/%lu err %lu q %u", ++ (unsigned long)context.observed_messages, (unsigned long)context.eligible_messages, ++ (unsigned long)context.sent_messages, (unsigned long)context.send_failures, ++ (unsigned long)context.suppressed_responses, (unsigned long)context.pending_responses, ++ (unsigned long)context.packets_recv, (unsigned long)context.packets_sent, ++ (unsigned long)context.packets_recv_errors, (unsigned)context.queue_depth); ++ case BOT_COMMAND_MAGIC8: ++ return executeMagic8(context, output, output_len); ++ case BOT_COMMAND_PATH: ++ return executePath(context, output, output_len); ++ case BOT_COMMAND_TRACE: ++ return command.args_len == 0 ? writeText(output, output_len, "Trace route unavailable") ++ : writeText(output, output_len, "Usage: trace [hex-path]"); ++ case BOT_COMMAND_TRACER: ++ return executeTracer(command, context, output, output_len); ++ case BOT_COMMAND_PREFIX: ++ return writeText(output, output_len, "Prefix lookup unavailable"); ++ case BOT_COMMAND_UNSUPPORTED: ++ return writeFormatted(output, output_len, "%s is unavailable in firmware", command.name); ++ case BOT_COMMAND_UNKNOWN: ++ return writeText(output, output_len, "Unknown command. Try help"); ++ default: ++ return makeResult(BOT_COMMAND_RESULT_NOT_HANDLED, 0); ++ } ++} ++ ++} +diff --git a/examples/companion_radio/BotCommands.h b/examples/companion_radio/BotCommands.h +new file mode 100644 +index 00000000..b0b4cddb +--- /dev/null ++++ b/examples/companion_radio/BotCommands.h +@@ -0,0 +1,10 @@ ++#pragma once ++ ++#include "BotTypes.h" ++ ++namespace BotCommands { ++ ++BotCommandResult executeCommand(const BotCommand& command, const BotCommandContext& context, char* output, ++ size_t output_len); ++ ++} +diff --git a/examples/companion_radio/BotPolicy.cpp b/examples/companion_radio/BotPolicy.cpp +new file mode 100644 +index 00000000..33de45f8 +--- /dev/null ++++ b/examples/companion_radio/BotPolicy.cpp +@@ -0,0 +1,81 @@ ++#include "BotPolicy.h" ++ ++#include ++#include ++ ++namespace { ++ ++bool equalsIgnoreCase(const char* value, size_t len, const char* expected) { ++ if (!value || !expected) return false; ++ ++ if (len > 0 && value[0] == '#') { ++ value++; ++ len--; ++ } ++ ++ size_t expected_len = strlen(expected); ++ if (expected_len > 0 && expected[0] == '#') { ++ expected++; ++ expected_len--; ++ } ++ if (len != expected_len) return false; ++ ++ for (size_t i = 0; i < len; i++) { ++ if (tolower((unsigned char)value[i]) != tolower((unsigned char)expected[i])) return false; ++ } ++ return true; ++} ++ ++bool equalsExact(const char* value, size_t len, const char* expected) { ++ if (!value || !expected) return false; ++ size_t expected_len = strlen(expected); ++ if (len != expected_len) return false; ++ for (size_t i = 0; i < len; i++) { ++ if (value[i] != expected[i]) return false; ++ } ++ return true; ++} ++ ++} ++ ++namespace BotPolicy { ++ ++BotChannelKind classifyChannel(const char* name, size_t len, bool direct_message) { ++ if (direct_message) return BOT_CHANNEL_DM; ++ if (equalsExact(name, len, "Public")) return BOT_CHANNEL_PUBLIC; ++ if (equalsIgnoreCase(name, len, "#bot")) return BOT_CHANNEL_BOT; ++ if (equalsIgnoreCase(name, len, "#testing")) return BOT_CHANNEL_TESTING; ++ if (equalsExact(name, len, "#emergency")) return BOT_CHANNEL_EMERGENCY; ++ return BOT_CHANNEL_OTHER; ++} ++ ++BotChannelKind classifyChannel(const char* name, size_t len, bool direct_message, const BotPrefs& prefs) { ++ if (direct_message) return BOT_CHANNEL_DM; ++ if (equalsExact(name, len, prefs.public_channel)) return BOT_CHANNEL_PUBLIC; ++ if (equalsIgnoreCase(name, len, prefs.bot_channel)) return BOT_CHANNEL_BOT; ++ if (equalsIgnoreCase(name, len, prefs.testing_channel)) return BOT_CHANNEL_TESTING; ++ if (equalsExact(name, len, prefs.emergency_channel)) return BOT_CHANNEL_EMERGENCY; ++ return BOT_CHANNEL_OTHER; ++} ++ ++BotPolicyDecision decide(BotChannelKind kind) { ++ if (kind == BOT_CHANNEL_DM || kind == BOT_CHANNEL_BOT || kind == BOT_CHANNEL_TESTING) { ++ return BOT_POLICY_ALLOW_NORMAL; ++ } ++ if (kind == BOT_CHANNEL_EMERGENCY) return BOT_POLICY_EMERGENCY_FORWARD; ++ return BOT_POLICY_IGNORE; ++} ++ ++bool isNormalAllowed(BotChannelKind kind) { ++ return decide(kind) == BOT_POLICY_ALLOW_NORMAL; ++} ++ ++bool isEmergency(BotChannelKind kind) { ++ return decide(kind) == BOT_POLICY_EMERGENCY_FORWARD; ++} ++ ++bool isPrefixlessCommandAllowed(BotChannelKind kind) { ++ return kind == BOT_CHANNEL_BOT || kind == BOT_CHANNEL_TESTING; ++} ++ ++} +diff --git a/examples/companion_radio/BotPolicy.h b/examples/companion_radio/BotPolicy.h +new file mode 100644 +index 00000000..69910692 +--- /dev/null ++++ b/examples/companion_radio/BotPolicy.h +@@ -0,0 +1,14 @@ ++#pragma once ++ ++#include "BotTypes.h" ++ ++namespace BotPolicy { ++ ++BotChannelKind classifyChannel(const char* name, size_t len, bool direct_message); ++BotChannelKind classifyChannel(const char* name, size_t len, bool direct_message, const BotPrefs& prefs); ++BotPolicyDecision decide(BotChannelKind kind); ++bool isNormalAllowed(BotChannelKind kind); ++bool isEmergency(BotChannelKind kind); ++bool isPrefixlessCommandAllowed(BotChannelKind kind); ++ ++} +diff --git a/examples/companion_radio/BotPrefs.cpp b/examples/companion_radio/BotPrefs.cpp +new file mode 100644 +index 00000000..799b6458 +--- /dev/null ++++ b/examples/companion_radio/BotPrefs.cpp +@@ -0,0 +1,364 @@ ++#include "BotPrefs.h" ++ ++#include "BotCommandRegistry.h" ++ ++#include ++ ++namespace { ++ ++const size_t SERIALIZED_SIZE = BOT_PREFS_SERIALIZED_SIZE; ++ ++size_t boundedStrLen(const char* value, size_t max_len) { ++ size_t len = 0; ++ while (value && len < max_len && value[len] != 0) len++; ++ return len; ++} ++ ++void copyString(char* dest, size_t dest_len, const char* src) { ++ if (!dest || dest_len == 0) return; ++ size_t len = boundedStrLen(src, dest_len - 1); ++ if (len > 0) memcpy(dest, src, len); ++ dest[len] = 0; ++} ++ ++bool channelNameEqual(const char* lhs, const char* rhs) { ++ size_t lhs_len = boundedStrLen(lhs, BOT_MAX_CHANNEL_NAME_LEN + 1); ++ size_t rhs_len = boundedStrLen(rhs, BOT_MAX_CHANNEL_NAME_LEN + 1); ++ if (lhs_len != rhs_len || lhs_len == 0) return false; ++ return memcmp(lhs, rhs, lhs_len) == 0; ++} ++ ++bool channelHasShape(const char* value, bool public_channel) { ++ size_t len = boundedStrLen(value, BOT_MAX_CHANNEL_NAME_LEN + 1); ++ if (len == 0 || len > BOT_MAX_CHANNEL_NAME_LEN) return false; ++ if (public_channel) return value[0] != '#'; ++ return value[0] == '#' && len > 1; ++} ++ ++uint32_t checksumBytes(const uint8_t* data, size_t len) { ++ uint32_t hash = 2166136261UL; ++ for (size_t i = 0; i < len; i++) { ++ hash ^= data[i]; ++ hash *= 16777619UL; ++ } ++ return hash; ++} ++ ++void put8(uint8_t* data, size_t& pos, uint8_t value) { ++ data[pos++] = value; ++} ++ ++void put16(uint8_t* data, size_t& pos, uint16_t value) { ++ data[pos++] = (uint8_t)(value & 0xFF); ++ data[pos++] = (uint8_t)(value >> 8); ++} ++ ++void put32(uint8_t* data, size_t& pos, uint32_t value) { ++ data[pos++] = (uint8_t)(value & 0xFF); ++ data[pos++] = (uint8_t)((value >> 8) & 0xFF); ++ data[pos++] = (uint8_t)((value >> 16) & 0xFF); ++ data[pos++] = (uint8_t)((value >> 24) & 0xFF); ++} ++ ++uint8_t get8(const uint8_t* data, size_t& pos) { ++ return data[pos++]; ++} ++ ++uint16_t get16(const uint8_t* data, size_t& pos) { ++ uint16_t value = data[pos]; ++ value |= ((uint16_t)data[pos + 1]) << 8; ++ pos += 2; ++ return value; ++} ++ ++uint32_t get32(const uint8_t* data, size_t& pos) { ++ uint32_t value = data[pos]; ++ value |= ((uint32_t)data[pos + 1]) << 8; ++ value |= ((uint32_t)data[pos + 2]) << 16; ++ value |= ((uint32_t)data[pos + 3]) << 24; ++ pos += 4; ++ return value; ++} ++ ++void putFixedString(uint8_t* data, size_t& pos, const char* value, size_t fixed_len) { ++ memset(&data[pos], 0, fixed_len); ++ size_t len = boundedStrLen(value, fixed_len); ++ if (len > 0) memcpy(&data[pos], value, len); ++ pos += fixed_len; ++} ++ ++void getFixedString(const uint8_t* data, size_t& pos, char* value, size_t fixed_len) { ++ memcpy(value, &data[pos], fixed_len); ++ value[fixed_len - 1] = 0; ++ pos += fixed_len; ++} ++ ++int hexValue(char c) { ++ if (c >= '0' && c <= '9') return c - '0'; ++ if (c >= 'a' && c <= 'f') return c - 'a' + 10; ++ if (c >= 'A' && c <= 'F') return c - 'A' + 10; ++ return -1; ++} ++ ++bool sameKeyPrefix(const uint8_t lhs[BOT_SENDER_KEY_PREFIX_LEN], const uint8_t rhs[BOT_SENDER_KEY_PREFIX_LEN]) { ++ return memcmp(lhs, rhs, BOT_SENDER_KEY_PREFIX_LEN) == 0; ++} ++ ++} ++ ++namespace BotPrefsCodec { ++ ++size_t serializedSize() { ++ return SERIALIZED_SIZE; ++} ++ ++void defaults(BotPrefs& prefs) { ++ memset(&prefs, 0, sizeof(prefs)); ++ prefs.enabled = true; ++ prefs.normal_delay_ms = BOT_RESPONSE_DELAY_BASE_MILLIS; ++ prefs.normal_jitter_ms = BOT_RESPONSE_DELAY_JITTER_MILLIS; ++ prefs.local_advert_interval_ms = BOT_PREFS_DEFAULT_LOCAL_ADVERT_MILLIS; ++ prefs.flood_advert_interval_ms = BOT_PREFS_DEFAULT_FLOOD_ADVERT_MILLIS; ++ prefs.command_mask = BOT_COMMAND_MASK_ALL; ++ prefs.max_response_parts = BOT_EMERGENCY_MAX_PARTS; ++ copyString(prefs.bot_channel, sizeof(prefs.bot_channel), "#bot"); ++ copyString(prefs.testing_channel, sizeof(prefs.testing_channel), "#testing"); ++ copyString(prefs.emergency_channel, sizeof(prefs.emergency_channel), "#emergency"); ++ copyString(prefs.public_channel, sizeof(prefs.public_channel), "Public"); ++} ++ ++void validate(BotPrefs& prefs) { ++ prefs.normal_delay_ms = prefs.normal_delay_ms > BOT_PREFS_MAX_DELAY_MILLIS ? BOT_PREFS_MAX_DELAY_MILLIS : prefs.normal_delay_ms; ++ prefs.normal_jitter_ms = prefs.normal_jitter_ms > BOT_PREFS_MAX_DELAY_MILLIS ? BOT_PREFS_MAX_DELAY_MILLIS : prefs.normal_jitter_ms; ++ if (prefs.local_advert_interval_ms > BOT_PREFS_MAX_ADVERT_MILLIS) prefs.local_advert_interval_ms = BOT_PREFS_MAX_ADVERT_MILLIS; ++ if (prefs.flood_advert_interval_ms > BOT_PREFS_MAX_ADVERT_MILLIS) prefs.flood_advert_interval_ms = BOT_PREFS_MAX_ADVERT_MILLIS; ++ prefs.command_mask &= BOT_COMMAND_MASK_ALL; ++ if (prefs.max_response_parts == 0 || prefs.max_response_parts > BOT_EMERGENCY_MAX_PARTS) { ++ prefs.max_response_parts = BOT_EMERGENCY_MAX_PARTS; ++ } ++ if (!channelHasShape(prefs.bot_channel, false)) copyString(prefs.bot_channel, sizeof(prefs.bot_channel), "#bot"); ++ if (!channelHasShape(prefs.testing_channel, false)) copyString(prefs.testing_channel, sizeof(prefs.testing_channel), "#testing"); ++ if (!channelHasShape(prefs.emergency_channel, false)) copyString(prefs.emergency_channel, sizeof(prefs.emergency_channel), "#emergency"); ++ if (!channelHasShape(prefs.public_channel, true)) copyString(prefs.public_channel, sizeof(prefs.public_channel), "Public"); ++ prefs.bot_channel[BOT_MAX_CHANNEL_NAME_LEN] = 0; ++ prefs.testing_channel[BOT_MAX_CHANNEL_NAME_LEN] = 0; ++ prefs.emergency_channel[BOT_MAX_CHANNEL_NAME_LEN] = 0; ++ prefs.public_channel[BOT_MAX_CHANNEL_NAME_LEN] = 0; ++ if (!channelConfigValid(prefs)) { ++ copyString(prefs.bot_channel, sizeof(prefs.bot_channel), "#bot"); ++ copyString(prefs.testing_channel, sizeof(prefs.testing_channel), "#testing"); ++ copyString(prefs.emergency_channel, sizeof(prefs.emergency_channel), "#emergency"); ++ copyString(prefs.public_channel, sizeof(prefs.public_channel), "Public"); ++ } ++ for (size_t i = 0; i < BOT_KNOWN_BOT_SLOTS; i++) { ++ prefs.known_bots[i].flags &= BOT_KNOWN_BOT_FLAG_SUPPRESS_NORMAL; ++ prefs.known_bots[i].label[BOT_KNOWN_BOT_LABEL_LEN - 1] = 0; ++ } ++} ++ ++bool channelNameValid(const char* value, bool public_channel) { ++ return channelHasShape(value, public_channel); ++} ++ ++bool channelConfigValid(const BotPrefs& prefs) { ++ return channelHasShape(prefs.bot_channel, false) && channelHasShape(prefs.testing_channel, false) && ++ channelHasShape(prefs.emergency_channel, false) && channelHasShape(prefs.public_channel, true) && ++ !channelNameEqual(prefs.bot_channel, prefs.testing_channel) && ++ !channelNameEqual(prefs.bot_channel, prefs.emergency_channel) && ++ !channelNameEqual(prefs.testing_channel, prefs.emergency_channel) && ++ !channelNameEqual(prefs.bot_channel, prefs.public_channel) && ++ !channelNameEqual(prefs.testing_channel, prefs.public_channel) && ++ !channelNameEqual(prefs.emergency_channel, prefs.public_channel); ++} ++ ++bool serialize(const BotPrefs& prefs, uint8_t* output, size_t output_len) { ++ if (!output || output_len < SERIALIZED_SIZE) return false; ++ ++ BotPrefs clean = prefs; ++ validate(clean); ++ ++ memset(output, 0, output_len); ++ size_t pos = 0; ++ put32(output, pos, BOT_PREFS_MAGIC); ++ put16(output, pos, BOT_PREFS_VERSION); ++ put16(output, pos, (uint16_t)SERIALIZED_SIZE); ++ size_t checksum_pos = pos; ++ put32(output, pos, 0); ++ put8(output, pos, clean.enabled ? 1 : 0); ++ put16(output, pos, clean.normal_delay_ms); ++ put16(output, pos, clean.normal_jitter_ms); ++ put32(output, pos, clean.local_advert_interval_ms); ++ put32(output, pos, clean.flood_advert_interval_ms); ++ put32(output, pos, clean.command_mask); ++ put8(output, pos, clean.max_response_parts); ++ putFixedString(output, pos, clean.bot_channel, BOT_MAX_CHANNEL_NAME_LEN + 1); ++ putFixedString(output, pos, clean.testing_channel, BOT_MAX_CHANNEL_NAME_LEN + 1); ++ putFixedString(output, pos, clean.emergency_channel, BOT_MAX_CHANNEL_NAME_LEN + 1); ++ putFixedString(output, pos, clean.public_channel, BOT_MAX_CHANNEL_NAME_LEN + 1); ++ for (size_t i = 0; i < BOT_KNOWN_BOT_SLOTS; i++) { ++ put8(output, pos, clean.known_bots[i].active ? 1 : 0); ++ memcpy(&output[pos], clean.known_bots[i].key_prefix, BOT_SENDER_KEY_PREFIX_LEN); ++ pos += BOT_SENDER_KEY_PREFIX_LEN; ++ put8(output, pos, clean.known_bots[i].flags); ++ putFixedString(output, pos, clean.known_bots[i].label, BOT_KNOWN_BOT_LABEL_LEN); ++ } ++ put32(output, pos, clean.prefs_load_failures); ++ put32(output, pos, clean.prefs_save_failures); ++ ++ if (pos != SERIALIZED_SIZE) return false; ++ uint32_t checksum = checksumBytes(&output[12], SERIALIZED_SIZE - 12); ++ size_t write_pos = checksum_pos; ++ put32(output, write_pos, checksum); ++ return true; ++} ++ ++bool deserialize(const uint8_t* data, size_t data_len, BotPrefs& prefs) { ++ if (!data || data_len != SERIALIZED_SIZE) { ++ defaults(prefs); ++ return false; ++ } ++ ++ size_t pos = 0; ++ uint32_t magic = get32(data, pos); ++ uint16_t version = get16(data, pos); ++ uint16_t length = get16(data, pos); ++ uint32_t checksum = get32(data, pos); ++ if (magic != BOT_PREFS_MAGIC || version != BOT_PREFS_VERSION || length != SERIALIZED_SIZE) { ++ defaults(prefs); ++ return false; ++ } ++ if (checksumBytes(&data[12], SERIALIZED_SIZE - 12) != checksum) { ++ defaults(prefs); ++ return false; ++ } ++ ++ BotPrefs loaded; ++ memset(&loaded, 0, sizeof(loaded)); ++ loaded.enabled = get8(data, pos) != 0; ++ loaded.normal_delay_ms = get16(data, pos); ++ loaded.normal_jitter_ms = get16(data, pos); ++ loaded.local_advert_interval_ms = get32(data, pos); ++ loaded.flood_advert_interval_ms = get32(data, pos); ++ loaded.command_mask = get32(data, pos); ++ loaded.max_response_parts = get8(data, pos); ++ getFixedString(data, pos, loaded.bot_channel, sizeof(loaded.bot_channel)); ++ getFixedString(data, pos, loaded.testing_channel, sizeof(loaded.testing_channel)); ++ getFixedString(data, pos, loaded.emergency_channel, sizeof(loaded.emergency_channel)); ++ getFixedString(data, pos, loaded.public_channel, sizeof(loaded.public_channel)); ++ for (size_t i = 0; i < BOT_KNOWN_BOT_SLOTS; i++) { ++ loaded.known_bots[i].active = get8(data, pos) != 0; ++ memcpy(loaded.known_bots[i].key_prefix, &data[pos], BOT_SENDER_KEY_PREFIX_LEN); ++ pos += BOT_SENDER_KEY_PREFIX_LEN; ++ loaded.known_bots[i].flags = get8(data, pos); ++ getFixedString(data, pos, loaded.known_bots[i].label, sizeof(loaded.known_bots[i].label)); ++ } ++ loaded.prefs_load_failures = get32(data, pos); ++ loaded.prefs_save_failures = get32(data, pos); ++ if (pos != SERIALIZED_SIZE) { ++ defaults(prefs); ++ return false; ++ } ++ ++ validate(loaded); ++ prefs = loaded; ++ return true; ++} ++ ++uint32_t commandMaskFor(BotCommandId command_id) { ++ return BotCommandRegistry::commandMask(command_id); ++} ++ ++bool commandEnabled(const BotPrefs& prefs, BotCommandId command_id) { ++ uint32_t mask = commandMaskFor(command_id); ++ return mask != 0 && (prefs.command_mask & mask) != 0; ++} ++ ++void setCommandEnabled(BotPrefs& prefs, BotCommandId command_id, bool enabled) { ++ uint32_t mask = commandMaskFor(command_id); ++ if (mask == 0) return; ++ if (enabled) { ++ prefs.command_mask |= mask; ++ } else { ++ prefs.command_mask &= ~mask; ++ } ++ validate(prefs); ++} ++ ++const char* commandName(BotCommandId command_id) { ++ return BotCommandRegistry::commandName(command_id); ++} ++ ++bool commandIdForName(const char* name, BotCommandId* command_id) { ++ if (!name || !command_id) return false; ++ size_t len = boundedStrLen(name, BOT_MAX_COMMAND_NAME_LEN + 1); ++ const BotCommandMetadata* command = BotCommandRegistry::findByName(name, len); ++ if (!command || command->mask == 0) return false; ++ *command_id = command->id; ++ return true; ++} ++ ++bool parseKeyPrefixHex(const char* text, uint8_t key_prefix[BOT_SENDER_KEY_PREFIX_LEN]) { ++ if (!text || !key_prefix) return false; ++ for (size_t i = 0; i < BOT_SENDER_KEY_PREFIX_LEN; i++) { ++ int high = hexValue(text[i * 2]); ++ int low = hexValue(text[i * 2 + 1]); ++ if (high < 0 || low < 0) return false; ++ key_prefix[i] = (uint8_t)((high << 4) | low); ++ } ++ return text[BOT_SENDER_KEY_PREFIX_LEN * 2] == 0; ++} ++ ++void formatKeyPrefixHex(const uint8_t key_prefix[BOT_SENDER_KEY_PREFIX_LEN], char* output, size_t output_len) { ++ static const char hex[] = "0123456789abcdef"; ++ if (!output || output_len == 0) return; ++ if (!key_prefix || output_len < BOT_SENDER_KEY_PREFIX_LEN * 2 + 1) { ++ output[0] = 0; ++ return; ++ } ++ for (size_t i = 0; i < BOT_SENDER_KEY_PREFIX_LEN; i++) { ++ output[i * 2] = hex[key_prefix[i] >> 4]; ++ output[i * 2 + 1] = hex[key_prefix[i] & 0x0F]; ++ } ++ output[BOT_SENDER_KEY_PREFIX_LEN * 2] = 0; ++} ++ ++const BotKnownBotEntry* findKnownBot(const BotPrefs& prefs, const uint8_t key_prefix[BOT_SENDER_KEY_PREFIX_LEN]) { ++ if (!key_prefix) return NULL; ++ for (size_t i = 0; i < BOT_KNOWN_BOT_SLOTS; i++) { ++ if (prefs.known_bots[i].active && sameKeyPrefix(prefs.known_bots[i].key_prefix, key_prefix)) return &prefs.known_bots[i]; ++ } ++ return NULL; ++} ++ ++bool addKnownBot(BotPrefs& prefs, const uint8_t key_prefix[BOT_SENDER_KEY_PREFIX_LEN], uint8_t flags, ++ const char* label) { ++ if (!key_prefix) return false; ++ size_t slot = BOT_KNOWN_BOT_SLOTS; ++ for (size_t i = 0; i < BOT_KNOWN_BOT_SLOTS; i++) { ++ if (prefs.known_bots[i].active && sameKeyPrefix(prefs.known_bots[i].key_prefix, key_prefix)) { ++ slot = i; ++ break; ++ } ++ if (slot == BOT_KNOWN_BOT_SLOTS && !prefs.known_bots[i].active) slot = i; ++ } ++ if (slot == BOT_KNOWN_BOT_SLOTS) return false; ++ ++ prefs.known_bots[slot].active = true; ++ memcpy(prefs.known_bots[slot].key_prefix, key_prefix, BOT_SENDER_KEY_PREFIX_LEN); ++ prefs.known_bots[slot].flags = flags & BOT_KNOWN_BOT_FLAG_SUPPRESS_NORMAL; ++ copyString(prefs.known_bots[slot].label, sizeof(prefs.known_bots[slot].label), label ? label : "bot"); ++ return true; ++} ++ ++bool removeKnownBot(BotPrefs& prefs, const uint8_t key_prefix[BOT_SENDER_KEY_PREFIX_LEN]) { ++ if (!key_prefix) return false; ++ for (size_t i = 0; i < BOT_KNOWN_BOT_SLOTS; i++) { ++ if (prefs.known_bots[i].active && sameKeyPrefix(prefs.known_bots[i].key_prefix, key_prefix)) { ++ memset(&prefs.known_bots[i], 0, sizeof(prefs.known_bots[i])); ++ return true; ++ } ++ } ++ return false; ++} ++ ++} +diff --git a/examples/companion_radio/BotPrefs.h b/examples/companion_radio/BotPrefs.h +new file mode 100644 +index 00000000..676f7de2 +--- /dev/null ++++ b/examples/companion_radio/BotPrefs.h +@@ -0,0 +1,31 @@ ++#pragma once ++ ++#include "BotTypes.h" ++ ++#include ++#include ++ ++namespace BotPrefsCodec { ++ ++size_t serializedSize(); ++void defaults(BotPrefs& prefs); ++void validate(BotPrefs& prefs); ++bool serialize(const BotPrefs& prefs, uint8_t* output, size_t output_len); ++bool deserialize(const uint8_t* data, size_t data_len, BotPrefs& prefs); ++bool channelNameValid(const char* value, bool public_channel); ++bool channelConfigValid(const BotPrefs& prefs); ++ ++uint32_t commandMaskFor(BotCommandId command_id); ++bool commandEnabled(const BotPrefs& prefs, BotCommandId command_id); ++void setCommandEnabled(BotPrefs& prefs, BotCommandId command_id, bool enabled); ++const char* commandName(BotCommandId command_id); ++bool commandIdForName(const char* name, BotCommandId* command_id); ++ ++bool parseKeyPrefixHex(const char* text, uint8_t key_prefix[BOT_SENDER_KEY_PREFIX_LEN]); ++void formatKeyPrefixHex(const uint8_t key_prefix[BOT_SENDER_KEY_PREFIX_LEN], char* output, size_t output_len); ++bool addKnownBot(BotPrefs& prefs, const uint8_t key_prefix[BOT_SENDER_KEY_PREFIX_LEN], uint8_t flags, ++ const char* label); ++bool removeKnownBot(BotPrefs& prefs, const uint8_t key_prefix[BOT_SENDER_KEY_PREFIX_LEN]); ++const BotKnownBotEntry* findKnownBot(const BotPrefs& prefs, const uint8_t key_prefix[BOT_SENDER_KEY_PREFIX_LEN]); ++ ++} +diff --git a/examples/companion_radio/BotTypes.h b/examples/companion_radio/BotTypes.h +new file mode 100644 +index 00000000..5c7430a9 +--- /dev/null ++++ b/examples/companion_radio/BotTypes.h +@@ -0,0 +1,331 @@ ++#pragma once ++ ++#include ++#include ++ ++#define BOT_MAX_TEXT_LEN 160 ++#define BOT_MAX_RESPONSE_LEN 144 ++#define BOT_MAX_COMMAND_NAME_LEN 15 ++#define BOT_MAX_COMMAND_ARGS_LEN 79 ++#define BOT_MAX_CHANNEL_NAME_LEN 23 ++#define BOT_MAX_SENDER_NAME_LEN 31 ++#define BOT_MAX_FIRMWARE_VERSION_LEN 19 ++#define BOT_MAX_BUILD_DATE_LEN 15 ++#define BOT_MAX_PATH_BYTES 64 ++#define BOT_GROUP_RESPONSE_PREFIX_RESERVE (BOT_MAX_SENDER_NAME_LEN + 2) ++#define BOT_MAX_GROUP_RESPONSE_LEN (BOT_MAX_TEXT_LEN - BOT_GROUP_RESPONSE_PREFIX_RESERVE) ++#define BOT_COMMAND_COOLDOWN_MILLIS 5000UL ++#define BOT_TRACE_COOLDOWN_MILLIS 60000UL ++#define BOT_TRACE_TIMEOUT_MILLIS 30000UL ++#define BOT_PENDING_TRACE_SLOTS 2 ++#define BOT_EMERGENCY_PREFIX "EMERGENCY MESSAGE FROM " ++#define BOT_EMERGENCY_MAX_PARTS 3 ++#define BOT_PENDING_EMERGENCY_SLOTS BOT_EMERGENCY_MAX_PARTS ++#define BOT_COORDINATOR_PENDING_SLOTS 8 ++#define BOT_COORDINATOR_RECENT_SLOTS 16 ++#define BOT_KNOWN_BOT_SLOTS 8 ++#define BOT_RESPONSE_DELAY_BASE_MILLIS 1200UL ++#define BOT_RESPONSE_DELAY_JITTER_MILLIS 1800UL ++#define BOT_RESPONSE_PENDING_TTL_MILLIS 15000UL ++#define BOT_RESPONSE_RECENT_TTL_MILLIS 30000UL ++#define BOT_KNOWN_BOT_FLAG_SUPPRESS_NORMAL 0x01 ++#define BOT_SENDER_KEY_PREFIX_LEN 6 ++#define BOT_MIN_AUTH_SENDER_KEY_PREFIX_LEN 4 ++#define BOT_KNOWN_BOT_LABEL_LEN 12 ++#define BOT_PREFS_MAGIC 0x31504642UL ++#define BOT_PREFS_VERSION 1 ++#define BOT_PREFS_DEFAULT_LOCAL_ADVERT_MILLIS (24UL * 60UL * 60UL * 1000UL) ++#define BOT_PREFS_DEFAULT_FLOOD_ADVERT_MILLIS (24UL * 60UL * 60UL * 1000UL) ++#define BOT_PREFS_INITIAL_LOCAL_ADVERT_MILLIS 60000UL ++#define BOT_PREFS_MAX_DELAY_MILLIS 60000U ++#define BOT_PREFS_MAX_ADVERT_MILLIS (7UL * 24UL * 60UL * 60UL * 1000UL) ++#define BOT_PREFS_SERIALIZED_SIZE 294 ++ ++enum BotChannelKind : uint8_t { ++ BOT_CHANNEL_DM = 0, ++ BOT_CHANNEL_PUBLIC, ++ BOT_CHANNEL_BOT, ++ BOT_CHANNEL_TESTING, ++ BOT_CHANNEL_EMERGENCY, ++ BOT_CHANNEL_OTHER ++}; ++ ++enum BotPolicyDecision : uint8_t { ++ BOT_POLICY_IGNORE = 0, ++ BOT_POLICY_ALLOW_NORMAL, ++ BOT_POLICY_EMERGENCY_FORWARD ++}; ++ ++enum BotCommandId : uint8_t { ++ BOT_COMMAND_NONE = 0, ++ BOT_COMMAND_HELP = 1, ++ BOT_COMMAND_CMD = 2, ++ BOT_COMMAND_PING = 3, ++ BOT_COMMAND_TEST = 4, ++ BOT_COMMAND_HELLO = 5, ++ BOT_COMMAND_ABOUT = 6, ++ BOT_COMMAND_ROLL = 7, ++ BOT_COMMAND_DICE = 8, ++ BOT_COMMAND_STATUS = 9, ++ BOT_COMMAND_CHANNELS = 10, ++ BOT_COMMAND_VERSION = 11, ++ BOT_COMMAND_STATS = 12, ++ BOT_COMMAND_MAGIC8 = 13, ++ BOT_COMMAND_PATH = 14, ++ BOT_COMMAND_TRACE = 15, ++ BOT_COMMAND_TRACER = 16, ++ BOT_COMMAND_PREFIX = 17, ++ BOT_COMMAND_UNSUPPORTED = 18, ++ BOT_COMMAND_UNKNOWN = 19 ++}; ++ ++enum BotCommandVisibility : uint8_t { ++ BOT_COMMAND_VISIBILITY_DISCOVERABLE = 0, ++ BOT_COMMAND_VISIBILITY_HIDDEN, ++ BOT_COMMAND_VISIBILITY_INTERNAL ++}; ++ ++enum BotCommandContextClass : uint8_t { ++ BOT_COMMAND_CONTEXT_NORMAL = 0, ++ BOT_COMMAND_CONTEXT_DIAGNOSTIC, ++ BOT_COMMAND_CONTEXT_TRACE, ++ BOT_COMMAND_CONTEXT_LOCAL_CONTACT, ++ BOT_COMMAND_CONTEXT_UNSUPPORTED, ++ BOT_COMMAND_CONTEXT_INTERNAL ++}; ++ ++struct BotCommandMetadata { ++ BotCommandId id; ++ const char* name; ++ const char* const* aliases; ++ uint8_t alias_count; ++ uint32_t mask; ++ BotCommandVisibility visibility; ++ BotCommandContextClass context_class; ++ const char* summary; ++ const char* usage; ++ const char* details; ++}; ++ ++#define BOT_COMMAND_MASK_HELP (1UL << BOT_COMMAND_HELP) ++#define BOT_COMMAND_MASK_CMD (1UL << BOT_COMMAND_CMD) ++#define BOT_COMMAND_MASK_PING (1UL << BOT_COMMAND_PING) ++#define BOT_COMMAND_MASK_TEST (1UL << BOT_COMMAND_TEST) ++#define BOT_COMMAND_MASK_HELLO (1UL << BOT_COMMAND_HELLO) ++#define BOT_COMMAND_MASK_ABOUT (1UL << BOT_COMMAND_ABOUT) ++#define BOT_COMMAND_MASK_ROLL (1UL << BOT_COMMAND_ROLL) ++#define BOT_COMMAND_MASK_DICE (1UL << BOT_COMMAND_DICE) ++#define BOT_COMMAND_MASK_STATUS (1UL << BOT_COMMAND_STATUS) ++#define BOT_COMMAND_MASK_CHANNELS (1UL << BOT_COMMAND_CHANNELS) ++#define BOT_COMMAND_MASK_VERSION (1UL << BOT_COMMAND_VERSION) ++#define BOT_COMMAND_MASK_STATS (1UL << BOT_COMMAND_STATS) ++#define BOT_COMMAND_MASK_MAGIC8 (1UL << BOT_COMMAND_MAGIC8) ++#define BOT_COMMAND_MASK_PATH (1UL << BOT_COMMAND_PATH) ++#define BOT_COMMAND_MASK_TRACE (1UL << BOT_COMMAND_TRACE) ++#define BOT_COMMAND_MASK_TRACER (1UL << BOT_COMMAND_TRACER) ++#define BOT_COMMAND_MASK_PREFIX (1UL << BOT_COMMAND_PREFIX) ++#define BOT_COMMAND_MASK_ALL (BOT_COMMAND_MASK_HELP | BOT_COMMAND_MASK_CMD | BOT_COMMAND_MASK_PING | \ ++ BOT_COMMAND_MASK_TEST | BOT_COMMAND_MASK_HELLO | BOT_COMMAND_MASK_ABOUT | \ ++ BOT_COMMAND_MASK_ROLL | BOT_COMMAND_MASK_DICE | BOT_COMMAND_MASK_STATUS | \ ++ BOT_COMMAND_MASK_CHANNELS | BOT_COMMAND_MASK_VERSION | BOT_COMMAND_MASK_STATS | \ ++ BOT_COMMAND_MASK_MAGIC8 | BOT_COMMAND_MASK_PATH | BOT_COMMAND_MASK_TRACE | \ ++ BOT_COMMAND_MASK_TRACER | BOT_COMMAND_MASK_PREFIX) ++ ++enum BotCommandResultCode : uint8_t { ++ BOT_COMMAND_RESULT_NOT_HANDLED = 0, ++ BOT_COMMAND_RESULT_OK, ++ BOT_COMMAND_RESULT_TRUNCATED, ++ BOT_COMMAND_RESULT_NO_SPACE ++}; ++ ++enum BotWriteResult : uint8_t { ++ BOT_WRITE_OK = 0, ++ BOT_WRITE_TRUNCATED, ++ BOT_WRITE_NO_SPACE ++}; ++ ++enum BotCoordinatorScheduleResult : uint8_t { ++ BOT_COORDINATOR_SCHEDULED = 0, ++ BOT_COORDINATOR_REPLACED, ++ BOT_COORDINATOR_NO_SPACE, ++ BOT_COORDINATOR_NOT_NORMAL ++}; ++ ++enum BotCoordinatorReadyResult : uint8_t { ++ BOT_COORDINATOR_READY_NONE = 0, ++ BOT_COORDINATOR_READY_SEND, ++ BOT_COORDINATOR_READY_SUPPRESSED, ++ BOT_COORDINATOR_READY_EXPIRED ++}; ++ ++struct BotFingerprint { ++ uint64_t value; ++}; ++ ++struct BotMessage { ++ BotChannelKind channel_kind; ++ char channel_name[BOT_MAX_CHANNEL_NAME_LEN + 1]; ++ char sender_name[BOT_MAX_SENDER_NAME_LEN + 1]; ++ uint8_t sender_key_prefix[BOT_SENDER_KEY_PREFIX_LEN]; ++ uint8_t sender_key_prefix_len; ++ bool text_truncated; ++ uint32_t sender_timestamp; ++ uint8_t path_len; ++ uint8_t path_hash_size; ++ uint8_t path_hash_count; ++ int8_t packet_snr_quarters; ++ char text[BOT_MAX_TEXT_LEN + 1]; ++ size_t text_len; ++ const uint8_t* path; ++}; ++ ++struct BotCommand { ++ BotCommandId id; ++ char name[BOT_MAX_COMMAND_NAME_LEN + 1]; ++ char args[BOT_MAX_COMMAND_ARGS_LEN + 1]; ++ size_t args_len; ++}; ++ ++struct BotResponse { ++ BotPolicyDecision decision; ++ BotFingerprint fingerprint; ++ char text[BOT_MAX_RESPONSE_LEN + 1]; ++ size_t text_len; ++ bool truncated; ++}; ++ ++struct BotCommandContext { ++ char node_name[BOT_MAX_SENDER_NAME_LEN + 1]; ++ char bot_channel[BOT_MAX_CHANNEL_NAME_LEN + 1]; ++ char testing_channel[BOT_MAX_CHANNEL_NAME_LEN + 1]; ++ char emergency_channel[BOT_MAX_CHANNEL_NAME_LEN + 1]; ++ char public_channel[BOT_MAX_CHANNEL_NAME_LEN + 1]; ++ char firmware_version[BOT_MAX_FIRMWARE_VERSION_LEN + 1]; ++ char firmware_build_date[BOT_MAX_BUILD_DATE_LEN + 1]; ++ uint32_t uptime_seconds; ++ uint16_t battery_millivolts; ++ uint32_t storage_used_kb; ++ uint32_t storage_total_kb; ++ uint32_t observed_messages; ++ uint32_t ignored_messages; ++ uint32_t eligible_messages; ++ uint32_t sent_messages; ++ uint32_t send_failures; ++ uint32_t suppressed_responses; ++ uint32_t pending_responses; ++ uint32_t emergency_forwards; ++ uint32_t emergency_forward_failures; ++ uint32_t packets_recv; ++ uint32_t packets_sent; ++ uint32_t packets_recv_errors; ++ uint32_t flood_recv; ++ uint32_t flood_sent; ++ uint32_t direct_recv; ++ uint32_t direct_sent; ++ uint32_t tx_airtime_seconds; ++ uint32_t rx_airtime_seconds; ++ uint32_t random_seed; ++ int16_t noise_floor; ++ int8_t last_rssi; ++ int8_t last_snr_quarters; ++ uint8_t queue_depth; ++ uint8_t channel_count; ++ uint8_t path_len; ++ uint8_t path_hash_size; ++ uint8_t path_hash_count; ++ int8_t path_snr_quarters; ++ const uint8_t* path; ++}; ++ ++struct BotCommandResult { ++ BotCommandResultCode code; ++ size_t text_len; ++}; ++ ++struct BotCommandCooldown { ++ BotCommandId command_id; ++ uint32_t expires_at_millis; ++}; ++ ++struct BotKnownBotEntry { ++ bool active; ++ uint8_t key_prefix[BOT_SENDER_KEY_PREFIX_LEN]; ++ uint8_t flags; ++ char label[BOT_KNOWN_BOT_LABEL_LEN]; ++}; ++ ++struct BotCoordinatorPending { ++ bool active; ++ bool suppressed; ++ BotFingerprint request_fingerprint; ++ BotFingerprint response_fingerprint; ++ uint32_t due_at_millis; ++ uint32_t expires_at_millis; ++}; ++ ++struct BotCoordinatorRecent { ++ bool active; ++ BotFingerprint response_fingerprint; ++ uint32_t expires_at_millis; ++}; ++ ++struct BotCoordinatorReady { ++ BotCoordinatorReadyResult result; ++ BotFingerprint request_fingerprint; ++ BotFingerprint response_fingerprint; ++}; ++ ++struct BotEmergencyForward { ++ uint8_t part_count; ++ bool truncated; ++ char parts[BOT_EMERGENCY_MAX_PARTS][BOT_MAX_GROUP_RESPONSE_LEN + 1]; ++ size_t part_lens[BOT_EMERGENCY_MAX_PARTS]; ++}; ++ ++struct BotPrefs { ++ bool enabled; ++ uint16_t normal_delay_ms; ++ uint16_t normal_jitter_ms; ++ uint32_t local_advert_interval_ms; ++ uint32_t flood_advert_interval_ms; ++ uint32_t command_mask; ++ uint8_t max_response_parts; ++ char bot_channel[BOT_MAX_CHANNEL_NAME_LEN + 1]; ++ char testing_channel[BOT_MAX_CHANNEL_NAME_LEN + 1]; ++ char emergency_channel[BOT_MAX_CHANNEL_NAME_LEN + 1]; ++ char public_channel[BOT_MAX_CHANNEL_NAME_LEN + 1]; ++ BotKnownBotEntry known_bots[BOT_KNOWN_BOT_SLOTS]; ++ uint32_t prefs_load_failures; ++ uint32_t prefs_save_failures; ++}; ++ ++struct BotStats { ++ uint32_t observed_messages; ++ uint32_t ignored_messages; ++ uint32_t eligible_messages; ++ uint32_t emergency_messages; ++ uint32_t emergency_forwards; ++ uint32_t emergency_forward_failures; ++ uint32_t parse_errors; ++ uint32_t pending_responses; ++ uint32_t suppressed_responses; ++ uint32_t expired_responses; ++ uint32_t known_bot_messages; ++ uint32_t sent_messages; ++ uint32_t send_failures; ++}; ++ ++static_assert(BOT_COMMAND_UNKNOWN < 32, "BotCommandId must fit uint32_t command masks"); ++static_assert(sizeof(BotMessage) <= 264, "BotMessage RAM budget exceeded"); ++static_assert(sizeof(BotCommand) <= 120, "BotCommand RAM budget exceeded"); ++static_assert(sizeof(BotResponse) <= 184, "BotResponse RAM budget exceeded"); ++static_assert(sizeof(BotCommandContext) <= 312, "BotCommandContext RAM budget exceeded"); ++static_assert(sizeof(BotCommandResult) <= 16, "BotCommandResult RAM budget exceeded"); ++static_assert(sizeof(BotCommandCooldown) <= 8, "BotCommandCooldown RAM budget exceeded"); ++static_assert(sizeof(BotKnownBotEntry) <= 24, "BotKnownBotEntry RAM budget exceeded"); ++static_assert(sizeof(BotCoordinatorPending) <= 32, "BotCoordinatorPending RAM budget exceeded"); ++static_assert(sizeof(BotCoordinatorRecent) <= 24, "BotCoordinatorRecent RAM budget exceeded"); ++static_assert(sizeof(BotCoordinatorReady) <= 24, "BotCoordinatorReady RAM budget exceeded"); ++static_assert(sizeof(BotEmergencyForward) <= 480, "BotEmergencyForward RAM budget exceeded"); ++static_assert(sizeof(BotPrefs) <= 320, "BotPrefs RAM budget exceeded"); ++static_assert(sizeof(BotStats) <= 64, "BotStats RAM budget exceeded"); +diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp +index c7988bb3..9a134389 100644 +--- a/examples/companion_radio/DataStore.cpp ++++ b/examples/companion_radio/DataStore.cpp +@@ -1,6 +1,10 @@ + #include + #include "DataStore.h" + ++#if CMESH_BOT_ENABLED ++#include "BotPrefs.h" ++#endif ++ + #if defined(EXTRAFS) || defined(QSPIFLASH) + #define MAX_BLOBRECS 100 + #else +@@ -199,6 +203,38 @@ void DataStore::loadPrefs(NodePrefs& prefs, double& node_lat, double& node_lon) + } + } + ++#if CMESH_BOT_ENABLED ++bool DataStore::loadBotPrefs(BotPrefs& prefs) { ++ if (!_fs->exists("/bot_prefs_v1")) { ++ BotPrefsCodec::defaults(prefs); ++ return false; ++ } ++ ++ uint8_t data[BOT_PREFS_SERIALIZED_SIZE]; ++ File file = openRead(_fs, "/bot_prefs_v1"); ++ bool success = false; ++ if (file) { ++ success = file.size() == sizeof(data) && file.read(data, sizeof(data)) == sizeof(data) && ++ BotPrefsCodec::deserialize(data, sizeof(data), prefs); ++ file.close(); ++ } ++ if (!success) BotPrefsCodec::defaults(prefs); ++ return success; ++} ++ ++bool DataStore::saveBotPrefs(const BotPrefs& prefs) { ++ uint8_t data[BOT_PREFS_SERIALIZED_SIZE]; ++ if (!BotPrefsCodec::serialize(prefs, data, sizeof(data))) return false; ++ ++ File file = openWrite(_fs, "/bot_prefs_v1"); ++ if (!file) return false; ++ bool success = file.write(data, sizeof(data)) == sizeof(data); ++ file.close(); ++ if (!success) _fs->remove("/bot_prefs_v1"); ++ return success; ++} ++#endif ++ + void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& node_lat, double& node_lon) { + File file = openRead(_fs, filename); + if (file) { +diff --git a/examples/companion_radio/DataStore.h b/examples/companion_radio/DataStore.h +index 58b4d5d2..187cd771 100644 +--- a/examples/companion_radio/DataStore.h ++++ b/examples/companion_radio/DataStore.h +@@ -5,6 +5,13 @@ + #include + #include "NodePrefs.h" + ++#ifndef CMESH_BOT_ENABLED ++#define CMESH_BOT_ENABLED 0 ++#endif ++#if CMESH_BOT_ENABLED ++#include "BotTypes.h" ++#endif ++ + class DataStoreHost { + public: + virtual bool onContactLoaded(const ContactInfo& contact) =0; +@@ -35,6 +42,10 @@ public: + bool saveMainIdentity(const mesh::LocalIdentity &identity); + void loadPrefs(NodePrefs& prefs, double& node_lat, double& node_lon); + void savePrefs(const NodePrefs& prefs, double node_lat, double node_lon); ++#if CMESH_BOT_ENABLED ++ bool loadBotPrefs(BotPrefs& prefs); ++ bool saveBotPrefs(const BotPrefs& prefs); ++#endif + void loadContacts(DataStoreHost* host); + void saveContacts(DataStoreHost* host); + void loadChannels(DataStoreHost* host); +diff --git a/examples/companion_radio/EmergencyForwarder.cpp b/examples/companion_radio/EmergencyForwarder.cpp +new file mode 100644 +index 00000000..b228a8fa +--- /dev/null ++++ b/examples/companion_radio/EmergencyForwarder.cpp +@@ -0,0 +1,118 @@ ++#include "EmergencyForwarder.h" ++ ++#include ++#include ++ ++namespace { ++ ++size_t boundedStrLen(const char* value, size_t max_len) { ++ size_t len = 0; ++ while (value && len < max_len && value[len] != 0) len++; ++ return len; ++} ++ ++bool prefixEqual(const char* text, size_t text_len, const char* prefix) { ++ size_t prefix_len = strlen(prefix); ++ if (text_len < prefix_len) return false; ++ for (size_t i = 0; i < prefix_len; i++) { ++ if (text[i] != prefix[i]) return false; ++ } ++ return true; ++} ++ ++size_t appendText(char* output, size_t output_len, size_t pos, const char* text, size_t text_len) { ++ if (!output || output_len == 0) return 0; ++ while (pos + 1 < output_len && text_len > 0) { ++ output[pos++] = *text++; ++ text_len--; ++ } ++ output[pos] = 0; ++ return pos; ++} ++ ++size_t appendRepeated(char* output, size_t output_len, size_t pos, char ch, size_t count) { ++ while (pos + 1 < output_len && count > 0) { ++ output[pos++] = ch; ++ count--; ++ } ++ output[pos] = 0; ++ return pos; ++} ++ ++void writePart(BotEmergencyForward& forward, uint8_t part_idx, const char* header, size_t header_len, ++ const char* text, size_t text_len, bool multipart) { ++ char* output = forward.parts[part_idx]; ++ size_t output_len = sizeof(forward.parts[part_idx]); ++ output[0] = 0; ++ size_t pos = appendText(output, output_len, 0, header, header_len); ++ ++ if (multipart) { ++ char marker[8]; ++ int n = snprintf(marker, sizeof(marker), "[%u/%u] ", (unsigned)(part_idx + 1), (unsigned)forward.part_count); ++ if (n > 0) pos = appendText(output, output_len, pos, marker, (size_t)n); ++ } ++ ++ pos = appendText(output, output_len, pos, text, text_len); ++ forward.part_lens[part_idx] = pos; ++} ++ ++} ++ ++namespace EmergencyForwarder { ++ ++bool isForwardedEmergencyText(const char* text, size_t text_len) { ++ if (!text) return false; ++ size_t len = boundedStrLen(text, text_len); ++ return prefixEqual(text, len, BOT_EMERGENCY_PREFIX); ++} ++ ++bool format(const BotMessage& message, BotEmergencyForward& forward) { ++ memset(&forward, 0, sizeof(forward)); ++ if (message.channel_kind != BOT_CHANNEL_EMERGENCY) return false; ++ if (isForwardedEmergencyText(message.text, message.text_len)) return false; ++ ++ char header[BOT_MAX_GROUP_RESPONSE_LEN + 1]; ++ const char* sender = message.sender_name[0] ? message.sender_name : "unknown"; ++ int header_len_int = snprintf(header, sizeof(header), BOT_EMERGENCY_PREFIX "%s: ", sender); ++ if (header_len_int < 0) return false; ++ size_t header_len = (size_t)header_len_int; ++ if (header_len >= sizeof(header)) header_len = sizeof(header) - 1; ++ if (header_len >= BOT_MAX_GROUP_RESPONSE_LEN) return false; ++ ++ size_t text_len = boundedStrLen(message.text, message.text_len); ++ size_t one_part_capacity = BOT_MAX_GROUP_RESPONSE_LEN - header_len; ++ if (text_len <= one_part_capacity) { ++ forward.part_count = 1; ++ forward.truncated = message.text_truncated; ++ writePart(forward, 0, header, header_len, message.text, text_len, false); ++ return true; ++ } ++ ++ size_t multipart_header_extra = 6; ++ if (header_len + multipart_header_extra >= BOT_MAX_GROUP_RESPONSE_LEN) return false; ++ size_t part_capacity = BOT_MAX_GROUP_RESPONSE_LEN - header_len - multipart_header_extra; ++ size_t needed_parts = (text_len + part_capacity - 1) / part_capacity; ++ forward.part_count = needed_parts > BOT_EMERGENCY_MAX_PARTS ? BOT_EMERGENCY_MAX_PARTS : (uint8_t)needed_parts; ++ forward.truncated = message.text_truncated || needed_parts > BOT_EMERGENCY_MAX_PARTS; ++ ++ size_t offset = 0; ++ for (uint8_t i = 0; i < forward.part_count; i++) { ++ size_t chunk_len = text_len - offset; ++ if (chunk_len > part_capacity) chunk_len = part_capacity; ++ writePart(forward, i, header, header_len, &message.text[offset], chunk_len, true); ++ offset += chunk_len; ++ } ++ ++ if (forward.truncated && forward.part_count > 0) { ++ uint8_t last = forward.part_count - 1; ++ size_t pos = forward.part_lens[last]; ++ if (pos > 3) pos -= 3; ++ forward.parts[last][pos] = 0; ++ pos = appendRepeated(forward.parts[last], sizeof(forward.parts[last]), pos, '.', 3); ++ forward.part_lens[last] = pos; ++ } ++ ++ return forward.part_count > 0; ++} ++ ++} +diff --git a/examples/companion_radio/EmergencyForwarder.h b/examples/companion_radio/EmergencyForwarder.h +new file mode 100644 +index 00000000..e59d29e2 +--- /dev/null ++++ b/examples/companion_radio/EmergencyForwarder.h +@@ -0,0 +1,10 @@ ++#pragma once ++ ++#include "BotTypes.h" ++ ++namespace EmergencyForwarder { ++ ++bool isForwardedEmergencyText(const char* text, size_t text_len); ++bool format(const BotMessage& message, BotEmergencyForward& forward); ++ ++} +diff --git a/examples/companion_radio/FirmwareBot.cpp b/examples/companion_radio/FirmwareBot.cpp +new file mode 100644 +index 00000000..d1b240bb +--- /dev/null ++++ b/examples/companion_radio/FirmwareBot.cpp +@@ -0,0 +1,272 @@ ++#include "FirmwareBot.h" ++ ++#include "BotCommandRegistry.h" ++ ++#include ++#include ++ ++namespace { ++ ++uint64_t fnv1aUpdate(uint64_t hash, uint8_t value) { ++ hash ^= value; ++ hash *= 1099511628211ULL; ++ return hash; ++} ++ ++uint64_t fnv1aUpdateBytes(uint64_t hash, const uint8_t* data, size_t len) { ++ for (size_t i = 0; i < len; i++) { ++ hash = fnv1aUpdate(hash, data[i]); ++ } ++ return hash; ++} ++ ++uint64_t fnv1aUpdateTextLower(uint64_t hash, const char* value, size_t len) { ++ for (size_t i = 0; i < len; i++) { ++ hash = fnv1aUpdate(hash, (uint8_t)tolower((unsigned char)value[i])); ++ } ++ return hash; ++} ++ ++size_t boundedStrLen(const char* value, size_t max_len); ++ ++uint64_t fnv1aUpdateChannel(uint64_t hash, const BotMessage& message) { ++ hash = fnv1aUpdate(hash, (uint8_t)message.channel_kind); ++ const char* channel_name = message.channel_name; ++ size_t channel_name_len = boundedStrLen(message.channel_name, sizeof(message.channel_name)); ++ if (channel_name_len > 0 && channel_name[0] == '#') { ++ channel_name++; ++ channel_name_len--; ++ } ++ return fnv1aUpdateTextLower(hash, channel_name, channel_name_len); ++} ++ ++bool isSpaceByte(char ch) { ++ return ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n'; ++} ++ ++bool isControlByte(char ch) { ++ unsigned char value = (unsigned char)ch; ++ return value < 0x20 || value == 0x7F; ++} ++ ++bool isCommandDelimiter(char ch) { ++ return ch == ' ' || ch == ':' || ch == ',' || ch == '.' || ch == ';' || ch == '?' || ch == '!'; ++} ++ ++uint64_t fnv1aUpdateU32(uint64_t hash, uint32_t value) { ++ hash = fnv1aUpdate(hash, (uint8_t)(value & 0xFF)); ++ hash = fnv1aUpdate(hash, (uint8_t)((value >> 8) & 0xFF)); ++ hash = fnv1aUpdate(hash, (uint8_t)((value >> 16) & 0xFF)); ++ hash = fnv1aUpdate(hash, (uint8_t)((value >> 24) & 0xFF)); ++ return hash; ++} ++ ++size_t boundedStrLen(const char* value, size_t max_len) { ++ size_t len = 0; ++ while (len < max_len && value[len] != 0) len++; ++ return len; ++} ++ ++} ++ ++namespace FirmwareBot { ++ ++BotWriteResult normalizeText(const char* input, size_t input_len, char* output, size_t output_len, size_t* written) { ++ if (written) *written = 0; ++ if (!output || output_len == 0) return BOT_WRITE_NO_SPACE; ++ ++ size_t out = 0; ++ bool pending_space = false; ++ bool truncated = false; ++ ++ for (size_t i = 0; i < input_len; i++) { ++ char ch = input ? input[i] : 0; ++ if (ch == 0) break; ++ ++ if (isSpaceByte(ch) || isControlByte(ch)) { ++ pending_space = out > 0; ++ continue; ++ } ++ ++ if (pending_space) { ++ if (out + 1 >= output_len) { ++ truncated = true; ++ break; ++ } ++ output[out++] = ' '; ++ pending_space = false; ++ } ++ ++ if (out + 1 >= output_len) { ++ truncated = true; ++ break; ++ } ++ output[out++] = ch; ++ } ++ ++ output[out] = 0; ++ if (written) *written = out; ++ return truncated ? BOT_WRITE_TRUNCATED : BOT_WRITE_OK; ++} ++ ++BotCommandId commandIdForName(const char* name, size_t len) { ++ const BotCommandMetadata* command = BotCommandRegistry::findByName(name, len); ++ return command ? command->id : BOT_COMMAND_UNKNOWN; ++} ++ ++size_t maxResponseLenForChannel(BotChannelKind channel_kind) { ++ return channel_kind == BOT_CHANNEL_DM ? BOT_MAX_RESPONSE_LEN : BOT_MAX_GROUP_RESPONSE_LEN; ++} ++ ++bool isCommandOnCooldown(const BotCommandCooldown* cooldowns, size_t cooldown_count, BotCommandId command_id, ++ uint32_t now_millis) { ++ if (!cooldowns || command_id == BOT_COMMAND_NONE) return false; ++ for (size_t i = 0; i < cooldown_count; i++) { ++ if (cooldowns[i].command_id == command_id && (int32_t)(cooldowns[i].expires_at_millis - now_millis) > 0) return true; ++ } ++ return false; ++} ++ ++void recordCommandCooldown(BotCommandCooldown* cooldowns, size_t cooldown_count, BotCommandId command_id, ++ uint32_t now_millis, uint32_t cooldown_millis) { ++ if (!cooldowns || cooldown_count == 0 || command_id == BOT_COMMAND_NONE || cooldown_millis == 0) return; ++ ++ size_t slot = cooldown_count; ++ for (size_t i = 0; i < cooldown_count; i++) { ++ if (cooldowns[i].command_id == command_id) { ++ slot = i; ++ break; ++ } ++ if (slot == cooldown_count && ++ (cooldowns[i].command_id == BOT_COMMAND_NONE || (int32_t)(cooldowns[i].expires_at_millis - now_millis) <= 0)) { ++ slot = i; ++ } ++ } ++ if (slot == cooldown_count) slot = 0; ++ ++ cooldowns[slot].command_id = command_id; ++ cooldowns[slot].expires_at_millis = now_millis + cooldown_millis; ++} ++ ++bool parseCommand(const char* text, size_t text_len, BotCommand* command) { ++ return parseCommand(text, text_len, command, false); ++} ++ ++bool parseCommand(const char* text, size_t text_len, BotCommand* command, bool allow_prefixless) { ++ if (!command) return false; ++ memset(command, 0, sizeof(*command)); ++ command->id = BOT_COMMAND_NONE; ++ ++ char normalized[BOT_MAX_TEXT_LEN + 1]; ++ size_t normalized_len = 0; ++ normalizeText(text, text_len, normalized, sizeof(normalized), &normalized_len); ++ ++ if (normalized_len == 0) return false; ++ ++ bool has_prefix = normalized[0] == '!' || normalized[0] == '/'; ++ if (!has_prefix && !allow_prefixless) return false; ++ if (has_prefix && normalized_len < 2) return false; ++ ++ size_t pos = has_prefix ? 1 : 0; ++ while (pos < normalized_len && normalized[pos] == ' ') pos++; ++ size_t name_start = pos; ++ while (pos < normalized_len && !isCommandDelimiter(normalized[pos])) pos++; ++ size_t name_len = pos - name_start; ++ if (name_len == 0) return false; ++ ++ size_t copy_name_len = name_len; ++ if (copy_name_len > BOT_MAX_COMMAND_NAME_LEN) copy_name_len = BOT_MAX_COMMAND_NAME_LEN; ++ for (size_t i = 0; i < copy_name_len; i++) { ++ command->name[i] = (char)tolower((unsigned char)normalized[name_start + i]); ++ } ++ command->name[copy_name_len] = 0; ++ const BotCommandMetadata* metadata = name_len > BOT_MAX_COMMAND_NAME_LEN ? NULL : BotCommandRegistry::findByName(command->name, copy_name_len); ++ command->id = metadata ? metadata->id : BOT_COMMAND_UNKNOWN; ++ if (!has_prefix && (!metadata || metadata->visibility != BOT_COMMAND_VISIBILITY_DISCOVERABLE)) return false; ++ ++ while (pos < normalized_len && isCommandDelimiter(normalized[pos])) pos++; ++ size_t args_len = normalized_len - pos; ++ if (args_len > BOT_MAX_COMMAND_ARGS_LEN) args_len = BOT_MAX_COMMAND_ARGS_LEN; ++ if (args_len > 0) memcpy(command->args, &normalized[pos], args_len); ++ command->args[args_len] = 0; ++ command->args_len = args_len; ++ ++ return true; ++} ++ ++bool splitChannelText(const char* text, size_t text_len, char* sender, size_t sender_len, const char** body, ++ size_t* body_len) { ++ if (body) *body = text; ++ if (body_len) *body_len = text_len; ++ if (!text) return false; ++ ++ for (size_t i = 0; i < text_len; i++) { ++ if (text[i] == 0) break; ++ if (text[i] == ':' && i + 1 < text_len && text[i + 1] == ' ') { ++ if (sender && sender_len > 0) { ++ size_t copy_len = i; ++ if (copy_len >= sender_len) copy_len = sender_len - 1; ++ if (copy_len > 0) memcpy(sender, text, copy_len); ++ sender[copy_len] = 0; ++ } ++ size_t start = i + 2; ++ if (body) *body = &text[start]; ++ if (body_len) *body_len = text_len - start; ++ return true; ++ } ++ } ++ return false; ++} ++ ++BotWriteResult writeResponse(char* output, size_t output_len, const char* text, size_t text_len, size_t* written) { ++ if (written) *written = 0; ++ if (!output || output_len == 0) return BOT_WRITE_NO_SPACE; ++ ++ if (!text && text_len > 0) { ++ output[0] = 0; ++ return BOT_WRITE_NO_SPACE; ++ } ++ ++ size_t copy_len = text_len; ++ if (copy_len + 1 > output_len) copy_len = output_len - 1; ++ if (copy_len > 0) memcpy(output, text, copy_len); ++ output[copy_len] = 0; ++ if (written) *written = copy_len; ++ ++ return copy_len < text_len ? BOT_WRITE_TRUNCATED : BOT_WRITE_OK; ++} ++ ++BotFingerprint fingerprintFor(const BotMessage& message) { ++ uint64_t hash = 1469598103934665603ULL; ++ hash = fnv1aUpdateChannel(hash, message); ++ hash = fnv1aUpdateBytes(hash, message.sender_key_prefix, sizeof(message.sender_key_prefix)); ++ hash = fnv1aUpdateTextLower(hash, message.sender_name, boundedStrLen(message.sender_name, sizeof(message.sender_name))); ++ hash = fnv1aUpdateU32(hash, message.sender_timestamp); ++ ++ char normalized[BOT_MAX_TEXT_LEN + 1]; ++ size_t normalized_len = 0; ++ normalizeText(message.text, message.text_len, normalized, sizeof(normalized), &normalized_len); ++ hash = fnv1aUpdateTextLower(hash, normalized, normalized_len); ++ ++ BotFingerprint fingerprint = { hash }; ++ return fingerprint; ++} ++ ++BotFingerprint responseFingerprintFor(const BotMessage& message, const char* response_text, size_t response_text_len) { ++ uint64_t hash = 1469598103934665603ULL; ++ hash = fnv1aUpdateChannel(hash, message); ++ if (message.channel_kind == BOT_CHANNEL_DM) { ++ hash = fnv1aUpdate(hash, message.sender_key_prefix_len); ++ hash = fnv1aUpdateBytes(hash, message.sender_key_prefix, message.sender_key_prefix_len); ++ } ++ ++ char normalized[BOT_MAX_RESPONSE_LEN + 1]; ++ size_t normalized_len = 0; ++ normalizeText(response_text, response_text_len, normalized, sizeof(normalized), &normalized_len); ++ hash = fnv1aUpdateTextLower(hash, normalized, normalized_len); ++ ++ BotFingerprint fingerprint = { hash }; ++ return fingerprint; ++} ++ ++} +diff --git a/examples/companion_radio/FirmwareBot.h b/examples/companion_radio/FirmwareBot.h +new file mode 100644 +index 00000000..e248adc1 +--- /dev/null ++++ b/examples/companion_radio/FirmwareBot.h +@@ -0,0 +1,22 @@ ++#pragma once ++ ++#include "BotTypes.h" ++ ++namespace FirmwareBot { ++ ++BotWriteResult normalizeText(const char* input, size_t input_len, char* output, size_t output_len, size_t* written); ++bool parseCommand(const char* text, size_t text_len, BotCommand* command); ++bool parseCommand(const char* text, size_t text_len, BotCommand* command, bool allow_prefixless); ++bool splitChannelText(const char* text, size_t text_len, char* sender, size_t sender_len, const char** body, ++ size_t* body_len); ++BotWriteResult writeResponse(char* output, size_t output_len, const char* text, size_t text_len, size_t* written); ++BotFingerprint fingerprintFor(const BotMessage& message); ++BotFingerprint responseFingerprintFor(const BotMessage& message, const char* response_text, size_t response_text_len); ++BotCommandId commandIdForName(const char* name, size_t len); ++size_t maxResponseLenForChannel(BotChannelKind channel_kind); ++bool isCommandOnCooldown(const BotCommandCooldown* cooldowns, size_t cooldown_count, BotCommandId command_id, ++ uint32_t now_millis); ++void recordCommandCooldown(BotCommandCooldown* cooldowns, size_t cooldown_count, BotCommandId command_id, ++ uint32_t now_millis, uint32_t cooldown_millis); ++ ++} +diff --git a/examples/companion_radio/KnownBotRegistry.cpp b/examples/companion_radio/KnownBotRegistry.cpp +new file mode 100644 +index 00000000..a3e764ee +--- /dev/null ++++ b/examples/companion_radio/KnownBotRegistry.cpp +@@ -0,0 +1,87 @@ ++#include "KnownBotRegistry.h" ++ ++#include ++ ++namespace { ++ ++bool keyEqual(const uint8_t a[BOT_SENDER_KEY_PREFIX_LEN], const uint8_t b[BOT_SENDER_KEY_PREFIX_LEN]) { ++ return memcmp(a, b, BOT_SENDER_KEY_PREFIX_LEN) == 0; ++} ++ ++bool keyPrefixEqual(const uint8_t a[BOT_SENDER_KEY_PREFIX_LEN], const uint8_t b[BOT_SENDER_KEY_PREFIX_LEN], size_t len) { ++ return memcmp(a, b, len) == 0; ++} ++ ++void copyLabel(char dest[12], const char* label) { ++ size_t i = 0; ++ if (label) { ++ while (i + 1 < 12 && label[i] != 0) { ++ dest[i] = label[i]; ++ i++; ++ } ++ } ++ dest[i] = 0; ++} ++ ++} ++ ++namespace KnownBotRegistry { ++ ++void clear(BotKnownBotEntry entries[], size_t entry_count) { ++ if (!entries) return; ++ memset(entries, 0, sizeof(BotKnownBotEntry) * entry_count); ++} ++ ++const BotKnownBotEntry* find(const BotKnownBotEntry entries[], size_t entry_count, ++ const uint8_t key_prefix[BOT_SENDER_KEY_PREFIX_LEN], size_t key_prefix_len) { ++ if (!entries || !key_prefix || key_prefix_len < BOT_MIN_AUTH_SENDER_KEY_PREFIX_LEN) return NULL; ++ if (key_prefix_len > BOT_SENDER_KEY_PREFIX_LEN) key_prefix_len = BOT_SENDER_KEY_PREFIX_LEN; ++ ++ const BotKnownBotEntry* match = NULL; ++ for (size_t i = 0; i < entry_count; i++) { ++ if (!entries[i].active || !keyPrefixEqual(entries[i].key_prefix, key_prefix, key_prefix_len)) continue; ++ if (match) return NULL; ++ match = &entries[i]; ++ } ++ return match; ++} ++ ++bool add(BotKnownBotEntry entries[], size_t entry_count, const uint8_t key_prefix[BOT_SENDER_KEY_PREFIX_LEN], ++ uint8_t flags, const char* label) { ++ if (!entries || !key_prefix || entry_count == 0) return false; ++ ++ size_t slot = entry_count; ++ for (size_t i = 0; i < entry_count; i++) { ++ if (entries[i].active && keyEqual(entries[i].key_prefix, key_prefix)) { ++ slot = i; ++ break; ++ } ++ if (slot == entry_count && !entries[i].active) slot = i; ++ } ++ if (slot == entry_count) return false; ++ ++ entries[slot].active = true; ++ memcpy(entries[slot].key_prefix, key_prefix, BOT_SENDER_KEY_PREFIX_LEN); ++ entries[slot].flags = flags; ++ copyLabel(entries[slot].label, label); ++ return true; ++} ++ ++bool remove(BotKnownBotEntry entries[], size_t entry_count, const uint8_t key_prefix[BOT_SENDER_KEY_PREFIX_LEN]) { ++ if (!entries || !key_prefix) return false; ++ for (size_t i = 0; i < entry_count; i++) { ++ if (entries[i].active && keyEqual(entries[i].key_prefix, key_prefix)) { ++ memset(&entries[i], 0, sizeof(entries[i])); ++ return true; ++ } ++ } ++ return false; ++} ++ ++bool canSuppressNormal(const BotKnownBotEntry entries[], size_t entry_count, ++ const uint8_t key_prefix[BOT_SENDER_KEY_PREFIX_LEN], size_t key_prefix_len) { ++ const BotKnownBotEntry* entry = find(entries, entry_count, key_prefix, key_prefix_len); ++ return entry && (entry->flags & BOT_KNOWN_BOT_FLAG_SUPPRESS_NORMAL) != 0; ++} ++ ++} +diff --git a/examples/companion_radio/KnownBotRegistry.h b/examples/companion_radio/KnownBotRegistry.h +new file mode 100644 +index 00000000..5e93dad0 +--- /dev/null ++++ b/examples/companion_radio/KnownBotRegistry.h +@@ -0,0 +1,16 @@ ++#pragma once ++ ++#include "BotTypes.h" ++ ++namespace KnownBotRegistry { ++ ++void clear(BotKnownBotEntry entries[], size_t entry_count); ++bool add(BotKnownBotEntry entries[], size_t entry_count, const uint8_t key_prefix[BOT_SENDER_KEY_PREFIX_LEN], ++ uint8_t flags, const char* label); ++bool remove(BotKnownBotEntry entries[], size_t entry_count, const uint8_t key_prefix[BOT_SENDER_KEY_PREFIX_LEN]); ++const BotKnownBotEntry* find(const BotKnownBotEntry entries[], size_t entry_count, ++ const uint8_t key_prefix[BOT_SENDER_KEY_PREFIX_LEN], size_t key_prefix_len); ++bool canSuppressNormal(const BotKnownBotEntry entries[], size_t entry_count, ++ const uint8_t key_prefix[BOT_SENDER_KEY_PREFIX_LEN], size_t key_prefix_len); ++ ++} +diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp +index e8c1914b..44fcd1ca 100644 +--- a/examples/companion_radio/MyMesh.cpp ++++ b/examples/companion_radio/MyMesh.cpp +@@ -2,6 +2,21 @@ + + #include // needed for PlatformIO + #include ++#include ++#include ++#include ++#include ++ ++#if CMESH_BOT_ENABLED ++#include "BotCommandRegistry.h" ++#include "BotCommands.h" ++#include "BotPolicy.h" ++#include "BotPrefs.h" ++#include "EmergencyForwarder.h" ++#include "FirmwareBot.h" ++#include "KnownBotRegistry.h" ++#include "ResponseCoordinator.h" ++#endif + + #define CMD_APP_START 1 + #define CMD_SEND_TXT_MSG 2 +@@ -105,8 +120,244 @@ + #define DIRECT_SEND_PERHOP_EXTRA_MILLIS 250 + #define LAZY_CONTACTS_WRITE_DELAY 5000 + ++#if CMESH_BOT_ENABLED ++#endif ++ + #define PUBLIC_GROUP_PSK "izOH6cXN6mrJ5e26oRXNcg==" + ++#if CMESH_BOT_ENABLED ++static size_t botBoundedStrLen(const char *value, size_t max_len) { ++ size_t len = 0; ++ while (value && len < max_len && value[len] != 0) len++; ++ return len; ++} ++ ++static void botCopyString(char *dest, size_t dest_len, const char *src) { ++ if (!dest || dest_len == 0) return; ++ size_t len = botBoundedStrLen(src, dest_len - 1); ++ if (len > 0) memcpy(dest, src, len); ++ dest[len] = 0; ++} ++ ++static bool botParseU32(const char *text, uint32_t *value, const char **end_out) { ++ if (!text || !value || !isdigit((unsigned char)text[0])) return false; ++ uint32_t parsed = 0; ++ while (isdigit((unsigned char)*text)) { ++ uint32_t next = parsed * 10UL + (uint32_t)(*text - '0'); ++ if (next < parsed) return false; ++ parsed = next; ++ text++; ++ } ++ *value = parsed; ++ if (end_out) *end_out = text; ++ return true; ++} ++ ++static void botSkipSpaces(const char **text) { ++ while (text && *text && **text == ' ') (*text)++; ++} ++ ++static bool botReadToken(const char **text, char *output, size_t output_len) { ++ if (!text || !*text || !output || output_len == 0) return false; ++ botSkipSpaces(text); ++ const char *start = *text; ++ size_t len = 0; ++ while (start[len] != 0 && start[len] != ' ') len++; ++ if (len == 0 || len + 1 > output_len) return false; ++ memcpy(output, start, len); ++ output[len] = 0; ++ *text = start + len; ++ return true; ++} ++ ++static bool botNoMoreTokens(const char *text) { ++ botSkipSpaces(&text); ++ return text && *text == 0; ++} ++ ++static int botHexValue(char ch) { ++ if (ch >= '0' && ch <= '9') return ch - '0'; ++ if (ch >= 'a' && ch <= 'f') return ch - 'a' + 10; ++ if (ch >= 'A' && ch <= 'F') return ch - 'A' + 10; ++ return -1; ++} ++ ++static bool botTraceFlagForHashSize(uint8_t hash_size, uint8_t *flags) { ++ if (!flags) return false; ++ if (hash_size == 1) { ++ *flags = 0; ++ return true; ++ } ++ if (hash_size == 2) { ++ *flags = 1; ++ return true; ++ } ++ if (hash_size == 4) { ++ *flags = 2; ++ return true; ++ } ++ return false; ++} ++ ++static uint8_t botConfiguredTraceHashSize(uint8_t path_hash_mode) { ++ if (path_hash_mode == 0) return 1; ++ if (path_hash_mode == 1) return 2; ++ return 4; ++} ++ ++static uint8_t botTraceHashSize(uint8_t flags) { ++ return (uint8_t)(1U << (flags & 0x03)); ++} ++ ++static bool botTracePathShapeValid(uint8_t path_len, uint8_t flags) { ++ uint8_t hash_size = botTraceHashSize(flags); ++ return path_len > 0 && path_len <= BOT_MAX_PATH_BYTES && path_len + 9 <= MAX_PACKET_PAYLOAD && ++ (path_len % hash_size) == 0 && (path_len / hash_size) <= MAX_PATH_SIZE; ++} ++ ++static void botCopyReversedPath(uint8_t *dest, const uint8_t *src, uint8_t hop_count, uint8_t hash_size) { ++ for (uint8_t i = 0; i < hop_count; i++) { ++ memcpy(&dest[i * hash_size], &src[(hop_count - i - 1) * hash_size], hash_size); ++ } ++} ++ ++static bool botParseTraceHexPath(const BotCommand &command, uint8_t flags, uint8_t path[BOT_MAX_PATH_BYTES], ++ uint8_t *path_len) { ++ if (!path || !path_len || command.args_len == 0) return false; ++ if ((command.args_len & 1) != 0 || command.args_len / 2 > BOT_MAX_PATH_BYTES) return false; ++ ++ uint8_t parsed_len = (uint8_t)(command.args_len / 2); ++ if (!botTracePathShapeValid(parsed_len, flags)) return false; ++ ++ for (uint8_t i = 0; i < parsed_len; i++) { ++ int high = botHexValue(command.args[i * 2]); ++ int low = botHexValue(command.args[i * 2 + 1]); ++ if (high < 0 || low < 0) return false; ++ path[i] = (uint8_t)((high << 4) | low); ++ } ++ *path_len = parsed_len; ++ return true; ++} ++ ++static BotCommandResult botCommandResult(BotCommandResultCode code, size_t text_len) { ++ BotCommandResult result = { code, text_len }; ++ return result; ++} ++ ++static BotCommandResult botWriteText(char *output, size_t output_len, const char *text) { ++ if (!output || output_len == 0) return botCommandResult(BOT_COMMAND_RESULT_NO_SPACE, 0); ++ size_t text_len = botBoundedStrLen(text, BOT_MAX_RESPONSE_LEN + 1); ++ size_t copy_len = text_len; ++ if (copy_len + 1 > output_len) copy_len = output_len - 1; ++ if (copy_len > 0) memcpy(output, text, copy_len); ++ output[copy_len] = 0; ++ return botCommandResult(copy_len < text_len ? BOT_COMMAND_RESULT_TRUNCATED : BOT_COMMAND_RESULT_OK, copy_len); ++} ++ ++static BotCommandResult botWriteFormatted(char *output, size_t output_len, const char *format, ...) { ++ if (!output || output_len == 0) return botCommandResult(BOT_COMMAND_RESULT_NO_SPACE, 0); ++ va_list args; ++ va_start(args, format); ++ int written = vsnprintf(output, output_len, format, args); ++ va_end(args); ++ if (written < 0) { ++ output[0] = 0; ++ return botCommandResult(BOT_COMMAND_RESULT_NO_SPACE, 0); ++ } ++ size_t actual = (size_t)written; ++ if (actual >= output_len) actual = output_len - 1; ++ return botCommandResult((size_t)written >= output_len ? BOT_COMMAND_RESULT_TRUNCATED : BOT_COMMAND_RESULT_OK, actual); ++} ++ ++static bool botParsePubKeyPrefixHex(const BotCommand &command, uint8_t prefix[PUB_KEY_SIZE], uint8_t *prefix_len) { ++ if (!prefix || !prefix_len || command.args_len == 0 || (command.args_len & 1) != 0) return false; ++ size_t byte_len = command.args_len / 2; ++ if (byte_len < BOT_MIN_AUTH_SENDER_KEY_PREFIX_LEN || byte_len > PUB_KEY_SIZE) return false; ++ for (size_t i = 0; i < byte_len; i++) { ++ int high = botHexValue(command.args[i * 2]); ++ int low = botHexValue(command.args[i * 2 + 1]); ++ if (high < 0 || low < 0) return false; ++ prefix[i] = (uint8_t)((high << 4) | low); ++ } ++ *prefix_len = (uint8_t)byte_len; ++ return true; ++} ++ ++static void botFormatKeyPrefixHex(const uint8_t *key, char *output, size_t output_len) { ++ static const char hex[] = "0123456789abcdef"; ++ if (!output || output_len == 0) return; ++ if (!key || output_len < BOT_SENDER_KEY_PREFIX_LEN * 2 + 1) { ++ output[0] = 0; ++ return; ++ } ++ for (size_t i = 0; i < BOT_SENDER_KEY_PREFIX_LEN; i++) { ++ output[i * 2] = hex[key[i] >> 4]; ++ output[i * 2 + 1] = hex[key[i] & 0x0F]; ++ } ++ output[BOT_SENDER_KEY_PREFIX_LEN * 2] = 0; ++} ++ ++static void botCopyContactName(const ContactInfo &contact, char *output, size_t output_len) { ++ if (!output || output_len == 0) return; ++ size_t len = botBoundedStrLen(contact.name, sizeof(contact.name)); ++ if (len == 0) { ++ botCopyString(output, output_len, "contact"); ++ return; ++ } ++ if (len + 1 > output_len) len = output_len - 1; ++ memcpy(output, contact.name, len); ++ output[len] = 0; ++} ++ ++static void botFormatQuarters(int8_t quarters, char *output, size_t output_len) { ++ if (!output || output_len == 0) return; ++ int value = quarters; ++ const char *sign = value < 0 ? "-" : ""; ++ if (value < 0) value = -value; ++ snprintf(output, output_len, "%s%d.%02d", sign, value / 4, (value % 4) * 25); ++} ++ ++static void botAppendHex(char *output, size_t output_len, size_t *pos, const uint8_t *data, size_t data_len) { ++ static const char hex[] = "0123456789abcdef"; ++ for (size_t i = 0; i < data_len; i++) { ++ if (*pos + 2 < output_len) { ++ output[*pos] = hex[data[i] >> 4]; ++ output[*pos + 1] = hex[data[i] & 0x0F]; ++ } ++ *pos += 2; ++ } ++ if (output_len > 0) output[*pos < output_len ? *pos : output_len - 1] = 0; ++} ++ ++static size_t botFormatTraceSent(char *output, size_t output_len) { ++ if (!output || output_len == 0) return 0; ++ const char *text = "Trace sent"; ++ size_t len = botBoundedStrLen(text, BOT_MAX_RESPONSE_LEN + 1); ++ if (len + 1 > output_len) len = output_len - 1; ++ memcpy(output, text, len); ++ output[len] = 0; ++ return len; ++} ++ ++static size_t botFormatTraceResult(char *output, size_t output_len, uint32_t tag, uint8_t flags, ++ const uint8_t *path_hashes, uint8_t path_len, int8_t snr_quarters) { ++ if (!output || output_len == 0) return 0; ++ uint8_t hash_size = botTraceHashSize(flags); ++ uint8_t hop_count = path_len / hash_size; ++ char snr[8]; ++ botFormatQuarters(snr_quarters, snr, sizeof(snr)); ++ int written = snprintf(output, output_len, "Trace %08lx %uh x %uB snr %s: ", (unsigned long)tag, ++ (unsigned)hop_count, (unsigned)hash_size, snr); ++ if (written < 0) { ++ output[0] = 0; ++ return 0; ++ } ++ size_t pos = (size_t)written; ++ botAppendHex(output, output_len, &pos, path_hashes, path_len); ++ return botBoundedStrLen(output, output_len); ++} ++#endif ++ + // these are _pushed_ to client app at any time + #define PUSH_CODE_ADVERT 0x80 + #define PUSH_CODE_PATH_UPDATED 0x81 +@@ -514,6 +765,9 @@ 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); ++#if CMESH_BOT_ENABLED ++ observeBotDirectMessage(from, sender_timestamp, from.id.pub_key, BOT_SENDER_KEY_PREFIX_LEN, text); ++#endif + } + + void MyMesh::onCommandDataRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp, +@@ -528,8 +782,840 @@ void MyMesh::onSignedMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uin + // from.sync_since change needs to be persisted + dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); + queueMessage(from, TXT_TYPE_SIGNED_PLAIN, pkt, sender_timestamp, sender_prefix, 4, text); ++#if CMESH_BOT_ENABLED ++ observeBotDirectMessage(from, sender_timestamp, sender_prefix, 4, text); ++#endif ++} ++ ++#if CMESH_BOT_ENABLED ++void MyMesh::applyBotPrefs() { ++ BotPrefsCodec::validate(bot_prefs); ++ KnownBotRegistry::clear(known_bot_entries, BOT_KNOWN_BOT_SLOTS); ++ for (size_t i = 0; i < BOT_KNOWN_BOT_SLOTS; i++) { ++ if (bot_prefs.known_bots[i].active) { ++ KnownBotRegistry::add(known_bot_entries, BOT_KNOWN_BOT_SLOTS, bot_prefs.known_bots[i].key_prefix, ++ bot_prefs.known_bots[i].flags, bot_prefs.known_bots[i].label); ++ } ++ } ++ if (bot_prefs.enabled) { ++ scheduleBotLocalAdvert(bot_prefs.local_advert_interval_ms ? BOT_PREFS_INITIAL_LOCAL_ADVERT_MILLIS : 0); ++ scheduleBotFloodAdvert(bot_prefs.flood_advert_interval_ms); ++ } else { ++ scheduleBotLocalAdvert(0); ++ scheduleBotFloodAdvert(0); ++ } ++} ++ ++bool MyMesh::saveBotPrefs() { ++ BotPrefsCodec::validate(bot_prefs); ++ bool success = _store->saveBotPrefs(bot_prefs); ++ if (!success) bot_prefs.prefs_save_failures++; ++ return success; ++} ++ ++static void printBotPrefsSaveResult(const char *success_message, bool saved) { ++ Serial.println(saved ? success_message : " Error: bot prefs save failed"); ++} ++ ++void MyMesh::printBotPrefs() { ++ Serial.printf(" > bot %s\n", bot_prefs.enabled ? "enabled" : "disabled"); ++ Serial.printf(" > channels bot=%s testing=%s emergency=%s public=%s\n", bot_prefs.bot_channel, ++ bot_prefs.testing_channel, bot_prefs.emergency_channel, bot_prefs.public_channel); ++ Serial.printf(" > delay base=%u jitter=%u\n", (unsigned)bot_prefs.normal_delay_ms, ++ (unsigned)bot_prefs.normal_jitter_ms); ++ Serial.printf(" > advert local=%lu flood=%lu\n", (unsigned long)bot_prefs.local_advert_interval_ms, ++ (unsigned long)bot_prefs.flood_advert_interval_ms); ++} ++ ++bool MyMesh::handleBotCLI(const char *args) { ++ if (!args) return false; ++ botSkipSpaces(&args); ++ if (*args == 0) { ++ printBotPrefs(); ++ return true; ++ } ++ if (strcmp(args, "enable") == 0) { ++ bot_prefs.enabled = true; ++ applyBotPrefs(); ++ printBotPrefsSaveResult(" > bot enabled", saveBotPrefs()); ++ return true; ++ } ++ if (strcmp(args, "disable") == 0) { ++ bot_prefs.enabled = false; ++ ResponseCoordinator::clear(bot_coordinator_pending, BOT_COORDINATOR_PENDING_SLOTS); ++ memset(pending_bot_responses, 0, sizeof(pending_bot_responses)); ++ memset(pending_bot_traces, 0, sizeof(pending_bot_traces)); ++ applyBotPrefs(); ++ printBotPrefsSaveResult(" > bot disabled", saveBotPrefs()); ++ return true; ++ } ++ if (strcmp(args, "channels") == 0) { ++ Serial.printf(" > %s %s %s %s\n", bot_prefs.bot_channel, bot_prefs.testing_channel, ++ bot_prefs.emergency_channel, bot_prefs.public_channel); ++ return true; ++ } ++ if (memcmp(args, "channels ", 9) == 0) { ++ const char *pos = args + 9; ++ char bot[BOT_MAX_CHANNEL_NAME_LEN + 1]; ++ char testing[BOT_MAX_CHANNEL_NAME_LEN + 1]; ++ char emergency[BOT_MAX_CHANNEL_NAME_LEN + 1]; ++ char public_name[BOT_MAX_CHANNEL_NAME_LEN + 1]; ++ BotPrefs updated = bot_prefs; ++ if (botReadToken(&pos, bot, sizeof(bot)) && botReadToken(&pos, testing, sizeof(testing)) && ++ botReadToken(&pos, emergency, sizeof(emergency)) && botReadToken(&pos, public_name, sizeof(public_name)) && ++ botNoMoreTokens(pos) && BotPrefsCodec::channelNameValid(bot, false) && ++ BotPrefsCodec::channelNameValid(testing, false) && BotPrefsCodec::channelNameValid(emergency, false) && ++ BotPrefsCodec::channelNameValid(public_name, true)) { ++ botCopyString(updated.bot_channel, sizeof(updated.bot_channel), bot); ++ botCopyString(updated.testing_channel, sizeof(updated.testing_channel), testing); ++ botCopyString(updated.emergency_channel, sizeof(updated.emergency_channel), emergency); ++ botCopyString(updated.public_channel, sizeof(updated.public_channel), public_name); ++ if (BotPrefsCodec::channelConfigValid(updated)) { ++ bot_prefs = updated; ++ printBotPrefsSaveResult(" > bot channels saved", saveBotPrefs()); ++ } else { ++ Serial.println(" Error: duplicate bot channel names"); ++ } ++ } else { ++ Serial.println(" Error: usage bot channels "); ++ } ++ return true; ++ } ++ if (memcmp(args, "delay ", 6) == 0) { ++ const char *pos = args + 6; ++ uint32_t base = 0; ++ uint32_t jitter = 0; ++ if (botParseU32(pos, &base, &pos)) { ++ botSkipSpaces(&pos); ++ if (botParseU32(pos, &jitter, &pos) && *pos == 0 && base <= BOT_PREFS_MAX_DELAY_MILLIS && ++ jitter <= BOT_PREFS_MAX_DELAY_MILLIS) { ++ bot_prefs.normal_delay_ms = (uint16_t)base; ++ bot_prefs.normal_jitter_ms = (uint16_t)jitter; ++ printBotPrefsSaveResult(" > bot delay saved", saveBotPrefs()); ++ } else { ++ Serial.println(" Error: usage bot delay "); ++ } ++ } else { ++ Serial.println(" Error: usage bot delay "); ++ } ++ return true; ++ } ++ if (memcmp(args, "advert ", 7) == 0) { ++ const char *pos = args + 7; ++ uint32_t local = 0; ++ uint32_t flood = 0; ++ if (botParseU32(pos, &local, &pos)) { ++ botSkipSpaces(&pos); ++ if (botParseU32(pos, &flood, &pos) && *pos == 0 && local <= BOT_PREFS_MAX_ADVERT_MILLIS && ++ flood <= BOT_PREFS_MAX_ADVERT_MILLIS) { ++ bot_prefs.local_advert_interval_ms = local; ++ bot_prefs.flood_advert_interval_ms = flood; ++ applyBotPrefs(); ++ printBotPrefsSaveResult(" > bot advert saved", saveBotPrefs()); ++ } else { ++ Serial.println(" Error: usage bot advert "); ++ } ++ } else { ++ Serial.println(" Error: usage bot advert "); ++ } ++ return true; ++ } ++ if (strcmp(args, "known list") == 0) { ++ char hex[BOT_SENDER_KEY_PREFIX_LEN * 2 + 1]; ++ for (size_t i = 0; i < BOT_KNOWN_BOT_SLOTS; i++) { ++ if (!bot_prefs.known_bots[i].active) continue; ++ BotPrefsCodec::formatKeyPrefixHex(bot_prefs.known_bots[i].key_prefix, hex, sizeof(hex)); ++ Serial.printf(" > %s %s flags=%u\n", hex, bot_prefs.known_bots[i].label, ++ (unsigned)bot_prefs.known_bots[i].flags); ++ } ++ return true; ++ } ++ if (memcmp(args, "known add ", 10) == 0) { ++ const char *pos = args + 10; ++ char key_hex[BOT_SENDER_KEY_PREFIX_LEN * 2 + 1]; ++ char label[BOT_KNOWN_BOT_LABEL_LEN]; ++ label[0] = 0; ++ if (botReadToken(&pos, key_hex, sizeof(key_hex))) { ++ bool have_label = botReadToken(&pos, label, sizeof(label)); ++ uint8_t key[BOT_SENDER_KEY_PREFIX_LEN]; ++ if (botNoMoreTokens(pos) && (!have_label || label[0] != 0) && BotPrefsCodec::parseKeyPrefixHex(key_hex, key) && ++ BotPrefsCodec::addKnownBot(bot_prefs, key, BOT_KNOWN_BOT_FLAG_SUPPRESS_NORMAL, have_label ? label : "bot")) { ++ applyBotPrefs(); ++ printBotPrefsSaveResult(" > known bot saved", saveBotPrefs()); ++ } else { ++ Serial.println(" Error: known bot table full or invalid key"); ++ } ++ } else { ++ Serial.println(" Error: usage bot known add [label]"); ++ } ++ return true; ++ } ++ if (memcmp(args, "known remove ", 13) == 0) { ++ const char *pos = args + 13; ++ char key_hex[BOT_SENDER_KEY_PREFIX_LEN * 2 + 1]; ++ uint8_t key[BOT_SENDER_KEY_PREFIX_LEN]; ++ if (botReadToken(&pos, key_hex, sizeof(key_hex)) && botNoMoreTokens(pos) && ++ BotPrefsCodec::parseKeyPrefixHex(key_hex, key) && BotPrefsCodec::removeKnownBot(bot_prefs, key)) { ++ applyBotPrefs(); ++ printBotPrefsSaveResult(" > known bot removed", saveBotPrefs()); ++ } else { ++ Serial.println(" Error: known bot not found"); ++ } ++ return true; ++ } ++ if (strcmp(args, "commands") == 0) { ++ for (size_t i = 0; i < BotCommandRegistry::commandCount(); i++) { ++ const BotCommandMetadata *command = BotCommandRegistry::commandAt(i); ++ if (!command || command->visibility != BOT_COMMAND_VISIBILITY_DISCOVERABLE) continue; ++ Serial.printf(" > %s %s\n", command->name, ++ BotPrefsCodec::commandEnabled(bot_prefs, command->id) ? "enabled" : "disabled"); ++ } ++ return true; ++ } ++ if (memcmp(args, "commands enable ", 16) == 0 || memcmp(args, "commands disable ", 17) == 0) { ++ bool enable = memcmp(args, "commands enable ", 16) == 0; ++ const char *name = args + (enable ? 16 : 17); ++ BotCommandId command_id; ++ if (BotPrefsCodec::commandIdForName(name, &command_id)) { ++ BotPrefsCodec::setCommandEnabled(bot_prefs, command_id, enable); ++ if (saveBotPrefs()) { ++ Serial.printf(" > command %s %s\n", BotPrefsCodec::commandName(command_id), enable ? "enabled" : "disabled"); ++ } else { ++ Serial.println(" Error: bot prefs save failed"); ++ } ++ } else { ++ Serial.println(" Error: unknown bot command"); ++ } ++ return true; ++ } ++ if (strcmp(args, "stats") == 0) { ++ Serial.printf(" > observed=%lu ignored=%lu eligible=%lu sent=%lu failed=%lu suppressed=%lu emergency=%lu/%lu\n", ++ (unsigned long)bot_stats.observed_messages, (unsigned long)bot_stats.ignored_messages, ++ (unsigned long)bot_stats.eligible_messages, (unsigned long)bot_stats.sent_messages, ++ (unsigned long)bot_stats.send_failures, (unsigned long)bot_stats.suppressed_responses, ++ (unsigned long)bot_stats.emergency_forwards, (unsigned long)bot_stats.emergency_forward_failures); ++ Serial.printf(" > prefs load_failures=%lu save_failures=%lu\n", (unsigned long)bot_prefs.prefs_load_failures, ++ (unsigned long)bot_prefs.prefs_save_failures); ++ return true; ++ } ++ if (strcmp(args, "save") == 0) { ++ Serial.println(saveBotPrefs() ? " > bot prefs saved" : " Error: bot prefs save failed"); ++ return true; ++ } ++ return false; ++} ++ ++void MyMesh::observeBotDirectMessage(const ContactInfo &from, uint32_t sender_timestamp, const uint8_t *sender_prefix, ++ size_t sender_prefix_len, const char *text) { ++ BotMessage message; ++ memset(&message, 0, sizeof(message)); ++ message.channel_kind = BotPolicy::classifyChannel(NULL, 0, true, bot_prefs); ++ StrHelper::strzcpy(message.sender_name, from.name, sizeof(message.sender_name)); ++ size_t prefix_len = sender_prefix_len; ++ if (prefix_len > sizeof(message.sender_key_prefix)) prefix_len = sizeof(message.sender_key_prefix); ++ if (sender_prefix && prefix_len > 0) memcpy(message.sender_key_prefix, sender_prefix, prefix_len); ++ message.sender_key_prefix_len = prefix_len; ++ message.sender_timestamp = sender_timestamp; ++ message.text_truncated = FirmwareBot::normalizeText(text, botBoundedStrLen(text, BOT_MAX_TEXT_LEN + 1), message.text, ++ sizeof(message.text), &message.text_len) == BOT_WRITE_TRUNCATED; ++ recordBotObservation(message, &from, 0xFF); ++} ++ ++void MyMesh::observeBotChannelMessage(uint8_t channel_idx, const char *channel_name, const char *text, ++ uint32_t sender_timestamp, const mesh::Packet *packet) { ++ BotMessage message; ++ memset(&message, 0, sizeof(message)); ++ size_t channel_len = botBoundedStrLen(channel_name, BOT_MAX_CHANNEL_NAME_LEN); ++ message.channel_kind = BotPolicy::classifyChannel(channel_name, channel_len, false, bot_prefs); ++ if (channel_name && channel_len > 0) { ++ memcpy(message.channel_name, channel_name, channel_len); ++ message.channel_name[channel_len] = 0; ++ } ++ message.sender_timestamp = sender_timestamp; ++ if (packet && packet->isRouteFlood() && packet->path_len <= 0xFF && mesh::Packet::isValidPathLen((uint8_t)packet->path_len)) { ++ message.path_len = (uint8_t)packet->path_len; ++ message.path_hash_size = packet->getPathHashSize(); ++ message.path_hash_count = packet->getPathHashCount(); ++ message.packet_snr_quarters = (int8_t)(packet->getSNR() * 4); ++ message.path = packet->path; ++ } ++ ++ const char *body = text; ++ size_t body_len = botBoundedStrLen(text, BOT_MAX_TEXT_LEN); ++ FirmwareBot::splitChannelText(text, body_len, message.sender_name, sizeof(message.sender_name), &body, &body_len); ++ message.text_truncated = FirmwareBot::normalizeText(body, body_len, message.text, sizeof(message.text), ++ &message.text_len) == BOT_WRITE_TRUNCATED; ++ recordBotObservation(message, NULL, channel_idx); ++} ++ ++void MyMesh::buildBotCommandContext(BotCommandContext &context, BotCommandId command_id) { ++ memset(&context, 0, sizeof(context)); ++ StrHelper::strzcpy(context.node_name, _prefs.node_name, sizeof(context.node_name)); ++ StrHelper::strzcpy(context.bot_channel, bot_prefs.bot_channel, sizeof(context.bot_channel)); ++ StrHelper::strzcpy(context.testing_channel, bot_prefs.testing_channel, sizeof(context.testing_channel)); ++ StrHelper::strzcpy(context.emergency_channel, bot_prefs.emergency_channel, sizeof(context.emergency_channel)); ++ StrHelper::strzcpy(context.public_channel, bot_prefs.public_channel, sizeof(context.public_channel)); ++ context.uptime_seconds = _ms->getMillis() / 1000; ++ context.observed_messages = bot_stats.observed_messages; ++ context.ignored_messages = bot_stats.ignored_messages; ++ context.eligible_messages = bot_stats.eligible_messages; ++ context.sent_messages = bot_stats.sent_messages; ++ context.send_failures = bot_stats.send_failures; ++ context.suppressed_responses = bot_stats.suppressed_responses; ++ context.pending_responses = bot_stats.pending_responses; ++ context.emergency_forwards = bot_stats.emergency_forwards; ++ context.emergency_forward_failures = bot_stats.emergency_forward_failures; ++ if (command_id == BOT_COMMAND_ROLL || command_id == BOT_COMMAND_DICE || command_id == BOT_COMMAND_MAGIC8) { ++ getRNG()->random((uint8_t *)&context.random_seed, sizeof(context.random_seed)); ++ } ++ if (command_id == BOT_COMMAND_STATUS || command_id == BOT_COMMAND_STATS) { ++ context.battery_millivolts = board.getBattMilliVolts(); ++ context.storage_used_kb = _store->getStorageUsedKb(); ++ context.storage_total_kb = _store->getStorageTotalKb(); ++ } ++ if (command_id == BOT_COMMAND_CHANNELS) { ++ for (uint8_t i = 0; i < MAX_GROUP_CHANNELS; i++) { ++ ChannelDetails channel; ++ if (getChannel(i, channel) && channel.name[0]) context.channel_count++; ++ } ++ } ++ if (command_id == BOT_COMMAND_VERSION) { ++ StrHelper::strzcpy(context.firmware_version, FIRMWARE_VERSION, sizeof(context.firmware_version)); ++ StrHelper::strzcpy(context.firmware_build_date, FIRMWARE_BUILD_DATE, sizeof(context.firmware_build_date)); ++ } ++ if (command_id == BOT_COMMAND_STATS) { ++ context.queue_depth = (uint8_t)_mgr->getOutboundTotal(); ++ context.noise_floor = (int16_t)_radio->getNoiseFloor(); ++ context.last_rssi = (int8_t)radio_driver.getLastRSSI(); ++ context.last_snr_quarters = (int8_t)(radio_driver.getLastSNR() * 4); ++ context.tx_airtime_seconds = getTotalAirTime() / 1000; ++ context.rx_airtime_seconds = getReceiveAirTime() / 1000; ++ context.packets_recv = radio_driver.getPacketsRecv(); ++ context.packets_sent = radio_driver.getPacketsSent(); ++ context.flood_sent = getNumSentFlood(); ++ context.direct_sent = getNumSentDirect(); ++ context.flood_recv = getNumRecvFlood(); ++ context.direct_recv = getNumRecvDirect(); ++ context.packets_recv_errors = radio_driver.getPacketsRecvErrors(); ++ } ++} ++ ++bool MyMesh::enqueueBotResponse(const BotMessage &message, const ContactInfo *direct_recipient, uint8_t channel_idx, ++ const char *text, size_t text_len, BotFingerprint request_fingerprint, ++ BotFingerprint response_fingerprint) { ++ size_t slot = BOT_PENDING_RESPONSE_SLOTS; ++ for (size_t i = 0; i < BOT_PENDING_RESPONSE_SLOTS; i++) { ++ if (pending_bot_responses[i].active && pending_bot_responses[i].request_fingerprint.value == request_fingerprint.value) { ++ slot = i; ++ break; ++ } ++ if (slot == BOT_PENDING_RESPONSE_SLOTS && !pending_bot_responses[i].active) slot = i; ++ } ++ if (slot == BOT_PENDING_RESPONSE_SLOTS) return false; ++ ++ PendingBotResponse *pending = &pending_bot_responses[slot]; ++ pending->direct = message.channel_kind == BOT_CHANNEL_DM; ++ if (pending->direct) { ++ if (!direct_recipient) return false; ++ memcpy(pending->recipient_pub_key, direct_recipient->id.pub_key, sizeof(pending->recipient_pub_key)); ++ } else { ++ memset(pending->recipient_pub_key, 0, sizeof(pending->recipient_pub_key)); ++ } ++ pending->channel_idx = channel_idx; ++ pending->request_fingerprint = request_fingerprint; ++ pending->response_fingerprint = response_fingerprint; ++ pending->text_len = text_len; ++ size_t max_text_len = FirmwareBot::maxResponseLenForChannel(message.channel_kind); ++ if (pending->text_len > max_text_len) pending->text_len = max_text_len; ++ if (pending->text_len > 0) memcpy(pending->text, text, pending->text_len); ++ pending->text[pending->text_len] = 0; ++ pending->active = true; ++ return true; + } + ++bool MyMesh::findBotChannel(BotChannelKind kind, uint8_t &channel_idx) { ++ for (uint8_t i = 0; i < MAX_GROUP_CHANNELS; i++) { ++ ChannelDetails channel; ++ if (getChannel(i, channel)) { ++ size_t name_len = botBoundedStrLen(channel.name, BOT_MAX_CHANNEL_NAME_LEN); ++ if (BotPolicy::classifyChannel(channel.name, name_len, false, bot_prefs) == kind) { ++ channel_idx = i; ++ return true; ++ } ++ } ++ } ++ return false; ++} ++ ++BotCommandResult MyMesh::executeBotPrefixCommand(const BotCommand &command, char *output, size_t output_len) { ++ uint8_t prefix[PUB_KEY_SIZE]; ++ uint8_t prefix_len = 0; ++ if (!botParsePubKeyPrefixHex(command, prefix, &prefix_len)) { ++ return botWriteText(output, output_len, "Usage: prefix <8+ even hex>"); ++ } ++ ++ ContactInfo match; ++ bool have_match = false; ++ uint8_t match_count = 0; ++ for (uint32_t i = 0; i < (uint32_t)getNumContacts(); i++) { ++ ContactInfo contact; ++ if (!getContactByIdx(i, contact)) continue; ++ if (memcmp(contact.id.pub_key, prefix, prefix_len) != 0) continue; ++ if (!have_match) { ++ match = contact; ++ have_match = true; ++ } ++ if (match_count < 2) match_count++; ++ } ++ ++ if (!have_match) return botWriteText(output, output_len, "Prefix no match"); ++ if (match_count > 1) return botWriteText(output, output_len, "Prefix ambiguous"); ++ ++ char name[sizeof(match.name)]; ++ char key_hex[BOT_SENDER_KEY_PREFIX_LEN * 2 + 1]; ++ botCopyContactName(match, name, sizeof(name)); ++ botFormatKeyPrefixHex(match.id.pub_key, key_hex, sizeof(key_hex)); ++ return botWriteFormatted(output, output_len, "Prefix %s %s", key_hex, name); ++} ++ ++bool MyMesh::handleBotTraceCommand(const BotMessage &message, const ContactInfo *direct_recipient, uint8_t channel_idx, ++ const BotCommand &command) { ++ uint8_t hash_size = command.args_len > 0 ? botConfiguredTraceHashSize(_prefs.path_hash_mode) : message.path_hash_size; ++ if (hash_size == 0) hash_size = botConfiguredTraceHashSize(_prefs.path_hash_mode); ++ uint8_t flags = 0; ++ if (!botTraceFlagForHashSize(hash_size, &flags)) return false; ++ ++ uint8_t path[BOT_MAX_PATH_BYTES]; ++ uint8_t path_len = 0; ++ bool have_path = false; ++ if (command.args_len > 0) { ++ have_path = botParseTraceHexPath(command, flags, path, &path_len); ++ } else if (message.path && message.path_hash_count > 0) { ++ uint8_t raw_len = (uint8_t)(message.path_hash_count * hash_size); ++ if (botTracePathShapeValid(raw_len, flags)) { ++ botCopyReversedPath(path, message.path, message.path_hash_count, hash_size); ++ path_len = raw_len; ++ have_path = true; ++ } ++ } ++ ++ if (!have_path) return false; ++ ++ BotFingerprint request_fingerprint = FirmwareBot::fingerprintFor(message); ++ char response[BOT_MAX_RESPONSE_LEN + 1]; ++ size_t response_len = botFormatTraceSent(response, sizeof(response)); ++ BotFingerprint response_fingerprint = FirmwareBot::responseFingerprintFor(message, response, response_len); ++ BotFingerprint fingerprint; ++ uint32_t due_at_millis = 0; ++ uint32_t now = _ms->getMillis(); ++ uint32_t bot_identity_seed; ++ memcpy(&bot_identity_seed, self_id.pub_key, sizeof(bot_identity_seed)); ++ uint32_t jitter_seed = request_fingerprint.value ? (uint32_t)request_fingerprint.value : 1; ++ uint8_t queue_depth = (uint8_t)_mgr->getOutboundTotal(); ++ BotCoordinatorScheduleResult schedule = ResponseCoordinator::schedule(bot_coordinator_pending, BOT_COORDINATOR_PENDING_SLOTS, ++ message, BOT_COMMAND_TRACE, request_fingerprint, ++ response_fingerprint, now, jitter_seed, ++ bot_identity_seed, queue_depth, bot_prefs.normal_delay_ms, ++ bot_prefs.normal_jitter_ms, &fingerprint, ++ &due_at_millis); ++ if (schedule == BOT_COORDINATOR_NO_SPACE || schedule == BOT_COORDINATOR_NOT_NORMAL) { ++ bot_stats.send_failures++; ++ return true; ++ } ++ ++ uint32_t tag = getRTCClock()->getCurrentTimeUnique(); ++ uint32_t auth_code = 0; ++ getRNG()->random((uint8_t *)&auth_code, sizeof(auth_code)); ++ if (auth_code == 0) auth_code = tag ^ 0x54435245UL; ++ ++ bot_stats.eligible_messages++; ++ bot_stats.pending_responses++; ++ FirmwareBot::recordCommandCooldown(bot_command_cooldowns, BOT_COMMAND_COOLDOWN_SLOTS, BOT_COMMAND_TRACE, now, ++ BOT_TRACE_COOLDOWN_MILLIS); ++ if (!enqueueBotTrace(message, direct_recipient, channel_idx, path, path_len, flags, fingerprint, response_fingerprint, tag, ++ auth_code)) { ++ ResponseCoordinator::cancel(bot_coordinator_pending, BOT_COORDINATOR_PENDING_SLOTS, fingerprint); ++ bot_stats.send_failures++; ++ } ++ return true; ++} ++ ++bool MyMesh::enqueueBotTrace(const BotMessage &message, const ContactInfo *direct_recipient, uint8_t channel_idx, ++ const uint8_t *path, uint8_t path_len, uint8_t flags, ++ BotFingerprint request_fingerprint, BotFingerprint response_fingerprint, uint32_t tag, ++ uint32_t auth_code) { ++ if (!path || !botTracePathShapeValid(path_len, flags)) return false; ++ ++ size_t slot = BOT_PENDING_TRACE_SLOTS; ++ for (size_t i = 0; i < BOT_PENDING_TRACE_SLOTS; i++) { ++ if (pending_bot_traces[i].active && pending_bot_traces[i].request_fingerprint.value == request_fingerprint.value) { ++ slot = i; ++ break; ++ } ++ if (slot == BOT_PENDING_TRACE_SLOTS && !pending_bot_traces[i].active) slot = i; ++ } ++ if (slot == BOT_PENDING_TRACE_SLOTS) return false; ++ ++ PendingBotTrace *pending = &pending_bot_traces[slot]; ++ memset(pending, 0, sizeof(*pending)); ++ pending->direct = message.channel_kind == BOT_CHANNEL_DM; ++ if (pending->direct) { ++ if (!direct_recipient) return false; ++ memcpy(pending->recipient_pub_key, direct_recipient->id.pub_key, sizeof(pending->recipient_pub_key)); ++ } ++ pending->channel_idx = channel_idx; ++ pending->request_fingerprint = request_fingerprint; ++ pending->response_fingerprint = response_fingerprint; ++ pending->tag = tag; ++ pending->auth_code = auth_code; ++ pending->flags = flags; ++ pending->path_len = path_len; ++ memcpy(pending->path, path, path_len); ++ pending->active = true; ++ return true; ++} ++ ++bool MyMesh::sendBotTraceText(const PendingBotTrace &pending, const char *text, size_t text_len, ++ BotFingerprint response_fingerprint, uint32_t now_millis) { ++ bool success = false; ++ if (pending.direct) { ++ ContactInfo *recipient = lookupContactByPubKey(pending.recipient_pub_key, PUB_KEY_SIZE); ++ if (recipient) { ++ uint32_t expected_ack = 0; ++ uint32_t est_timeout = 0; ++ uint32_t timestamp = getRTCClock()->getCurrentTimeUnique(); ++ char response[BOT_MAX_RESPONSE_LEN + 1]; ++ size_t copy_len = text_len; ++ if (copy_len > BOT_MAX_RESPONSE_LEN) copy_len = BOT_MAX_RESPONSE_LEN; ++ if (copy_len > 0) memcpy(response, text, copy_len); ++ response[copy_len] = 0; ++ int result = sendMessage(*recipient, timestamp, 0, response, expected_ack, est_timeout); ++ success = result != MSG_SEND_FAILED; ++ if (success && expected_ack) { ++ expected_ack_table[next_ack_idx].msg_sent = _ms->getMillis(); ++ expected_ack_table[next_ack_idx].ack = expected_ack; ++ expected_ack_table[next_ack_idx].contact = recipient; ++ next_ack_idx = (next_ack_idx + 1) % EXPECTED_ACK_TABLE_SIZE; ++ } ++ } ++ } else if (pending.channel_idx != 0xFF) { ++ ChannelDetails channel; ++ uint32_t timestamp = getRTCClock()->getCurrentTimeUnique(); ++ success = getChannel(pending.channel_idx, channel) && ++ sendGroupMessage(timestamp, channel.channel, _prefs.node_name, text, text_len); ++ } ++ ++ if (success) { ++ bot_stats.sent_messages++; ++ ResponseCoordinator::recordRecent(bot_coordinator_recent, BOT_COORDINATOR_RECENT_SLOTS, response_fingerprint, ++ now_millis); ++ } else { ++ bot_stats.send_failures++; ++ } ++ return success; ++} ++ ++bool MyMesh::sendPendingBotTrace(PendingBotTrace &pending, uint32_t now_millis) { ++ mesh::Packet *pkt = createTrace(pending.tag, pending.auth_code, pending.flags); ++ if (!pkt) return false; ++ ++ sendDirect(pkt, pending.path, pending.path_len); ++ pending.sent = true; ++ pending.expires_at_millis = now_millis + BOT_TRACE_TIMEOUT_MILLIS; ++ ++ char response[BOT_MAX_RESPONSE_LEN + 1]; ++ size_t response_len = botFormatTraceSent(response, sizeof(response)); ++ return sendBotTraceText(pending, response, response_len, pending.response_fingerprint, now_millis); ++} ++ ++void MyMesh::expirePendingBotTraces(uint32_t now_millis) { ++ for (size_t i = 0; i < BOT_PENDING_TRACE_SLOTS; i++) { ++ PendingBotTrace *pending = &pending_bot_traces[i]; ++ if (!pending->active || !pending->sent || (int32_t)(now_millis - pending->expires_at_millis) < 0) continue; ++ ++ const char *response = "Trace timed out"; ++ BotFingerprint response_fingerprint; ++ response_fingerprint.value = pending->response_fingerprint.value ^ 0x74696d656f7574ULL; ++ sendBotTraceText(*pending, response, botBoundedStrLen(response, BOT_MAX_RESPONSE_LEN + 1), response_fingerprint, ++ now_millis); ++ pending->active = false; ++ } ++} ++ ++bool MyMesh::enqueueEmergencyForward(const BotMessage &message) { ++ BotEmergencyForward forward; ++ if (!EmergencyForwarder::format(message, forward)) return false; ++ ++ uint8_t free_slots = 0; ++ for (size_t i = 0; i < BOT_PENDING_EMERGENCY_SLOTS; i++) { ++ if (!pending_emergency_forwards[i].active) free_slots++; ++ } ++ if (free_slots < forward.part_count) return false; ++ ++ uint8_t part_idx = 0; ++ for (size_t i = 0; i < BOT_PENDING_EMERGENCY_SLOTS && part_idx < forward.part_count; i++) { ++ PendingEmergencyForward *pending = &pending_emergency_forwards[i]; ++ if (!pending->active) { ++ pending->text_len = forward.part_lens[part_idx]; ++ if (pending->text_len > BOT_MAX_GROUP_RESPONSE_LEN) pending->text_len = BOT_MAX_GROUP_RESPONSE_LEN; ++ if (pending->text_len > 0) memcpy(pending->text, forward.parts[part_idx], pending->text_len); ++ pending->text[pending->text_len] = 0; ++ pending->active = true; ++ part_idx++; ++ } ++ } ++ ++ return true; ++} ++ ++bool MyMesh::observeKnownBotResponse(const BotMessage &message, bool authoritative_sender) { ++ if (!authoritative_sender || message.channel_kind != BOT_CHANNEL_DM) return false; ++ if (!KnownBotRegistry::canSuppressNormal(known_bot_entries, BOT_KNOWN_BOT_SLOTS, message.sender_key_prefix, ++ message.sender_key_prefix_len)) return false; ++ ++ bot_stats.known_bot_messages++; ++ BotFingerprint fingerprint = FirmwareBot::responseFingerprintFor(message, message.text, message.text_len); ++ if (ResponseCoordinator::recentlySent(bot_coordinator_recent, BOT_COORDINATOR_RECENT_SLOTS, fingerprint, _ms->getMillis())) { ++ return true; ++ } ++ if (ResponseCoordinator::suppress(bot_coordinator_pending, BOT_COORDINATOR_PENDING_SLOTS, fingerprint)) { ++ return true; ++ } ++ return false; ++} ++ ++void MyMesh::recordBotObservation(const BotMessage &message, const ContactInfo *direct_recipient, uint8_t channel_idx) { ++ bot_stats.observed_messages++; ++ BotPolicyDecision decision = BotPolicy::decide(message.channel_kind); ++ if (decision == BOT_POLICY_IGNORE) { ++ bot_stats.ignored_messages++; ++ return; ++ } ++ if (decision == BOT_POLICY_EMERGENCY_FORWARD) { ++ bot_stats.emergency_messages++; ++ if (!enqueueEmergencyForward(message)) bot_stats.emergency_forward_failures++; ++ return; ++ } ++ ++ if (!bot_prefs.enabled) { ++ bot_stats.ignored_messages++; ++ return; ++ } ++ ++ if (observeKnownBotResponse(message, direct_recipient != NULL)) return; ++ sendQueuedBotResponses(); ++ ++ BotCommand command; ++ if (!FirmwareBot::parseCommand(message.text, message.text_len, &command, ++ BotPolicy::isPrefixlessCommandAllowed(message.channel_kind))) { ++ if (message.text_len > 0 && (message.text[0] == '!' || message.text[0] == '/')) bot_stats.parse_errors++; ++ return; ++ } ++ ++ if ((command.id != BOT_COMMAND_UNKNOWN && command.id != BOT_COMMAND_UNSUPPORTED && ++ !BotPrefsCodec::commandEnabled(bot_prefs, command.id)) || ++ FirmwareBot::isCommandOnCooldown(bot_command_cooldowns, BOT_COMMAND_COOLDOWN_SLOTS, command.id, _ms->getMillis())) { ++ bot_stats.ignored_messages++; ++ return; ++ } ++ ++ char response[BOT_MAX_RESPONSE_LEN + 1]; ++ BotCommandResult result; ++ if (command.id == BOT_COMMAND_TRACE && handleBotTraceCommand(message, direct_recipient, channel_idx, command)) return; ++ ++ BotCommandContext context; ++ buildBotCommandContext(context, command.id); ++ if (command.id == BOT_COMMAND_PATH || command.id == BOT_COMMAND_TRACER) { ++ context.path_len = message.path_len; ++ context.path_hash_size = message.path_hash_size; ++ context.path_hash_count = message.path_hash_count; ++ context.path_snr_quarters = message.packet_snr_quarters; ++ context.path = message.path; ++ } ++ result = command.id == BOT_COMMAND_PREFIX ? executeBotPrefixCommand(command, response, sizeof(response)) ++ : BotCommands::executeCommand(command, context, response, sizeof(response)); ++ if (result.code == BOT_COMMAND_RESULT_NOT_HANDLED || result.code == BOT_COMMAND_RESULT_NO_SPACE || result.text_len == 0) { ++ bot_stats.parse_errors++; ++ return; ++ } ++ ++ BotFingerprint request_fingerprint = FirmwareBot::fingerprintFor(message); ++ BotFingerprint response_fingerprint = FirmwareBot::responseFingerprintFor(message, response, result.text_len); ++ BotFingerprint fingerprint; ++ uint32_t due_at_millis = 0; ++ uint32_t bot_identity_seed; ++ memcpy(&bot_identity_seed, self_id.pub_key, sizeof(bot_identity_seed)); ++ uint8_t queue_depth = (uint8_t)_mgr->getOutboundTotal(); ++ BotCoordinatorScheduleResult schedule = ResponseCoordinator::schedule(bot_coordinator_pending, BOT_COORDINATOR_PENDING_SLOTS, ++ message, command.id, request_fingerprint, ++ response_fingerprint, _ms->getMillis(), ++ context.random_seed, bot_identity_seed, ++ queue_depth, bot_prefs.normal_delay_ms, ++ bot_prefs.normal_jitter_ms, &fingerprint, ++ &due_at_millis); ++ if (schedule == BOT_COORDINATOR_NO_SPACE || schedule == BOT_COORDINATOR_NOT_NORMAL) { ++ bot_stats.send_failures++; ++ return; ++ } ++ ++ bot_stats.eligible_messages++; ++ bot_stats.pending_responses++; ++ FirmwareBot::recordCommandCooldown(bot_command_cooldowns, BOT_COMMAND_COOLDOWN_SLOTS, command.id, _ms->getMillis(), ++ BOT_COMMAND_COOLDOWN_MILLIS); ++ if (!enqueueBotResponse(message, direct_recipient, channel_idx, response, result.text_len, fingerprint, response_fingerprint)) { ++ ResponseCoordinator::cancel(bot_coordinator_pending, BOT_COORDINATOR_PENDING_SLOTS, fingerprint); ++ bot_stats.send_failures++; ++ } ++} ++ ++void MyMesh::sendQueuedBotResponses() { ++ uint32_t now = _ms->getMillis(); ++ while (true) { ++ BotCoordinatorReady ready = ResponseCoordinator::poll(bot_coordinator_pending, BOT_COORDINATOR_PENDING_SLOTS, now); ++ if (ready.result == BOT_COORDINATOR_READY_NONE) return; ++ ++ if (ready.result == BOT_COORDINATOR_READY_SUPPRESSED) { ++ bot_stats.suppressed_responses++; ++ } else if (ready.result == BOT_COORDINATOR_READY_EXPIRED) { ++ bot_stats.expired_responses++; ++ } ++ ++ PendingBotTrace *pending_trace = NULL; ++ for (size_t i = 0; i < BOT_PENDING_TRACE_SLOTS; i++) { ++ if (pending_bot_traces[i].active && ++ pending_bot_traces[i].request_fingerprint.value == ready.request_fingerprint.value) { ++ pending_trace = &pending_bot_traces[i]; ++ break; ++ } ++ } ++ if (pending_trace) { ++ if (ready.result == BOT_COORDINATOR_READY_SEND) { ++ if (!sendPendingBotTrace(*pending_trace, now)) pending_trace->active = false; ++ } else { ++ pending_trace->active = false; ++ } ++ continue; ++ } ++ ++ PendingBotResponse *pending = NULL; ++ for (size_t i = 0; i < BOT_PENDING_RESPONSE_SLOTS; i++) { ++ if (pending_bot_responses[i].active && pending_bot_responses[i].request_fingerprint.value == ready.request_fingerprint.value) { ++ pending = &pending_bot_responses[i]; ++ break; ++ } ++ } ++ if (!pending) continue; ++ ++ if (ready.result != BOT_COORDINATOR_READY_SEND) { ++ pending->active = false; ++ continue; ++ } ++ ++ bool success = false; ++ if (pending->direct) { ++ ContactInfo *recipient = lookupContactByPubKey(pending->recipient_pub_key, PUB_KEY_SIZE); ++ if (recipient) { ++ uint32_t expected_ack = 0; ++ uint32_t est_timeout = 0; ++ uint32_t timestamp = getRTCClock()->getCurrentTimeUnique(); ++ int result = sendMessage(*recipient, timestamp, 0, pending->text, expected_ack, est_timeout); ++ success = result != MSG_SEND_FAILED; ++ if (success && expected_ack) { ++ expected_ack_table[next_ack_idx].msg_sent = _ms->getMillis(); ++ expected_ack_table[next_ack_idx].ack = expected_ack; ++ expected_ack_table[next_ack_idx].contact = recipient; ++ next_ack_idx = (next_ack_idx + 1) % EXPECTED_ACK_TABLE_SIZE; ++ } ++ } ++ } else if (pending->channel_idx != 0xFF) { ++ ChannelDetails channel; ++ uint32_t timestamp = getRTCClock()->getCurrentTimeUnique(); ++ success = getChannel(pending->channel_idx, channel) && ++ sendGroupMessage(timestamp, channel.channel, _prefs.node_name, pending->text, pending->text_len); ++ } ++ ++ if (success) { ++ bot_stats.sent_messages++; ++ ResponseCoordinator::recordRecent(bot_coordinator_recent, BOT_COORDINATOR_RECENT_SLOTS, ++ pending->response_fingerprint, now); ++ } else { ++ bot_stats.send_failures++; ++ } ++ pending->active = false; ++ } ++} ++ ++void MyMesh::sendQueuedEmergencyForwards() { ++ uint8_t public_channel_idx = 0xFF; ++ bool have_public = findBotChannel(BOT_CHANNEL_PUBLIC, public_channel_idx); ++ ChannelDetails public_channel; ++ if (have_public) have_public = getChannel(public_channel_idx, public_channel); ++ ++ for (size_t i = 0; i < BOT_PENDING_EMERGENCY_SLOTS; i++) { ++ PendingEmergencyForward *pending = &pending_emergency_forwards[i]; ++ if (!pending->active) continue; ++ ++ bool success = false; ++ if (have_public) { ++ uint32_t timestamp = getRTCClock()->getCurrentTimeUnique(); ++ success = sendGroupMessage(timestamp, public_channel.channel, _prefs.node_name, pending->text, pending->text_len); ++ } ++ ++ if (success) { ++ bot_stats.emergency_forwards++; ++ pending->active = false; ++ } else { ++ bot_stats.emergency_forward_failures++; ++ } ++ } ++} ++ ++bool MyMesh::sendBotSelfAdvert(bool flood) { ++ mesh::Packet* pkt; ++ if (_prefs.advert_loc_policy == ADVERT_LOC_NONE) { ++ pkt = createSelfAdvert(_prefs.node_name); ++ } else { ++ pkt = createSelfAdvert(_prefs.node_name, sensors.node_lat, sensors.node_lon); ++ } ++ if (!pkt) return false; ++ ++ if (flood) { ++ TransportKey default_scope; ++ memcpy(&default_scope.key, _prefs.default_scope_key, sizeof(default_scope.key)); ++ sendFloodScoped(default_scope, pkt, 0); ++ } else { ++ sendZeroHop(pkt); ++ } ++ return true; ++} ++ ++void MyMesh::scheduleBotLocalAdvert(unsigned long interval_millis) { ++ next_bot_local_advert = interval_millis > 0 ? futureMillis(interval_millis) : 0; ++} ++ ++void MyMesh::scheduleBotFloodAdvert(unsigned long interval_millis) { ++ next_bot_flood_advert = interval_millis > 0 ? futureMillis(interval_millis) : 0; ++} ++ ++void MyMesh::tickBot() { ++ uint32_t now = _ms->getMillis(); ++ sendQueuedEmergencyForwards(); ++ if (bot_prefs.enabled) { ++ sendQueuedBotResponses(); ++ expirePendingBotTraces(now); ++ } ++ if (!bot_prefs.enabled) return; ++ if (next_bot_local_advert && millisHasNowPassed(next_bot_local_advert)) { ++ sendBotSelfAdvert(false); ++ scheduleBotLocalAdvert(bot_prefs.local_advert_interval_ms); ++ } ++ if (next_bot_flood_advert && millisHasNowPassed(next_bot_flood_advert)) { ++ sendBotSelfAdvert(true); ++ scheduleBotFloodAdvert(bot_prefs.flood_advert_interval_ms); ++ } ++} ++#endif ++ + void MyMesh::onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packet *pkt, uint32_t timestamp, + const char *text) { + int i = 0; +@@ -566,15 +1652,17 @@ void MyMesh::onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packe + if (_ui) _ui->notify(UIEventType::channelMessage); + #endif + } +-#ifdef DISPLAY_CLASS +- // Get the channel name from the channel index + const char *channel_name = "Unknown"; + ChannelDetails channel_details; + if (getChannel(channel_idx, channel_details)) { + channel_name = channel_details.name; + } ++#ifdef DISPLAY_CLASS + if (_ui) _ui->newMsg(path_len, channel_name, text, offline_queue_len); + #endif ++#if CMESH_BOT_ENABLED ++ observeBotChannelMessage(channel_idx, channel_name, text, timestamp, pkt); ++#endif + } + + void MyMesh::onChannelDataRecv(const mesh::GroupChannel &channel, mesh::Packet *pkt, uint16_t data_type, +@@ -829,6 +1917,23 @@ void MyMesh::onTraceRecv(mesh::Packet *packet, uint32_t tag, uint32_t auth_code, + } else { + MESH_DEBUG_PRINTLN("onTraceRecv(), data received while app offline"); + } ++ ++#if CMESH_BOT_ENABLED ++ uint32_t now = _ms->getMillis(); ++ for (size_t idx = 0; idx < BOT_PENDING_TRACE_SLOTS; idx++) { ++ PendingBotTrace *pending = &pending_bot_traces[idx]; ++ if (!pending->active || !pending->sent || pending->tag != tag || pending->auth_code != auth_code) continue; ++ ++ char response[BOT_MAX_RESPONSE_LEN + 1]; ++ size_t response_len = botFormatTraceResult(response, sizeof(response), tag, flags, path_hashes, path_len, ++ (int8_t)(packet->getSNR() * 4)); ++ BotFingerprint response_fingerprint; ++ response_fingerprint.value = pending->response_fingerprint.value ^ 0x726573756c74ULL; ++ sendBotTraceText(*pending, response, response_len, response_fingerprint, now); ++ pending->active = false; ++ break; ++ } ++#endif + } + + uint32_t MyMesh::calcFloodTimeoutMillisFor(uint32_t pkt_airtime_millis) const { +@@ -856,6 +1961,19 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe + dirty_contacts_expiry = 0; + memset(advert_paths, 0, sizeof(advert_paths)); + memset(send_scope.key, 0, sizeof(send_scope.key)); ++#if CMESH_BOT_ENABLED ++ BotPrefsCodec::defaults(bot_prefs); ++ memset(&bot_stats, 0, sizeof(bot_stats)); ++ memset(pending_bot_responses, 0, sizeof(pending_bot_responses)); ++ memset(pending_bot_traces, 0, sizeof(pending_bot_traces)); ++ memset(pending_emergency_forwards, 0, sizeof(pending_emergency_forwards)); ++ memset(bot_command_cooldowns, 0, sizeof(bot_command_cooldowns)); ++ ResponseCoordinator::clear(bot_coordinator_pending, BOT_COORDINATOR_PENDING_SLOTS); ++ ResponseCoordinator::clearRecent(bot_coordinator_recent, BOT_COORDINATOR_RECENT_SLOTS); ++ KnownBotRegistry::clear(known_bot_entries, BOT_KNOWN_BOT_SLOTS); ++ next_bot_local_advert = 0; ++ next_bot_flood_advert = 0; ++#endif + + // defaults + memset(&_prefs, 0, sizeof(_prefs)); +@@ -868,6 +1986,9 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe + _prefs.tx_power_dbm = LORA_TX_POWER; + _prefs.gps_enabled = 0; // GPS disabled by default + _prefs.gps_interval = 0; // No automatic GPS updates by default ++#if CMESH_BOT_ENABLED ++ _prefs.path_hash_mode = 1; ++#endif + //_prefs.rx_delay_base = 10.0f; enable once new algo fixed + #if defined(USE_SX1262) || defined(USE_SX1268) + #ifdef SX126X_RX_BOOSTED_GAIN +@@ -956,6 +2077,11 @@ void MyMesh::begin(bool has_display) { + radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain); + MESH_DEBUG_PRINTLN("RX Boosted Gain Mode: %s", + radio_driver.getRxBoostedGainMode() ? "Enabled" : "Disabled"); ++#if CMESH_BOT_ENABLED ++ if (!_store->loadBotPrefs(bot_prefs)) bot_prefs.prefs_load_failures++; ++ BotPrefsCodec::validate(bot_prefs); ++ applyBotPrefs(); ++#endif + } + + const char *MyMesh::getNodeName() { +@@ -1975,7 +3101,13 @@ void MyMesh::checkCLIRescueCmd() { + if (len > 0 && cli_command[len - 1] == '\r') { // received complete line + cli_command[len - 1] = 0; // replace newline with C string null terminator + +- if (memcmp(cli_command, "set ", 4) == 0) { ++ if (memcmp(cli_command, "bot", 3) == 0 && (cli_command[3] == 0 || cli_command[3] == ' ')) { ++#if CMESH_BOT_ENABLED ++ if (!handleBotCLI(&cli_command[3])) Serial.println(" Error: unknown bot command"); ++#else ++ Serial.println(" Error: bot support is disabled in this build"); ++#endif ++ } else if (memcmp(cli_command, "set ", 4) == 0) { + const char* config = &cli_command[4]; + if (memcmp(config, "pin ", 4) == 0) { + _prefs.ble_pin = atoi(&config[4]); +@@ -2172,6 +3304,10 @@ void MyMesh::loop() { + dirty_contacts_expiry = 0; + } + ++#if CMESH_BOT_ENABLED ++ tickBot(); ++#endif ++ + #ifdef DISPLAY_CLASS + if (_ui) _ui->setHasConnection(_serial->isConnected()); + #endif +diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h +index aeff591c..8d848fef 100644 +--- a/examples/companion_radio/MyMesh.h ++++ b/examples/companion_radio/MyMesh.h +@@ -4,6 +4,15 @@ + #include + #include "AbstractUITask.h" + ++#ifndef CMESH_BOT_ENABLED ++#define CMESH_BOT_ENABLED 0 ++#endif ++#if CMESH_BOT_ENABLED ++#include "BotTypes.h" ++#define BOT_PENDING_RESPONSE_SLOTS BOT_COORDINATOR_PENDING_SLOTS ++#define BOT_COMMAND_COOLDOWN_SLOTS 9 ++#endif ++ + /*------------ Frame Protocol --------------*/ + #define FIRMWARE_VER_CODE 11 + +@@ -192,6 +201,43 @@ private: + return _store->putBlobByKey(key, key_len, src_buf, len); + } + ++#if CMESH_BOT_ENABLED ++ struct PendingBotTrace; ++ ++ void applyBotPrefs(); ++ bool saveBotPrefs(); ++ void printBotPrefs(); ++ bool handleBotCLI(const char *args); ++ void observeBotDirectMessage(const ContactInfo &from, uint32_t sender_timestamp, const uint8_t *sender_prefix, ++ size_t sender_prefix_len, const char *text); ++ void observeBotChannelMessage(uint8_t channel_idx, const char *channel_name, const char *text, ++ uint32_t sender_timestamp, const mesh::Packet *packet); ++ void recordBotObservation(const BotMessage &message, const ContactInfo *direct_recipient, uint8_t channel_idx); ++ bool observeKnownBotResponse(const BotMessage &message, bool authoritative_sender); ++ void buildBotCommandContext(BotCommandContext &context, BotCommandId command_id); ++ bool enqueueBotResponse(const BotMessage &message, const ContactInfo *direct_recipient, uint8_t channel_idx, ++ const char *text, size_t text_len, BotFingerprint request_fingerprint, ++ BotFingerprint response_fingerprint); ++ bool handleBotTraceCommand(const BotMessage &message, const ContactInfo *direct_recipient, uint8_t channel_idx, ++ const BotCommand &command); ++ BotCommandResult executeBotPrefixCommand(const BotCommand &command, char *output, size_t output_len); ++ bool enqueueBotTrace(const BotMessage &message, const ContactInfo *direct_recipient, uint8_t channel_idx, ++ const uint8_t *path, uint8_t path_len, uint8_t flags, BotFingerprint request_fingerprint, ++ BotFingerprint response_fingerprint, uint32_t tag, uint32_t auth_code); ++ bool sendBotTraceText(const PendingBotTrace &pending, const char *text, size_t text_len, ++ BotFingerprint response_fingerprint, uint32_t now_millis); ++ bool sendPendingBotTrace(PendingBotTrace &pending, uint32_t now_millis); ++ void expirePendingBotTraces(uint32_t now_millis); ++ bool enqueueEmergencyForward(const BotMessage &message); ++ bool findBotChannel(BotChannelKind kind, uint8_t &channel_idx); ++ void sendQueuedBotResponses(); ++ void sendQueuedEmergencyForwards(); ++ void tickBot(); ++ void scheduleBotLocalAdvert(unsigned long interval_millis); ++ void scheduleBotFloodAdvert(unsigned long interval_millis); ++ bool sendBotSelfAdvert(bool flood); ++#endif ++ + void checkCLIRescueCmd(); + void checkSerialInterface(); + bool isValidClientRepeatFreq(uint32_t f) const; +@@ -223,6 +269,53 @@ private: + + TransportKey send_scope; + ++#if CMESH_BOT_ENABLED ++ struct PendingBotResponse { ++ bool active; ++ bool direct; ++ uint8_t recipient_pub_key[PUB_KEY_SIZE]; ++ uint8_t channel_idx; ++ BotFingerprint request_fingerprint; ++ BotFingerprint response_fingerprint; ++ char text[BOT_MAX_RESPONSE_LEN + 1]; ++ size_t text_len; ++ }; ++ ++ struct PendingEmergencyForward { ++ bool active; ++ char text[BOT_MAX_GROUP_RESPONSE_LEN + 1]; ++ size_t text_len; ++ }; ++ ++ struct PendingBotTrace { ++ bool active; ++ bool direct; ++ bool sent; ++ uint8_t recipient_pub_key[PUB_KEY_SIZE]; ++ uint8_t channel_idx; ++ BotFingerprint request_fingerprint; ++ BotFingerprint response_fingerprint; ++ uint32_t tag; ++ uint32_t auth_code; ++ uint32_t expires_at_millis; ++ uint8_t flags; ++ uint8_t path_len; ++ uint8_t path[MAX_PATH_SIZE]; ++ }; ++ ++ BotPrefs bot_prefs; ++ BotStats bot_stats; ++ PendingBotResponse pending_bot_responses[BOT_PENDING_RESPONSE_SLOTS]; ++ PendingBotTrace pending_bot_traces[BOT_PENDING_TRACE_SLOTS]; ++ PendingEmergencyForward pending_emergency_forwards[BOT_PENDING_EMERGENCY_SLOTS]; ++ BotCommandCooldown bot_command_cooldowns[BOT_COMMAND_COOLDOWN_SLOTS]; ++ BotCoordinatorPending bot_coordinator_pending[BOT_COORDINATOR_PENDING_SLOTS]; ++ BotCoordinatorRecent bot_coordinator_recent[BOT_COORDINATOR_RECENT_SLOTS]; ++ BotKnownBotEntry known_bot_entries[BOT_KNOWN_BOT_SLOTS]; ++ unsigned long next_bot_local_advert; ++ unsigned long next_bot_flood_advert; ++#endif ++ + uint8_t cmd_frame[MAX_FRAME_SIZE + 1]; + uint8_t out_frame[MAX_FRAME_SIZE + 1]; + CayenneLPP telemetry; +diff --git a/examples/companion_radio/ResponseCoordinator.cpp b/examples/companion_radio/ResponseCoordinator.cpp +new file mode 100644 +index 00000000..25c03f18 +--- /dev/null ++++ b/examples/companion_radio/ResponseCoordinator.cpp +@@ -0,0 +1,210 @@ ++#include "ResponseCoordinator.h" ++ ++#include "BotPolicy.h" ++ ++#include ++ ++namespace { ++ ++bool isNormalChannel(BotChannelKind kind) { ++ return BotPolicy::isNormalAllowed(kind); ++} ++ ++bool sameFingerprint(BotFingerprint a, BotFingerprint b) { ++ return a.value == b.value; ++} ++ ++uint32_t channelDelayBias(BotChannelKind kind) { ++ if (kind == BOT_CHANNEL_DM) return 0; ++ if (kind == BOT_CHANNEL_BOT) return 200; ++ if (kind == BOT_CHANNEL_TESTING) return 400; ++ return 800; ++} ++ ++uint32_t commandDelayBias(BotCommandId command_id) { ++ if (command_id == BOT_COMMAND_PING || command_id == BOT_COMMAND_TEST) return 0; ++ if (command_id == BOT_COMMAND_ROLL || command_id == BOT_COMMAND_DICE) return 200; ++ if (command_id == BOT_COMMAND_STATUS || command_id == BOT_COMMAND_CHANNELS) return 400; ++ if (command_id == BOT_COMMAND_TRACE || command_id == BOT_COMMAND_TRACER) return 800; ++ return 100; ++} ++ ++uint32_t tieBreakBias(BotFingerprint request_fingerprint, uint32_t bot_identity_seed) { ++ uint32_t mixed = (uint32_t)request_fingerprint.value ^ (uint32_t)(request_fingerprint.value >> 32) ^ bot_identity_seed; ++ mixed ^= mixed >> 16; ++ mixed *= 0x7feb352dUL; ++ mixed ^= mixed >> 15; ++ return mixed % 900UL; ++} ++ ++uint32_t queueDelayBias(uint8_t queue_depth) { ++ return (uint32_t)queue_depth * 150UL; ++} ++ ++bool millisDue(uint32_t now_millis, uint32_t then_millis) { ++ return (int32_t)(now_millis - then_millis) >= 0; ++} ++ ++} ++ ++namespace ResponseCoordinator { ++ ++void clear(BotCoordinatorPending pending[], size_t pending_count) { ++ if (!pending) return; ++ memset(pending, 0, sizeof(BotCoordinatorPending) * pending_count); ++} ++ ++void clearRecent(BotCoordinatorRecent recent[], size_t recent_count) { ++ if (!recent) return; ++ memset(recent, 0, sizeof(BotCoordinatorRecent) * recent_count); ++} ++ ++uint32_t responseDelayMillis(const BotMessage& message, BotCommandId command_id, BotFingerprint request_fingerprint, ++ uint32_t bot_identity_seed, uint8_t queue_depth, uint32_t jitter_seed) { ++ return responseDelayMillis(message, command_id, request_fingerprint, bot_identity_seed, queue_depth, jitter_seed, ++ BOT_RESPONSE_DELAY_BASE_MILLIS, BOT_RESPONSE_DELAY_JITTER_MILLIS); ++} ++ ++uint32_t responseDelayMillis(const BotMessage& message, BotCommandId command_id, BotFingerprint request_fingerprint, ++ uint32_t bot_identity_seed, uint8_t queue_depth, uint32_t jitter_seed, ++ uint16_t base_delay_millis, uint16_t jitter_millis) { ++ uint32_t jitter = jitter_millis ? jitter_seed % jitter_millis : 0; ++ return (uint32_t)base_delay_millis + channelDelayBias(message.channel_kind) + commandDelayBias(command_id) + ++ queueDelayBias(queue_depth) + tieBreakBias(request_fingerprint, bot_identity_seed) + jitter; ++} ++ ++BotCoordinatorScheduleResult schedule(BotCoordinatorPending pending[], size_t pending_count, ++ const BotMessage& message, BotCommandId command_id, ++ BotFingerprint request_fingerprint, BotFingerprint response_fingerprint, ++ uint32_t now_millis, uint32_t jitter_seed, uint32_t bot_identity_seed, ++ uint8_t queue_depth, BotFingerprint* fingerprint, uint32_t* due_at_millis) { ++ return schedule(pending, pending_count, message, command_id, request_fingerprint, response_fingerprint, now_millis, ++ jitter_seed, bot_identity_seed, queue_depth, BOT_RESPONSE_DELAY_BASE_MILLIS, ++ BOT_RESPONSE_DELAY_JITTER_MILLIS, fingerprint, due_at_millis); ++} ++ ++BotCoordinatorScheduleResult schedule(BotCoordinatorPending pending[], size_t pending_count, ++ const BotMessage& message, BotCommandId command_id, ++ BotFingerprint request_fingerprint, BotFingerprint response_fingerprint, ++ uint32_t now_millis, uint32_t jitter_seed, uint32_t bot_identity_seed, ++ uint8_t queue_depth, uint16_t base_delay_millis, uint16_t jitter_millis, ++ BotFingerprint* fingerprint, uint32_t* due_at_millis) { ++ if (fingerprint) fingerprint->value = 0; ++ if (due_at_millis) *due_at_millis = 0; ++ if (!pending || pending_count == 0 || !isNormalChannel(message.channel_kind) || request_fingerprint.value == 0 || response_fingerprint.value == 0) return BOT_COORDINATOR_NOT_NORMAL; ++ ++ uint32_t due = now_millis + responseDelayMillis(message, command_id, request_fingerprint, bot_identity_seed, queue_depth, ++ jitter_seed, base_delay_millis, jitter_millis); ++ size_t slot = pending_count; ++ ++ for (size_t i = 0; i < pending_count; i++) { ++ if (pending[i].active && sameFingerprint(pending[i].request_fingerprint, request_fingerprint)) { ++ slot = i; ++ break; ++ } ++ if (slot == pending_count && !pending[i].active) slot = i; ++ } ++ if (slot == pending_count) return BOT_COORDINATOR_NO_SPACE; ++ ++ bool replaced = pending[slot].active; ++ pending[slot].active = true; ++ pending[slot].suppressed = false; ++ pending[slot].request_fingerprint = request_fingerprint; ++ pending[slot].response_fingerprint = response_fingerprint; ++ pending[slot].due_at_millis = due; ++ pending[slot].expires_at_millis = now_millis + BOT_RESPONSE_PENDING_TTL_MILLIS; ++ if (fingerprint) *fingerprint = request_fingerprint; ++ if (due_at_millis) *due_at_millis = due; ++ return replaced ? BOT_COORDINATOR_REPLACED : BOT_COORDINATOR_SCHEDULED; ++} ++ ++bool suppress(BotCoordinatorPending pending[], size_t pending_count, BotFingerprint response_fingerprint) { ++ if (!pending || response_fingerprint.value == 0) return false; ++ for (size_t i = 0; i < pending_count; i++) { ++ if (pending[i].active && sameFingerprint(pending[i].response_fingerprint, response_fingerprint)) { ++ pending[i].suppressed = true; ++ return true; ++ } ++ } ++ return false; ++} ++ ++bool cancel(BotCoordinatorPending pending[], size_t pending_count, BotFingerprint request_fingerprint) { ++ if (!pending || request_fingerprint.value == 0) return false; ++ for (size_t i = 0; i < pending_count; i++) { ++ if (pending[i].active && sameFingerprint(pending[i].request_fingerprint, request_fingerprint)) { ++ pending[i].active = false; ++ return true; ++ } ++ } ++ return false; ++} ++ ++BotCoordinatorReady poll(BotCoordinatorPending pending[], size_t pending_count, uint32_t now_millis) { ++ BotCoordinatorReady ready; ++ ready.result = BOT_COORDINATOR_READY_NONE; ++ ready.request_fingerprint.value = 0; ++ ready.response_fingerprint.value = 0; ++ if (!pending) return ready; ++ ++ for (size_t i = 0; i < pending_count; i++) { ++ if (!pending[i].active) continue; ++ if (pending[i].suppressed) { ++ ready.result = BOT_COORDINATOR_READY_SUPPRESSED; ++ ready.request_fingerprint = pending[i].request_fingerprint; ++ ready.response_fingerprint = pending[i].response_fingerprint; ++ pending[i].active = false; ++ return ready; ++ } ++ if (millisDue(now_millis, pending[i].expires_at_millis)) { ++ ready.result = BOT_COORDINATOR_READY_EXPIRED; ++ ready.request_fingerprint = pending[i].request_fingerprint; ++ ready.response_fingerprint = pending[i].response_fingerprint; ++ pending[i].active = false; ++ return ready; ++ } ++ if (millisDue(now_millis, pending[i].due_at_millis)) { ++ ready.result = BOT_COORDINATOR_READY_SEND; ++ ready.request_fingerprint = pending[i].request_fingerprint; ++ ready.response_fingerprint = pending[i].response_fingerprint; ++ pending[i].active = false; ++ return ready; ++ } ++ } ++ ++ return ready; ++} ++ ++void recordRecent(BotCoordinatorRecent recent[], size_t recent_count, BotFingerprint response_fingerprint, ++ uint32_t now_millis) { ++ if (!recent || recent_count == 0 || response_fingerprint.value == 0) return; ++ ++ size_t slot = recent_count; ++ for (size_t i = 0; i < recent_count; i++) { ++ if (recent[i].active && sameFingerprint(recent[i].response_fingerprint, response_fingerprint)) { ++ slot = i; ++ break; ++ } ++ if (slot == recent_count && (!recent[i].active || millisDue(now_millis, recent[i].expires_at_millis))) slot = i; ++ } ++ if (slot == recent_count) slot = 0; ++ recent[slot].active = true; ++ recent[slot].response_fingerprint = response_fingerprint; ++ recent[slot].expires_at_millis = now_millis + BOT_RESPONSE_RECENT_TTL_MILLIS; ++} ++ ++bool recentlySent(BotCoordinatorRecent recent[], size_t recent_count, BotFingerprint response_fingerprint, ++ uint32_t now_millis) { ++ if (!recent || response_fingerprint.value == 0) return false; ++ for (size_t i = 0; i < recent_count; i++) { ++ if (!recent[i].active) continue; ++ if (millisDue(now_millis, recent[i].expires_at_millis)) { ++ recent[i].active = false; ++ continue; ++ } ++ if (sameFingerprint(recent[i].response_fingerprint, response_fingerprint)) return true; ++ } ++ return false; ++} ++ ++} +diff --git a/examples/companion_radio/ResponseCoordinator.h b/examples/companion_radio/ResponseCoordinator.h +new file mode 100644 +index 00000000..7717bee7 +--- /dev/null ++++ b/examples/companion_radio/ResponseCoordinator.h +@@ -0,0 +1,33 @@ ++#pragma once ++ ++#include "BotTypes.h" ++ ++namespace ResponseCoordinator { ++ ++void clear(BotCoordinatorPending pending[], size_t pending_count); ++void clearRecent(BotCoordinatorRecent recent[], size_t recent_count); ++uint32_t responseDelayMillis(const BotMessage& message, BotCommandId command_id, BotFingerprint request_fingerprint, ++ uint32_t bot_identity_seed, uint8_t queue_depth, uint32_t jitter_seed); ++uint32_t responseDelayMillis(const BotMessage& message, BotCommandId command_id, BotFingerprint request_fingerprint, ++ uint32_t bot_identity_seed, uint8_t queue_depth, uint32_t jitter_seed, ++ uint16_t base_delay_millis, uint16_t jitter_millis); ++BotCoordinatorScheduleResult schedule(BotCoordinatorPending pending[], size_t pending_count, ++ const BotMessage& message, BotCommandId command_id, ++ BotFingerprint request_fingerprint, BotFingerprint response_fingerprint, ++ uint32_t now_millis, uint32_t jitter_seed, uint32_t bot_identity_seed, ++ uint8_t queue_depth, BotFingerprint* fingerprint, uint32_t* due_at_millis); ++BotCoordinatorScheduleResult schedule(BotCoordinatorPending pending[], size_t pending_count, ++ const BotMessage& message, BotCommandId command_id, ++ BotFingerprint request_fingerprint, BotFingerprint response_fingerprint, ++ uint32_t now_millis, uint32_t jitter_seed, uint32_t bot_identity_seed, ++ uint8_t queue_depth, uint16_t base_delay_millis, uint16_t jitter_millis, ++ BotFingerprint* fingerprint, uint32_t* due_at_millis); ++bool suppress(BotCoordinatorPending pending[], size_t pending_count, BotFingerprint response_fingerprint); ++bool cancel(BotCoordinatorPending pending[], size_t pending_count, BotFingerprint request_fingerprint); ++BotCoordinatorReady poll(BotCoordinatorPending pending[], size_t pending_count, uint32_t now_millis); ++void recordRecent(BotCoordinatorRecent recent[], size_t recent_count, BotFingerprint response_fingerprint, ++ uint32_t now_millis); ++bool recentlySent(BotCoordinatorRecent recent[], size_t recent_count, BotFingerprint response_fingerprint, ++ uint32_t now_millis); ++ ++} +diff --git a/platformio.ini b/platformio.ini +index 864e5e1f..dfed7dbd 100644 +--- a/platformio.ini ++++ b/platformio.ini +@@ -54,6 +54,14 @@ build_src_filter = + + ; ----------------- ESP32 --------------------- + ++[cmesh_bot_production] ++build_flags = ++ -D CMESH_BOT_ENABLED=1 ++ -UENABLE_PRIVATE_KEY_IMPORT ++ -UENABLE_PRIVATE_KEY_EXPORT ++ -D ENABLE_PRIVATE_KEY_IMPORT=0 ++ -D ENABLE_PRIVATE_KEY_EXPORT=0 ++ + [esp32_base] + extends = arduino_base + platform = platformio/espressif32@6.11.0 +diff --git a/variants/heltec_v3/platformio.ini b/variants/heltec_v3/platformio.ini +index 803ee683..ca9cf00d 100644 +--- a/variants/heltec_v3/platformio.ini ++++ b/variants/heltec_v3/platformio.ini +@@ -144,6 +144,7 @@ build_flags = + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D DISPLAY_CLASS=SSD1306Display ++ ${cmesh_bot_production.build_flags} + ; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 + ; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 + build_src_filter = ${Heltec_lora32_v3.build_src_filter} +@@ -163,6 +164,7 @@ build_flags = + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D DISPLAY_CLASS=SSD1306Display ++ ${cmesh_bot_production.build_flags} + -D BLE_PIN_CODE=123456 ; dynamic, random PIN + -D AUTO_SHUTDOWN_MILLIVOLTS=3400 + -D BLE_DEBUG_LOGGING=1 +diff --git a/variants/rak4631/platformio.ini b/variants/rak4631/platformio.ini +index ea7e49c3..d6610098 100644 +--- a/variants/rak4631/platformio.ini ++++ b/variants/rak4631/platformio.ini +@@ -122,6 +122,7 @@ build_flags = + -D DISPLAY_CLASS=SSD1306Display + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 ++ ${cmesh_bot_production.build_flags} + ; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 + ; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 + build_src_filter = ${rak4631.build_src_filter} +@@ -143,6 +144,7 @@ build_flags = + -D DISPLAY_CLASS=SSD1306Display + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 ++ ${cmesh_bot_production.build_flags} + -D BLE_PIN_CODE=123456 + -D BLE_DEBUG_LOGGING=1 + -D OFFLINE_QUEUE_SIZE=256 diff --git a/patches/meshcore/0002-Wire-companion-firmware-bot-runtime.patch b/patches/meshcore/0002-Wire-companion-firmware-bot-runtime.patch deleted file mode 100644 index daec0bb..0000000 --- a/patches/meshcore/0002-Wire-companion-firmware-bot-runtime.patch +++ /dev/null @@ -1,316 +0,0 @@ -From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 -From: cj-vana -Date: Thu, 14 May 2026 14:03:07 -0600 -Subject: [PATCH 2/2] Wire companion firmware bot runtime - ---- - examples/companion_radio/FirmwareBot.cpp | 24 ++++ - examples/companion_radio/FirmwareBot.h | 2 + - examples/companion_radio/MyMesh.cpp | 136 ++++++++++++++++++++++- - examples/companion_radio/MyMesh.h | 24 ++++ - 4 files changed, 184 insertions(+), 2 deletions(-) - -diff --git a/examples/companion_radio/FirmwareBot.cpp b/examples/companion_radio/FirmwareBot.cpp -index c9447282..2d4b7395 100644 ---- a/examples/companion_radio/FirmwareBot.cpp -+++ b/examples/companion_radio/FirmwareBot.cpp -@@ -150,6 +150,30 @@ bool parseCommand(const char* text, size_t text_len, BotCommand* command) { - return true; - } - -+bool splitChannelText(const char* text, size_t text_len, char* sender, size_t sender_len, const char** body, -+ size_t* body_len) { -+ if (body) *body = text; -+ if (body_len) *body_len = text_len; -+ if (!text) return false; -+ -+ for (size_t i = 0; i < text_len; i++) { -+ if (text[i] == 0) break; -+ if (text[i] == ':' && i + 1 < text_len && text[i + 1] == ' ') { -+ if (sender && sender_len > 0) { -+ size_t copy_len = i; -+ if (copy_len >= sender_len) copy_len = sender_len - 1; -+ if (copy_len > 0) memcpy(sender, text, copy_len); -+ sender[copy_len] = 0; -+ } -+ size_t start = i + 2; -+ if (body) *body = &text[start]; -+ if (body_len) *body_len = text_len - start; -+ return true; -+ } -+ } -+ return false; -+} -+ - BotWriteResult writeResponse(char* output, size_t output_len, const char* text, size_t text_len, size_t* written) { - if (written) *written = 0; - if (!output || output_len == 0) return BOT_WRITE_NO_SPACE; -diff --git a/examples/companion_radio/FirmwareBot.h b/examples/companion_radio/FirmwareBot.h -index 8fce16ab..8b267b89 100644 ---- a/examples/companion_radio/FirmwareBot.h -+++ b/examples/companion_radio/FirmwareBot.h -@@ -6,6 +6,8 @@ namespace FirmwareBot { - - BotWriteResult normalizeText(const char* input, size_t input_len, char* output, size_t output_len, size_t* written); - bool parseCommand(const char* text, size_t text_len, BotCommand* command); -+bool splitChannelText(const char* text, size_t text_len, char* sender, size_t sender_len, const char** body, -+ size_t* body_len); - BotWriteResult writeResponse(char* output, size_t output_len, const char* text, size_t text_len, size_t* written); - BotFingerprint fingerprintFor(const BotMessage& message); - BotCommandId commandIdForName(const char* name, size_t len); -diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp -index e8c1914b..481038f4 100644 ---- a/examples/companion_radio/MyMesh.cpp -+++ b/examples/companion_radio/MyMesh.cpp -@@ -3,6 +3,11 @@ - #include // needed for PlatformIO - #include - -+#if CMESH_BOT_ENABLED -+#include "BotPolicy.h" -+#include "FirmwareBot.h" -+#endif -+ - #define CMD_APP_START 1 - #define CMD_SEND_TXT_MSG 2 - #define CMD_SEND_CHANNEL_TXT_MSG 3 -@@ -105,8 +110,21 @@ - #define DIRECT_SEND_PERHOP_EXTRA_MILLIS 250 - #define LAZY_CONTACTS_WRITE_DELAY 5000 - -+#if CMESH_BOT_ENABLED -+#define BOT_AUTO_LOCAL_FIRST_DELAY_MILLIS 60000UL -+#define BOT_AUTO_ADVERT_INTERVAL_MILLIS (24UL * 60UL * 60UL * 1000UL) -+#endif -+ - #define PUBLIC_GROUP_PSK "izOH6cXN6mrJ5e26oRXNcg==" - -+#if CMESH_BOT_ENABLED -+static size_t botBoundedStrLen(const char *value, size_t max_len) { -+ size_t len = 0; -+ while (value && len < max_len && value[len] != 0) len++; -+ return len; -+} -+#endif -+ - // these are _pushed_ to client app at any time - #define PUSH_CODE_ADVERT 0x80 - #define PUSH_CODE_PATH_UPDATED 0x81 -@@ -514,6 +532,9 @@ 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); -+#if CMESH_BOT_ENABLED -+ observeBotDirectMessage(from, sender_timestamp, from.id.pub_key, BOT_SENDER_KEY_PREFIX_LEN, text); -+#endif - } - - void MyMesh::onCommandDataRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp, -@@ -528,8 +549,104 @@ void MyMesh::onSignedMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uin - // from.sync_since change needs to be persisted - dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); - queueMessage(from, TXT_TYPE_SIGNED_PLAIN, pkt, sender_timestamp, sender_prefix, 4, text); -+#if CMESH_BOT_ENABLED -+ observeBotDirectMessage(from, sender_timestamp, sender_prefix, 4, text); -+#endif -+} -+ -+#if CMESH_BOT_ENABLED -+void MyMesh::observeBotDirectMessage(const ContactInfo &from, uint32_t sender_timestamp, const uint8_t *sender_prefix, -+ size_t sender_prefix_len, const char *text) { -+ BotMessage message; -+ memset(&message, 0, sizeof(message)); -+ message.channel_kind = BotPolicy::classifyChannel(NULL, 0, true); -+ StrHelper::strzcpy(message.sender_name, from.name, sizeof(message.sender_name)); -+ size_t prefix_len = sender_prefix_len; -+ if (prefix_len > sizeof(message.sender_key_prefix)) prefix_len = sizeof(message.sender_key_prefix); -+ if (sender_prefix && prefix_len > 0) memcpy(message.sender_key_prefix, sender_prefix, prefix_len); -+ message.sender_timestamp = sender_timestamp; -+ FirmwareBot::normalizeText(text, botBoundedStrLen(text, BOT_MAX_TEXT_LEN), message.text, sizeof(message.text), -+ &message.text_len); -+ recordBotObservation(message); -+} -+ -+void MyMesh::observeBotChannelMessage(const char *channel_name, const char *text, uint32_t sender_timestamp) { -+ BotMessage message; -+ memset(&message, 0, sizeof(message)); -+ size_t channel_len = botBoundedStrLen(channel_name, BOT_MAX_CHANNEL_NAME_LEN); -+ message.channel_kind = BotPolicy::classifyChannel(channel_name, channel_len, false); -+ if (channel_name && channel_len > 0) { -+ memcpy(message.channel_name, channel_name, channel_len); -+ message.channel_name[channel_len] = 0; -+ } -+ message.sender_timestamp = sender_timestamp; -+ -+ const char *body = text; -+ size_t body_len = botBoundedStrLen(text, BOT_MAX_TEXT_LEN); -+ FirmwareBot::splitChannelText(text, body_len, message.sender_name, sizeof(message.sender_name), &body, &body_len); -+ FirmwareBot::normalizeText(body, body_len, message.text, sizeof(message.text), &message.text_len); -+ recordBotObservation(message); -+} -+ -+void MyMesh::recordBotObservation(const BotMessage &message) { -+ bot_stats.observed_messages++; -+ BotPolicyDecision decision = BotPolicy::decide(message.channel_kind); -+ if (decision == BOT_POLICY_IGNORE) { -+ bot_stats.ignored_messages++; -+ return; -+ } -+ if (decision == BOT_POLICY_EMERGENCY_FORWARD) { -+ bot_stats.emergency_messages++; -+ return; -+ } -+ -+ BotCommand command; -+ if (FirmwareBot::parseCommand(message.text, message.text_len, &command)) { -+ bot_stats.eligible_messages++; -+ } else if (message.text_len > 0 && (message.text[0] == '!' || message.text[0] == '/')) { -+ bot_stats.parse_errors++; -+ } -+} -+ -+bool MyMesh::sendBotSelfAdvert(bool flood) { -+ mesh::Packet* pkt; -+ if (_prefs.advert_loc_policy == ADVERT_LOC_NONE) { -+ pkt = createSelfAdvert(_prefs.node_name); -+ } else { -+ pkt = createSelfAdvert(_prefs.node_name, sensors.node_lat, sensors.node_lon); -+ } -+ if (!pkt) return false; -+ -+ if (flood) { -+ TransportKey default_scope; -+ memcpy(&default_scope.key, _prefs.default_scope_key, sizeof(default_scope.key)); -+ sendFloodScoped(default_scope, pkt, 0); -+ } else { -+ sendZeroHop(pkt); -+ } -+ return true; - } - -+void MyMesh::scheduleBotLocalAdvert(unsigned long interval_millis) { -+ next_bot_local_advert = interval_millis > 0 ? futureMillis(interval_millis) : 0; -+} -+ -+void MyMesh::scheduleBotFloodAdvert(unsigned long interval_millis) { -+ next_bot_flood_advert = interval_millis > 0 ? futureMillis(interval_millis) : 0; -+} -+ -+void MyMesh::tickBot() { -+ if (next_bot_local_advert && millisHasNowPassed(next_bot_local_advert)) { -+ sendBotSelfAdvert(false); -+ scheduleBotLocalAdvert(BOT_AUTO_ADVERT_INTERVAL_MILLIS); -+ } -+ if (next_bot_flood_advert && millisHasNowPassed(next_bot_flood_advert)) { -+ sendBotSelfAdvert(true); -+ scheduleBotFloodAdvert(BOT_AUTO_ADVERT_INTERVAL_MILLIS); -+ } -+} -+#endif -+ - void MyMesh::onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packet *pkt, uint32_t timestamp, - const char *text) { - int i = 0; -@@ -566,15 +683,17 @@ void MyMesh::onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packe - if (_ui) _ui->notify(UIEventType::channelMessage); - #endif - } --#ifdef DISPLAY_CLASS -- // Get the channel name from the channel index - const char *channel_name = "Unknown"; - ChannelDetails channel_details; - if (getChannel(channel_idx, channel_details)) { - channel_name = channel_details.name; - } -+#ifdef DISPLAY_CLASS - if (_ui) _ui->newMsg(path_len, channel_name, text, offline_queue_len); - #endif -+#if CMESH_BOT_ENABLED -+ observeBotChannelMessage(channel_name, text, timestamp); -+#endif - } - - void MyMesh::onChannelDataRecv(const mesh::GroupChannel &channel, mesh::Packet *pkt, uint16_t data_type, -@@ -856,6 +975,11 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe - dirty_contacts_expiry = 0; - memset(advert_paths, 0, sizeof(advert_paths)); - memset(send_scope.key, 0, sizeof(send_scope.key)); -+#if CMESH_BOT_ENABLED -+ memset(&bot_stats, 0, sizeof(bot_stats)); -+ next_bot_local_advert = 0; -+ next_bot_flood_advert = 0; -+#endif - - // defaults - memset(&_prefs, 0, sizeof(_prefs)); -@@ -956,6 +1080,10 @@ void MyMesh::begin(bool has_display) { - radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain); - MESH_DEBUG_PRINTLN("RX Boosted Gain Mode: %s", - radio_driver.getRxBoostedGainMode() ? "Enabled" : "Disabled"); -+#if CMESH_BOT_ENABLED -+ scheduleBotLocalAdvert(BOT_AUTO_LOCAL_FIRST_DELAY_MILLIS); -+ scheduleBotFloodAdvert(BOT_AUTO_ADVERT_INTERVAL_MILLIS); -+#endif - } - - const char *MyMesh::getNodeName() { -@@ -2172,6 +2300,10 @@ void MyMesh::loop() { - dirty_contacts_expiry = 0; - } - -+#if CMESH_BOT_ENABLED -+ tickBot(); -+#endif -+ - #ifdef DISPLAY_CLASS - if (_ui) _ui->setHasConnection(_serial->isConnected()); - #endif -diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h -index aeff591c..d72a496a 100644 ---- a/examples/companion_radio/MyMesh.h -+++ b/examples/companion_radio/MyMesh.h -@@ -4,6 +4,13 @@ - #include - #include "AbstractUITask.h" - -+#ifndef CMESH_BOT_ENABLED -+#define CMESH_BOT_ENABLED 0 -+#endif -+#if CMESH_BOT_ENABLED -+#include "BotTypes.h" -+#endif -+ - /*------------ Frame Protocol --------------*/ - #define FIRMWARE_VER_CODE 11 - -@@ -192,6 +199,17 @@ private: - return _store->putBlobByKey(key, key_len, src_buf, len); - } - -+#if CMESH_BOT_ENABLED -+ void observeBotDirectMessage(const ContactInfo &from, uint32_t sender_timestamp, const uint8_t *sender_prefix, -+ size_t sender_prefix_len, const char *text); -+ void observeBotChannelMessage(const char *channel_name, const char *text, uint32_t sender_timestamp); -+ void recordBotObservation(const BotMessage &message); -+ void tickBot(); -+ void scheduleBotLocalAdvert(unsigned long interval_millis); -+ void scheduleBotFloodAdvert(unsigned long interval_millis); -+ bool sendBotSelfAdvert(bool flood); -+#endif -+ - void checkCLIRescueCmd(); - void checkSerialInterface(); - bool isValidClientRepeatFreq(uint32_t f) const; -@@ -223,6 +241,12 @@ private: - - TransportKey send_scope; - -+#if CMESH_BOT_ENABLED -+ BotStats bot_stats; -+ unsigned long next_bot_local_advert; -+ unsigned long next_bot_flood_advert; -+#endif -+ - uint8_t cmd_frame[MAX_FRAME_SIZE + 1]; - uint8_t out_frame[MAX_FRAME_SIZE + 1]; - CayenneLPP telemetry; diff --git a/patches/meshcore/0003-Add-firmware-bot-commands.patch b/patches/meshcore/0003-Add-firmware-bot-commands.patch deleted file mode 100644 index 16f8be3..0000000 --- a/patches/meshcore/0003-Add-firmware-bot-commands.patch +++ /dev/null @@ -1,596 +0,0 @@ -From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 -From: cj-vana -Date: Thu, 14 May 2026 14:03:07 -0600 -Subject: [PATCH 3/3] Add firmware bot commands - ---- - examples/companion_radio/BotCommands.cpp | 171 +++++++++++++++++++++++++++++++ - examples/companion_radio/BotCommands.h | 10 ++ - examples/companion_radio/BotTypes.h | 43 +++++++- - examples/companion_radio/FirmwareBot.cpp | 35 +++++++ - examples/companion_radio/FirmwareBot.h | 5 + - examples/companion_radio/MyMesh.cpp | 131 +++++++++++++++++++++-- - examples/companion_radio/MyMesh.h | 22 +++- - 7 files changed, 406 insertions(+), 11 deletions(-) -diff --git a/examples/companion_radio/BotCommands.cpp b/examples/companion_radio/BotCommands.cpp -new file mode 100644 -index 00000000..5b13fbfe ---- /dev/null -+++ b/examples/companion_radio/BotCommands.cpp -@@ -0,0 +1,171 @@ -+#include "BotCommands.h" -+ -+#include -+#include -+#include -+#include -+ -+namespace { -+ -+BotCommandResult makeResult(BotCommandResultCode code, size_t text_len) { -+ BotCommandResult result = { code, text_len }; -+ return result; -+} -+ -+size_t boundedStrLen(const char* value, size_t max_len) { -+ size_t len = 0; -+ while (value && len < max_len && value[len] != 0) len++; -+ return len; -+} -+ -+BotCommandResult writeText(char* output, size_t output_len, const char* text) { -+ if (!output || output_len == 0) return makeResult(BOT_COMMAND_RESULT_NO_SPACE, 0); -+ -+ size_t text_len = boundedStrLen(text, BOT_MAX_RESPONSE_LEN + 1); -+ size_t copy_len = text_len; -+ if (copy_len + 1 > output_len) copy_len = output_len - 1; -+ if (copy_len > 0) memcpy(output, text, copy_len); -+ output[copy_len] = 0; -+ -+ return makeResult(copy_len < text_len ? BOT_COMMAND_RESULT_TRUNCATED : BOT_COMMAND_RESULT_OK, copy_len); -+} -+ -+BotCommandResult writeFormatted(char* output, size_t output_len, const char* format, ...) { -+ if (!output || output_len == 0) return makeResult(BOT_COMMAND_RESULT_NO_SPACE, 0); -+ -+ va_list args; -+ va_start(args, format); -+ int n = vsnprintf(output, output_len, format, args); -+ va_end(args); -+ -+ if (n < 0) { -+ output[0] = 0; -+ return makeResult(BOT_COMMAND_RESULT_NO_SPACE, 0); -+ } -+ -+ size_t written = (size_t)n; -+ if (written >= output_len) written = output_len - 1; -+ return makeResult((size_t)n >= output_len ? BOT_COMMAND_RESULT_TRUNCATED : BOT_COMMAND_RESULT_OK, written); -+} -+ -+bool parseUInt(const char* text, size_t len, size_t* pos, uint16_t* value) { -+ uint32_t parsed = 0; -+ size_t start = *pos; -+ while (*pos < len && isdigit((unsigned char)text[*pos])) { -+ parsed = parsed * 10 + (uint32_t)(text[*pos] - '0'); -+ if (parsed > 1000) return false; -+ (*pos)++; -+ } -+ if (*pos == start) return false; -+ *value = (uint16_t)parsed; -+ return true; -+} -+ -+bool isSupportedSides(uint16_t sides) { -+ return sides == 4 || sides == 6 || sides == 8 || sides == 10 || sides == 12 || sides == 16 || sides == 20; -+} -+ -+bool parseDice(const BotCommand& command, uint16_t* count, uint16_t* sides) { -+ *count = 1; -+ *sides = 6; -+ if (command.args_len == 0) return true; -+ -+ const char* text = command.args; -+ size_t len = command.args_len; -+ size_t pos = 0; -+ -+ if (text[pos] == 'd' || text[pos] == 'D') { -+ pos++; -+ if (!parseUInt(text, len, &pos, sides)) return false; -+ } else { -+ uint16_t first = 0; -+ if (!parseUInt(text, len, &pos, &first)) return false; -+ if (pos < len && (text[pos] == 'd' || text[pos] == 'D')) { -+ *count = first; -+ pos++; -+ if (!parseUInt(text, len, &pos, sides)) return false; -+ } else { -+ *sides = first; -+ } -+ } -+ -+ return pos == len && *count >= 1 && *count <= 10 && isSupportedSides(*sides); -+} -+ -+uint16_t rollOnce(uint32_t* state, uint16_t sides) { -+ *state = (*state * 1664525UL) + 1013904223UL; -+ return (uint16_t)((*state >> 16) % sides) + 1; -+} -+ -+BotCommandResult executeDice(const BotCommand& command, const BotCommandContext& context, char* output, size_t output_len) { -+ uint16_t count = 1; -+ uint16_t sides = 6; -+ if (!parseDice(command, &count, &sides)) { -+ return writeText(output, output_len, "Usage: !roll [d6|d20|2d6], sides: d4 d6 d8 d10 d12 d16 d20"); -+ } -+ -+ uint32_t state = context.random_seed ^ ((uint32_t)count << 16) ^ sides; -+ if (state == 0) state = 1; -+ -+ if (!output || output_len == 0) return makeResult(BOT_COMMAND_RESULT_NO_SPACE, 0); -+ int written = count == 1 ? snprintf(output, output_len, "Rolled d%u: ", (unsigned)sides) -+ : snprintf(output, output_len, "Rolled %ud%u: ", (unsigned)count, (unsigned)sides); -+ if (written < 0) return makeResult(BOT_COMMAND_RESULT_NO_SPACE, 0); -+ -+ size_t pos = (size_t)written; -+ uint16_t total = 0; -+ for (uint16_t i = 0; i < count; i++) { -+ uint16_t roll = rollOnce(&state, sides); -+ total += roll; -+ if (pos < output_len) { -+ int n = snprintf(&output[pos], output_len - pos, i == 0 ? "%u" : "+%u", (unsigned)roll); -+ if (n < 0) return makeResult(BOT_COMMAND_RESULT_NO_SPACE, 0); -+ pos += (size_t)n; -+ } -+ } -+ if (count > 1 && pos < output_len) { -+ int n = snprintf(&output[pos], output_len - pos, "=%u", (unsigned)total); -+ if (n < 0) return makeResult(BOT_COMMAND_RESULT_NO_SPACE, 0); -+ pos += (size_t)n; -+ } -+ -+ size_t actual = boundedStrLen(output, output_len); -+ return makeResult(pos >= output_len ? BOT_COMMAND_RESULT_TRUNCATED : BOT_COMMAND_RESULT_OK, actual); -+} -+ -+} -+ -+namespace BotCommands { -+ -+BotCommandResult executeCommand(const BotCommand& command, const BotCommandContext& context, char* output, -+ size_t output_len) { -+ switch (command.id) { -+ case BOT_COMMAND_HELP: -+ return writeText(output, output_len, "Commands: !ping !test !hello !about !roll [d20|2d6] !status !channels"); -+ case BOT_COMMAND_PING: -+ return writeText(output, output_len, "Pong!"); -+ case BOT_COMMAND_TEST: -+ return writeText(output, output_len, "Bot test OK"); -+ case BOT_COMMAND_HELLO: -+ return writeFormatted(output, output_len, "Hello from %s", context.node_name[0] ? context.node_name : "MeshCore bot"); -+ case BOT_COMMAND_ABOUT: -+ return writeText(output, output_len, "Colorado Mesh firmware bot: local commands only, no internet required."); -+ case BOT_COMMAND_DICE: -+ return executeDice(command, context, output, output_len); -+ case BOT_COMMAND_STATUS: -+ return writeFormatted(output, output_len, "%s up %lus batt %umV storage %lu/%luKB seen %lu sent %lu fail %lu", -+ context.node_name[0] ? context.node_name : "bot", (unsigned long)context.uptime_seconds, -+ (unsigned)context.battery_millivolts, (unsigned long)context.storage_used_kb, -+ (unsigned long)context.storage_total_kb, (unsigned long)context.observed_messages, -+ (unsigned long)context.sent_messages, (unsigned long)context.send_failures); -+ case BOT_COMMAND_CHANNELS: -+ return writeFormatted(output, output_len, "Configured channels: %u. Normal bot replies: DM, #bot, #testing.", -+ (unsigned)context.channel_count); -+ case BOT_COMMAND_UNKNOWN: -+ return writeText(output, output_len, "Unknown command. Try !help"); -+ default: -+ return makeResult(BOT_COMMAND_RESULT_NOT_HANDLED, 0); -+ } -+} -+ -+} -diff --git a/examples/companion_radio/BotCommands.h b/examples/companion_radio/BotCommands.h -new file mode 100644 -index 00000000..b0b4cddb ---- /dev/null -+++ b/examples/companion_radio/BotCommands.h -@@ -0,0 +1,10 @@ -+#pragma once -+ -+#include "BotTypes.h" -+ -+namespace BotCommands { -+ -+BotCommandResult executeCommand(const BotCommand& command, const BotCommandContext& context, char* output, -+ size_t output_len); -+ -+} -diff --git a/examples/companion_radio/BotTypes.h b/examples/companion_radio/BotTypes.h -index 869ae3ae..a7575a7c 100644 ---- a/examples/companion_radio/BotTypes.h -+++ b/examples/companion_radio/BotTypes.h -@@ -9,6 +9,9 @@ - #define BOT_MAX_COMMAND_ARGS_LEN 79 - #define BOT_MAX_CHANNEL_NAME_LEN 23 - #define BOT_MAX_SENDER_NAME_LEN 31 -+#define BOT_GROUP_RESPONSE_PREFIX_RESERVE (BOT_MAX_SENDER_NAME_LEN + 2) -+#define BOT_MAX_GROUP_RESPONSE_LEN (BOT_MAX_TEXT_LEN - BOT_GROUP_RESPONSE_PREFIX_RESERVE) -+#define BOT_COMMAND_COOLDOWN_MILLIS 5000UL - #define BOT_SENDER_KEY_PREFIX_LEN 6 - - enum BotChannelKind : uint8_t { -@@ -35,9 +38,17 @@ enum BotCommandId : uint8_t { - BOT_COMMAND_ABOUT, - BOT_COMMAND_DICE, - BOT_COMMAND_STATUS, -+ BOT_COMMAND_CHANNELS, - BOT_COMMAND_UNKNOWN - }; - -+enum BotCommandResultCode : uint8_t { -+ BOT_COMMAND_RESULT_NOT_HANDLED = 0, -+ BOT_COMMAND_RESULT_OK, -+ BOT_COMMAND_RESULT_TRUNCATED, -+ BOT_COMMAND_RESULT_NO_SPACE -+}; -+ - enum BotWriteResult : uint8_t { - BOT_WRITE_OK = 0, - BOT_WRITE_TRUNCATED, -@@ -73,6 +84,31 @@ struct BotResponse { - bool truncated; - }; - -+struct BotCommandContext { -+ char node_name[BOT_MAX_SENDER_NAME_LEN + 1]; -+ uint32_t uptime_seconds; -+ uint16_t battery_millivolts; -+ uint32_t storage_used_kb; -+ uint32_t storage_total_kb; -+ uint32_t observed_messages; -+ uint32_t ignored_messages; -+ uint32_t eligible_messages; -+ uint32_t sent_messages; -+ uint32_t send_failures; -+ uint32_t random_seed; -+ uint8_t channel_count; -+}; -+ -+struct BotCommandResult { -+ BotCommandResultCode code; -+ size_t text_len; -+}; -+ -+struct BotCommandCooldown { -+ BotCommandId command_id; -+ uint32_t expires_at_millis; -+}; -+ - struct BotPrefs { - bool enabled; - uint16_t normal_delay_ms; -@@ -90,10 +126,15 @@ struct BotStats { - uint32_t eligible_messages; - uint32_t emergency_messages; - uint32_t parse_errors; -+ uint32_t sent_messages; -+ uint32_t send_failures; - }; - - static_assert(sizeof(BotMessage) <= 240, "BotMessage RAM budget exceeded"); - static_assert(sizeof(BotCommand) <= 120, "BotCommand RAM budget exceeded"); - static_assert(sizeof(BotResponse) <= 184, "BotResponse RAM budget exceeded"); -+static_assert(sizeof(BotCommandContext) <= 96, "BotCommandContext RAM budget exceeded"); -+static_assert(sizeof(BotCommandResult) <= 16, "BotCommandResult RAM budget exceeded"); -+static_assert(sizeof(BotCommandCooldown) <= 8, "BotCommandCooldown RAM budget exceeded"); - static_assert(sizeof(BotPrefs) <= 128, "BotPrefs RAM budget exceeded"); --static_assert(sizeof(BotStats) <= 32, "BotStats RAM budget exceeded"); -+static_assert(sizeof(BotStats) <= 48, "BotStats RAM budget exceeded"); -diff --git a/examples/companion_radio/FirmwareBot.cpp b/examples/companion_radio/FirmwareBot.cpp -index 2d4b7395..78b66937 100644 ---- a/examples/companion_radio/FirmwareBot.cpp -+++ b/examples/companion_radio/FirmwareBot.cpp -@@ -111,9 +111,44 @@ BotCommandId commandIdForName(const char* name, size_t len) { - if (namesEqual(name, len, "about")) return BOT_COMMAND_ABOUT; - if (namesEqual(name, len, "dice") || namesEqual(name, len, "roll")) return BOT_COMMAND_DICE; - if (namesEqual(name, len, "status")) return BOT_COMMAND_STATUS; -+ if (namesEqual(name, len, "channels")) return BOT_COMMAND_CHANNELS; - return BOT_COMMAND_UNKNOWN; - } - -+size_t maxResponseLenForChannel(BotChannelKind channel_kind) { -+ return channel_kind == BOT_CHANNEL_DM ? BOT_MAX_RESPONSE_LEN : BOT_MAX_GROUP_RESPONSE_LEN; -+} -+ -+bool isCommandOnCooldown(const BotCommandCooldown* cooldowns, size_t cooldown_count, BotCommandId command_id, -+ uint32_t now_millis) { -+ if (!cooldowns || command_id == BOT_COMMAND_NONE) return false; -+ for (size_t i = 0; i < cooldown_count; i++) { -+ if (cooldowns[i].command_id == command_id && (int32_t)(cooldowns[i].expires_at_millis - now_millis) > 0) return true; -+ } -+ return false; -+} -+ -+void recordCommandCooldown(BotCommandCooldown* cooldowns, size_t cooldown_count, BotCommandId command_id, -+ uint32_t now_millis, uint32_t cooldown_millis) { -+ if (!cooldowns || cooldown_count == 0 || command_id == BOT_COMMAND_NONE || cooldown_millis == 0) return; -+ -+ size_t slot = cooldown_count; -+ for (size_t i = 0; i < cooldown_count; i++) { -+ if (cooldowns[i].command_id == command_id) { -+ slot = i; -+ break; -+ } -+ if (slot == cooldown_count && -+ (cooldowns[i].command_id == BOT_COMMAND_NONE || (int32_t)(cooldowns[i].expires_at_millis - now_millis) <= 0)) { -+ slot = i; -+ } -+ } -+ if (slot == cooldown_count) slot = 0; -+ -+ cooldowns[slot].command_id = command_id; -+ cooldowns[slot].expires_at_millis = now_millis + cooldown_millis; -+} -+ - bool parseCommand(const char* text, size_t text_len, BotCommand* command) { - if (!command) return false; - memset(command, 0, sizeof(*command)); -diff --git a/examples/companion_radio/FirmwareBot.h b/examples/companion_radio/FirmwareBot.h -index 8b267b89..b718985f 100644 ---- a/examples/companion_radio/FirmwareBot.h -+++ b/examples/companion_radio/FirmwareBot.h -@@ -11,5 +11,10 @@ bool splitChannelText(const char* text, size_t text_len, char* sender, size_t se - BotWriteResult writeResponse(char* output, size_t output_len, const char* text, size_t text_len, size_t* written); - BotFingerprint fingerprintFor(const BotMessage& message); - BotCommandId commandIdForName(const char* name, size_t len); -+size_t maxResponseLenForChannel(BotChannelKind channel_kind); -+bool isCommandOnCooldown(const BotCommandCooldown* cooldowns, size_t cooldown_count, BotCommandId command_id, -+ uint32_t now_millis); -+void recordCommandCooldown(BotCommandCooldown* cooldowns, size_t cooldown_count, BotCommandId command_id, -+ uint32_t now_millis, uint32_t cooldown_millis); - - } -diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp -index 1596875a..72873d37 100644 ---- a/examples/companion_radio/MyMesh.cpp -+++ b/examples/companion_radio/MyMesh.cpp -@@ -4,6 +4,7 @@ - #include - - #if CMESH_BOT_ENABLED -+#include "BotCommands.h" - #include "BotPolicy.h" - #include "FirmwareBot.h" - #endif -@@ -567,10 +568,11 @@ void MyMesh::observeBotDirectMessage(const ContactInfo &from, uint32_t sender_ti - message.sender_timestamp = sender_timestamp; - FirmwareBot::normalizeText(text, botBoundedStrLen(text, BOT_MAX_TEXT_LEN), message.text, sizeof(message.text), - &message.text_len); -- recordBotObservation(message); -+ recordBotObservation(message, &from, 0xFF); - } - --void MyMesh::observeBotChannelMessage(const char *channel_name, const char *text, uint32_t sender_timestamp) { -+void MyMesh::observeBotChannelMessage(uint8_t channel_idx, const char *channel_name, const char *text, -+ uint32_t sender_timestamp) { - BotMessage message; - memset(&message, 0, sizeof(message)); - size_t channel_len = botBoundedStrLen(channel_name, BOT_MAX_CHANNEL_NAME_LEN); -@@ -585,10 +587,60 @@ void MyMesh::observeBotChannelMessage(const char *channel_name, const char *text - size_t body_len = botBoundedStrLen(text, BOT_MAX_TEXT_LEN); - FirmwareBot::splitChannelText(text, body_len, message.sender_name, sizeof(message.sender_name), &body, &body_len); - FirmwareBot::normalizeText(body, body_len, message.text, sizeof(message.text), &message.text_len); -- recordBotObservation(message); -+ recordBotObservation(message, NULL, channel_idx); -+} -+ -+void MyMesh::buildBotCommandContext(BotCommandContext &context, BotCommandId command_id) { -+ memset(&context, 0, sizeof(context)); -+ StrHelper::strzcpy(context.node_name, _prefs.node_name, sizeof(context.node_name)); -+ context.uptime_seconds = _ms->getMillis() / 1000; -+ context.observed_messages = bot_stats.observed_messages; -+ context.ignored_messages = bot_stats.ignored_messages; -+ context.eligible_messages = bot_stats.eligible_messages; -+ context.sent_messages = bot_stats.sent_messages; -+ context.send_failures = bot_stats.send_failures; -+ if (command_id == BOT_COMMAND_DICE) { -+ getRNG()->random((uint8_t *)&context.random_seed, sizeof(context.random_seed)); -+ } -+ if (command_id == BOT_COMMAND_STATUS) { -+ context.battery_millivolts = board.getBattMilliVolts(); -+ context.storage_used_kb = _store->getStorageUsedKb(); -+ context.storage_total_kb = _store->getStorageTotalKb(); -+ } -+ if (command_id == BOT_COMMAND_CHANNELS) { -+ for (uint8_t i = 0; i < MAX_GROUP_CHANNELS; i++) { -+ ChannelDetails channel; -+ if (getChannel(i, channel) && channel.name[0]) context.channel_count++; -+ } -+ } - } - --void MyMesh::recordBotObservation(const BotMessage &message) { -+bool MyMesh::enqueueBotResponse(const BotMessage &message, const ContactInfo *direct_recipient, uint8_t channel_idx, -+ const char *text, size_t text_len) { -+ for (size_t i = 0; i < BOT_PENDING_RESPONSE_SLOTS; i++) { -+ PendingBotResponse *pending = &pending_bot_responses[i]; -+ if (!pending->active) { -+ pending->direct = message.channel_kind == BOT_CHANNEL_DM; -+ if (pending->direct) { -+ if (!direct_recipient) return false; -+ memcpy(pending->recipient_pub_key, direct_recipient->id.pub_key, sizeof(pending->recipient_pub_key)); -+ } else { -+ memset(pending->recipient_pub_key, 0, sizeof(pending->recipient_pub_key)); -+ } -+ pending->channel_idx = channel_idx; -+ pending->text_len = text_len; -+ size_t max_text_len = FirmwareBot::maxResponseLenForChannel(message.channel_kind); -+ if (pending->text_len > max_text_len) pending->text_len = max_text_len; -+ if (pending->text_len > 0) memcpy(pending->text, text, pending->text_len); -+ pending->text[pending->text_len] = 0; -+ pending->active = true; -+ return true; -+ } -+ } -+ return false; -+} -+ -+void MyMesh::recordBotObservation(const BotMessage &message, const ContactInfo *direct_recipient, uint8_t channel_idx) { - bot_stats.observed_messages++; - BotPolicyDecision decision = BotPolicy::decide(message.channel_kind); - if (decision == BOT_POLICY_IGNORE) { -@@ -601,10 +653,67 @@ void MyMesh::recordBotObservation(const BotMessage &message) { - } - - BotCommand command; -- if (FirmwareBot::parseCommand(message.text, message.text_len, &command)) { -- bot_stats.eligible_messages++; -- } else if (message.text_len > 0 && (message.text[0] == '!' || message.text[0] == '/')) { -+ if (!FirmwareBot::parseCommand(message.text, message.text_len, &command)) { -+ if (message.text_len > 0 && (message.text[0] == '!' || message.text[0] == '/')) bot_stats.parse_errors++; -+ return; -+ } -+ -+ if (FirmwareBot::isCommandOnCooldown(bot_command_cooldowns, BOT_COMMAND_COOLDOWN_SLOTS, command.id, _ms->getMillis())) { -+ bot_stats.ignored_messages++; -+ return; -+ } -+ -+ BotCommandContext context; -+ buildBotCommandContext(context, command.id); -+ char response[BOT_MAX_RESPONSE_LEN + 1]; -+ BotCommandResult result = BotCommands::executeCommand(command, context, response, sizeof(response)); -+ if (result.code == BOT_COMMAND_RESULT_NOT_HANDLED || result.code == BOT_COMMAND_RESULT_NO_SPACE || result.text_len == 0) { - bot_stats.parse_errors++; -+ return; -+ } -+ -+ bot_stats.eligible_messages++; -+ FirmwareBot::recordCommandCooldown(bot_command_cooldowns, BOT_COMMAND_COOLDOWN_SLOTS, command.id, _ms->getMillis(), -+ BOT_COMMAND_COOLDOWN_MILLIS); -+ if (!enqueueBotResponse(message, direct_recipient, channel_idx, response, result.text_len)) { -+ bot_stats.send_failures++; -+ } -+} -+ -+void MyMesh::sendQueuedBotResponses() { -+ for (size_t i = 0; i < BOT_PENDING_RESPONSE_SLOTS; i++) { -+ PendingBotResponse *pending = &pending_bot_responses[i]; -+ if (!pending->active) continue; -+ -+ bool success = false; -+ if (pending->direct) { -+ ContactInfo *recipient = lookupContactByPubKey(pending->recipient_pub_key, PUB_KEY_SIZE); -+ if (recipient) { -+ uint32_t expected_ack = 0; -+ uint32_t est_timeout = 0; -+ uint32_t timestamp = getRTCClock()->getCurrentTimeUnique(); -+ int result = sendMessage(*recipient, timestamp, 0, pending->text, expected_ack, est_timeout); -+ success = result != MSG_SEND_FAILED; -+ if (success && expected_ack) { -+ expected_ack_table[next_ack_idx].msg_sent = _ms->getMillis(); -+ expected_ack_table[next_ack_idx].ack = expected_ack; -+ expected_ack_table[next_ack_idx].contact = recipient; -+ next_ack_idx = (next_ack_idx + 1) % EXPECTED_ACK_TABLE_SIZE; -+ } -+ } -+ } else if (pending->channel_idx != 0xFF) { -+ ChannelDetails channel; -+ uint32_t timestamp = getRTCClock()->getCurrentTimeUnique(); -+ success = getChannel(pending->channel_idx, channel) && -+ sendGroupMessage(timestamp, channel.channel, _prefs.node_name, pending->text, pending->text_len); -+ } -+ -+ if (success) { -+ bot_stats.sent_messages++; -+ } else { -+ bot_stats.send_failures++; -+ } -+ pending->active = false; - } - } - -@@ -636,6 +745,7 @@ void MyMesh::scheduleBotFloodAdvert(unsigned long interval_millis) { - } - - void MyMesh::tickBot() { -+ sendQueuedBotResponses(); - if (next_bot_local_advert && millisHasNowPassed(next_bot_local_advert)) { - sendBotSelfAdvert(false); - scheduleBotLocalAdvert(BOT_AUTO_ADVERT_INTERVAL_MILLIS); -@@ -692,7 +802,7 @@ void MyMesh::onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packe - if (_ui) _ui->newMsg(path_len, channel_name, text, offline_queue_len); - #endif - #if CMESH_BOT_ENABLED -- observeBotChannelMessage(channel_name, text, timestamp); -+ observeBotChannelMessage(channel_idx, channel_name, text, timestamp); - #endif - } - -@@ -977,6 +1087,8 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe - memset(send_scope.key, 0, sizeof(send_scope.key)); - #if CMESH_BOT_ENABLED - memset(&bot_stats, 0, sizeof(bot_stats)); -+ memset(pending_bot_responses, 0, sizeof(pending_bot_responses)); -+ memset(bot_command_cooldowns, 0, sizeof(bot_command_cooldowns)); - next_bot_local_advert = 0; - next_bot_flood_advert = 0; - #endif -@@ -992,6 +1104,9 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe - _prefs.tx_power_dbm = LORA_TX_POWER; - _prefs.gps_enabled = 0; // GPS disabled by default - _prefs.gps_interval = 0; // No automatic GPS updates by default -+#if CMESH_BOT_ENABLED -+ _prefs.path_hash_mode = 1; -+#endif - //_prefs.rx_delay_base = 10.0f; enable once new algo fixed - #if defined(USE_SX1262) || defined(USE_SX1268) - #ifdef SX126X_RX_BOOSTED_GAIN -diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h -index dc375c5b..ed915b28 100644 ---- a/examples/companion_radio/MyMesh.h -+++ b/examples/companion_radio/MyMesh.h -@@ -9,6 +9,8 @@ - #endif - #if CMESH_BOT_ENABLED - #include "BotTypes.h" -+#define BOT_PENDING_RESPONSE_SLOTS 2 -+#define BOT_COMMAND_COOLDOWN_SLOTS 9 - #endif - - /*------------ Frame Protocol --------------*/ -@@ -202,8 +204,13 @@ private: - #if CMESH_BOT_ENABLED - void observeBotDirectMessage(const ContactInfo &from, uint32_t sender_timestamp, const uint8_t *sender_prefix, - size_t sender_prefix_len, const char *text); -- void observeBotChannelMessage(const char *channel_name, const char *text, uint32_t sender_timestamp); -- void recordBotObservation(const BotMessage &message); -+ void observeBotChannelMessage(uint8_t channel_idx, const char *channel_name, const char *text, -+ uint32_t sender_timestamp); -+ void recordBotObservation(const BotMessage &message, const ContactInfo *direct_recipient, uint8_t channel_idx); -+ void buildBotCommandContext(BotCommandContext &context, BotCommandId command_id); -+ bool enqueueBotResponse(const BotMessage &message, const ContactInfo *direct_recipient, uint8_t channel_idx, -+ const char *text, size_t text_len); -+ void sendQueuedBotResponses(); - void tickBot(); - void scheduleBotLocalAdvert(unsigned long interval_millis); - void scheduleBotFloodAdvert(unsigned long interval_millis); -@@ -242,7 +249,18 @@ private: - TransportKey send_scope; - - #if CMESH_BOT_ENABLED -+ struct PendingBotResponse { -+ bool active; -+ bool direct; -+ uint8_t recipient_pub_key[PUB_KEY_SIZE]; -+ uint8_t channel_idx; -+ char text[BOT_MAX_RESPONSE_LEN + 1]; -+ size_t text_len; -+ }; -+ - BotStats bot_stats; -+ PendingBotResponse pending_bot_responses[BOT_PENDING_RESPONSE_SLOTS]; -+ BotCommandCooldown bot_command_cooldowns[BOT_COMMAND_COOLDOWN_SLOTS]; - unsigned long next_bot_local_advert; - unsigned long next_bot_flood_advert; - #endif diff --git a/patches/meshcore/0004-Add-emergency-forwarder.patch b/patches/meshcore/0004-Add-emergency-forwarder.patch deleted file mode 100644 index 2b3a804..0000000 --- a/patches/meshcore/0004-Add-emergency-forwarder.patch +++ /dev/null @@ -1,419 +0,0 @@ -diff --git a/examples/companion_radio/BotPolicy.cpp b/examples/companion_radio/BotPolicy.cpp -index bd3fc6c7..1cf75c73 100644 ---- a/examples/companion_radio/BotPolicy.cpp -+++ b/examples/companion_radio/BotPolicy.cpp -@@ -26,16 +26,26 @@ bool equalsIgnoreCase(const char* value, size_t len, const char* expected) { - return true; - } - -+bool equalsExact(const char* value, size_t len, const char* expected) { -+ if (!value || !expected) return false; -+ size_t expected_len = strlen(expected); -+ if (len != expected_len) return false; -+ for (size_t i = 0; i < len; i++) { -+ if (value[i] != expected[i]) return false; -+ } -+ return true; -+} -+ - } - - namespace BotPolicy { - - BotChannelKind classifyChannel(const char* name, size_t len, bool direct_message) { - if (direct_message) return BOT_CHANNEL_DM; -- if (equalsIgnoreCase(name, len, "Public")) return BOT_CHANNEL_PUBLIC; -+ if (equalsExact(name, len, "Public")) return BOT_CHANNEL_PUBLIC; - if (equalsIgnoreCase(name, len, "#bot")) return BOT_CHANNEL_BOT; - if (equalsIgnoreCase(name, len, "#testing")) return BOT_CHANNEL_TESTING; -- if (equalsIgnoreCase(name, len, "#emergency")) return BOT_CHANNEL_EMERGENCY; -+ if (equalsExact(name, len, "#emergency")) return BOT_CHANNEL_EMERGENCY; - return BOT_CHANNEL_OTHER; - } - -diff --git a/examples/companion_radio/BotTypes.h b/examples/companion_radio/BotTypes.h -index a7575a7c..1e6f72c1 100644 ---- a/examples/companion_radio/BotTypes.h -+++ b/examples/companion_radio/BotTypes.h -@@ -12,6 +12,11 @@ - #define BOT_GROUP_RESPONSE_PREFIX_RESERVE (BOT_MAX_SENDER_NAME_LEN + 2) - #define BOT_MAX_GROUP_RESPONSE_LEN (BOT_MAX_TEXT_LEN - BOT_GROUP_RESPONSE_PREFIX_RESERVE) - #define BOT_COMMAND_COOLDOWN_MILLIS 5000UL -+#define BOT_EMERGENCY_PREFIX "EMERGENCY MESSAGE FROM " -+#define BOT_EMERGENCY_MAX_PARTS 3 -+#define BOT_EMERGENCY_RATE_LIMIT_MILLIS 60000UL -+#define BOT_EMERGENCY_RATE_LIMIT_COUNT 3 -+#define BOT_PENDING_EMERGENCY_SLOTS BOT_EMERGENCY_MAX_PARTS - #define BOT_SENDER_KEY_PREFIX_LEN 6 - - enum BotChannelKind : uint8_t { -@@ -64,6 +69,7 @@ struct BotMessage { - char channel_name[BOT_MAX_CHANNEL_NAME_LEN + 1]; - char sender_name[BOT_MAX_SENDER_NAME_LEN + 1]; - uint8_t sender_key_prefix[BOT_SENDER_KEY_PREFIX_LEN]; -+ bool text_truncated; - uint32_t sender_timestamp; - char text[BOT_MAX_TEXT_LEN + 1]; - size_t text_len; -@@ -109,6 +115,13 @@ struct BotCommandCooldown { - uint32_t expires_at_millis; - }; - -+struct BotEmergencyForward { -+ uint8_t part_count; -+ bool truncated; -+ char parts[BOT_EMERGENCY_MAX_PARTS][BOT_MAX_GROUP_RESPONSE_LEN + 1]; -+ size_t part_lens[BOT_EMERGENCY_MAX_PARTS]; -+}; -+ - struct BotPrefs { - bool enabled; - uint16_t normal_delay_ms; -@@ -125,6 +138,8 @@ struct BotStats { - uint32_t ignored_messages; - uint32_t eligible_messages; - uint32_t emergency_messages; -+ uint32_t emergency_forwards; -+ uint32_t emergency_forward_failures; - uint32_t parse_errors; - uint32_t sent_messages; - uint32_t send_failures; -@@ -136,5 +151,6 @@ static_assert(sizeof(BotResponse) <= 184, "BotResponse RAM budget exceeded"); - static_assert(sizeof(BotCommandContext) <= 96, "BotCommandContext RAM budget exceeded"); - static_assert(sizeof(BotCommandResult) <= 16, "BotCommandResult RAM budget exceeded"); - static_assert(sizeof(BotCommandCooldown) <= 8, "BotCommandCooldown RAM budget exceeded"); -+static_assert(sizeof(BotEmergencyForward) <= 480, "BotEmergencyForward RAM budget exceeded"); - static_assert(sizeof(BotPrefs) <= 128, "BotPrefs RAM budget exceeded"); - static_assert(sizeof(BotStats) <= 48, "BotStats RAM budget exceeded"); -diff --git a/examples/companion_radio/EmergencyForwarder.cpp b/examples/companion_radio/EmergencyForwarder.cpp -new file mode 100644 -index 00000000..b228a8fa ---- /dev/null -+++ b/examples/companion_radio/EmergencyForwarder.cpp -@@ -0,0 +1,118 @@ -+#include "EmergencyForwarder.h" -+ -+#include -+#include -+ -+namespace { -+ -+size_t boundedStrLen(const char* value, size_t max_len) { -+ size_t len = 0; -+ while (value && len < max_len && value[len] != 0) len++; -+ return len; -+} -+ -+bool prefixEqual(const char* text, size_t text_len, const char* prefix) { -+ size_t prefix_len = strlen(prefix); -+ if (text_len < prefix_len) return false; -+ for (size_t i = 0; i < prefix_len; i++) { -+ if (text[i] != prefix[i]) return false; -+ } -+ return true; -+} -+ -+size_t appendText(char* output, size_t output_len, size_t pos, const char* text, size_t text_len) { -+ if (!output || output_len == 0) return 0; -+ while (pos + 1 < output_len && text_len > 0) { -+ output[pos++] = *text++; -+ text_len--; -+ } -+ output[pos] = 0; -+ return pos; -+} -+ -+size_t appendRepeated(char* output, size_t output_len, size_t pos, char ch, size_t count) { -+ while (pos + 1 < output_len && count > 0) { -+ output[pos++] = ch; -+ count--; -+ } -+ output[pos] = 0; -+ return pos; -+} -+ -+void writePart(BotEmergencyForward& forward, uint8_t part_idx, const char* header, size_t header_len, -+ const char* text, size_t text_len, bool multipart) { -+ char* output = forward.parts[part_idx]; -+ size_t output_len = sizeof(forward.parts[part_idx]); -+ output[0] = 0; -+ size_t pos = appendText(output, output_len, 0, header, header_len); -+ -+ if (multipart) { -+ char marker[8]; -+ int n = snprintf(marker, sizeof(marker), "[%u/%u] ", (unsigned)(part_idx + 1), (unsigned)forward.part_count); -+ if (n > 0) pos = appendText(output, output_len, pos, marker, (size_t)n); -+ } -+ -+ pos = appendText(output, output_len, pos, text, text_len); -+ forward.part_lens[part_idx] = pos; -+} -+ -+} -+ -+namespace EmergencyForwarder { -+ -+bool isForwardedEmergencyText(const char* text, size_t text_len) { -+ if (!text) return false; -+ size_t len = boundedStrLen(text, text_len); -+ return prefixEqual(text, len, BOT_EMERGENCY_PREFIX); -+} -+ -+bool format(const BotMessage& message, BotEmergencyForward& forward) { -+ memset(&forward, 0, sizeof(forward)); -+ if (message.channel_kind != BOT_CHANNEL_EMERGENCY) return false; -+ if (isForwardedEmergencyText(message.text, message.text_len)) return false; -+ -+ char header[BOT_MAX_GROUP_RESPONSE_LEN + 1]; -+ const char* sender = message.sender_name[0] ? message.sender_name : "unknown"; -+ int header_len_int = snprintf(header, sizeof(header), BOT_EMERGENCY_PREFIX "%s: ", sender); -+ if (header_len_int < 0) return false; -+ size_t header_len = (size_t)header_len_int; -+ if (header_len >= sizeof(header)) header_len = sizeof(header) - 1; -+ if (header_len >= BOT_MAX_GROUP_RESPONSE_LEN) return false; -+ -+ size_t text_len = boundedStrLen(message.text, message.text_len); -+ size_t one_part_capacity = BOT_MAX_GROUP_RESPONSE_LEN - header_len; -+ if (text_len <= one_part_capacity) { -+ forward.part_count = 1; -+ forward.truncated = message.text_truncated; -+ writePart(forward, 0, header, header_len, message.text, text_len, false); -+ return true; -+ } -+ -+ size_t multipart_header_extra = 6; -+ if (header_len + multipart_header_extra >= BOT_MAX_GROUP_RESPONSE_LEN) return false; -+ size_t part_capacity = BOT_MAX_GROUP_RESPONSE_LEN - header_len - multipart_header_extra; -+ size_t needed_parts = (text_len + part_capacity - 1) / part_capacity; -+ forward.part_count = needed_parts > BOT_EMERGENCY_MAX_PARTS ? BOT_EMERGENCY_MAX_PARTS : (uint8_t)needed_parts; -+ forward.truncated = message.text_truncated || needed_parts > BOT_EMERGENCY_MAX_PARTS; -+ -+ size_t offset = 0; -+ for (uint8_t i = 0; i < forward.part_count; i++) { -+ size_t chunk_len = text_len - offset; -+ if (chunk_len > part_capacity) chunk_len = part_capacity; -+ writePart(forward, i, header, header_len, &message.text[offset], chunk_len, true); -+ offset += chunk_len; -+ } -+ -+ if (forward.truncated && forward.part_count > 0) { -+ uint8_t last = forward.part_count - 1; -+ size_t pos = forward.part_lens[last]; -+ if (pos > 3) pos -= 3; -+ forward.parts[last][pos] = 0; -+ pos = appendRepeated(forward.parts[last], sizeof(forward.parts[last]), pos, '.', 3); -+ forward.part_lens[last] = pos; -+ } -+ -+ return forward.part_count > 0; -+} -+ -+} -diff --git a/examples/companion_radio/EmergencyForwarder.h b/examples/companion_radio/EmergencyForwarder.h -new file mode 100644 -index 00000000..e59d29e2 ---- /dev/null -+++ b/examples/companion_radio/EmergencyForwarder.h -@@ -0,0 +1,10 @@ -+#pragma once -+ -+#include "BotTypes.h" -+ -+namespace EmergencyForwarder { -+ -+bool isForwardedEmergencyText(const char* text, size_t text_len); -+bool format(const BotMessage& message, BotEmergencyForward& forward); -+ -+} -diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp -index 72873d37..ad72ccc5 100644 ---- a/examples/companion_radio/MyMesh.cpp -+++ b/examples/companion_radio/MyMesh.cpp -@@ -6,6 +6,7 @@ - #if CMESH_BOT_ENABLED - #include "BotCommands.h" - #include "BotPolicy.h" -+#include "EmergencyForwarder.h" - #include "FirmwareBot.h" - #endif - -@@ -566,8 +567,8 @@ void MyMesh::observeBotDirectMessage(const ContactInfo &from, uint32_t sender_ti - if (prefix_len > sizeof(message.sender_key_prefix)) prefix_len = sizeof(message.sender_key_prefix); - if (sender_prefix && prefix_len > 0) memcpy(message.sender_key_prefix, sender_prefix, prefix_len); - message.sender_timestamp = sender_timestamp; -- FirmwareBot::normalizeText(text, botBoundedStrLen(text, BOT_MAX_TEXT_LEN), message.text, sizeof(message.text), -- &message.text_len); -+ message.text_truncated = FirmwareBot::normalizeText(text, botBoundedStrLen(text, BOT_MAX_TEXT_LEN + 1), message.text, -+ sizeof(message.text), &message.text_len) == BOT_WRITE_TRUNCATED; - recordBotObservation(message, &from, 0xFF); - } - -@@ -586,7 +587,8 @@ void MyMesh::observeBotChannelMessage(uint8_t channel_idx, const char *channel_n - const char *body = text; - size_t body_len = botBoundedStrLen(text, BOT_MAX_TEXT_LEN); - FirmwareBot::splitChannelText(text, body_len, message.sender_name, sizeof(message.sender_name), &body, &body_len); -- FirmwareBot::normalizeText(body, body_len, message.text, sizeof(message.text), &message.text_len); -+ message.text_truncated = FirmwareBot::normalizeText(body, body_len, message.text, sizeof(message.text), -+ &message.text_len) == BOT_WRITE_TRUNCATED; - recordBotObservation(message, NULL, channel_idx); - } - -@@ -640,6 +642,63 @@ bool MyMesh::enqueueBotResponse(const BotMessage &message, const ContactInfo *di - return false; - } - -+bool MyMesh::findBotChannel(BotChannelKind kind, uint8_t &channel_idx) { -+ for (uint8_t i = 0; i < MAX_GROUP_CHANNELS; i++) { -+ ChannelDetails channel; -+ if (getChannel(i, channel)) { -+ size_t name_len = botBoundedStrLen(channel.name, BOT_MAX_CHANNEL_NAME_LEN); -+ if (BotPolicy::classifyChannel(channel.name, name_len, false) == kind) { -+ channel_idx = i; -+ return true; -+ } -+ } -+ } -+ return false; -+} -+ -+bool MyMesh::isEmergencyRateLimited() { -+ unsigned long now = _ms->getMillis(); -+ if (!emergency_rate_window_started || millisHasNowPassed(emergency_rate_window_started + BOT_EMERGENCY_RATE_LIMIT_MILLIS)) { -+ emergency_rate_window_started = now; -+ emergency_rate_count = 0; -+ return false; -+ } -+ return emergency_rate_count >= BOT_EMERGENCY_RATE_LIMIT_COUNT; -+} -+ -+void MyMesh::recordEmergencyRateLimitEvent() { -+ if (emergency_rate_count < 0xFF) emergency_rate_count++; -+} -+ -+bool MyMesh::enqueueEmergencyForward(const BotMessage &message) { -+ if (isEmergencyRateLimited()) return false; -+ -+ BotEmergencyForward forward; -+ if (!EmergencyForwarder::format(message, forward)) return false; -+ -+ uint8_t free_slots = 0; -+ for (size_t i = 0; i < BOT_PENDING_EMERGENCY_SLOTS; i++) { -+ if (!pending_emergency_forwards[i].active) free_slots++; -+ } -+ if (free_slots < forward.part_count) return false; -+ -+ uint8_t part_idx = 0; -+ for (size_t i = 0; i < BOT_PENDING_EMERGENCY_SLOTS && part_idx < forward.part_count; i++) { -+ PendingEmergencyForward *pending = &pending_emergency_forwards[i]; -+ if (!pending->active) { -+ pending->text_len = forward.part_lens[part_idx]; -+ if (pending->text_len > BOT_MAX_GROUP_RESPONSE_LEN) pending->text_len = BOT_MAX_GROUP_RESPONSE_LEN; -+ if (pending->text_len > 0) memcpy(pending->text, forward.parts[part_idx], pending->text_len); -+ pending->text[pending->text_len] = 0; -+ pending->active = true; -+ part_idx++; -+ } -+ } -+ -+ recordEmergencyRateLimitEvent(); -+ return true; -+} -+ - void MyMesh::recordBotObservation(const BotMessage &message, const ContactInfo *direct_recipient, uint8_t channel_idx) { - bot_stats.observed_messages++; - BotPolicyDecision decision = BotPolicy::decide(message.channel_kind); -@@ -649,6 +708,7 @@ void MyMesh::recordBotObservation(const BotMessage &message, const ContactInfo * - } - if (decision == BOT_POLICY_EMERGENCY_FORWARD) { - bot_stats.emergency_messages++; -+ if (!enqueueEmergencyForward(message)) bot_stats.emergency_forward_failures++; - return; - } - -@@ -717,6 +777,31 @@ void MyMesh::sendQueuedBotResponses() { - } - } - -+void MyMesh::sendQueuedEmergencyForwards() { -+ uint8_t public_channel_idx = 0xFF; -+ bool have_public = findBotChannel(BOT_CHANNEL_PUBLIC, public_channel_idx); -+ ChannelDetails public_channel; -+ if (have_public) have_public = getChannel(public_channel_idx, public_channel); -+ -+ for (size_t i = 0; i < BOT_PENDING_EMERGENCY_SLOTS; i++) { -+ PendingEmergencyForward *pending = &pending_emergency_forwards[i]; -+ if (!pending->active) continue; -+ -+ bool success = false; -+ if (have_public) { -+ uint32_t timestamp = getRTCClock()->getCurrentTimeUnique(); -+ success = sendGroupMessage(timestamp, public_channel.channel, _prefs.node_name, pending->text, pending->text_len); -+ } -+ -+ if (success) { -+ bot_stats.emergency_forwards++; -+ } else { -+ bot_stats.emergency_forward_failures++; -+ } -+ pending->active = false; -+ } -+} -+ - bool MyMesh::sendBotSelfAdvert(bool flood) { - mesh::Packet* pkt; - if (_prefs.advert_loc_policy == ADVERT_LOC_NONE) { -@@ -745,6 +830,7 @@ void MyMesh::scheduleBotFloodAdvert(unsigned long interval_millis) { - } - - void MyMesh::tickBot() { -+ sendQueuedEmergencyForwards(); - sendQueuedBotResponses(); - if (next_bot_local_advert && millisHasNowPassed(next_bot_local_advert)) { - sendBotSelfAdvert(false); -@@ -1088,7 +1174,10 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe - #if CMESH_BOT_ENABLED - memset(&bot_stats, 0, sizeof(bot_stats)); - memset(pending_bot_responses, 0, sizeof(pending_bot_responses)); -+ memset(pending_emergency_forwards, 0, sizeof(pending_emergency_forwards)); - memset(bot_command_cooldowns, 0, sizeof(bot_command_cooldowns)); -+ emergency_rate_window_started = 0; -+ emergency_rate_count = 0; - next_bot_local_advert = 0; - next_bot_flood_advert = 0; - #endif -diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h -index ed915b28..10b27feb 100644 ---- a/examples/companion_radio/MyMesh.h -+++ b/examples/companion_radio/MyMesh.h -@@ -210,7 +210,12 @@ private: - void buildBotCommandContext(BotCommandContext &context, BotCommandId command_id); - bool enqueueBotResponse(const BotMessage &message, const ContactInfo *direct_recipient, uint8_t channel_idx, - const char *text, size_t text_len); -+ bool enqueueEmergencyForward(const BotMessage &message); -+ bool findBotChannel(BotChannelKind kind, uint8_t &channel_idx); -+ bool isEmergencyRateLimited(); -+ void recordEmergencyRateLimitEvent(); - void sendQueuedBotResponses(); -+ void sendQueuedEmergencyForwards(); - void tickBot(); - void scheduleBotLocalAdvert(unsigned long interval_millis); - void scheduleBotFloodAdvert(unsigned long interval_millis); -@@ -258,9 +263,18 @@ private: - size_t text_len; - }; - -+ struct PendingEmergencyForward { -+ bool active; -+ char text[BOT_MAX_GROUP_RESPONSE_LEN + 1]; -+ size_t text_len; -+ }; -+ - BotStats bot_stats; - PendingBotResponse pending_bot_responses[BOT_PENDING_RESPONSE_SLOTS]; -+ PendingEmergencyForward pending_emergency_forwards[BOT_PENDING_EMERGENCY_SLOTS]; - BotCommandCooldown bot_command_cooldowns[BOT_COMMAND_COOLDOWN_SLOTS]; -+ unsigned long emergency_rate_window_started; -+ uint8_t emergency_rate_count; - unsigned long next_bot_local_advert; - unsigned long next_bot_flood_advert; - #endif diff --git a/patches/meshcore/0005-Add-response-coordinator.patch b/patches/meshcore/0005-Add-response-coordinator.patch deleted file mode 100644 index b7f95bb..0000000 --- a/patches/meshcore/0005-Add-response-coordinator.patch +++ /dev/null @@ -1,766 +0,0 @@ -diff --git a/examples/companion_radio/BotTypes.h b/examples/companion_radio/BotTypes.h -index 1e6f72c..d44aadb 100644 ---- a/examples/companion_radio/BotTypes.h -+++ b/examples/companion_radio/BotTypes.h -@@ -17,7 +17,16 @@ - #define BOT_EMERGENCY_RATE_LIMIT_MILLIS 60000UL - #define BOT_EMERGENCY_RATE_LIMIT_COUNT 3 - #define BOT_PENDING_EMERGENCY_SLOTS BOT_EMERGENCY_MAX_PARTS -+#define BOT_COORDINATOR_PENDING_SLOTS 8 -+#define BOT_COORDINATOR_RECENT_SLOTS 16 -+#define BOT_KNOWN_BOT_SLOTS 8 -+#define BOT_RESPONSE_DELAY_BASE_MILLIS 1200UL -+#define BOT_RESPONSE_DELAY_JITTER_MILLIS 1800UL -+#define BOT_RESPONSE_PENDING_TTL_MILLIS 15000UL -+#define BOT_RESPONSE_RECENT_TTL_MILLIS 30000UL -+#define BOT_KNOWN_BOT_FLAG_SUPPRESS_NORMAL 0x01 - #define BOT_SENDER_KEY_PREFIX_LEN 6 -+#define BOT_MIN_AUTH_SENDER_KEY_PREFIX_LEN 4 - - enum BotChannelKind : uint8_t { - BOT_CHANNEL_DM = 0, -@@ -60,6 +69,20 @@ enum BotWriteResult : uint8_t { - BOT_WRITE_NO_SPACE - }; - -+enum BotCoordinatorScheduleResult : uint8_t { -+ BOT_COORDINATOR_SCHEDULED = 0, -+ BOT_COORDINATOR_REPLACED, -+ BOT_COORDINATOR_NO_SPACE, -+ BOT_COORDINATOR_NOT_NORMAL -+}; -+ -+enum BotCoordinatorReadyResult : uint8_t { -+ BOT_COORDINATOR_READY_NONE = 0, -+ BOT_COORDINATOR_READY_SEND, -+ BOT_COORDINATOR_READY_SUPPRESSED, -+ BOT_COORDINATOR_READY_EXPIRED -+}; -+ - struct BotFingerprint { - uint64_t value; - }; -@@ -69,6 +92,7 @@ struct BotMessage { - char channel_name[BOT_MAX_CHANNEL_NAME_LEN + 1]; - char sender_name[BOT_MAX_SENDER_NAME_LEN + 1]; - uint8_t sender_key_prefix[BOT_SENDER_KEY_PREFIX_LEN]; -+ uint8_t sender_key_prefix_len; - bool text_truncated; - uint32_t sender_timestamp; - char text[BOT_MAX_TEXT_LEN + 1]; -@@ -115,6 +139,34 @@ struct BotCommandCooldown { - uint32_t expires_at_millis; - }; - -+struct BotKnownBotEntry { -+ bool active; -+ uint8_t key_prefix[BOT_SENDER_KEY_PREFIX_LEN]; -+ uint8_t flags; -+ char label[12]; -+}; -+ -+struct BotCoordinatorPending { -+ bool active; -+ bool suppressed; -+ BotFingerprint request_fingerprint; -+ BotFingerprint response_fingerprint; -+ uint32_t due_at_millis; -+ uint32_t expires_at_millis; -+}; -+ -+struct BotCoordinatorRecent { -+ bool active; -+ BotFingerprint response_fingerprint; -+ uint32_t expires_at_millis; -+}; -+ -+struct BotCoordinatorReady { -+ BotCoordinatorReadyResult result; -+ BotFingerprint request_fingerprint; -+ BotFingerprint response_fingerprint; -+}; -+ - struct BotEmergencyForward { - uint8_t part_count; - bool truncated; -@@ -141,16 +193,24 @@ struct BotStats { - uint32_t emergency_forwards; - uint32_t emergency_forward_failures; - uint32_t parse_errors; -+ uint32_t pending_responses; -+ uint32_t suppressed_responses; -+ uint32_t expired_responses; -+ uint32_t known_bot_messages; - uint32_t sent_messages; - uint32_t send_failures; - }; - --static_assert(sizeof(BotMessage) <= 240, "BotMessage RAM budget exceeded"); -+static_assert(sizeof(BotMessage) <= 248, "BotMessage RAM budget exceeded"); - static_assert(sizeof(BotCommand) <= 120, "BotCommand RAM budget exceeded"); - static_assert(sizeof(BotResponse) <= 184, "BotResponse RAM budget exceeded"); - static_assert(sizeof(BotCommandContext) <= 96, "BotCommandContext RAM budget exceeded"); - static_assert(sizeof(BotCommandResult) <= 16, "BotCommandResult RAM budget exceeded"); - static_assert(sizeof(BotCommandCooldown) <= 8, "BotCommandCooldown RAM budget exceeded"); -+static_assert(sizeof(BotKnownBotEntry) <= 24, "BotKnownBotEntry RAM budget exceeded"); -+static_assert(sizeof(BotCoordinatorPending) <= 32, "BotCoordinatorPending RAM budget exceeded"); -+static_assert(sizeof(BotCoordinatorRecent) <= 24, "BotCoordinatorRecent RAM budget exceeded"); -+static_assert(sizeof(BotCoordinatorReady) <= 24, "BotCoordinatorReady RAM budget exceeded"); - static_assert(sizeof(BotEmergencyForward) <= 480, "BotEmergencyForward RAM budget exceeded"); - static_assert(sizeof(BotPrefs) <= 128, "BotPrefs RAM budget exceeded"); --static_assert(sizeof(BotStats) <= 48, "BotStats RAM budget exceeded"); -+static_assert(sizeof(BotStats) <= 64, "BotStats RAM budget exceeded"); -diff --git a/examples/companion_radio/FirmwareBot.cpp b/examples/companion_radio/FirmwareBot.cpp -index 78b6693..56a3395 100644 ---- a/examples/companion_radio/FirmwareBot.cpp -+++ b/examples/companion_radio/FirmwareBot.cpp -@@ -25,6 +25,19 @@ uint64_t fnv1aUpdateTextLower(uint64_t hash, const char* value, size_t len) { - return hash; - } - -+size_t boundedStrLen(const char* value, size_t max_len); -+ -+uint64_t fnv1aUpdateChannel(uint64_t hash, const BotMessage& message) { -+ hash = fnv1aUpdate(hash, (uint8_t)message.channel_kind); -+ const char* channel_name = message.channel_name; -+ size_t channel_name_len = boundedStrLen(message.channel_name, sizeof(message.channel_name)); -+ if (channel_name_len > 0 && channel_name[0] == '#') { -+ channel_name++; -+ channel_name_len--; -+ } -+ return fnv1aUpdateTextLower(hash, channel_name, channel_name_len); -+} -+ - bool isSpaceByte(char ch) { - return ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n'; - } -@@ -229,15 +242,9 @@ BotWriteResult writeResponse(char* output, size_t output_len, const char* text, - - BotFingerprint fingerprintFor(const BotMessage& message) { - uint64_t hash = 1469598103934665603ULL; -- hash = fnv1aUpdate(hash, (uint8_t)message.channel_kind); -- const char* channel_name = message.channel_name; -- size_t channel_name_len = boundedStrLen(message.channel_name, sizeof(message.channel_name)); -- if (channel_name_len > 0 && channel_name[0] == '#') { -- channel_name++; -- channel_name_len--; -- } -- hash = fnv1aUpdateTextLower(hash, channel_name, channel_name_len); -+ hash = fnv1aUpdateChannel(hash, message); - hash = fnv1aUpdateBytes(hash, message.sender_key_prefix, sizeof(message.sender_key_prefix)); -+ hash = fnv1aUpdateTextLower(hash, message.sender_name, boundedStrLen(message.sender_name, sizeof(message.sender_name))); - hash = fnv1aUpdateU32(hash, message.sender_timestamp); - - char normalized[BOT_MAX_TEXT_LEN + 1]; -@@ -249,4 +256,21 @@ BotFingerprint fingerprintFor(const BotMessage& message) { - return fingerprint; - } - -+BotFingerprint responseFingerprintFor(const BotMessage& message, const char* response_text, size_t response_text_len) { -+ uint64_t hash = 1469598103934665603ULL; -+ hash = fnv1aUpdateChannel(hash, message); -+ if (message.channel_kind == BOT_CHANNEL_DM) { -+ hash = fnv1aUpdate(hash, message.sender_key_prefix_len); -+ hash = fnv1aUpdateBytes(hash, message.sender_key_prefix, message.sender_key_prefix_len); -+ } -+ -+ char normalized[BOT_MAX_RESPONSE_LEN + 1]; -+ size_t normalized_len = 0; -+ normalizeText(response_text, response_text_len, normalized, sizeof(normalized), &normalized_len); -+ hash = fnv1aUpdateTextLower(hash, normalized, normalized_len); -+ -+ BotFingerprint fingerprint = { hash }; -+ return fingerprint; -+} -+ - } -diff --git a/examples/companion_radio/FirmwareBot.h b/examples/companion_radio/FirmwareBot.h -index b718985..42b9c5b 100644 ---- a/examples/companion_radio/FirmwareBot.h -+++ b/examples/companion_radio/FirmwareBot.h -@@ -10,6 +10,7 @@ bool splitChannelText(const char* text, size_t text_len, char* sender, size_t se - size_t* body_len); - BotWriteResult writeResponse(char* output, size_t output_len, const char* text, size_t text_len, size_t* written); - BotFingerprint fingerprintFor(const BotMessage& message); -+BotFingerprint responseFingerprintFor(const BotMessage& message, const char* response_text, size_t response_text_len); - BotCommandId commandIdForName(const char* name, size_t len); - size_t maxResponseLenForChannel(BotChannelKind channel_kind); - bool isCommandOnCooldown(const BotCommandCooldown* cooldowns, size_t cooldown_count, BotCommandId command_id, -diff --git a/examples/companion_radio/KnownBotRegistry.cpp b/examples/companion_radio/KnownBotRegistry.cpp -new file mode 100644 -index 0000000..a3e764e ---- /dev/null -+++ b/examples/companion_radio/KnownBotRegistry.cpp -@@ -0,0 +1,87 @@ -+#include "KnownBotRegistry.h" -+ -+#include -+ -+namespace { -+ -+bool keyEqual(const uint8_t a[BOT_SENDER_KEY_PREFIX_LEN], const uint8_t b[BOT_SENDER_KEY_PREFIX_LEN]) { -+ return memcmp(a, b, BOT_SENDER_KEY_PREFIX_LEN) == 0; -+} -+ -+bool keyPrefixEqual(const uint8_t a[BOT_SENDER_KEY_PREFIX_LEN], const uint8_t b[BOT_SENDER_KEY_PREFIX_LEN], size_t len) { -+ return memcmp(a, b, len) == 0; -+} -+ -+void copyLabel(char dest[12], const char* label) { -+ size_t i = 0; -+ if (label) { -+ while (i + 1 < 12 && label[i] != 0) { -+ dest[i] = label[i]; -+ i++; -+ } -+ } -+ dest[i] = 0; -+} -+ -+} -+ -+namespace KnownBotRegistry { -+ -+void clear(BotKnownBotEntry entries[], size_t entry_count) { -+ if (!entries) return; -+ memset(entries, 0, sizeof(BotKnownBotEntry) * entry_count); -+} -+ -+const BotKnownBotEntry* find(const BotKnownBotEntry entries[], size_t entry_count, -+ const uint8_t key_prefix[BOT_SENDER_KEY_PREFIX_LEN], size_t key_prefix_len) { -+ if (!entries || !key_prefix || key_prefix_len < BOT_MIN_AUTH_SENDER_KEY_PREFIX_LEN) return NULL; -+ if (key_prefix_len > BOT_SENDER_KEY_PREFIX_LEN) key_prefix_len = BOT_SENDER_KEY_PREFIX_LEN; -+ -+ const BotKnownBotEntry* match = NULL; -+ for (size_t i = 0; i < entry_count; i++) { -+ if (!entries[i].active || !keyPrefixEqual(entries[i].key_prefix, key_prefix, key_prefix_len)) continue; -+ if (match) return NULL; -+ match = &entries[i]; -+ } -+ return match; -+} -+ -+bool add(BotKnownBotEntry entries[], size_t entry_count, const uint8_t key_prefix[BOT_SENDER_KEY_PREFIX_LEN], -+ uint8_t flags, const char* label) { -+ if (!entries || !key_prefix || entry_count == 0) return false; -+ -+ size_t slot = entry_count; -+ for (size_t i = 0; i < entry_count; i++) { -+ if (entries[i].active && keyEqual(entries[i].key_prefix, key_prefix)) { -+ slot = i; -+ break; -+ } -+ if (slot == entry_count && !entries[i].active) slot = i; -+ } -+ if (slot == entry_count) return false; -+ -+ entries[slot].active = true; -+ memcpy(entries[slot].key_prefix, key_prefix, BOT_SENDER_KEY_PREFIX_LEN); -+ entries[slot].flags = flags; -+ copyLabel(entries[slot].label, label); -+ return true; -+} -+ -+bool remove(BotKnownBotEntry entries[], size_t entry_count, const uint8_t key_prefix[BOT_SENDER_KEY_PREFIX_LEN]) { -+ if (!entries || !key_prefix) return false; -+ for (size_t i = 0; i < entry_count; i++) { -+ if (entries[i].active && keyEqual(entries[i].key_prefix, key_prefix)) { -+ memset(&entries[i], 0, sizeof(entries[i])); -+ return true; -+ } -+ } -+ return false; -+} -+ -+bool canSuppressNormal(const BotKnownBotEntry entries[], size_t entry_count, -+ const uint8_t key_prefix[BOT_SENDER_KEY_PREFIX_LEN], size_t key_prefix_len) { -+ const BotKnownBotEntry* entry = find(entries, entry_count, key_prefix, key_prefix_len); -+ return entry && (entry->flags & BOT_KNOWN_BOT_FLAG_SUPPRESS_NORMAL) != 0; -+} -+ -+} -diff --git a/examples/companion_radio/KnownBotRegistry.h b/examples/companion_radio/KnownBotRegistry.h -new file mode 100644 -index 0000000..5e93dad ---- /dev/null -+++ b/examples/companion_radio/KnownBotRegistry.h -@@ -0,0 +1,16 @@ -+#pragma once -+ -+#include "BotTypes.h" -+ -+namespace KnownBotRegistry { -+ -+void clear(BotKnownBotEntry entries[], size_t entry_count); -+bool add(BotKnownBotEntry entries[], size_t entry_count, const uint8_t key_prefix[BOT_SENDER_KEY_PREFIX_LEN], -+ uint8_t flags, const char* label); -+bool remove(BotKnownBotEntry entries[], size_t entry_count, const uint8_t key_prefix[BOT_SENDER_KEY_PREFIX_LEN]); -+const BotKnownBotEntry* find(const BotKnownBotEntry entries[], size_t entry_count, -+ const uint8_t key_prefix[BOT_SENDER_KEY_PREFIX_LEN], size_t key_prefix_len); -+bool canSuppressNormal(const BotKnownBotEntry entries[], size_t entry_count, -+ const uint8_t key_prefix[BOT_SENDER_KEY_PREFIX_LEN], size_t key_prefix_len); -+ -+} -diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp -index ad72ccc..31e9bad 100644 ---- a/examples/companion_radio/MyMesh.cpp -+++ b/examples/companion_radio/MyMesh.cpp -@@ -8,6 +8,8 @@ - #include "BotPolicy.h" - #include "EmergencyForwarder.h" - #include "FirmwareBot.h" -+#include "KnownBotRegistry.h" -+#include "ResponseCoordinator.h" - #endif - - #define CMD_APP_START 1 -@@ -566,6 +568,7 @@ void MyMesh::observeBotDirectMessage(const ContactInfo &from, uint32_t sender_ti - size_t prefix_len = sender_prefix_len; - if (prefix_len > sizeof(message.sender_key_prefix)) prefix_len = sizeof(message.sender_key_prefix); - if (sender_prefix && prefix_len > 0) memcpy(message.sender_key_prefix, sender_prefix, prefix_len); -+ message.sender_key_prefix_len = prefix_len; - message.sender_timestamp = sender_timestamp; - message.text_truncated = FirmwareBot::normalizeText(text, botBoundedStrLen(text, BOT_MAX_TEXT_LEN + 1), message.text, - sizeof(message.text), &message.text_len) == BOT_WRITE_TRUNCATED; -@@ -618,28 +621,36 @@ void MyMesh::buildBotCommandContext(BotCommandContext &context, BotCommandId com - } - - bool MyMesh::enqueueBotResponse(const BotMessage &message, const ContactInfo *direct_recipient, uint8_t channel_idx, -- const char *text, size_t text_len) { -+ const char *text, size_t text_len, BotFingerprint request_fingerprint, -+ BotFingerprint response_fingerprint) { -+ size_t slot = BOT_PENDING_RESPONSE_SLOTS; - for (size_t i = 0; i < BOT_PENDING_RESPONSE_SLOTS; i++) { -- PendingBotResponse *pending = &pending_bot_responses[i]; -- if (!pending->active) { -- pending->direct = message.channel_kind == BOT_CHANNEL_DM; -- if (pending->direct) { -- if (!direct_recipient) return false; -- memcpy(pending->recipient_pub_key, direct_recipient->id.pub_key, sizeof(pending->recipient_pub_key)); -- } else { -- memset(pending->recipient_pub_key, 0, sizeof(pending->recipient_pub_key)); -- } -- pending->channel_idx = channel_idx; -- pending->text_len = text_len; -- size_t max_text_len = FirmwareBot::maxResponseLenForChannel(message.channel_kind); -- if (pending->text_len > max_text_len) pending->text_len = max_text_len; -- if (pending->text_len > 0) memcpy(pending->text, text, pending->text_len); -- pending->text[pending->text_len] = 0; -- pending->active = true; -- return true; -+ if (pending_bot_responses[i].active && pending_bot_responses[i].request_fingerprint.value == request_fingerprint.value) { -+ slot = i; -+ break; - } -+ if (slot == BOT_PENDING_RESPONSE_SLOTS && !pending_bot_responses[i].active) slot = i; - } -- return false; -+ if (slot == BOT_PENDING_RESPONSE_SLOTS) return false; -+ -+ PendingBotResponse *pending = &pending_bot_responses[slot]; -+ pending->direct = message.channel_kind == BOT_CHANNEL_DM; -+ if (pending->direct) { -+ if (!direct_recipient) return false; -+ memcpy(pending->recipient_pub_key, direct_recipient->id.pub_key, sizeof(pending->recipient_pub_key)); -+ } else { -+ memset(pending->recipient_pub_key, 0, sizeof(pending->recipient_pub_key)); -+ } -+ pending->channel_idx = channel_idx; -+ pending->request_fingerprint = request_fingerprint; -+ pending->response_fingerprint = response_fingerprint; -+ pending->text_len = text_len; -+ size_t max_text_len = FirmwareBot::maxResponseLenForChannel(message.channel_kind); -+ if (pending->text_len > max_text_len) pending->text_len = max_text_len; -+ if (pending->text_len > 0) memcpy(pending->text, text, pending->text_len); -+ pending->text[pending->text_len] = 0; -+ pending->active = true; -+ return true; - } - - bool MyMesh::findBotChannel(BotChannelKind kind, uint8_t &channel_idx) { -@@ -699,6 +710,22 @@ bool MyMesh::enqueueEmergencyForward(const BotMessage &message) { - return true; - } - -+bool MyMesh::observeKnownBotResponse(const BotMessage &message, bool authoritative_sender) { -+ if (!authoritative_sender || message.channel_kind != BOT_CHANNEL_DM) return false; -+ if (!KnownBotRegistry::canSuppressNormal(known_bot_entries, BOT_KNOWN_BOT_SLOTS, message.sender_key_prefix, -+ message.sender_key_prefix_len)) return false; -+ -+ bot_stats.known_bot_messages++; -+ BotFingerprint fingerprint = FirmwareBot::responseFingerprintFor(message, message.text, message.text_len); -+ if (ResponseCoordinator::recentlySent(bot_coordinator_recent, BOT_COORDINATOR_RECENT_SLOTS, fingerprint, _ms->getMillis())) { -+ return true; -+ } -+ if (ResponseCoordinator::suppress(bot_coordinator_pending, BOT_COORDINATOR_PENDING_SLOTS, fingerprint)) { -+ return true; -+ } -+ return false; -+} -+ - void MyMesh::recordBotObservation(const BotMessage &message, const ContactInfo *direct_recipient, uint8_t channel_idx) { - bot_stats.observed_messages++; - BotPolicyDecision decision = BotPolicy::decide(message.channel_kind); -@@ -712,6 +739,9 @@ void MyMesh::recordBotObservation(const BotMessage &message, const ContactInfo * - return; - } - -+ if (observeKnownBotResponse(message, direct_recipient != NULL)) return; -+ sendQueuedBotResponses(); -+ - BotCommand command; - if (!FirmwareBot::parseCommand(message.text, message.text_len, &command)) { - if (message.text_len > 0 && (message.text[0] == '!' || message.text[0] == '/')) bot_stats.parse_errors++; -@@ -732,18 +762,58 @@ void MyMesh::recordBotObservation(const BotMessage &message, const ContactInfo * - return; - } - -+ BotFingerprint request_fingerprint = FirmwareBot::fingerprintFor(message); -+ BotFingerprint response_fingerprint = FirmwareBot::responseFingerprintFor(message, response, result.text_len); -+ BotFingerprint fingerprint; -+ uint32_t due_at_millis = 0; -+ uint32_t bot_identity_seed; -+ memcpy(&bot_identity_seed, self_id.pub_key, sizeof(bot_identity_seed)); -+ uint8_t queue_depth = (uint8_t)_mgr->getOutboundTotal(); -+ BotCoordinatorScheduleResult schedule = ResponseCoordinator::schedule(bot_coordinator_pending, BOT_COORDINATOR_PENDING_SLOTS, -+ message, command.id, request_fingerprint, -+ response_fingerprint, _ms->getMillis(), -+ context.random_seed, bot_identity_seed, -+ queue_depth, &fingerprint, &due_at_millis); -+ if (schedule == BOT_COORDINATOR_NO_SPACE || schedule == BOT_COORDINATOR_NOT_NORMAL) { -+ bot_stats.send_failures++; -+ return; -+ } -+ - bot_stats.eligible_messages++; -+ bot_stats.pending_responses++; - FirmwareBot::recordCommandCooldown(bot_command_cooldowns, BOT_COMMAND_COOLDOWN_SLOTS, command.id, _ms->getMillis(), - BOT_COMMAND_COOLDOWN_MILLIS); -- if (!enqueueBotResponse(message, direct_recipient, channel_idx, response, result.text_len)) { -+ if (!enqueueBotResponse(message, direct_recipient, channel_idx, response, result.text_len, fingerprint, response_fingerprint)) { -+ ResponseCoordinator::cancel(bot_coordinator_pending, BOT_COORDINATOR_PENDING_SLOTS, fingerprint); - bot_stats.send_failures++; - } - } - - void MyMesh::sendQueuedBotResponses() { -- for (size_t i = 0; i < BOT_PENDING_RESPONSE_SLOTS; i++) { -- PendingBotResponse *pending = &pending_bot_responses[i]; -- if (!pending->active) continue; -+ uint32_t now = _ms->getMillis(); -+ while (true) { -+ BotCoordinatorReady ready = ResponseCoordinator::poll(bot_coordinator_pending, BOT_COORDINATOR_PENDING_SLOTS, now); -+ if (ready.result == BOT_COORDINATOR_READY_NONE) return; -+ -+ if (ready.result == BOT_COORDINATOR_READY_SUPPRESSED) { -+ bot_stats.suppressed_responses++; -+ } else if (ready.result == BOT_COORDINATOR_READY_EXPIRED) { -+ bot_stats.expired_responses++; -+ } -+ -+ PendingBotResponse *pending = NULL; -+ for (size_t i = 0; i < BOT_PENDING_RESPONSE_SLOTS; i++) { -+ if (pending_bot_responses[i].active && pending_bot_responses[i].request_fingerprint.value == ready.request_fingerprint.value) { -+ pending = &pending_bot_responses[i]; -+ break; -+ } -+ } -+ if (!pending) continue; -+ -+ if (ready.result != BOT_COORDINATOR_READY_SEND) { -+ pending->active = false; -+ continue; -+ } - - bool success = false; - if (pending->direct) { -@@ -770,6 +840,8 @@ void MyMesh::sendQueuedBotResponses() { - - if (success) { - bot_stats.sent_messages++; -+ ResponseCoordinator::recordRecent(bot_coordinator_recent, BOT_COORDINATOR_RECENT_SLOTS, -+ pending->response_fingerprint, now); - } else { - bot_stats.send_failures++; - } -@@ -1176,6 +1248,9 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe - memset(pending_bot_responses, 0, sizeof(pending_bot_responses)); - memset(pending_emergency_forwards, 0, sizeof(pending_emergency_forwards)); - memset(bot_command_cooldowns, 0, sizeof(bot_command_cooldowns)); -+ ResponseCoordinator::clear(bot_coordinator_pending, BOT_COORDINATOR_PENDING_SLOTS); -+ ResponseCoordinator::clearRecent(bot_coordinator_recent, BOT_COORDINATOR_RECENT_SLOTS); -+ KnownBotRegistry::clear(known_bot_entries, BOT_KNOWN_BOT_SLOTS); - emergency_rate_window_started = 0; - emergency_rate_count = 0; - next_bot_local_advert = 0; -diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h -index 10b27fe..698ecad 100644 ---- a/examples/companion_radio/MyMesh.h -+++ b/examples/companion_radio/MyMesh.h -@@ -9,7 +9,7 @@ - #endif - #if CMESH_BOT_ENABLED - #include "BotTypes.h" --#define BOT_PENDING_RESPONSE_SLOTS 2 -+#define BOT_PENDING_RESPONSE_SLOTS BOT_COORDINATOR_PENDING_SLOTS - #define BOT_COMMAND_COOLDOWN_SLOTS 9 - #endif - -@@ -207,9 +207,11 @@ private: - void observeBotChannelMessage(uint8_t channel_idx, const char *channel_name, const char *text, - uint32_t sender_timestamp); - void recordBotObservation(const BotMessage &message, const ContactInfo *direct_recipient, uint8_t channel_idx); -+ bool observeKnownBotResponse(const BotMessage &message, bool authoritative_sender); - void buildBotCommandContext(BotCommandContext &context, BotCommandId command_id); - bool enqueueBotResponse(const BotMessage &message, const ContactInfo *direct_recipient, uint8_t channel_idx, -- const char *text, size_t text_len); -+ const char *text, size_t text_len, BotFingerprint request_fingerprint, -+ BotFingerprint response_fingerprint); - bool enqueueEmergencyForward(const BotMessage &message); - bool findBotChannel(BotChannelKind kind, uint8_t &channel_idx); - bool isEmergencyRateLimited(); -@@ -259,6 +261,8 @@ private: - bool direct; - uint8_t recipient_pub_key[PUB_KEY_SIZE]; - uint8_t channel_idx; -+ BotFingerprint request_fingerprint; -+ BotFingerprint response_fingerprint; - char text[BOT_MAX_RESPONSE_LEN + 1]; - size_t text_len; - }; -@@ -273,6 +277,9 @@ private: - PendingBotResponse pending_bot_responses[BOT_PENDING_RESPONSE_SLOTS]; - PendingEmergencyForward pending_emergency_forwards[BOT_PENDING_EMERGENCY_SLOTS]; - BotCommandCooldown bot_command_cooldowns[BOT_COMMAND_COOLDOWN_SLOTS]; -+ BotCoordinatorPending bot_coordinator_pending[BOT_COORDINATOR_PENDING_SLOTS]; -+ BotCoordinatorRecent bot_coordinator_recent[BOT_COORDINATOR_RECENT_SLOTS]; -+ BotKnownBotEntry known_bot_entries[BOT_KNOWN_BOT_SLOTS]; - unsigned long emergency_rate_window_started; - uint8_t emergency_rate_count; - unsigned long next_bot_local_advert; -diff --git a/examples/companion_radio/ResponseCoordinator.cpp b/examples/companion_radio/ResponseCoordinator.cpp -new file mode 100644 -index 0000000..c5c8fc3 ---- /dev/null -+++ b/examples/companion_radio/ResponseCoordinator.cpp -@@ -0,0 +1,190 @@ -+#include "ResponseCoordinator.h" -+ -+#include "BotPolicy.h" -+ -+#include -+ -+namespace { -+ -+bool isNormalChannel(BotChannelKind kind) { -+ return BotPolicy::isNormalAllowed(kind); -+} -+ -+bool sameFingerprint(BotFingerprint a, BotFingerprint b) { -+ return a.value == b.value; -+} -+ -+uint32_t channelDelayBias(BotChannelKind kind) { -+ if (kind == BOT_CHANNEL_DM) return 0; -+ if (kind == BOT_CHANNEL_BOT) return 200; -+ if (kind == BOT_CHANNEL_TESTING) return 400; -+ return 800; -+} -+ -+uint32_t commandDelayBias(BotCommandId command_id) { -+ if (command_id == BOT_COMMAND_PING || command_id == BOT_COMMAND_TEST) return 0; -+ if (command_id == BOT_COMMAND_DICE) return 200; -+ if (command_id == BOT_COMMAND_STATUS || command_id == BOT_COMMAND_CHANNELS) return 400; -+ return 100; -+} -+ -+uint32_t tieBreakBias(BotFingerprint request_fingerprint, uint32_t bot_identity_seed) { -+ uint32_t mixed = (uint32_t)request_fingerprint.value ^ (uint32_t)(request_fingerprint.value >> 32) ^ bot_identity_seed; -+ mixed ^= mixed >> 16; -+ mixed *= 0x7feb352dUL; -+ mixed ^= mixed >> 15; -+ return mixed % 900UL; -+} -+ -+uint32_t queueDelayBias(uint8_t queue_depth) { -+ return (uint32_t)queue_depth * 150UL; -+} -+ -+bool millisDue(uint32_t now_millis, uint32_t then_millis) { -+ return (int32_t)(now_millis - then_millis) >= 0; -+} -+ -+} -+ -+namespace ResponseCoordinator { -+ -+void clear(BotCoordinatorPending pending[], size_t pending_count) { -+ if (!pending) return; -+ memset(pending, 0, sizeof(BotCoordinatorPending) * pending_count); -+} -+ -+void clearRecent(BotCoordinatorRecent recent[], size_t recent_count) { -+ if (!recent) return; -+ memset(recent, 0, sizeof(BotCoordinatorRecent) * recent_count); -+} -+ -+uint32_t responseDelayMillis(const BotMessage& message, BotCommandId command_id, BotFingerprint request_fingerprint, -+ uint32_t bot_identity_seed, uint8_t queue_depth, uint32_t jitter_seed) { -+ uint32_t jitter = BOT_RESPONSE_DELAY_JITTER_MILLIS ? jitter_seed % BOT_RESPONSE_DELAY_JITTER_MILLIS : 0; -+ return BOT_RESPONSE_DELAY_BASE_MILLIS + channelDelayBias(message.channel_kind) + commandDelayBias(command_id) + -+ queueDelayBias(queue_depth) + tieBreakBias(request_fingerprint, bot_identity_seed) + jitter; -+} -+ -+BotCoordinatorScheduleResult schedule(BotCoordinatorPending pending[], size_t pending_count, -+ const BotMessage& message, BotCommandId command_id, -+ BotFingerprint request_fingerprint, BotFingerprint response_fingerprint, -+ uint32_t now_millis, uint32_t jitter_seed, uint32_t bot_identity_seed, -+ uint8_t queue_depth, BotFingerprint* fingerprint, uint32_t* due_at_millis) { -+ if (fingerprint) fingerprint->value = 0; -+ if (due_at_millis) *due_at_millis = 0; -+ if (!pending || pending_count == 0 || !isNormalChannel(message.channel_kind) || request_fingerprint.value == 0 || response_fingerprint.value == 0) return BOT_COORDINATOR_NOT_NORMAL; -+ -+ uint32_t due = now_millis + responseDelayMillis(message, command_id, request_fingerprint, bot_identity_seed, queue_depth, jitter_seed); -+ size_t slot = pending_count; -+ -+ for (size_t i = 0; i < pending_count; i++) { -+ if (pending[i].active && sameFingerprint(pending[i].request_fingerprint, request_fingerprint)) { -+ slot = i; -+ break; -+ } -+ if (slot == pending_count && !pending[i].active) slot = i; -+ } -+ if (slot == pending_count) return BOT_COORDINATOR_NO_SPACE; -+ -+ bool replaced = pending[slot].active; -+ pending[slot].active = true; -+ pending[slot].suppressed = false; -+ pending[slot].request_fingerprint = request_fingerprint; -+ pending[slot].response_fingerprint = response_fingerprint; -+ pending[slot].due_at_millis = due; -+ pending[slot].expires_at_millis = now_millis + BOT_RESPONSE_PENDING_TTL_MILLIS; -+ if (fingerprint) *fingerprint = request_fingerprint; -+ if (due_at_millis) *due_at_millis = due; -+ return replaced ? BOT_COORDINATOR_REPLACED : BOT_COORDINATOR_SCHEDULED; -+} -+ -+bool suppress(BotCoordinatorPending pending[], size_t pending_count, BotFingerprint response_fingerprint) { -+ if (!pending || response_fingerprint.value == 0) return false; -+ for (size_t i = 0; i < pending_count; i++) { -+ if (pending[i].active && sameFingerprint(pending[i].response_fingerprint, response_fingerprint)) { -+ pending[i].suppressed = true; -+ return true; -+ } -+ } -+ return false; -+} -+ -+bool cancel(BotCoordinatorPending pending[], size_t pending_count, BotFingerprint request_fingerprint) { -+ if (!pending || request_fingerprint.value == 0) return false; -+ for (size_t i = 0; i < pending_count; i++) { -+ if (pending[i].active && sameFingerprint(pending[i].request_fingerprint, request_fingerprint)) { -+ pending[i].active = false; -+ return true; -+ } -+ } -+ return false; -+} -+ -+BotCoordinatorReady poll(BotCoordinatorPending pending[], size_t pending_count, uint32_t now_millis) { -+ BotCoordinatorReady ready; -+ ready.result = BOT_COORDINATOR_READY_NONE; -+ ready.request_fingerprint.value = 0; -+ ready.response_fingerprint.value = 0; -+ if (!pending) return ready; -+ -+ for (size_t i = 0; i < pending_count; i++) { -+ if (!pending[i].active) continue; -+ if (pending[i].suppressed) { -+ ready.result = BOT_COORDINATOR_READY_SUPPRESSED; -+ ready.request_fingerprint = pending[i].request_fingerprint; -+ ready.response_fingerprint = pending[i].response_fingerprint; -+ pending[i].active = false; -+ return ready; -+ } -+ if (millisDue(now_millis, pending[i].expires_at_millis)) { -+ ready.result = BOT_COORDINATOR_READY_EXPIRED; -+ ready.request_fingerprint = pending[i].request_fingerprint; -+ ready.response_fingerprint = pending[i].response_fingerprint; -+ pending[i].active = false; -+ return ready; -+ } -+ if (millisDue(now_millis, pending[i].due_at_millis)) { -+ ready.result = BOT_COORDINATOR_READY_SEND; -+ ready.request_fingerprint = pending[i].request_fingerprint; -+ ready.response_fingerprint = pending[i].response_fingerprint; -+ pending[i].active = false; -+ return ready; -+ } -+ } -+ -+ return ready; -+} -+ -+void recordRecent(BotCoordinatorRecent recent[], size_t recent_count, BotFingerprint response_fingerprint, -+ uint32_t now_millis) { -+ if (!recent || recent_count == 0 || response_fingerprint.value == 0) return; -+ -+ size_t slot = recent_count; -+ for (size_t i = 0; i < recent_count; i++) { -+ if (recent[i].active && sameFingerprint(recent[i].response_fingerprint, response_fingerprint)) { -+ slot = i; -+ break; -+ } -+ if (slot == recent_count && (!recent[i].active || millisDue(now_millis, recent[i].expires_at_millis))) slot = i; -+ } -+ if (slot == recent_count) slot = 0; -+ recent[slot].active = true; -+ recent[slot].response_fingerprint = response_fingerprint; -+ recent[slot].expires_at_millis = now_millis + BOT_RESPONSE_RECENT_TTL_MILLIS; -+} -+ -+bool recentlySent(BotCoordinatorRecent recent[], size_t recent_count, BotFingerprint response_fingerprint, -+ uint32_t now_millis) { -+ if (!recent || response_fingerprint.value == 0) return false; -+ for (size_t i = 0; i < recent_count; i++) { -+ if (!recent[i].active) continue; -+ if (millisDue(now_millis, recent[i].expires_at_millis)) { -+ recent[i].active = false; -+ continue; -+ } -+ if (sameFingerprint(recent[i].response_fingerprint, response_fingerprint)) return true; -+ } -+ return false; -+} -+ -+} -diff --git a/examples/companion_radio/ResponseCoordinator.h b/examples/companion_radio/ResponseCoordinator.h -new file mode 100644 -index 0000000..d900633 ---- /dev/null -+++ b/examples/companion_radio/ResponseCoordinator.h -@@ -0,0 +1,24 @@ -+#pragma once -+ -+#include "BotTypes.h" -+ -+namespace ResponseCoordinator { -+ -+void clear(BotCoordinatorPending pending[], size_t pending_count); -+void clearRecent(BotCoordinatorRecent recent[], size_t recent_count); -+uint32_t responseDelayMillis(const BotMessage& message, BotCommandId command_id, BotFingerprint request_fingerprint, -+ uint32_t bot_identity_seed, uint8_t queue_depth, uint32_t jitter_seed); -+BotCoordinatorScheduleResult schedule(BotCoordinatorPending pending[], size_t pending_count, -+ const BotMessage& message, BotCommandId command_id, -+ BotFingerprint request_fingerprint, BotFingerprint response_fingerprint, -+ uint32_t now_millis, uint32_t jitter_seed, uint32_t bot_identity_seed, -+ uint8_t queue_depth, BotFingerprint* fingerprint, uint32_t* due_at_millis); -+bool suppress(BotCoordinatorPending pending[], size_t pending_count, BotFingerprint response_fingerprint); -+bool cancel(BotCoordinatorPending pending[], size_t pending_count, BotFingerprint request_fingerprint); -+BotCoordinatorReady poll(BotCoordinatorPending pending[], size_t pending_count, uint32_t now_millis); -+void recordRecent(BotCoordinatorRecent recent[], size_t recent_count, BotFingerprint response_fingerprint, -+ uint32_t now_millis); -+bool recentlySent(BotCoordinatorRecent recent[], size_t recent_count, BotFingerprint response_fingerprint, -+ uint32_t now_millis); -+ -+} diff --git a/patches/meshcore/0006-Add-bot-prefs-and-cli.patch b/patches/meshcore/0006-Add-bot-prefs-and-cli.patch deleted file mode 100644 index 72ac236..0000000 --- a/patches/meshcore/0006-Add-bot-prefs-and-cli.patch +++ /dev/null @@ -1,1167 +0,0 @@ -diff --git a/examples/companion_radio/BotCommands.cpp b/examples/companion_radio/BotCommands.cpp -index 5b13fbfe..2c206a9f 100644 ---- a/examples/companion_radio/BotCommands.cpp -+++ b/examples/companion_radio/BotCommands.cpp -@@ -159,8 +159,11 @@ BotCommandResult executeCommand(const BotCommand& command, const BotCommandConte - (unsigned long)context.storage_total_kb, (unsigned long)context.observed_messages, - (unsigned long)context.sent_messages, (unsigned long)context.send_failures); - case BOT_COMMAND_CHANNELS: -- return writeFormatted(output, output_len, "Configured channels: %u. Normal bot replies: DM, #bot, #testing.", -- (unsigned)context.channel_count); -+ return writeFormatted(output, output_len, "Channels: %s %s emergency=%s public=%s (%u configured)", -+ context.bot_channel[0] ? context.bot_channel : "#bot", -+ context.testing_channel[0] ? context.testing_channel : "#testing", -+ context.emergency_channel[0] ? context.emergency_channel : "#emergency", -+ context.public_channel[0] ? context.public_channel : "Public", (unsigned)context.channel_count); - case BOT_COMMAND_UNKNOWN: - return writeText(output, output_len, "Unknown command. Try !help"); - default: -diff --git a/examples/companion_radio/BotPolicy.cpp b/examples/companion_radio/BotPolicy.cpp -index 1cf75c73..66d338ef 100644 ---- a/examples/companion_radio/BotPolicy.cpp -+++ b/examples/companion_radio/BotPolicy.cpp -@@ -49,6 +49,15 @@ BotChannelKind classifyChannel(const char* name, size_t len, bool direct_message - return BOT_CHANNEL_OTHER; - } - -+BotChannelKind classifyChannel(const char* name, size_t len, bool direct_message, const BotPrefs& prefs) { -+ if (direct_message) return BOT_CHANNEL_DM; -+ if (equalsExact(name, len, prefs.public_channel)) return BOT_CHANNEL_PUBLIC; -+ if (equalsIgnoreCase(name, len, prefs.bot_channel)) return BOT_CHANNEL_BOT; -+ if (equalsIgnoreCase(name, len, prefs.testing_channel)) return BOT_CHANNEL_TESTING; -+ if (equalsExact(name, len, prefs.emergency_channel)) return BOT_CHANNEL_EMERGENCY; -+ return BOT_CHANNEL_OTHER; -+} -+ - BotPolicyDecision decide(BotChannelKind kind) { - if (kind == BOT_CHANNEL_DM || kind == BOT_CHANNEL_BOT || kind == BOT_CHANNEL_TESTING) { - return BOT_POLICY_ALLOW_NORMAL; -diff --git a/examples/companion_radio/BotPolicy.h b/examples/companion_radio/BotPolicy.h -index e074e3eb..418ae3ac 100644 ---- a/examples/companion_radio/BotPolicy.h -+++ b/examples/companion_radio/BotPolicy.h -@@ -5,6 +5,7 @@ - namespace BotPolicy { - - BotChannelKind classifyChannel(const char* name, size_t len, bool direct_message); -+BotChannelKind classifyChannel(const char* name, size_t len, bool direct_message, const BotPrefs& prefs); - BotPolicyDecision decide(BotChannelKind kind); - bool isNormalAllowed(BotChannelKind kind); - bool isEmergency(BotChannelKind kind); -diff --git a/examples/companion_radio/BotPrefs.cpp b/examples/companion_radio/BotPrefs.cpp -new file mode 100644 -index 00000000..1d0810a8 ---- /dev/null -+++ b/examples/companion_radio/BotPrefs.cpp -@@ -0,0 +1,389 @@ -+#include "BotPrefs.h" -+ -+#include -+#include -+ -+namespace { -+ -+const size_t SERIALIZED_SIZE = BOT_PREFS_SERIALIZED_SIZE; -+ -+size_t boundedStrLen(const char* value, size_t max_len) { -+ size_t len = 0; -+ while (value && len < max_len && value[len] != 0) len++; -+ return len; -+} -+ -+void copyString(char* dest, size_t dest_len, const char* src) { -+ if (!dest || dest_len == 0) return; -+ size_t len = boundedStrLen(src, dest_len - 1); -+ if (len > 0) memcpy(dest, src, len); -+ dest[len] = 0; -+} -+ -+bool channelNameEqual(const char* lhs, const char* rhs) { -+ size_t lhs_len = boundedStrLen(lhs, BOT_MAX_CHANNEL_NAME_LEN + 1); -+ size_t rhs_len = boundedStrLen(rhs, BOT_MAX_CHANNEL_NAME_LEN + 1); -+ if (lhs_len != rhs_len || lhs_len == 0) return false; -+ return memcmp(lhs, rhs, lhs_len) == 0; -+} -+ -+bool channelHasShape(const char* value, bool public_channel) { -+ size_t len = boundedStrLen(value, BOT_MAX_CHANNEL_NAME_LEN + 1); -+ if (len == 0 || len > BOT_MAX_CHANNEL_NAME_LEN) return false; -+ if (public_channel) return value[0] != '#'; -+ return value[0] == '#' && len > 1; -+} -+ -+uint32_t checksumBytes(const uint8_t* data, size_t len) { -+ uint32_t hash = 2166136261UL; -+ for (size_t i = 0; i < len; i++) { -+ hash ^= data[i]; -+ hash *= 16777619UL; -+ } -+ return hash; -+} -+ -+void put8(uint8_t* data, size_t& pos, uint8_t value) { -+ data[pos++] = value; -+} -+ -+void put16(uint8_t* data, size_t& pos, uint16_t value) { -+ data[pos++] = (uint8_t)(value & 0xFF); -+ data[pos++] = (uint8_t)(value >> 8); -+} -+ -+void put32(uint8_t* data, size_t& pos, uint32_t value) { -+ data[pos++] = (uint8_t)(value & 0xFF); -+ data[pos++] = (uint8_t)((value >> 8) & 0xFF); -+ data[pos++] = (uint8_t)((value >> 16) & 0xFF); -+ data[pos++] = (uint8_t)((value >> 24) & 0xFF); -+} -+ -+uint8_t get8(const uint8_t* data, size_t& pos) { -+ return data[pos++]; -+} -+ -+uint16_t get16(const uint8_t* data, size_t& pos) { -+ uint16_t value = data[pos]; -+ value |= ((uint16_t)data[pos + 1]) << 8; -+ pos += 2; -+ return value; -+} -+ -+uint32_t get32(const uint8_t* data, size_t& pos) { -+ uint32_t value = data[pos]; -+ value |= ((uint32_t)data[pos + 1]) << 8; -+ value |= ((uint32_t)data[pos + 2]) << 16; -+ value |= ((uint32_t)data[pos + 3]) << 24; -+ pos += 4; -+ return value; -+} -+ -+void putFixedString(uint8_t* data, size_t& pos, const char* value, size_t fixed_len) { -+ memset(&data[pos], 0, fixed_len); -+ size_t len = boundedStrLen(value, fixed_len); -+ if (len > 0) memcpy(&data[pos], value, len); -+ pos += fixed_len; -+} -+ -+void getFixedString(const uint8_t* data, size_t& pos, char* value, size_t fixed_len) { -+ memcpy(value, &data[pos], fixed_len); -+ value[fixed_len - 1] = 0; -+ pos += fixed_len; -+} -+ -+int hexValue(char c) { -+ if (c >= '0' && c <= '9') return c - '0'; -+ if (c >= 'a' && c <= 'f') return c - 'a' + 10; -+ if (c >= 'A' && c <= 'F') return c - 'A' + 10; -+ return -1; -+} -+ -+bool sameKeyPrefix(const uint8_t lhs[BOT_SENDER_KEY_PREFIX_LEN], const uint8_t rhs[BOT_SENDER_KEY_PREFIX_LEN]) { -+ return memcmp(lhs, rhs, BOT_SENDER_KEY_PREFIX_LEN) == 0; -+} -+ -+} -+ -+namespace BotPrefsCodec { -+ -+size_t serializedSize() { -+ return SERIALIZED_SIZE; -+} -+ -+void defaults(BotPrefs& prefs) { -+ memset(&prefs, 0, sizeof(prefs)); -+ prefs.enabled = true; -+ prefs.normal_delay_ms = BOT_RESPONSE_DELAY_BASE_MILLIS; -+ prefs.normal_jitter_ms = BOT_RESPONSE_DELAY_JITTER_MILLIS; -+ prefs.local_advert_interval_ms = BOT_PREFS_DEFAULT_LOCAL_ADVERT_MILLIS; -+ prefs.flood_advert_interval_ms = BOT_PREFS_DEFAULT_FLOOD_ADVERT_MILLIS; -+ prefs.command_mask = BOT_COMMAND_MASK_ALL; -+ prefs.max_response_parts = BOT_EMERGENCY_MAX_PARTS; -+ copyString(prefs.bot_channel, sizeof(prefs.bot_channel), "#bot"); -+ copyString(prefs.testing_channel, sizeof(prefs.testing_channel), "#testing"); -+ copyString(prefs.emergency_channel, sizeof(prefs.emergency_channel), "#emergency"); -+ copyString(prefs.public_channel, sizeof(prefs.public_channel), "Public"); -+} -+ -+void validate(BotPrefs& prefs) { -+ prefs.normal_delay_ms = prefs.normal_delay_ms > BOT_PREFS_MAX_DELAY_MILLIS ? BOT_PREFS_MAX_DELAY_MILLIS : prefs.normal_delay_ms; -+ prefs.normal_jitter_ms = prefs.normal_jitter_ms > BOT_PREFS_MAX_DELAY_MILLIS ? BOT_PREFS_MAX_DELAY_MILLIS : prefs.normal_jitter_ms; -+ if (prefs.local_advert_interval_ms > BOT_PREFS_MAX_ADVERT_MILLIS) prefs.local_advert_interval_ms = BOT_PREFS_MAX_ADVERT_MILLIS; -+ if (prefs.flood_advert_interval_ms > BOT_PREFS_MAX_ADVERT_MILLIS) prefs.flood_advert_interval_ms = BOT_PREFS_MAX_ADVERT_MILLIS; -+ prefs.command_mask &= BOT_COMMAND_MASK_ALL; -+ if (prefs.max_response_parts == 0 || prefs.max_response_parts > BOT_EMERGENCY_MAX_PARTS) { -+ prefs.max_response_parts = BOT_EMERGENCY_MAX_PARTS; -+ } -+ if (!channelHasShape(prefs.bot_channel, false)) copyString(prefs.bot_channel, sizeof(prefs.bot_channel), "#bot"); -+ if (!channelHasShape(prefs.testing_channel, false)) copyString(prefs.testing_channel, sizeof(prefs.testing_channel), "#testing"); -+ if (!channelHasShape(prefs.emergency_channel, false)) copyString(prefs.emergency_channel, sizeof(prefs.emergency_channel), "#emergency"); -+ if (!channelHasShape(prefs.public_channel, true)) copyString(prefs.public_channel, sizeof(prefs.public_channel), "Public"); -+ prefs.bot_channel[BOT_MAX_CHANNEL_NAME_LEN] = 0; -+ prefs.testing_channel[BOT_MAX_CHANNEL_NAME_LEN] = 0; -+ prefs.emergency_channel[BOT_MAX_CHANNEL_NAME_LEN] = 0; -+ prefs.public_channel[BOT_MAX_CHANNEL_NAME_LEN] = 0; -+ if (!channelConfigValid(prefs)) { -+ copyString(prefs.bot_channel, sizeof(prefs.bot_channel), "#bot"); -+ copyString(prefs.testing_channel, sizeof(prefs.testing_channel), "#testing"); -+ copyString(prefs.emergency_channel, sizeof(prefs.emergency_channel), "#emergency"); -+ copyString(prefs.public_channel, sizeof(prefs.public_channel), "Public"); -+ } -+ for (size_t i = 0; i < BOT_KNOWN_BOT_SLOTS; i++) { -+ prefs.known_bots[i].flags &= BOT_KNOWN_BOT_FLAG_SUPPRESS_NORMAL; -+ prefs.known_bots[i].label[BOT_KNOWN_BOT_LABEL_LEN - 1] = 0; -+ } -+} -+ -+bool channelNameValid(const char* value, bool public_channel) { -+ return channelHasShape(value, public_channel); -+} -+ -+bool channelConfigValid(const BotPrefs& prefs) { -+ return channelHasShape(prefs.bot_channel, false) && channelHasShape(prefs.testing_channel, false) && -+ channelHasShape(prefs.emergency_channel, false) && channelHasShape(prefs.public_channel, true) && -+ !channelNameEqual(prefs.bot_channel, prefs.testing_channel) && -+ !channelNameEqual(prefs.bot_channel, prefs.emergency_channel) && -+ !channelNameEqual(prefs.testing_channel, prefs.emergency_channel) && -+ !channelNameEqual(prefs.bot_channel, prefs.public_channel) && -+ !channelNameEqual(prefs.testing_channel, prefs.public_channel) && -+ !channelNameEqual(prefs.emergency_channel, prefs.public_channel); -+} -+ -+bool serialize(const BotPrefs& prefs, uint8_t* output, size_t output_len) { -+ if (!output || output_len < SERIALIZED_SIZE) return false; -+ -+ BotPrefs clean = prefs; -+ validate(clean); -+ -+ memset(output, 0, output_len); -+ size_t pos = 0; -+ put32(output, pos, BOT_PREFS_MAGIC); -+ put16(output, pos, BOT_PREFS_VERSION); -+ put16(output, pos, (uint16_t)SERIALIZED_SIZE); -+ size_t checksum_pos = pos; -+ put32(output, pos, 0); -+ put8(output, pos, clean.enabled ? 1 : 0); -+ put16(output, pos, clean.normal_delay_ms); -+ put16(output, pos, clean.normal_jitter_ms); -+ put32(output, pos, clean.local_advert_interval_ms); -+ put32(output, pos, clean.flood_advert_interval_ms); -+ put32(output, pos, clean.command_mask); -+ put8(output, pos, clean.max_response_parts); -+ putFixedString(output, pos, clean.bot_channel, BOT_MAX_CHANNEL_NAME_LEN + 1); -+ putFixedString(output, pos, clean.testing_channel, BOT_MAX_CHANNEL_NAME_LEN + 1); -+ putFixedString(output, pos, clean.emergency_channel, BOT_MAX_CHANNEL_NAME_LEN + 1); -+ putFixedString(output, pos, clean.public_channel, BOT_MAX_CHANNEL_NAME_LEN + 1); -+ for (size_t i = 0; i < BOT_KNOWN_BOT_SLOTS; i++) { -+ put8(output, pos, clean.known_bots[i].active ? 1 : 0); -+ memcpy(&output[pos], clean.known_bots[i].key_prefix, BOT_SENDER_KEY_PREFIX_LEN); -+ pos += BOT_SENDER_KEY_PREFIX_LEN; -+ put8(output, pos, clean.known_bots[i].flags); -+ putFixedString(output, pos, clean.known_bots[i].label, BOT_KNOWN_BOT_LABEL_LEN); -+ } -+ put32(output, pos, clean.prefs_load_failures); -+ put32(output, pos, clean.prefs_save_failures); -+ -+ if (pos != SERIALIZED_SIZE) return false; -+ uint32_t checksum = checksumBytes(&output[12], SERIALIZED_SIZE - 12); -+ size_t write_pos = checksum_pos; -+ put32(output, write_pos, checksum); -+ return true; -+} -+ -+bool deserialize(const uint8_t* data, size_t data_len, BotPrefs& prefs) { -+ if (!data || data_len != SERIALIZED_SIZE) { -+ defaults(prefs); -+ return false; -+ } -+ -+ size_t pos = 0; -+ uint32_t magic = get32(data, pos); -+ uint16_t version = get16(data, pos); -+ uint16_t length = get16(data, pos); -+ uint32_t checksum = get32(data, pos); -+ if (magic != BOT_PREFS_MAGIC || version != BOT_PREFS_VERSION || length != SERIALIZED_SIZE) { -+ defaults(prefs); -+ return false; -+ } -+ if (checksumBytes(&data[12], SERIALIZED_SIZE - 12) != checksum) { -+ defaults(prefs); -+ return false; -+ } -+ -+ BotPrefs loaded; -+ memset(&loaded, 0, sizeof(loaded)); -+ loaded.enabled = get8(data, pos) != 0; -+ loaded.normal_delay_ms = get16(data, pos); -+ loaded.normal_jitter_ms = get16(data, pos); -+ loaded.local_advert_interval_ms = get32(data, pos); -+ loaded.flood_advert_interval_ms = get32(data, pos); -+ loaded.command_mask = get32(data, pos); -+ loaded.max_response_parts = get8(data, pos); -+ getFixedString(data, pos, loaded.bot_channel, sizeof(loaded.bot_channel)); -+ getFixedString(data, pos, loaded.testing_channel, sizeof(loaded.testing_channel)); -+ getFixedString(data, pos, loaded.emergency_channel, sizeof(loaded.emergency_channel)); -+ getFixedString(data, pos, loaded.public_channel, sizeof(loaded.public_channel)); -+ for (size_t i = 0; i < BOT_KNOWN_BOT_SLOTS; i++) { -+ loaded.known_bots[i].active = get8(data, pos) != 0; -+ memcpy(loaded.known_bots[i].key_prefix, &data[pos], BOT_SENDER_KEY_PREFIX_LEN); -+ pos += BOT_SENDER_KEY_PREFIX_LEN; -+ loaded.known_bots[i].flags = get8(data, pos); -+ getFixedString(data, pos, loaded.known_bots[i].label, sizeof(loaded.known_bots[i].label)); -+ } -+ loaded.prefs_load_failures = get32(data, pos); -+ loaded.prefs_save_failures = get32(data, pos); -+ if (pos != SERIALIZED_SIZE) { -+ defaults(prefs); -+ return false; -+ } -+ -+ validate(loaded); -+ prefs = loaded; -+ return true; -+} -+ -+uint32_t commandMaskFor(BotCommandId command_id) { -+ if (command_id <= BOT_COMMAND_NONE || command_id > BOT_COMMAND_UNKNOWN) return 0; -+ return 1UL << command_id; -+} -+ -+bool commandEnabled(const BotPrefs& prefs, BotCommandId command_id) { -+ uint32_t mask = commandMaskFor(command_id); -+ return mask != 0 && (prefs.command_mask & mask) != 0; -+} -+ -+void setCommandEnabled(BotPrefs& prefs, BotCommandId command_id, bool enabled) { -+ uint32_t mask = commandMaskFor(command_id); -+ if (mask == 0) return; -+ if (enabled) { -+ prefs.command_mask |= mask; -+ } else { -+ prefs.command_mask &= ~mask; -+ } -+ validate(prefs); -+} -+ -+const char* commandName(BotCommandId command_id) { -+ switch (command_id) { -+ case BOT_COMMAND_HELP: return "help"; -+ case BOT_COMMAND_PING: return "ping"; -+ case BOT_COMMAND_TEST: return "test"; -+ case BOT_COMMAND_HELLO: return "hello"; -+ case BOT_COMMAND_ABOUT: return "about"; -+ case BOT_COMMAND_DICE: return "roll"; -+ case BOT_COMMAND_STATUS: return "status"; -+ case BOT_COMMAND_CHANNELS: return "channels"; -+ case BOT_COMMAND_UNKNOWN: return "unknown"; -+ default: return ""; -+ } -+} -+ -+bool commandIdForName(const char* name, BotCommandId* command_id) { -+ if (!name || !command_id) return false; -+ for (uint8_t id = BOT_COMMAND_HELP; id <= BOT_COMMAND_UNKNOWN; id++) { -+ const char* candidate = commandName((BotCommandId)id); -+ if (candidate[0] == 0) continue; -+ size_t len = boundedStrLen(name, BOT_MAX_COMMAND_NAME_LEN + 1); -+ size_t candidate_len = boundedStrLen(candidate, BOT_MAX_COMMAND_NAME_LEN + 1); -+ if (len != candidate_len) continue; -+ bool match = true; -+ for (size_t i = 0; i < len; i++) { -+ if (tolower((unsigned char)name[i]) != tolower((unsigned char)candidate[i])) { -+ match = false; -+ break; -+ } -+ } -+ if (match) { -+ *command_id = (BotCommandId)id; -+ return true; -+ } -+ } -+ return false; -+} -+ -+bool parseKeyPrefixHex(const char* text, uint8_t key_prefix[BOT_SENDER_KEY_PREFIX_LEN]) { -+ if (!text || !key_prefix) return false; -+ for (size_t i = 0; i < BOT_SENDER_KEY_PREFIX_LEN; i++) { -+ int high = hexValue(text[i * 2]); -+ int low = hexValue(text[i * 2 + 1]); -+ if (high < 0 || low < 0) return false; -+ key_prefix[i] = (uint8_t)((high << 4) | low); -+ } -+ return text[BOT_SENDER_KEY_PREFIX_LEN * 2] == 0; -+} -+ -+void formatKeyPrefixHex(const uint8_t key_prefix[BOT_SENDER_KEY_PREFIX_LEN], char* output, size_t output_len) { -+ static const char hex[] = "0123456789abcdef"; -+ if (!output || output_len == 0) return; -+ if (!key_prefix || output_len < BOT_SENDER_KEY_PREFIX_LEN * 2 + 1) { -+ output[0] = 0; -+ return; -+ } -+ for (size_t i = 0; i < BOT_SENDER_KEY_PREFIX_LEN; i++) { -+ output[i * 2] = hex[key_prefix[i] >> 4]; -+ output[i * 2 + 1] = hex[key_prefix[i] & 0x0F]; -+ } -+ output[BOT_SENDER_KEY_PREFIX_LEN * 2] = 0; -+} -+ -+const BotKnownBotEntry* findKnownBot(const BotPrefs& prefs, const uint8_t key_prefix[BOT_SENDER_KEY_PREFIX_LEN]) { -+ if (!key_prefix) return NULL; -+ for (size_t i = 0; i < BOT_KNOWN_BOT_SLOTS; i++) { -+ if (prefs.known_bots[i].active && sameKeyPrefix(prefs.known_bots[i].key_prefix, key_prefix)) return &prefs.known_bots[i]; -+ } -+ return NULL; -+} -+ -+bool addKnownBot(BotPrefs& prefs, const uint8_t key_prefix[BOT_SENDER_KEY_PREFIX_LEN], uint8_t flags, -+ const char* label) { -+ if (!key_prefix) return false; -+ size_t slot = BOT_KNOWN_BOT_SLOTS; -+ for (size_t i = 0; i < BOT_KNOWN_BOT_SLOTS; i++) { -+ if (prefs.known_bots[i].active && sameKeyPrefix(prefs.known_bots[i].key_prefix, key_prefix)) { -+ slot = i; -+ break; -+ } -+ if (slot == BOT_KNOWN_BOT_SLOTS && !prefs.known_bots[i].active) slot = i; -+ } -+ if (slot == BOT_KNOWN_BOT_SLOTS) return false; -+ -+ prefs.known_bots[slot].active = true; -+ memcpy(prefs.known_bots[slot].key_prefix, key_prefix, BOT_SENDER_KEY_PREFIX_LEN); -+ prefs.known_bots[slot].flags = flags & BOT_KNOWN_BOT_FLAG_SUPPRESS_NORMAL; -+ copyString(prefs.known_bots[slot].label, sizeof(prefs.known_bots[slot].label), label ? label : "bot"); -+ return true; -+} -+ -+bool removeKnownBot(BotPrefs& prefs, const uint8_t key_prefix[BOT_SENDER_KEY_PREFIX_LEN]) { -+ if (!key_prefix) return false; -+ for (size_t i = 0; i < BOT_KNOWN_BOT_SLOTS; i++) { -+ if (prefs.known_bots[i].active && sameKeyPrefix(prefs.known_bots[i].key_prefix, key_prefix)) { -+ memset(&prefs.known_bots[i], 0, sizeof(prefs.known_bots[i])); -+ return true; -+ } -+ } -+ return false; -+} -+ -+} -diff --git a/examples/companion_radio/BotPrefs.h b/examples/companion_radio/BotPrefs.h -new file mode 100644 -index 00000000..676f7de2 ---- /dev/null -+++ b/examples/companion_radio/BotPrefs.h -@@ -0,0 +1,31 @@ -+#pragma once -+ -+#include "BotTypes.h" -+ -+#include -+#include -+ -+namespace BotPrefsCodec { -+ -+size_t serializedSize(); -+void defaults(BotPrefs& prefs); -+void validate(BotPrefs& prefs); -+bool serialize(const BotPrefs& prefs, uint8_t* output, size_t output_len); -+bool deserialize(const uint8_t* data, size_t data_len, BotPrefs& prefs); -+bool channelNameValid(const char* value, bool public_channel); -+bool channelConfigValid(const BotPrefs& prefs); -+ -+uint32_t commandMaskFor(BotCommandId command_id); -+bool commandEnabled(const BotPrefs& prefs, BotCommandId command_id); -+void setCommandEnabled(BotPrefs& prefs, BotCommandId command_id, bool enabled); -+const char* commandName(BotCommandId command_id); -+bool commandIdForName(const char* name, BotCommandId* command_id); -+ -+bool parseKeyPrefixHex(const char* text, uint8_t key_prefix[BOT_SENDER_KEY_PREFIX_LEN]); -+void formatKeyPrefixHex(const uint8_t key_prefix[BOT_SENDER_KEY_PREFIX_LEN], char* output, size_t output_len); -+bool addKnownBot(BotPrefs& prefs, const uint8_t key_prefix[BOT_SENDER_KEY_PREFIX_LEN], uint8_t flags, -+ const char* label); -+bool removeKnownBot(BotPrefs& prefs, const uint8_t key_prefix[BOT_SENDER_KEY_PREFIX_LEN]); -+const BotKnownBotEntry* findKnownBot(const BotPrefs& prefs, const uint8_t key_prefix[BOT_SENDER_KEY_PREFIX_LEN]); -+ -+} -diff --git a/examples/companion_radio/BotTypes.h b/examples/companion_radio/BotTypes.h -index d44aadb2..98d4a583 100644 ---- a/examples/companion_radio/BotTypes.h -+++ b/examples/companion_radio/BotTypes.h -@@ -27,6 +27,27 @@ - #define BOT_KNOWN_BOT_FLAG_SUPPRESS_NORMAL 0x01 - #define BOT_SENDER_KEY_PREFIX_LEN 6 - #define BOT_MIN_AUTH_SENDER_KEY_PREFIX_LEN 4 -+#define BOT_KNOWN_BOT_LABEL_LEN 12 -+#define BOT_PREFS_MAGIC 0x31504642UL -+#define BOT_PREFS_VERSION 1 -+#define BOT_PREFS_DEFAULT_LOCAL_ADVERT_MILLIS (24UL * 60UL * 60UL * 1000UL) -+#define BOT_PREFS_DEFAULT_FLOOD_ADVERT_MILLIS (24UL * 60UL * 60UL * 1000UL) -+#define BOT_PREFS_INITIAL_LOCAL_ADVERT_MILLIS 60000UL -+#define BOT_PREFS_MAX_DELAY_MILLIS 60000U -+#define BOT_PREFS_MAX_ADVERT_MILLIS (7UL * 24UL * 60UL * 60UL * 1000UL) -+#define BOT_COMMAND_MASK_HELP (1UL << BOT_COMMAND_HELP) -+#define BOT_COMMAND_MASK_PING (1UL << BOT_COMMAND_PING) -+#define BOT_COMMAND_MASK_TEST (1UL << BOT_COMMAND_TEST) -+#define BOT_COMMAND_MASK_HELLO (1UL << BOT_COMMAND_HELLO) -+#define BOT_COMMAND_MASK_ABOUT (1UL << BOT_COMMAND_ABOUT) -+#define BOT_COMMAND_MASK_DICE (1UL << BOT_COMMAND_DICE) -+#define BOT_COMMAND_MASK_STATUS (1UL << BOT_COMMAND_STATUS) -+#define BOT_COMMAND_MASK_CHANNELS (1UL << BOT_COMMAND_CHANNELS) -+#define BOT_COMMAND_MASK_UNKNOWN (1UL << BOT_COMMAND_UNKNOWN) -+#define BOT_COMMAND_MASK_ALL (BOT_COMMAND_MASK_HELP | BOT_COMMAND_MASK_PING | BOT_COMMAND_MASK_TEST | \ -+ BOT_COMMAND_MASK_HELLO | BOT_COMMAND_MASK_ABOUT | BOT_COMMAND_MASK_DICE | \ -+ BOT_COMMAND_MASK_STATUS | BOT_COMMAND_MASK_CHANNELS | BOT_COMMAND_MASK_UNKNOWN) -+#define BOT_PREFS_SERIALIZED_SIZE 294 - - enum BotChannelKind : uint8_t { - BOT_CHANNEL_DM = 0, -@@ -116,6 +137,10 @@ struct BotResponse { - - struct BotCommandContext { - char node_name[BOT_MAX_SENDER_NAME_LEN + 1]; -+ char bot_channel[BOT_MAX_CHANNEL_NAME_LEN + 1]; -+ char testing_channel[BOT_MAX_CHANNEL_NAME_LEN + 1]; -+ char emergency_channel[BOT_MAX_CHANNEL_NAME_LEN + 1]; -+ char public_channel[BOT_MAX_CHANNEL_NAME_LEN + 1]; - uint32_t uptime_seconds; - uint16_t battery_millivolts; - uint32_t storage_used_kb; -@@ -143,7 +168,7 @@ struct BotKnownBotEntry { - bool active; - uint8_t key_prefix[BOT_SENDER_KEY_PREFIX_LEN]; - uint8_t flags; -- char label[12]; -+ char label[BOT_KNOWN_BOT_LABEL_LEN]; - }; - - struct BotCoordinatorPending { -@@ -178,11 +203,17 @@ struct BotPrefs { - bool enabled; - uint16_t normal_delay_ms; - uint16_t normal_jitter_ms; -+ uint32_t local_advert_interval_ms; -+ uint32_t flood_advert_interval_ms; -+ uint32_t command_mask; - uint8_t max_response_parts; - char bot_channel[BOT_MAX_CHANNEL_NAME_LEN + 1]; - char testing_channel[BOT_MAX_CHANNEL_NAME_LEN + 1]; - char emergency_channel[BOT_MAX_CHANNEL_NAME_LEN + 1]; - char public_channel[BOT_MAX_CHANNEL_NAME_LEN + 1]; -+ BotKnownBotEntry known_bots[BOT_KNOWN_BOT_SLOTS]; -+ uint32_t prefs_load_failures; -+ uint32_t prefs_save_failures; - }; - - struct BotStats { -@@ -204,7 +235,7 @@ struct BotStats { - static_assert(sizeof(BotMessage) <= 248, "BotMessage RAM budget exceeded"); - static_assert(sizeof(BotCommand) <= 120, "BotCommand RAM budget exceeded"); - static_assert(sizeof(BotResponse) <= 184, "BotResponse RAM budget exceeded"); --static_assert(sizeof(BotCommandContext) <= 96, "BotCommandContext RAM budget exceeded"); -+static_assert(sizeof(BotCommandContext) <= 192, "BotCommandContext RAM budget exceeded"); - static_assert(sizeof(BotCommandResult) <= 16, "BotCommandResult RAM budget exceeded"); - static_assert(sizeof(BotCommandCooldown) <= 8, "BotCommandCooldown RAM budget exceeded"); - static_assert(sizeof(BotKnownBotEntry) <= 24, "BotKnownBotEntry RAM budget exceeded"); -@@ -212,5 +243,5 @@ static_assert(sizeof(BotCoordinatorPending) <= 32, "BotCoordinatorPending RAM bu - static_assert(sizeof(BotCoordinatorRecent) <= 24, "BotCoordinatorRecent RAM budget exceeded"); - static_assert(sizeof(BotCoordinatorReady) <= 24, "BotCoordinatorReady RAM budget exceeded"); - static_assert(sizeof(BotEmergencyForward) <= 480, "BotEmergencyForward RAM budget exceeded"); --static_assert(sizeof(BotPrefs) <= 128, "BotPrefs RAM budget exceeded"); -+static_assert(sizeof(BotPrefs) <= 320, "BotPrefs RAM budget exceeded"); - static_assert(sizeof(BotStats) <= 64, "BotStats RAM budget exceeded"); -diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp -index c7988bb3..9a134389 100644 ---- a/examples/companion_radio/DataStore.cpp -+++ b/examples/companion_radio/DataStore.cpp -@@ -1,6 +1,10 @@ - #include - #include "DataStore.h" - -+#if CMESH_BOT_ENABLED -+#include "BotPrefs.h" -+#endif -+ - #if defined(EXTRAFS) || defined(QSPIFLASH) - #define MAX_BLOBRECS 100 - #else -@@ -199,6 +203,38 @@ void DataStore::loadPrefs(NodePrefs& prefs, double& node_lat, double& node_lon) - } - } - -+#if CMESH_BOT_ENABLED -+bool DataStore::loadBotPrefs(BotPrefs& prefs) { -+ if (!_fs->exists("/bot_prefs_v1")) { -+ BotPrefsCodec::defaults(prefs); -+ return false; -+ } -+ -+ uint8_t data[BOT_PREFS_SERIALIZED_SIZE]; -+ File file = openRead(_fs, "/bot_prefs_v1"); -+ bool success = false; -+ if (file) { -+ success = file.size() == sizeof(data) && file.read(data, sizeof(data)) == sizeof(data) && -+ BotPrefsCodec::deserialize(data, sizeof(data), prefs); -+ file.close(); -+ } -+ if (!success) BotPrefsCodec::defaults(prefs); -+ return success; -+} -+ -+bool DataStore::saveBotPrefs(const BotPrefs& prefs) { -+ uint8_t data[BOT_PREFS_SERIALIZED_SIZE]; -+ if (!BotPrefsCodec::serialize(prefs, data, sizeof(data))) return false; -+ -+ File file = openWrite(_fs, "/bot_prefs_v1"); -+ if (!file) return false; -+ bool success = file.write(data, sizeof(data)) == sizeof(data); -+ file.close(); -+ if (!success) _fs->remove("/bot_prefs_v1"); -+ return success; -+} -+#endif -+ - void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& node_lat, double& node_lon) { - File file = openRead(_fs, filename); - if (file) { -diff --git a/examples/companion_radio/DataStore.h b/examples/companion_radio/DataStore.h -index 58b4d5d2..187cd771 100644 ---- a/examples/companion_radio/DataStore.h -+++ b/examples/companion_radio/DataStore.h -@@ -5,6 +5,13 @@ - #include - #include "NodePrefs.h" - -+#ifndef CMESH_BOT_ENABLED -+#define CMESH_BOT_ENABLED 0 -+#endif -+#if CMESH_BOT_ENABLED -+#include "BotTypes.h" -+#endif -+ - class DataStoreHost { - public: - virtual bool onContactLoaded(const ContactInfo& contact) =0; -@@ -35,6 +42,10 @@ public: - bool saveMainIdentity(const mesh::LocalIdentity &identity); - void loadPrefs(NodePrefs& prefs, double& node_lat, double& node_lon); - void savePrefs(const NodePrefs& prefs, double node_lat, double node_lon); -+#if CMESH_BOT_ENABLED -+ bool loadBotPrefs(BotPrefs& prefs); -+ bool saveBotPrefs(const BotPrefs& prefs); -+#endif - void loadContacts(DataStoreHost* host); - void saveContacts(DataStoreHost* host); - void loadChannels(DataStoreHost* host); -diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp -index 31e9bad8..13ead485 100644 ---- a/examples/companion_radio/MyMesh.cpp -+++ b/examples/companion_radio/MyMesh.cpp -@@ -2,10 +2,13 @@ - - #include // needed for PlatformIO - #include -+#include -+#include - - #if CMESH_BOT_ENABLED - #include "BotCommands.h" - #include "BotPolicy.h" -+#include "BotPrefs.h" - #include "EmergencyForwarder.h" - #include "FirmwareBot.h" - #include "KnownBotRegistry.h" -@@ -115,8 +118,6 @@ - #define LAZY_CONTACTS_WRITE_DELAY 5000 - - #if CMESH_BOT_ENABLED --#define BOT_AUTO_LOCAL_FIRST_DELAY_MILLIS 60000UL --#define BOT_AUTO_ADVERT_INTERVAL_MILLIS (24UL * 60UL * 60UL * 1000UL) - #endif - - #define PUBLIC_GROUP_PSK "izOH6cXN6mrJ5e26oRXNcg==" -@@ -127,6 +128,49 @@ static size_t botBoundedStrLen(const char *value, size_t max_len) { - while (value && len < max_len && value[len] != 0) len++; - return len; - } -+ -+static void botCopyString(char *dest, size_t dest_len, const char *src) { -+ if (!dest || dest_len == 0) return; -+ size_t len = botBoundedStrLen(src, dest_len - 1); -+ if (len > 0) memcpy(dest, src, len); -+ dest[len] = 0; -+} -+ -+static bool botParseU32(const char *text, uint32_t *value, const char **end_out) { -+ if (!text || !value || !isdigit((unsigned char)text[0])) return false; -+ uint32_t parsed = 0; -+ while (isdigit((unsigned char)*text)) { -+ uint32_t next = parsed * 10UL + (uint32_t)(*text - '0'); -+ if (next < parsed) return false; -+ parsed = next; -+ text++; -+ } -+ *value = parsed; -+ if (end_out) *end_out = text; -+ return true; -+} -+ -+static void botSkipSpaces(const char **text) { -+ while (text && *text && **text == ' ') (*text)++; -+} -+ -+static bool botReadToken(const char **text, char *output, size_t output_len) { -+ if (!text || !*text || !output || output_len == 0) return false; -+ botSkipSpaces(text); -+ const char *start = *text; -+ size_t len = 0; -+ while (start[len] != 0 && start[len] != ' ') len++; -+ if (len == 0 || len + 1 > output_len) return false; -+ memcpy(output, start, len); -+ output[len] = 0; -+ *text = start + len; -+ return true; -+} -+ -+static bool botNoMoreTokens(const char *text) { -+ botSkipSpaces(&text); -+ return text && *text == 0; -+} - #endif - - // these are _pushed_ to client app at any time -@@ -559,11 +603,226 @@ void MyMesh::onSignedMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uin - } - - #if CMESH_BOT_ENABLED -+void MyMesh::applyBotPrefs() { -+ BotPrefsCodec::validate(bot_prefs); -+ KnownBotRegistry::clear(known_bot_entries, BOT_KNOWN_BOT_SLOTS); -+ for (size_t i = 0; i < BOT_KNOWN_BOT_SLOTS; i++) { -+ if (bot_prefs.known_bots[i].active) { -+ KnownBotRegistry::add(known_bot_entries, BOT_KNOWN_BOT_SLOTS, bot_prefs.known_bots[i].key_prefix, -+ bot_prefs.known_bots[i].flags, bot_prefs.known_bots[i].label); -+ } -+ } -+ if (bot_prefs.enabled) { -+ scheduleBotLocalAdvert(bot_prefs.local_advert_interval_ms ? BOT_PREFS_INITIAL_LOCAL_ADVERT_MILLIS : 0); -+ scheduleBotFloodAdvert(bot_prefs.flood_advert_interval_ms); -+ } else { -+ scheduleBotLocalAdvert(0); -+ scheduleBotFloodAdvert(0); -+ } -+} -+ -+bool MyMesh::saveBotPrefs() { -+ BotPrefsCodec::validate(bot_prefs); -+ bool success = _store->saveBotPrefs(bot_prefs); -+ if (!success) bot_prefs.prefs_save_failures++; -+ return success; -+} -+ -+static void printBotPrefsSaveResult(const char *success_message, bool saved) { -+ Serial.println(saved ? success_message : " Error: bot prefs save failed"); -+} -+ -+void MyMesh::printBotPrefs() { -+ Serial.printf(" > bot %s\n", bot_prefs.enabled ? "enabled" : "disabled"); -+ Serial.printf(" > channels bot=%s testing=%s emergency=%s public=%s\n", bot_prefs.bot_channel, -+ bot_prefs.testing_channel, bot_prefs.emergency_channel, bot_prefs.public_channel); -+ Serial.printf(" > delay base=%u jitter=%u\n", (unsigned)bot_prefs.normal_delay_ms, -+ (unsigned)bot_prefs.normal_jitter_ms); -+ Serial.printf(" > advert local=%lu flood=%lu\n", (unsigned long)bot_prefs.local_advert_interval_ms, -+ (unsigned long)bot_prefs.flood_advert_interval_ms); -+} -+ -+bool MyMesh::handleBotCLI(const char *args) { -+ if (!args) return false; -+ botSkipSpaces(&args); -+ if (*args == 0) { -+ printBotPrefs(); -+ return true; -+ } -+ if (strcmp(args, "enable") == 0) { -+ bot_prefs.enabled = true; -+ applyBotPrefs(); -+ printBotPrefsSaveResult(" > bot enabled", saveBotPrefs()); -+ return true; -+ } -+ if (strcmp(args, "disable") == 0) { -+ bot_prefs.enabled = false; -+ ResponseCoordinator::clear(bot_coordinator_pending, BOT_COORDINATOR_PENDING_SLOTS); -+ memset(pending_bot_responses, 0, sizeof(pending_bot_responses)); -+ applyBotPrefs(); -+ printBotPrefsSaveResult(" > bot disabled", saveBotPrefs()); -+ return true; -+ } -+ if (strcmp(args, "channels") == 0) { -+ Serial.printf(" > %s %s %s %s\n", bot_prefs.bot_channel, bot_prefs.testing_channel, -+ bot_prefs.emergency_channel, bot_prefs.public_channel); -+ return true; -+ } -+ if (memcmp(args, "channels ", 9) == 0) { -+ const char *pos = args + 9; -+ char bot[BOT_MAX_CHANNEL_NAME_LEN + 1]; -+ char testing[BOT_MAX_CHANNEL_NAME_LEN + 1]; -+ char emergency[BOT_MAX_CHANNEL_NAME_LEN + 1]; -+ char public_name[BOT_MAX_CHANNEL_NAME_LEN + 1]; -+ BotPrefs updated = bot_prefs; -+ if (botReadToken(&pos, bot, sizeof(bot)) && botReadToken(&pos, testing, sizeof(testing)) && -+ botReadToken(&pos, emergency, sizeof(emergency)) && botReadToken(&pos, public_name, sizeof(public_name)) && -+ botNoMoreTokens(pos) && BotPrefsCodec::channelNameValid(bot, false) && -+ BotPrefsCodec::channelNameValid(testing, false) && BotPrefsCodec::channelNameValid(emergency, false) && -+ BotPrefsCodec::channelNameValid(public_name, true)) { -+ botCopyString(updated.bot_channel, sizeof(updated.bot_channel), bot); -+ botCopyString(updated.testing_channel, sizeof(updated.testing_channel), testing); -+ botCopyString(updated.emergency_channel, sizeof(updated.emergency_channel), emergency); -+ botCopyString(updated.public_channel, sizeof(updated.public_channel), public_name); -+ if (BotPrefsCodec::channelConfigValid(updated)) { -+ bot_prefs = updated; -+ printBotPrefsSaveResult(" > bot channels saved", saveBotPrefs()); -+ } else { -+ Serial.println(" Error: duplicate bot channel names"); -+ } -+ } else { -+ Serial.println(" Error: usage bot channels "); -+ } -+ return true; -+ } -+ if (memcmp(args, "delay ", 6) == 0) { -+ const char *pos = args + 6; -+ uint32_t base = 0; -+ uint32_t jitter = 0; -+ if (botParseU32(pos, &base, &pos)) { -+ botSkipSpaces(&pos); -+ if (botParseU32(pos, &jitter, &pos) && *pos == 0 && base <= BOT_PREFS_MAX_DELAY_MILLIS && -+ jitter <= BOT_PREFS_MAX_DELAY_MILLIS) { -+ bot_prefs.normal_delay_ms = (uint16_t)base; -+ bot_prefs.normal_jitter_ms = (uint16_t)jitter; -+ printBotPrefsSaveResult(" > bot delay saved", saveBotPrefs()); -+ } else { -+ Serial.println(" Error: usage bot delay "); -+ } -+ } else { -+ Serial.println(" Error: usage bot delay "); -+ } -+ return true; -+ } -+ if (memcmp(args, "advert ", 7) == 0) { -+ const char *pos = args + 7; -+ uint32_t local = 0; -+ uint32_t flood = 0; -+ if (botParseU32(pos, &local, &pos)) { -+ botSkipSpaces(&pos); -+ if (botParseU32(pos, &flood, &pos) && *pos == 0 && local <= BOT_PREFS_MAX_ADVERT_MILLIS && -+ flood <= BOT_PREFS_MAX_ADVERT_MILLIS) { -+ bot_prefs.local_advert_interval_ms = local; -+ bot_prefs.flood_advert_interval_ms = flood; -+ applyBotPrefs(); -+ printBotPrefsSaveResult(" > bot advert saved", saveBotPrefs()); -+ } else { -+ Serial.println(" Error: usage bot advert "); -+ } -+ } else { -+ Serial.println(" Error: usage bot advert "); -+ } -+ return true; -+ } -+ if (strcmp(args, "known list") == 0) { -+ char hex[BOT_SENDER_KEY_PREFIX_LEN * 2 + 1]; -+ for (size_t i = 0; i < BOT_KNOWN_BOT_SLOTS; i++) { -+ if (!bot_prefs.known_bots[i].active) continue; -+ BotPrefsCodec::formatKeyPrefixHex(bot_prefs.known_bots[i].key_prefix, hex, sizeof(hex)); -+ Serial.printf(" > %s %s flags=%u\n", hex, bot_prefs.known_bots[i].label, -+ (unsigned)bot_prefs.known_bots[i].flags); -+ } -+ return true; -+ } -+ if (memcmp(args, "known add ", 10) == 0) { -+ const char *pos = args + 10; -+ char key_hex[BOT_SENDER_KEY_PREFIX_LEN * 2 + 1]; -+ char label[BOT_KNOWN_BOT_LABEL_LEN]; -+ label[0] = 0; -+ if (botReadToken(&pos, key_hex, sizeof(key_hex))) { -+ bool have_label = botReadToken(&pos, label, sizeof(label)); -+ uint8_t key[BOT_SENDER_KEY_PREFIX_LEN]; -+ if (botNoMoreTokens(pos) && (!have_label || label[0] != 0) && BotPrefsCodec::parseKeyPrefixHex(key_hex, key) && -+ BotPrefsCodec::addKnownBot(bot_prefs, key, BOT_KNOWN_BOT_FLAG_SUPPRESS_NORMAL, have_label ? label : "bot")) { -+ applyBotPrefs(); -+ printBotPrefsSaveResult(" > known bot saved", saveBotPrefs()); -+ } else { -+ Serial.println(" Error: known bot table full or invalid key"); -+ } -+ } else { -+ Serial.println(" Error: usage bot known add [label]"); -+ } -+ return true; -+ } -+ if (memcmp(args, "known remove ", 13) == 0) { -+ const char *pos = args + 13; -+ char key_hex[BOT_SENDER_KEY_PREFIX_LEN * 2 + 1]; -+ uint8_t key[BOT_SENDER_KEY_PREFIX_LEN]; -+ if (botReadToken(&pos, key_hex, sizeof(key_hex)) && botNoMoreTokens(pos) && -+ BotPrefsCodec::parseKeyPrefixHex(key_hex, key) && BotPrefsCodec::removeKnownBot(bot_prefs, key)) { -+ applyBotPrefs(); -+ printBotPrefsSaveResult(" > known bot removed", saveBotPrefs()); -+ } else { -+ Serial.println(" Error: known bot not found"); -+ } -+ return true; -+ } -+ if (strcmp(args, "commands") == 0) { -+ for (uint8_t id = BOT_COMMAND_HELP; id <= BOT_COMMAND_UNKNOWN; id++) { -+ BotCommandId command_id = (BotCommandId)id; -+ Serial.printf(" > %s %s\n", BotPrefsCodec::commandName(command_id), -+ BotPrefsCodec::commandEnabled(bot_prefs, command_id) ? "enabled" : "disabled"); -+ } -+ return true; -+ } -+ if (memcmp(args, "commands enable ", 16) == 0 || memcmp(args, "commands disable ", 17) == 0) { -+ bool enable = memcmp(args, "commands enable ", 16) == 0; -+ const char *name = args + (enable ? 16 : 17); -+ BotCommandId command_id; -+ if (BotPrefsCodec::commandIdForName(name, &command_id)) { -+ BotPrefsCodec::setCommandEnabled(bot_prefs, command_id, enable); -+ if (saveBotPrefs()) { -+ Serial.printf(" > command %s %s\n", BotPrefsCodec::commandName(command_id), enable ? "enabled" : "disabled"); -+ } else { -+ Serial.println(" Error: bot prefs save failed"); -+ } -+ } else { -+ Serial.println(" Error: unknown bot command"); -+ } -+ return true; -+ } -+ if (strcmp(args, "stats") == 0) { -+ Serial.printf(" > observed=%lu ignored=%lu eligible=%lu sent=%lu failed=%lu suppressed=%lu emergency=%lu/%lu\n", -+ (unsigned long)bot_stats.observed_messages, (unsigned long)bot_stats.ignored_messages, -+ (unsigned long)bot_stats.eligible_messages, (unsigned long)bot_stats.sent_messages, -+ (unsigned long)bot_stats.send_failures, (unsigned long)bot_stats.suppressed_responses, -+ (unsigned long)bot_stats.emergency_forwards, (unsigned long)bot_stats.emergency_forward_failures); -+ Serial.printf(" > prefs load_failures=%lu save_failures=%lu\n", (unsigned long)bot_prefs.prefs_load_failures, -+ (unsigned long)bot_prefs.prefs_save_failures); -+ return true; -+ } -+ if (strcmp(args, "save") == 0) { -+ Serial.println(saveBotPrefs() ? " > bot prefs saved" : " Error: bot prefs save failed"); -+ return true; -+ } -+ return false; -+} -+ - void MyMesh::observeBotDirectMessage(const ContactInfo &from, uint32_t sender_timestamp, const uint8_t *sender_prefix, - size_t sender_prefix_len, const char *text) { - BotMessage message; - memset(&message, 0, sizeof(message)); -- message.channel_kind = BotPolicy::classifyChannel(NULL, 0, true); -+ message.channel_kind = BotPolicy::classifyChannel(NULL, 0, true, bot_prefs); - StrHelper::strzcpy(message.sender_name, from.name, sizeof(message.sender_name)); - size_t prefix_len = sender_prefix_len; - if (prefix_len > sizeof(message.sender_key_prefix)) prefix_len = sizeof(message.sender_key_prefix); -@@ -580,7 +839,7 @@ void MyMesh::observeBotChannelMessage(uint8_t channel_idx, const char *channel_n - BotMessage message; - memset(&message, 0, sizeof(message)); - size_t channel_len = botBoundedStrLen(channel_name, BOT_MAX_CHANNEL_NAME_LEN); -- message.channel_kind = BotPolicy::classifyChannel(channel_name, channel_len, false); -+ message.channel_kind = BotPolicy::classifyChannel(channel_name, channel_len, false, bot_prefs); - if (channel_name && channel_len > 0) { - memcpy(message.channel_name, channel_name, channel_len); - message.channel_name[channel_len] = 0; -@@ -598,6 +857,10 @@ void MyMesh::observeBotChannelMessage(uint8_t channel_idx, const char *channel_n - void MyMesh::buildBotCommandContext(BotCommandContext &context, BotCommandId command_id) { - memset(&context, 0, sizeof(context)); - StrHelper::strzcpy(context.node_name, _prefs.node_name, sizeof(context.node_name)); -+ StrHelper::strzcpy(context.bot_channel, bot_prefs.bot_channel, sizeof(context.bot_channel)); -+ StrHelper::strzcpy(context.testing_channel, bot_prefs.testing_channel, sizeof(context.testing_channel)); -+ StrHelper::strzcpy(context.emergency_channel, bot_prefs.emergency_channel, sizeof(context.emergency_channel)); -+ StrHelper::strzcpy(context.public_channel, bot_prefs.public_channel, sizeof(context.public_channel)); - context.uptime_seconds = _ms->getMillis() / 1000; - context.observed_messages = bot_stats.observed_messages; - context.ignored_messages = bot_stats.ignored_messages; -@@ -658,7 +921,7 @@ bool MyMesh::findBotChannel(BotChannelKind kind, uint8_t &channel_idx) { - ChannelDetails channel; - if (getChannel(i, channel)) { - size_t name_len = botBoundedStrLen(channel.name, BOT_MAX_CHANNEL_NAME_LEN); -- if (BotPolicy::classifyChannel(channel.name, name_len, false) == kind) { -+ if (BotPolicy::classifyChannel(channel.name, name_len, false, bot_prefs) == kind) { - channel_idx = i; - return true; - } -@@ -739,6 +1002,11 @@ void MyMesh::recordBotObservation(const BotMessage &message, const ContactInfo * - return; - } - -+ if (!bot_prefs.enabled) { -+ bot_stats.ignored_messages++; -+ return; -+ } -+ - if (observeKnownBotResponse(message, direct_recipient != NULL)) return; - sendQueuedBotResponses(); - -@@ -748,7 +1016,8 @@ void MyMesh::recordBotObservation(const BotMessage &message, const ContactInfo * - return; - } - -- if (FirmwareBot::isCommandOnCooldown(bot_command_cooldowns, BOT_COMMAND_COOLDOWN_SLOTS, command.id, _ms->getMillis())) { -+ if (!BotPrefsCodec::commandEnabled(bot_prefs, command.id) || -+ FirmwareBot::isCommandOnCooldown(bot_command_cooldowns, BOT_COMMAND_COOLDOWN_SLOTS, command.id, _ms->getMillis())) { - bot_stats.ignored_messages++; - return; - } -@@ -773,7 +1042,9 @@ void MyMesh::recordBotObservation(const BotMessage &message, const ContactInfo * - message, command.id, request_fingerprint, - response_fingerprint, _ms->getMillis(), - context.random_seed, bot_identity_seed, -- queue_depth, &fingerprint, &due_at_millis); -+ queue_depth, bot_prefs.normal_delay_ms, -+ bot_prefs.normal_jitter_ms, &fingerprint, -+ &due_at_millis); - if (schedule == BOT_COORDINATOR_NO_SPACE || schedule == BOT_COORDINATOR_NOT_NORMAL) { - bot_stats.send_failures++; - return; -@@ -903,14 +1174,15 @@ void MyMesh::scheduleBotFloodAdvert(unsigned long interval_millis) { - - void MyMesh::tickBot() { - sendQueuedEmergencyForwards(); -- sendQueuedBotResponses(); -+ if (bot_prefs.enabled) sendQueuedBotResponses(); -+ if (!bot_prefs.enabled) return; - if (next_bot_local_advert && millisHasNowPassed(next_bot_local_advert)) { - sendBotSelfAdvert(false); -- scheduleBotLocalAdvert(BOT_AUTO_ADVERT_INTERVAL_MILLIS); -+ scheduleBotLocalAdvert(bot_prefs.local_advert_interval_ms); - } - if (next_bot_flood_advert && millisHasNowPassed(next_bot_flood_advert)) { - sendBotSelfAdvert(true); -- scheduleBotFloodAdvert(BOT_AUTO_ADVERT_INTERVAL_MILLIS); -+ scheduleBotFloodAdvert(bot_prefs.flood_advert_interval_ms); - } - } - #endif -@@ -1244,6 +1516,7 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe - memset(advert_paths, 0, sizeof(advert_paths)); - memset(send_scope.key, 0, sizeof(send_scope.key)); - #if CMESH_BOT_ENABLED -+ BotPrefsCodec::defaults(bot_prefs); - memset(&bot_stats, 0, sizeof(bot_stats)); - memset(pending_bot_responses, 0, sizeof(pending_bot_responses)); - memset(pending_emergency_forwards, 0, sizeof(pending_emergency_forwards)); -@@ -1360,8 +1633,9 @@ void MyMesh::begin(bool has_display) { - MESH_DEBUG_PRINTLN("RX Boosted Gain Mode: %s", - radio_driver.getRxBoostedGainMode() ? "Enabled" : "Disabled"); - #if CMESH_BOT_ENABLED -- scheduleBotLocalAdvert(BOT_AUTO_LOCAL_FIRST_DELAY_MILLIS); -- scheduleBotFloodAdvert(BOT_AUTO_ADVERT_INTERVAL_MILLIS); -+ if (!_store->loadBotPrefs(bot_prefs)) bot_prefs.prefs_load_failures++; -+ BotPrefsCodec::validate(bot_prefs); -+ applyBotPrefs(); - #endif - } - -@@ -2382,7 +2656,13 @@ void MyMesh::checkCLIRescueCmd() { - if (len > 0 && cli_command[len - 1] == '\r') { // received complete line - cli_command[len - 1] = 0; // replace newline with C string null terminator - -- if (memcmp(cli_command, "set ", 4) == 0) { -+ if (memcmp(cli_command, "bot", 3) == 0 && (cli_command[3] == 0 || cli_command[3] == ' ')) { -+#if CMESH_BOT_ENABLED -+ if (!handleBotCLI(&cli_command[3])) Serial.println(" Error: unknown bot command"); -+#else -+ Serial.println(" Error: bot support is disabled in this build"); -+#endif -+ } else if (memcmp(cli_command, "set ", 4) == 0) { - const char* config = &cli_command[4]; - if (memcmp(config, "pin ", 4) == 0) { - _prefs.ble_pin = atoi(&config[4]); -diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h -index 698ecad8..6f8d4329 100644 ---- a/examples/companion_radio/MyMesh.h -+++ b/examples/companion_radio/MyMesh.h -@@ -202,6 +202,10 @@ private: - } - - #if CMESH_BOT_ENABLED -+ void applyBotPrefs(); -+ bool saveBotPrefs(); -+ void printBotPrefs(); -+ bool handleBotCLI(const char *args); - void observeBotDirectMessage(const ContactInfo &from, uint32_t sender_timestamp, const uint8_t *sender_prefix, - size_t sender_prefix_len, const char *text); - void observeBotChannelMessage(uint8_t channel_idx, const char *channel_name, const char *text, -@@ -273,6 +277,7 @@ private: - size_t text_len; - }; - -+ BotPrefs bot_prefs; - BotStats bot_stats; - PendingBotResponse pending_bot_responses[BOT_PENDING_RESPONSE_SLOTS]; - PendingEmergencyForward pending_emergency_forwards[BOT_PENDING_EMERGENCY_SLOTS]; -diff --git a/examples/companion_radio/ResponseCoordinator.cpp b/examples/companion_radio/ResponseCoordinator.cpp -index c5c8fc35..74ae3150 100644 ---- a/examples/companion_radio/ResponseCoordinator.cpp -+++ b/examples/companion_radio/ResponseCoordinator.cpp -@@ -60,8 +60,15 @@ void clearRecent(BotCoordinatorRecent recent[], size_t recent_count) { - - uint32_t responseDelayMillis(const BotMessage& message, BotCommandId command_id, BotFingerprint request_fingerprint, - uint32_t bot_identity_seed, uint8_t queue_depth, uint32_t jitter_seed) { -- uint32_t jitter = BOT_RESPONSE_DELAY_JITTER_MILLIS ? jitter_seed % BOT_RESPONSE_DELAY_JITTER_MILLIS : 0; -- return BOT_RESPONSE_DELAY_BASE_MILLIS + channelDelayBias(message.channel_kind) + commandDelayBias(command_id) + -+ return responseDelayMillis(message, command_id, request_fingerprint, bot_identity_seed, queue_depth, jitter_seed, -+ BOT_RESPONSE_DELAY_BASE_MILLIS, BOT_RESPONSE_DELAY_JITTER_MILLIS); -+} -+ -+uint32_t responseDelayMillis(const BotMessage& message, BotCommandId command_id, BotFingerprint request_fingerprint, -+ uint32_t bot_identity_seed, uint8_t queue_depth, uint32_t jitter_seed, -+ uint16_t base_delay_millis, uint16_t jitter_millis) { -+ uint32_t jitter = jitter_millis ? jitter_seed % jitter_millis : 0; -+ return (uint32_t)base_delay_millis + channelDelayBias(message.channel_kind) + commandDelayBias(command_id) + - queueDelayBias(queue_depth) + tieBreakBias(request_fingerprint, bot_identity_seed) + jitter; - } - -@@ -70,11 +77,23 @@ BotCoordinatorScheduleResult schedule(BotCoordinatorPending pending[], size_t pe - BotFingerprint request_fingerprint, BotFingerprint response_fingerprint, - uint32_t now_millis, uint32_t jitter_seed, uint32_t bot_identity_seed, - uint8_t queue_depth, BotFingerprint* fingerprint, uint32_t* due_at_millis) { -+ return schedule(pending, pending_count, message, command_id, request_fingerprint, response_fingerprint, now_millis, -+ jitter_seed, bot_identity_seed, queue_depth, BOT_RESPONSE_DELAY_BASE_MILLIS, -+ BOT_RESPONSE_DELAY_JITTER_MILLIS, fingerprint, due_at_millis); -+} -+ -+BotCoordinatorScheduleResult schedule(BotCoordinatorPending pending[], size_t pending_count, -+ const BotMessage& message, BotCommandId command_id, -+ BotFingerprint request_fingerprint, BotFingerprint response_fingerprint, -+ uint32_t now_millis, uint32_t jitter_seed, uint32_t bot_identity_seed, -+ uint8_t queue_depth, uint16_t base_delay_millis, uint16_t jitter_millis, -+ BotFingerprint* fingerprint, uint32_t* due_at_millis) { - if (fingerprint) fingerprint->value = 0; - if (due_at_millis) *due_at_millis = 0; - if (!pending || pending_count == 0 || !isNormalChannel(message.channel_kind) || request_fingerprint.value == 0 || response_fingerprint.value == 0) return BOT_COORDINATOR_NOT_NORMAL; - -- uint32_t due = now_millis + responseDelayMillis(message, command_id, request_fingerprint, bot_identity_seed, queue_depth, jitter_seed); -+ uint32_t due = now_millis + responseDelayMillis(message, command_id, request_fingerprint, bot_identity_seed, queue_depth, -+ jitter_seed, base_delay_millis, jitter_millis); - size_t slot = pending_count; - - for (size_t i = 0; i < pending_count; i++) { -diff --git a/examples/companion_radio/ResponseCoordinator.h b/examples/companion_radio/ResponseCoordinator.h -index d900633e..7717bee7 100644 ---- a/examples/companion_radio/ResponseCoordinator.h -+++ b/examples/companion_radio/ResponseCoordinator.h -@@ -8,11 +8,20 @@ void clear(BotCoordinatorPending pending[], size_t pending_count); - void clearRecent(BotCoordinatorRecent recent[], size_t recent_count); - uint32_t responseDelayMillis(const BotMessage& message, BotCommandId command_id, BotFingerprint request_fingerprint, - uint32_t bot_identity_seed, uint8_t queue_depth, uint32_t jitter_seed); -+uint32_t responseDelayMillis(const BotMessage& message, BotCommandId command_id, BotFingerprint request_fingerprint, -+ uint32_t bot_identity_seed, uint8_t queue_depth, uint32_t jitter_seed, -+ uint16_t base_delay_millis, uint16_t jitter_millis); - BotCoordinatorScheduleResult schedule(BotCoordinatorPending pending[], size_t pending_count, - const BotMessage& message, BotCommandId command_id, - BotFingerprint request_fingerprint, BotFingerprint response_fingerprint, - uint32_t now_millis, uint32_t jitter_seed, uint32_t bot_identity_seed, - uint8_t queue_depth, BotFingerprint* fingerprint, uint32_t* due_at_millis); -+BotCoordinatorScheduleResult schedule(BotCoordinatorPending pending[], size_t pending_count, -+ const BotMessage& message, BotCommandId command_id, -+ BotFingerprint request_fingerprint, BotFingerprint response_fingerprint, -+ uint32_t now_millis, uint32_t jitter_seed, uint32_t bot_identity_seed, -+ uint8_t queue_depth, uint16_t base_delay_millis, uint16_t jitter_millis, -+ BotFingerprint* fingerprint, uint32_t* due_at_millis); - bool suppress(BotCoordinatorPending pending[], size_t pending_count, BotFingerprint response_fingerprint); - bool cancel(BotCoordinatorPending pending[], size_t pending_count, BotFingerprint request_fingerprint); - BotCoordinatorReady poll(BotCoordinatorPending pending[], size_t pending_count, uint32_t now_millis); diff --git a/patches/meshcore/0007-Add-bot-build-flags-and-key-hardening.patch b/patches/meshcore/0007-Add-bot-build-flags-and-key-hardening.patch deleted file mode 100644 index 9597828..0000000 --- a/patches/meshcore/0007-Add-bot-build-flags-and-key-hardening.patch +++ /dev/null @@ -1,59 +0,0 @@ -diff --git a/platformio.ini b/platformio.ini -index 864e5e1f..dfed7dbd 100644 ---- a/platformio.ini -+++ b/platformio.ini -@@ -54,6 +54,14 @@ build_src_filter = - - ; ----------------- ESP32 --------------------- - -+[cmesh_bot_production] -+build_flags = -+ -D CMESH_BOT_ENABLED=1 -+ -UENABLE_PRIVATE_KEY_IMPORT -+ -UENABLE_PRIVATE_KEY_EXPORT -+ -D ENABLE_PRIVATE_KEY_IMPORT=0 -+ -D ENABLE_PRIVATE_KEY_EXPORT=0 -+ - [esp32_base] - extends = arduino_base - platform = platformio/espressif32@6.11.0 -diff --git a/variants/heltec_v3/platformio.ini b/variants/heltec_v3/platformio.ini -index 803ee683..ca9cf00d 100644 ---- a/variants/heltec_v3/platformio.ini -+++ b/variants/heltec_v3/platformio.ini -@@ -144,6 +144,7 @@ build_flags = - -D MAX_CONTACTS=350 - -D MAX_GROUP_CHANNELS=40 - -D DISPLAY_CLASS=SSD1306Display -+ ${cmesh_bot_production.build_flags} - ; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 - ; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 - build_src_filter = ${Heltec_lora32_v3.build_src_filter} -@@ -163,6 +164,7 @@ build_flags = - -D MAX_CONTACTS=350 - -D MAX_GROUP_CHANNELS=40 - -D DISPLAY_CLASS=SSD1306Display -+ ${cmesh_bot_production.build_flags} - -D BLE_PIN_CODE=123456 ; dynamic, random PIN - -D AUTO_SHUTDOWN_MILLIVOLTS=3400 - -D BLE_DEBUG_LOGGING=1 -diff --git a/variants/rak4631/platformio.ini b/variants/rak4631/platformio.ini -index ea7e49c3..d6610098 100644 ---- a/variants/rak4631/platformio.ini -+++ b/variants/rak4631/platformio.ini -@@ -122,6 +122,7 @@ build_flags = - -D DISPLAY_CLASS=SSD1306Display - -D MAX_CONTACTS=350 - -D MAX_GROUP_CHANNELS=40 -+ ${cmesh_bot_production.build_flags} - ; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 - ; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 - build_src_filter = ${rak4631.build_src_filter} -@@ -143,6 +144,7 @@ build_flags = - -D DISPLAY_CLASS=SSD1306Display - -D MAX_CONTACTS=350 - -D MAX_GROUP_CHANNELS=40 -+ ${cmesh_bot_production.build_flags} - -D BLE_PIN_CODE=123456 - -D BLE_DEBUG_LOGGING=1 - -D OFFLINE_QUEUE_SIZE=256 diff --git a/patches/meshcore/0008-Allow-prefixless-normal-bot-commands.patch b/patches/meshcore/0008-Allow-prefixless-normal-bot-commands.patch deleted file mode 100644 index f3b14b1..0000000 --- a/patches/meshcore/0008-Allow-prefixless-normal-bot-commands.patch +++ /dev/null @@ -1,64 +0,0 @@ -diff --git a/examples/companion_radio/FirmwareBot.cpp b/examples/companion_radio/FirmwareBot.cpp -index 56a3395..de44fb9 100644 ---- a/examples/companion_radio/FirmwareBot.cpp -+++ b/examples/companion_radio/FirmwareBot.cpp -@@ -163,6 +163,10 @@ void recordCommandCooldown(BotCommandCooldown* cooldowns, size_t cooldown_count, - } - - bool parseCommand(const char* text, size_t text_len, BotCommand* command) { -+ return parseCommand(text, text_len, command, false); -+} -+ -+bool parseCommand(const char* text, size_t text_len, BotCommand* command, bool allow_prefixless) { - if (!command) return false; - memset(command, 0, sizeof(*command)); - command->id = BOT_COMMAND_NONE; -@@ -171,9 +175,13 @@ bool parseCommand(const char* text, size_t text_len, BotCommand* command) { - size_t normalized_len = 0; - normalizeText(text, text_len, normalized, sizeof(normalized), &normalized_len); - -- if (normalized_len < 2 || (normalized[0] != '!' && normalized[0] != '/')) return false; -+ if (normalized_len == 0) return false; -+ -+ bool has_prefix = normalized[0] == '!' || normalized[0] == '/'; -+ if (!has_prefix && !allow_prefixless) return false; -+ if (has_prefix && normalized_len < 2) return false; - -- size_t pos = 1; -+ size_t pos = has_prefix ? 1 : 0; - while (pos < normalized_len && normalized[pos] == ' ') pos++; - size_t name_start = pos; - while (pos < normalized_len && !isCommandDelimiter(normalized[pos])) pos++; -@@ -187,6 +195,7 @@ bool parseCommand(const char* text, size_t text_len, BotCommand* command) { - } - command->name[copy_name_len] = 0; - command->id = name_len > BOT_MAX_COMMAND_NAME_LEN ? BOT_COMMAND_UNKNOWN : commandIdForName(command->name, copy_name_len); -+ if (!has_prefix && command->id == BOT_COMMAND_UNKNOWN) return false; - - while (pos < normalized_len && isCommandDelimiter(normalized[pos])) pos++; - size_t args_len = normalized_len - pos; -diff --git a/examples/companion_radio/FirmwareBot.h b/examples/companion_radio/FirmwareBot.h -index 42b9c5b..e248adc 100644 ---- a/examples/companion_radio/FirmwareBot.h -+++ b/examples/companion_radio/FirmwareBot.h -@@ -6,6 +6,7 @@ namespace FirmwareBot { - - BotWriteResult normalizeText(const char* input, size_t input_len, char* output, size_t output_len, size_t* written); - bool parseCommand(const char* text, size_t text_len, BotCommand* command); -+bool parseCommand(const char* text, size_t text_len, BotCommand* command, bool allow_prefixless); - bool splitChannelText(const char* text, size_t text_len, char* sender, size_t sender_len, const char** body, - size_t* body_len); - BotWriteResult writeResponse(char* output, size_t output_len, const char* text, size_t text_len, size_t* written); -diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp -index 13ead48..ec6a780 100644 ---- a/examples/companion_radio/MyMesh.cpp -+++ b/examples/companion_radio/MyMesh.cpp -@@ -1011,7 +1011,7 @@ void MyMesh::recordBotObservation(const BotMessage &message, const ContactInfo * - sendQueuedBotResponses(); - - BotCommand command; -- if (!FirmwareBot::parseCommand(message.text, message.text_len, &command)) { -+ if (!FirmwareBot::parseCommand(message.text, message.text_len, &command, true)) { - if (message.text_len > 0 && (message.text[0] == '!' || message.text[0] == '/')) bot_stats.parse_errors++; - return; - } diff --git a/patches/meshcore/0009-Add-firmware-bot-utility-commands.patch b/patches/meshcore/0009-Add-firmware-bot-utility-commands.patch deleted file mode 100644 index 9cb7427..0000000 --- a/patches/meshcore/0009-Add-firmware-bot-utility-commands.patch +++ /dev/null @@ -1,410 +0,0 @@ -diff --git a/examples/companion_radio/BotCommands.cpp b/examples/companion_radio/BotCommands.cpp -index 2c206a9..6222be5 100644 ---- a/examples/companion_radio/BotCommands.cpp -+++ b/examples/companion_radio/BotCommands.cpp -@@ -62,7 +62,7 @@ bool parseUInt(const char* text, size_t len, size_t* pos, uint16_t* value) { - } - - bool isSupportedSides(uint16_t sides) { -- return sides == 4 || sides == 6 || sides == 8 || sides == 10 || sides == 12 || sides == 16 || sides == 20; -+ return sides >= 2 && sides <= 1000; - } - - bool parseDice(const BotCommand& command, uint16_t* count, uint16_t* sides) { -@@ -97,11 +97,57 @@ uint16_t rollOnce(uint32_t* state, uint16_t sides) { - return (uint16_t)((*state >> 16) % sides) + 1; - } - -+void appendPathHex(char* output, size_t output_len, size_t* pos, const uint8_t* path, size_t path_len) { -+ static const char hex[] = "0123456789abcdef"; -+ for (size_t i = 0; i < path_len; i++) { -+ if (*pos + 2 < output_len) { -+ output[*pos] = hex[path[i] >> 4]; -+ output[*pos + 1] = hex[path[i] & 0x0F]; -+ } -+ *pos += 2; -+ } -+ if (output_len > 0) output[*pos < output_len ? *pos : output_len - 1] = 0; -+} -+ -+void formatQuarters(int8_t quarters, char* output, size_t output_len) { -+ if (!output || output_len == 0) return; -+ int value = quarters; -+ const char* sign = value < 0 ? "-" : ""; -+ if (value < 0) value = -value; -+ snprintf(output, output_len, "%s%d.%02d", sign, value / 4, (value % 4) * 25); -+} -+ -+BotCommandResult executeMagic8(const BotCommandContext& context, char* output, size_t output_len) { -+ static const char* responses[] = { -+ "It is certain", "Looks good", "Ask again later", "Cannot predict now", "Doubtful", "Very likely", -+ "Signs point yes", "No", "Reply hazy", "Absolutely" -+ }; -+ uint32_t seed = context.random_seed ? context.random_seed : 1; -+ seed = seed * 1664525UL + 1013904223UL; -+ return writeFormatted(output, output_len, "Magic 8-ball: %s", responses[(seed >> 16) % (sizeof(responses) / sizeof(responses[0]))]); -+} -+ -+BotCommandResult executePath(const BotCommandContext& context, char* output, size_t output_len) { -+ if (!context.path || context.path_len == 0 || context.path_hash_count == 0) return writeText(output, output_len, "Path unavailable"); -+ if (!output || output_len == 0) return makeResult(BOT_COMMAND_RESULT_NO_SPACE, 0); -+ -+ size_t byte_len = (size_t)context.path_hash_size * context.path_hash_count; -+ char snr[8]; -+ formatQuarters(context.path_snr_quarters, snr, sizeof(snr)); -+ int written = snprintf(output, output_len, "Path %uh x %uB snr %s: ", (unsigned)context.path_hash_count, -+ (unsigned)context.path_hash_size, snr); -+ if (written < 0) return makeResult(BOT_COMMAND_RESULT_NO_SPACE, 0); -+ size_t pos = (size_t)written; -+ appendPathHex(output, output_len, &pos, context.path, byte_len); -+ size_t actual = boundedStrLen(output, output_len); -+ return makeResult(pos >= output_len ? BOT_COMMAND_RESULT_TRUNCATED : BOT_COMMAND_RESULT_OK, actual); -+} -+ - BotCommandResult executeDice(const BotCommand& command, const BotCommandContext& context, char* output, size_t output_len) { - uint16_t count = 1; - uint16_t sides = 6; - if (!parseDice(command, &count, &sides)) { -- return writeText(output, output_len, "Usage: !roll [d6|d20|2d6], sides: d4 d6 d8 d10 d12 d16 d20"); -+ return writeText(output, output_len, "Usage: roll [N|dN|NdN], max 10 dice, sides 2-1000"); - } - - uint32_t state = context.random_seed ^ ((uint32_t)count << 16) ^ sides; -@@ -141,7 +187,7 @@ BotCommandResult executeCommand(const BotCommand& command, const BotCommandConte - size_t output_len) { - switch (command.id) { - case BOT_COMMAND_HELP: -- return writeText(output, output_len, "Commands: !ping !test !hello !about !roll [d20|2d6] !status !channels"); -+ return writeText(output, output_len, "Commands: ping t hello about roll stats version path magic8 status channels"); - case BOT_COMMAND_PING: - return writeText(output, output_len, "Pong!"); - case BOT_COMMAND_TEST: -@@ -164,8 +210,22 @@ BotCommandResult executeCommand(const BotCommand& command, const BotCommandConte - context.testing_channel[0] ? context.testing_channel : "#testing", - context.emergency_channel[0] ? context.emergency_channel : "#emergency", - context.public_channel[0] ? context.public_channel : "Public", (unsigned)context.channel_count); -+ case BOT_COMMAND_VERSION: -+ return writeFormatted(output, output_len, "Firmware %s built %s", context.firmware_version[0] ? context.firmware_version : "unknown", -+ context.firmware_build_date[0] ? context.firmware_build_date : "unknown"); -+ case BOT_COMMAND_STATS: -+ return writeFormatted(output, output_len, "Bot seen %lu ok %lu sent %lu fail %lu sup %lu pend %lu rf rx/tx %lu/%lu err %lu q %u", -+ (unsigned long)context.observed_messages, (unsigned long)context.eligible_messages, -+ (unsigned long)context.sent_messages, (unsigned long)context.send_failures, -+ (unsigned long)context.suppressed_responses, (unsigned long)context.pending_responses, -+ (unsigned long)context.packets_recv, (unsigned long)context.packets_sent, -+ (unsigned long)context.packets_recv_errors, (unsigned)context.queue_depth); -+ case BOT_COMMAND_MAGIC8: -+ return executeMagic8(context, output, output_len); -+ case BOT_COMMAND_PATH: -+ return executePath(context, output, output_len); - case BOT_COMMAND_UNKNOWN: -- return writeText(output, output_len, "Unknown command. Try !help"); -+ return writeText(output, output_len, "Unknown command. Try help"); - default: - return makeResult(BOT_COMMAND_RESULT_NOT_HANDLED, 0); - } -diff --git a/examples/companion_radio/BotPrefs.cpp b/examples/companion_radio/BotPrefs.cpp -index 1d0810a..0eff075 100644 ---- a/examples/companion_radio/BotPrefs.cpp -+++ b/examples/companion_radio/BotPrefs.cpp -@@ -294,27 +294,51 @@ const char* commandName(BotCommandId command_id) { - case BOT_COMMAND_DICE: return "roll"; - case BOT_COMMAND_STATUS: return "status"; - case BOT_COMMAND_CHANNELS: return "channels"; -+ case BOT_COMMAND_VERSION: return "version"; -+ case BOT_COMMAND_STATS: return "stats"; -+ case BOT_COMMAND_MAGIC8: return "magic8"; -+ case BOT_COMMAND_PATH: return "path"; - case BOT_COMMAND_UNKNOWN: return "unknown"; - default: return ""; - } - } - -+bool namesEqual(const char* name, size_t len, const char* expected) { -+ size_t expected_len = boundedStrLen(expected, BOT_MAX_COMMAND_NAME_LEN + 1); -+ if (len != expected_len) return false; -+ for (size_t i = 0; i < len; i++) { -+ if (tolower((unsigned char)name[i]) != tolower((unsigned char)expected[i])) return false; -+ } -+ return true; -+} -+ - bool commandIdForName(const char* name, BotCommandId* command_id) { - if (!name || !command_id) return false; -+ size_t len = boundedStrLen(name, BOT_MAX_COMMAND_NAME_LEN + 1); -+ if (namesEqual(name, len, "t")) { -+ *command_id = BOT_COMMAND_TEST; -+ return true; -+ } -+ if (namesEqual(name, len, "dice")) { -+ *command_id = BOT_COMMAND_DICE; -+ return true; -+ } -+ if (namesEqual(name, len, "ver")) { -+ *command_id = BOT_COMMAND_VERSION; -+ return true; -+ } -+ if (namesEqual(name, len, "8ball") || namesEqual(name, len, "eightball")) { -+ *command_id = BOT_COMMAND_MAGIC8; -+ return true; -+ } -+ if (namesEqual(name, len, "p") || namesEqual(name, len, "decode") || namesEqual(name, len, "route")) { -+ *command_id = BOT_COMMAND_PATH; -+ return true; -+ } - for (uint8_t id = BOT_COMMAND_HELP; id <= BOT_COMMAND_UNKNOWN; id++) { - const char* candidate = commandName((BotCommandId)id); - if (candidate[0] == 0) continue; -- size_t len = boundedStrLen(name, BOT_MAX_COMMAND_NAME_LEN + 1); -- size_t candidate_len = boundedStrLen(candidate, BOT_MAX_COMMAND_NAME_LEN + 1); -- if (len != candidate_len) continue; -- bool match = true; -- for (size_t i = 0; i < len; i++) { -- if (tolower((unsigned char)name[i]) != tolower((unsigned char)candidate[i])) { -- match = false; -- break; -- } -- } -- if (match) { -+ if (namesEqual(name, len, candidate)) { - *command_id = (BotCommandId)id; - return true; - } -diff --git a/examples/companion_radio/BotTypes.h b/examples/companion_radio/BotTypes.h -index 98d4a58..5eb71c6 100644 ---- a/examples/companion_radio/BotTypes.h -+++ b/examples/companion_radio/BotTypes.h -@@ -9,6 +9,9 @@ - #define BOT_MAX_COMMAND_ARGS_LEN 79 - #define BOT_MAX_CHANNEL_NAME_LEN 23 - #define BOT_MAX_SENDER_NAME_LEN 31 -+#define BOT_MAX_FIRMWARE_VERSION_LEN 19 -+#define BOT_MAX_BUILD_DATE_LEN 15 -+#define BOT_MAX_PATH_BYTES 64 - #define BOT_GROUP_RESPONSE_PREFIX_RESERVE (BOT_MAX_SENDER_NAME_LEN + 2) - #define BOT_MAX_GROUP_RESPONSE_LEN (BOT_MAX_TEXT_LEN - BOT_GROUP_RESPONSE_PREFIX_RESERVE) - #define BOT_COMMAND_COOLDOWN_MILLIS 5000UL -@@ -43,10 +46,16 @@ - #define BOT_COMMAND_MASK_DICE (1UL << BOT_COMMAND_DICE) - #define BOT_COMMAND_MASK_STATUS (1UL << BOT_COMMAND_STATUS) - #define BOT_COMMAND_MASK_CHANNELS (1UL << BOT_COMMAND_CHANNELS) -+#define BOT_COMMAND_MASK_VERSION (1UL << BOT_COMMAND_VERSION) -+#define BOT_COMMAND_MASK_STATS (1UL << BOT_COMMAND_STATS) -+#define BOT_COMMAND_MASK_MAGIC8 (1UL << BOT_COMMAND_MAGIC8) -+#define BOT_COMMAND_MASK_PATH (1UL << BOT_COMMAND_PATH) - #define BOT_COMMAND_MASK_UNKNOWN (1UL << BOT_COMMAND_UNKNOWN) - #define BOT_COMMAND_MASK_ALL (BOT_COMMAND_MASK_HELP | BOT_COMMAND_MASK_PING | BOT_COMMAND_MASK_TEST | \ - BOT_COMMAND_MASK_HELLO | BOT_COMMAND_MASK_ABOUT | BOT_COMMAND_MASK_DICE | \ -- BOT_COMMAND_MASK_STATUS | BOT_COMMAND_MASK_CHANNELS | BOT_COMMAND_MASK_UNKNOWN) -+ BOT_COMMAND_MASK_STATUS | BOT_COMMAND_MASK_CHANNELS | BOT_COMMAND_MASK_VERSION | \ -+ BOT_COMMAND_MASK_STATS | BOT_COMMAND_MASK_MAGIC8 | BOT_COMMAND_MASK_PATH | \ -+ BOT_COMMAND_MASK_UNKNOWN) - #define BOT_PREFS_SERIALIZED_SIZE 294 - - enum BotChannelKind : uint8_t { -@@ -74,6 +83,10 @@ enum BotCommandId : uint8_t { - BOT_COMMAND_DICE, - BOT_COMMAND_STATUS, - BOT_COMMAND_CHANNELS, -+ BOT_COMMAND_VERSION, -+ BOT_COMMAND_STATS, -+ BOT_COMMAND_MAGIC8, -+ BOT_COMMAND_PATH, - BOT_COMMAND_UNKNOWN - }; - -@@ -116,8 +129,13 @@ struct BotMessage { - uint8_t sender_key_prefix_len; - bool text_truncated; - uint32_t sender_timestamp; -+ uint8_t path_len; -+ uint8_t path_hash_size; -+ uint8_t path_hash_count; -+ int8_t packet_snr_quarters; - char text[BOT_MAX_TEXT_LEN + 1]; - size_t text_len; -+ const uint8_t* path; - }; - - struct BotCommand { -@@ -141,6 +159,8 @@ struct BotCommandContext { - char testing_channel[BOT_MAX_CHANNEL_NAME_LEN + 1]; - char emergency_channel[BOT_MAX_CHANNEL_NAME_LEN + 1]; - char public_channel[BOT_MAX_CHANNEL_NAME_LEN + 1]; -+ char firmware_version[BOT_MAX_FIRMWARE_VERSION_LEN + 1]; -+ char firmware_build_date[BOT_MAX_BUILD_DATE_LEN + 1]; - uint32_t uptime_seconds; - uint16_t battery_millivolts; - uint32_t storage_used_kb; -@@ -150,8 +170,30 @@ struct BotCommandContext { - uint32_t eligible_messages; - uint32_t sent_messages; - uint32_t send_failures; -+ uint32_t suppressed_responses; -+ uint32_t pending_responses; -+ uint32_t emergency_forwards; -+ uint32_t emergency_forward_failures; -+ uint32_t packets_recv; -+ uint32_t packets_sent; -+ uint32_t packets_recv_errors; -+ uint32_t flood_recv; -+ uint32_t flood_sent; -+ uint32_t direct_recv; -+ uint32_t direct_sent; -+ uint32_t tx_airtime_seconds; -+ uint32_t rx_airtime_seconds; - uint32_t random_seed; -+ int16_t noise_floor; -+ int8_t last_rssi; -+ int8_t last_snr_quarters; -+ uint8_t queue_depth; - uint8_t channel_count; -+ uint8_t path_len; -+ uint8_t path_hash_size; -+ uint8_t path_hash_count; -+ int8_t path_snr_quarters; -+ const uint8_t* path; - }; - - struct BotCommandResult { -@@ -232,10 +274,10 @@ struct BotStats { - uint32_t send_failures; - }; - --static_assert(sizeof(BotMessage) <= 248, "BotMessage RAM budget exceeded"); -+static_assert(sizeof(BotMessage) <= 264, "BotMessage RAM budget exceeded"); - static_assert(sizeof(BotCommand) <= 120, "BotCommand RAM budget exceeded"); - static_assert(sizeof(BotResponse) <= 184, "BotResponse RAM budget exceeded"); --static_assert(sizeof(BotCommandContext) <= 192, "BotCommandContext RAM budget exceeded"); -+static_assert(sizeof(BotCommandContext) <= 312, "BotCommandContext RAM budget exceeded"); - static_assert(sizeof(BotCommandResult) <= 16, "BotCommandResult RAM budget exceeded"); - static_assert(sizeof(BotCommandCooldown) <= 8, "BotCommandCooldown RAM budget exceeded"); - static_assert(sizeof(BotKnownBotEntry) <= 24, "BotKnownBotEntry RAM budget exceeded"); -diff --git a/examples/companion_radio/FirmwareBot.cpp b/examples/companion_radio/FirmwareBot.cpp -index de44fb9..8cdf764 100644 ---- a/examples/companion_radio/FirmwareBot.cpp -+++ b/examples/companion_radio/FirmwareBot.cpp -@@ -119,12 +119,16 @@ BotWriteResult normalizeText(const char* input, size_t input_len, char* output, - BotCommandId commandIdForName(const char* name, size_t len) { - if (namesEqual(name, len, "help") || namesEqual(name, len, "cmd") || namesEqual(name, len, "commands")) return BOT_COMMAND_HELP; - if (namesEqual(name, len, "ping")) return BOT_COMMAND_PING; -- if (namesEqual(name, len, "test")) return BOT_COMMAND_TEST; -+ if (namesEqual(name, len, "test") || namesEqual(name, len, "t")) return BOT_COMMAND_TEST; - if (namesEqual(name, len, "hello") || namesEqual(name, len, "hi")) return BOT_COMMAND_HELLO; - if (namesEqual(name, len, "about")) return BOT_COMMAND_ABOUT; - if (namesEqual(name, len, "dice") || namesEqual(name, len, "roll")) return BOT_COMMAND_DICE; - if (namesEqual(name, len, "status")) return BOT_COMMAND_STATUS; - if (namesEqual(name, len, "channels")) return BOT_COMMAND_CHANNELS; -+ if (namesEqual(name, len, "version") || namesEqual(name, len, "ver")) return BOT_COMMAND_VERSION; -+ if (namesEqual(name, len, "stats")) return BOT_COMMAND_STATS; -+ if (namesEqual(name, len, "magic8") || namesEqual(name, len, "8ball") || namesEqual(name, len, "eightball")) return BOT_COMMAND_MAGIC8; -+ if (namesEqual(name, len, "path") || namesEqual(name, len, "p") || namesEqual(name, len, "decode") || namesEqual(name, len, "route")) return BOT_COMMAND_PATH; - return BOT_COMMAND_UNKNOWN; - } - -diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp -index ec6a780..895be4b 100644 ---- a/examples/companion_radio/MyMesh.cpp -+++ b/examples/companion_radio/MyMesh.cpp -@@ -835,7 +835,7 @@ void MyMesh::observeBotDirectMessage(const ContactInfo &from, uint32_t sender_ti - } - - void MyMesh::observeBotChannelMessage(uint8_t channel_idx, const char *channel_name, const char *text, -- uint32_t sender_timestamp) { -+ uint32_t sender_timestamp, const mesh::Packet *packet) { - BotMessage message; - memset(&message, 0, sizeof(message)); - size_t channel_len = botBoundedStrLen(channel_name, BOT_MAX_CHANNEL_NAME_LEN); -@@ -845,6 +845,13 @@ void MyMesh::observeBotChannelMessage(uint8_t channel_idx, const char *channel_n - message.channel_name[channel_len] = 0; - } - message.sender_timestamp = sender_timestamp; -+ if (packet && packet->isRouteFlood() && packet->path_len <= 0xFF && mesh::Packet::isValidPathLen((uint8_t)packet->path_len)) { -+ message.path_len = (uint8_t)packet->path_len; -+ message.path_hash_size = packet->getPathHashSize(); -+ message.path_hash_count = packet->getPathHashCount(); -+ message.packet_snr_quarters = (int8_t)(packet->getSNR() * 4); -+ message.path = packet->path; -+ } - - const char *body = text; - size_t body_len = botBoundedStrLen(text, BOT_MAX_TEXT_LEN); -@@ -867,10 +874,14 @@ void MyMesh::buildBotCommandContext(BotCommandContext &context, BotCommandId com - context.eligible_messages = bot_stats.eligible_messages; - context.sent_messages = bot_stats.sent_messages; - context.send_failures = bot_stats.send_failures; -- if (command_id == BOT_COMMAND_DICE) { -+ context.suppressed_responses = bot_stats.suppressed_responses; -+ context.pending_responses = bot_stats.pending_responses; -+ context.emergency_forwards = bot_stats.emergency_forwards; -+ context.emergency_forward_failures = bot_stats.emergency_forward_failures; -+ if (command_id == BOT_COMMAND_DICE || command_id == BOT_COMMAND_MAGIC8) { - getRNG()->random((uint8_t *)&context.random_seed, sizeof(context.random_seed)); - } -- if (command_id == BOT_COMMAND_STATUS) { -+ if (command_id == BOT_COMMAND_STATUS || command_id == BOT_COMMAND_STATS) { - context.battery_millivolts = board.getBattMilliVolts(); - context.storage_used_kb = _store->getStorageUsedKb(); - context.storage_total_kb = _store->getStorageTotalKb(); -@@ -881,6 +892,25 @@ void MyMesh::buildBotCommandContext(BotCommandContext &context, BotCommandId com - if (getChannel(i, channel) && channel.name[0]) context.channel_count++; - } - } -+ if (command_id == BOT_COMMAND_VERSION) { -+ StrHelper::strzcpy(context.firmware_version, FIRMWARE_VERSION, sizeof(context.firmware_version)); -+ StrHelper::strzcpy(context.firmware_build_date, FIRMWARE_BUILD_DATE, sizeof(context.firmware_build_date)); -+ } -+ if (command_id == BOT_COMMAND_STATS) { -+ context.queue_depth = (uint8_t)_mgr->getOutboundTotal(); -+ context.noise_floor = (int16_t)_radio->getNoiseFloor(); -+ context.last_rssi = (int8_t)radio_driver.getLastRSSI(); -+ context.last_snr_quarters = (int8_t)(radio_driver.getLastSNR() * 4); -+ context.tx_airtime_seconds = getTotalAirTime() / 1000; -+ context.rx_airtime_seconds = getReceiveAirTime() / 1000; -+ context.packets_recv = radio_driver.getPacketsRecv(); -+ context.packets_sent = radio_driver.getPacketsSent(); -+ context.flood_sent = getNumSentFlood(); -+ context.direct_sent = getNumSentDirect(); -+ context.flood_recv = getNumRecvFlood(); -+ context.direct_recv = getNumRecvDirect(); -+ context.packets_recv_errors = radio_driver.getPacketsRecvErrors(); -+ } - } - - bool MyMesh::enqueueBotResponse(const BotMessage &message, const ContactInfo *direct_recipient, uint8_t channel_idx, -@@ -1024,6 +1054,13 @@ void MyMesh::recordBotObservation(const BotMessage &message, const ContactInfo * - - BotCommandContext context; - buildBotCommandContext(context, command.id); -+ if (command.id == BOT_COMMAND_PATH) { -+ context.path_len = message.path_len; -+ context.path_hash_size = message.path_hash_size; -+ context.path_hash_count = message.path_hash_count; -+ context.path_snr_quarters = message.packet_snr_quarters; -+ context.path = message.path; -+ } - char response[BOT_MAX_RESPONSE_LEN + 1]; - BotCommandResult result = BotCommands::executeCommand(command, context, response, sizeof(response)); - if (result.code == BOT_COMMAND_RESULT_NOT_HANDLED || result.code == BOT_COMMAND_RESULT_NO_SPACE || result.text_len == 0) { -@@ -1232,7 +1269,7 @@ void MyMesh::onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packe - if (_ui) _ui->newMsg(path_len, channel_name, text, offline_queue_len); - #endif - #if CMESH_BOT_ENABLED -- observeBotChannelMessage(channel_idx, channel_name, text, timestamp); -+ observeBotChannelMessage(channel_idx, channel_name, text, timestamp, pkt); - #endif - } - -diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h -index 6f8d432..e25f9a5 100644 ---- a/examples/companion_radio/MyMesh.h -+++ b/examples/companion_radio/MyMesh.h -@@ -209,7 +209,7 @@ private: - void observeBotDirectMessage(const ContactInfo &from, uint32_t sender_timestamp, const uint8_t *sender_prefix, - size_t sender_prefix_len, const char *text); - void observeBotChannelMessage(uint8_t channel_idx, const char *channel_name, const char *text, -- uint32_t sender_timestamp); -+ uint32_t sender_timestamp, const mesh::Packet *packet); - void recordBotObservation(const BotMessage &message, const ContactInfo *direct_recipient, uint8_t channel_idx); - bool observeKnownBotResponse(const BotMessage &message, bool authoritative_sender); - void buildBotCommandContext(BotCommandContext &context, BotCommandId command_id); diff --git a/patches/meshcore/0010-Harden-emergency-forward-reliability.patch b/patches/meshcore/0010-Harden-emergency-forward-reliability.patch deleted file mode 100644 index 43c97a0..0000000 --- a/patches/meshcore/0010-Harden-emergency-forward-reliability.patch +++ /dev/null @@ -1,92 +0,0 @@ -diff --git a/examples/companion_radio/BotTypes.h b/examples/companion_radio/BotTypes.h -index 5eb71c6..7ee34c8 100644 ---- a/examples/companion_radio/BotTypes.h -+++ b/examples/companion_radio/BotTypes.h -@@ -17,8 +17,6 @@ - #define BOT_COMMAND_COOLDOWN_MILLIS 5000UL - #define BOT_EMERGENCY_PREFIX "EMERGENCY MESSAGE FROM " - #define BOT_EMERGENCY_MAX_PARTS 3 --#define BOT_EMERGENCY_RATE_LIMIT_MILLIS 60000UL --#define BOT_EMERGENCY_RATE_LIMIT_COUNT 3 - #define BOT_PENDING_EMERGENCY_SLOTS BOT_EMERGENCY_MAX_PARTS - #define BOT_COORDINATOR_PENDING_SLOTS 8 - #define BOT_COORDINATOR_RECENT_SLOTS 16 -diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp -index 895be4b..89e130b 100644 ---- a/examples/companion_radio/MyMesh.cpp -+++ b/examples/companion_radio/MyMesh.cpp -@@ -960,23 +960,7 @@ bool MyMesh::findBotChannel(BotChannelKind kind, uint8_t &channel_idx) { - return false; - } - --bool MyMesh::isEmergencyRateLimited() { -- unsigned long now = _ms->getMillis(); -- if (!emergency_rate_window_started || millisHasNowPassed(emergency_rate_window_started + BOT_EMERGENCY_RATE_LIMIT_MILLIS)) { -- emergency_rate_window_started = now; -- emergency_rate_count = 0; -- return false; -- } -- return emergency_rate_count >= BOT_EMERGENCY_RATE_LIMIT_COUNT; --} -- --void MyMesh::recordEmergencyRateLimitEvent() { -- if (emergency_rate_count < 0xFF) emergency_rate_count++; --} -- - bool MyMesh::enqueueEmergencyForward(const BotMessage &message) { -- if (isEmergencyRateLimited()) return false; -- - BotEmergencyForward forward; - if (!EmergencyForwarder::format(message, forward)) return false; - -@@ -999,7 +983,6 @@ bool MyMesh::enqueueEmergencyForward(const BotMessage &message) { - } - } - -- recordEmergencyRateLimitEvent(); - return true; - } - -@@ -1175,10 +1158,10 @@ void MyMesh::sendQueuedEmergencyForwards() { - - if (success) { - bot_stats.emergency_forwards++; -+ pending->active = false; - } else { - bot_stats.emergency_forward_failures++; - } -- pending->active = false; - } - } - -@@ -1561,8 +1544,6 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe - ResponseCoordinator::clear(bot_coordinator_pending, BOT_COORDINATOR_PENDING_SLOTS); - ResponseCoordinator::clearRecent(bot_coordinator_recent, BOT_COORDINATOR_RECENT_SLOTS); - KnownBotRegistry::clear(known_bot_entries, BOT_KNOWN_BOT_SLOTS); -- emergency_rate_window_started = 0; -- emergency_rate_count = 0; - next_bot_local_advert = 0; - next_bot_flood_advert = 0; - #endif -diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h -index e25f9a5..20497b7 100644 ---- a/examples/companion_radio/MyMesh.h -+++ b/examples/companion_radio/MyMesh.h -@@ -218,8 +218,6 @@ private: - BotFingerprint response_fingerprint); - bool enqueueEmergencyForward(const BotMessage &message); - bool findBotChannel(BotChannelKind kind, uint8_t &channel_idx); -- bool isEmergencyRateLimited(); -- void recordEmergencyRateLimitEvent(); - void sendQueuedBotResponses(); - void sendQueuedEmergencyForwards(); - void tickBot(); -@@ -285,8 +283,6 @@ private: - BotCoordinatorPending bot_coordinator_pending[BOT_COORDINATOR_PENDING_SLOTS]; - BotCoordinatorRecent bot_coordinator_recent[BOT_COORDINATOR_RECENT_SLOTS]; - BotKnownBotEntry known_bot_entries[BOT_KNOWN_BOT_SLOTS]; -- unsigned long emergency_rate_window_started; -- uint8_t emergency_rate_count; - unsigned long next_bot_local_advert; - unsigned long next_bot_flood_advert; - #endif diff --git a/scripts/check-bot-safety.sh b/scripts/check-bot-safety.sh index f45959e..b70c2be 100644 --- a/scripts/check-bot-safety.sh +++ b/scripts/check-bot-safety.sh @@ -9,6 +9,8 @@ bot_sources=( "${MESHCORE_DIR}/examples/companion_radio/FirmwareBot.cpp" "${MESHCORE_DIR}/examples/companion_radio/BotCommands.h" "${MESHCORE_DIR}/examples/companion_radio/BotCommands.cpp" + "${MESHCORE_DIR}/examples/companion_radio/BotCommandRegistry.h" + "${MESHCORE_DIR}/examples/companion_radio/BotCommandRegistry.cpp" "${MESHCORE_DIR}/examples/companion_radio/BotPolicy.h" "${MESHCORE_DIR}/examples/companion_radio/BotPolicy.cpp" "${MESHCORE_DIR}/examples/companion_radio/BotPrefs.h" @@ -65,6 +67,8 @@ require_pattern 'recordBotObservation\(|sendQueuedEmergencyForwards\(|tickBot\(' "${MESHCORE_DIR}/examples/companion_radio/MyMesh.cpp" require_pattern '_prefs\.path_hash_mode[[:space:]]*=[[:space:]]*1' 'bot firmware defaults to two-byte path hashes' \ "${MESHCORE_DIR}/examples/companion_radio/MyMesh.cpp" +require_pattern 'command\.args_len > 0 \? botConfiguredTraceHashSize\(_prefs\.path_hash_mode\) : message\.path_hash_size' 'explicit bot trace paths use configured hash width' \ + "${MESHCORE_DIR}/examples/companion_radio/MyMesh.cpp" if grep -R -n -E 'BOT_EMERGENCY_RATE_LIMIT|isEmergencyRateLimited|recordEmergencyRateLimitEvent|emergency_rate_window_started|emergency_rate_count' \ "${MESHCORE_DIR}/examples/companion_radio"; then echo "Emergency forwarding must not be globally rate limited or dropped by a bot-local quota." >&2 diff --git a/tests/firmware_bot/run_tests.py b/tests/firmware_bot/run_tests.py index 6997508..ac55024 100644 --- a/tests/firmware_bot/run_tests.py +++ b/tests/firmware_bot/run_tests.py @@ -21,6 +21,7 @@ def main(): str(SRC_DIR), str(ROOT / "tests" / "firmware_bot" / "test_firmware_bot.cpp"), str(SRC_DIR / "FirmwareBot.cpp"), + str(SRC_DIR / "BotCommandRegistry.cpp"), str(SRC_DIR / "BotPrefs.cpp"), str(SRC_DIR / "BotPolicy.cpp"), str(SRC_DIR / "BotCommands.cpp"), diff --git a/tests/firmware_bot/test_firmware_bot.cpp b/tests/firmware_bot/test_firmware_bot.cpp index e50f1fd..bc89366 100644 --- a/tests/firmware_bot/test_firmware_bot.cpp +++ b/tests/firmware_bot/test_firmware_bot.cpp @@ -2,6 +2,7 @@ #include #include +#include "BotCommandRegistry.h" #include "BotCommands.h" #include "BotPolicy.h" #include "BotPrefs.h" @@ -10,6 +11,48 @@ #include "KnownBotRegistry.h" #include "ResponseCoordinator.h" +static void test_command_registry() { + assert(BotCommandRegistry::commandCount() >= 18); + uint32_t seen_masks = 0; + bool saw_hidden = false; + bool saw_internal = false; + for (size_t i = 0; i < BotCommandRegistry::commandCount(); i++) { + const BotCommandMetadata* command = BotCommandRegistry::commandAt(i); + assert(command != NULL); + assert(BotCommandRegistry::findById(command->id) == command); + assert(command->name != NULL); + assert(command->name[0] != 0); + assert(command->summary != NULL); + assert(command->usage != NULL); + assert(command->details != NULL); + const BotCommandMetadata* by_name = BotCommandRegistry::findByName(command->name, strlen(command->name)); + assert(by_name == command); + if (command->visibility == BOT_COMMAND_VISIBILITY_DISCOVERABLE) { + assert(command->mask != 0); + assert((seen_masks & command->mask) == 0); + seen_masks |= command->mask; + assert(BotCommandRegistry::isDiscoverable(command->id)); + } else { + assert(command->mask == 0); + assert(!BotCommandRegistry::isDiscoverable(command->id)); + saw_hidden = saw_hidden || command->visibility == BOT_COMMAND_VISIBILITY_HIDDEN; + saw_internal = saw_internal || command->visibility == BOT_COMMAND_VISIBILITY_INTERNAL; + } + } + assert((seen_masks & BOT_COMMAND_MASK_ALL) == BOT_COMMAND_MASK_ALL); + assert(saw_hidden); + assert(saw_internal); + assert(BotCommandRegistry::findByName("ROLL", 4)->id == BOT_COMMAND_ROLL); + assert(BotCommandRegistry::findByName("dice", 4)->id == BOT_COMMAND_DICE); + assert(BotCommandRegistry::findByName("commands", 8)->id == BOT_COMMAND_CMD); + assert(BotCommandRegistry::findByName("t", 1)->id == BOT_COMMAND_TEST); + assert(BotCommandRegistry::findByName("tracer", 6)->id == BOT_COMMAND_TRACER); + assert(BotCommandRegistry::findByName("weather", 7)->id == BOT_COMMAND_UNSUPPORTED); + assert(BotCommandRegistry::findByName("nope", 4) == NULL); + assert(strcmp(BotCommandRegistry::commandName(BOT_COMMAND_ROLL), "roll") == 0); + assert(BotCommandRegistry::commandMask(BOT_COMMAND_UNKNOWN) == 0); +} + static void test_channel_policy() { assert(BotPolicy::classifyChannel(NULL, 0, true) == BOT_CHANNEL_DM); assert(BotPolicy::decide(BOT_CHANNEL_DM) == BOT_POLICY_ALLOW_NORMAL); @@ -19,20 +62,26 @@ static void test_channel_policy() { assert(BotPolicy::classifyChannel("#Public", 7, false) == BOT_CHANNEL_OTHER); assert(BotPolicy::decide(BOT_CHANNEL_PUBLIC) == BOT_POLICY_IGNORE); assert(!BotPolicy::isNormalAllowed(BOT_CHANNEL_PUBLIC)); + assert(!BotPolicy::isPrefixlessCommandAllowed(BOT_CHANNEL_PUBLIC)); assert(BotPolicy::classifyChannel("#bot", 4, false) == BOT_CHANNEL_BOT); assert(BotPolicy::decide(BOT_CHANNEL_BOT) == BOT_POLICY_ALLOW_NORMAL); assert(BotPolicy::isNormalAllowed(BOT_CHANNEL_BOT)); + assert(BotPolicy::isPrefixlessCommandAllowed(BOT_CHANNEL_BOT)); assert(BotPolicy::classifyChannel("testing", 7, false) == BOT_CHANNEL_TESTING); assert(BotPolicy::decide(BOT_CHANNEL_TESTING) == BOT_POLICY_ALLOW_NORMAL); assert(BotPolicy::isNormalAllowed(BOT_CHANNEL_TESTING)); + assert(BotPolicy::isPrefixlessCommandAllowed(BOT_CHANNEL_TESTING)); assert(BotPolicy::classifyChannel("#emergency", 10, false) == BOT_CHANNEL_EMERGENCY); assert(BotPolicy::classifyChannel("emergency", 9, false) == BOT_CHANNEL_OTHER); assert(BotPolicy::classifyChannel("#Emergency", 10, false) == BOT_CHANNEL_OTHER); assert(BotPolicy::decide(BOT_CHANNEL_EMERGENCY) == BOT_POLICY_EMERGENCY_FORWARD); assert(BotPolicy::isEmergency(BOT_CHANNEL_EMERGENCY)); assert(!BotPolicy::isNormalAllowed(BOT_CHANNEL_EMERGENCY)); + assert(!BotPolicy::isPrefixlessCommandAllowed(BOT_CHANNEL_EMERGENCY)); assert(BotPolicy::classifyChannel("#botnet", 7, false) == BOT_CHANNEL_OTHER); assert(BotPolicy::decide(BOT_CHANNEL_OTHER) == BOT_POLICY_IGNORE); + assert(!BotPolicy::isPrefixlessCommandAllowed(BOT_CHANNEL_OTHER)); + assert(!BotPolicy::isPrefixlessCommandAllowed(BOT_CHANNEL_DM)); } static void test_bot_prefs_defaults() { @@ -49,7 +98,7 @@ static void test_bot_prefs_defaults() { assert(strcmp(prefs.public_channel, "Public") == 0); assert(BotPrefsCodec::serializedSize() == BOT_PREFS_SERIALIZED_SIZE); assert(BotPrefsCodec::commandEnabled(prefs, BOT_COMMAND_PING)); - assert(BotPrefsCodec::commandEnabled(prefs, BOT_COMMAND_UNKNOWN)); + assert(!BotPrefsCodec::commandEnabled(prefs, BOT_COMMAND_UNKNOWN)); } static void test_bot_prefs_serialization_round_trip() { @@ -134,9 +183,14 @@ static void test_bot_prefs_validation_and_command_mask() { assert(!BotPrefsCodec::commandEnabled(prefs, BOT_COMMAND_PING)); BotPrefsCodec::setCommandEnabled(prefs, BOT_COMMAND_PING, true); assert(BotPrefsCodec::commandEnabled(prefs, BOT_COMMAND_PING)); + assert(BotPrefsCodec::commandEnabled(prefs, BOT_COMMAND_CMD)); + assert(BotPrefsCodec::commandEnabled(prefs, BOT_COMMAND_TRACE)); + assert(BotPrefsCodec::commandEnabled(prefs, BOT_COMMAND_PREFIX)); BotCommandId id = BOT_COMMAND_NONE; + assert(BotPrefsCodec::commandIdForName("commands", &id)); + assert(id == BOT_COMMAND_CMD); assert(BotPrefsCodec::commandIdForName("ROLL", &id)); - assert(id == BOT_COMMAND_DICE); + assert(id == BOT_COMMAND_ROLL); assert(BotPrefsCodec::commandIdForName("t", &id)); assert(id == BOT_COMMAND_TEST); assert(BotPrefsCodec::commandIdForName("ver", &id)); @@ -145,9 +199,31 @@ static void test_bot_prefs_validation_and_command_mask() { assert(id == BOT_COMMAND_MAGIC8); assert(BotPrefsCodec::commandIdForName("p", &id)); assert(id == BOT_COMMAND_PATH); + assert(BotPrefsCodec::commandIdForName("tracer", &id)); + assert(id == BOT_COMMAND_TRACER); + assert(!BotPrefsCodec::commandIdForName("weather", &id)); + assert(BotPrefsCodec::commandIdForName("prefix", &id)); + assert(id == BOT_COMMAND_PREFIX); + assert(!BotPrefsCodec::commandIdForName("unknown", &id)); assert(!BotPrefsCodec::commandIdForName("nope", &id)); } +static bool command_allowed_by_runtime_gate(const BotPrefs& prefs, BotCommandId command_id) { + return command_id == BOT_COMMAND_UNKNOWN || command_id == BOT_COMMAND_UNSUPPORTED || BotPrefsCodec::commandEnabled(prefs, command_id); +} + +static void test_unknown_command_runtime_gate() { + BotPrefs prefs; + BotPrefsCodec::defaults(prefs); + assert(command_allowed_by_runtime_gate(prefs, BOT_COMMAND_UNKNOWN)); + assert(command_allowed_by_runtime_gate(prefs, BOT_COMMAND_UNSUPPORTED)); + assert(!BotPrefsCodec::commandEnabled(prefs, BOT_COMMAND_UNKNOWN)); + assert(!BotPrefsCodec::commandEnabled(prefs, BOT_COMMAND_UNSUPPORTED)); + assert(!BotPrefsCodec::commandIdForName("weather", NULL)); + BotPrefsCodec::setCommandEnabled(prefs, BOT_COMMAND_PING, false); + assert(!command_allowed_by_runtime_gate(prefs, BOT_COMMAND_PING)); +} + static void test_bot_prefs_known_bot_helpers() { BotPrefs prefs; BotPrefsCodec::defaults(prefs); @@ -226,6 +302,11 @@ static void test_normalize_truncation() { assert(written == BOT_MAX_TEXT_LEN); } +static bool parse_channel_command(BotChannelKind channel_kind, const char* text, BotCommand* command) { + return BotPolicy::isNormalAllowed(channel_kind) && + FirmwareBot::parseCommand(text, strlen(text), command, BotPolicy::isPrefixlessCommandAllowed(channel_kind)); +} + static void test_parse_command() { const char* body = NULL; size_t body_len = 0; @@ -242,9 +323,22 @@ static void test_parse_command() { assert(FirmwareBot::parseCommand("ping", 4, &command, true)); assert(command.id == BOT_COMMAND_PING); assert(command.args_len == 0); + assert(parse_channel_command(BOT_CHANNEL_BOT, "ping", &command)); + assert(command.id == BOT_COMMAND_PING); + assert(parse_channel_command(BOT_CHANNEL_TESTING, "ping", &command)); + assert(command.id == BOT_COMMAND_PING); + assert(!parse_channel_command(BOT_CHANNEL_DM, "ping", &command)); + assert(!parse_channel_command(BOT_CHANNEL_PUBLIC, "ping", &command)); + assert(!parse_channel_command(BOT_CHANNEL_OTHER, "ping", &command)); + assert(!parse_channel_command(BOT_CHANNEL_EMERGENCY, "ping", &command)); + assert(parse_channel_command(BOT_CHANNEL_DM, "!ping", &command)); + assert(command.id == BOT_COMMAND_PING); assert(FirmwareBot::parseCommand("status please", 13, &command, true)); assert(command.id == BOT_COMMAND_STATUS); assert(strcmp(command.args, "please") == 0); + assert(!parse_channel_command(BOT_CHANNEL_DM, "status please", &command)); + assert(!parse_channel_command(BOT_CHANNEL_PUBLIC, "status please", &command)); + assert(!parse_channel_command(BOT_CHANNEL_BOT, "random prose", &command)); assert(!FirmwareBot::parseCommand("random prose", 12, &command, true)); assert(FirmwareBot::parseCommand("!PING", 5, &command)); assert(command.id == BOT_COMMAND_PING); @@ -252,7 +346,7 @@ static void test_parse_command() { assert(command.args_len == 0); assert(FirmwareBot::parseCommand("/roll: 2d6", 10, &command)); - assert(command.id == BOT_COMMAND_DICE); + assert(command.id == BOT_COMMAND_ROLL); assert(strcmp(command.name, "roll") == 0); assert(strcmp(command.args, "2d6") == 0); @@ -263,8 +357,10 @@ static void test_parse_command() { assert(FirmwareBot::parseCommand("!channels", 9, &command)); assert(command.id == BOT_COMMAND_CHANNELS); + assert(FirmwareBot::parseCommand("!cmd", 4, &command)); + assert(command.id == BOT_COMMAND_CMD); assert(FirmwareBot::parseCommand("!commands", 9, &command)); - assert(command.id == BOT_COMMAND_HELP); + assert(command.id == BOT_COMMAND_CMD); assert(FirmwareBot::parseCommand("!hi", 3, &command)); assert(command.id == BOT_COMMAND_HELLO); @@ -279,6 +375,21 @@ static void test_parse_command() { assert(command.id == BOT_COMMAND_PATH); assert(FirmwareBot::parseCommand("decode", 6, &command, true)); assert(command.id == BOT_COMMAND_PATH); + assert(FirmwareBot::parseCommand("trace", 5, &command, true)); + assert(command.id == BOT_COMMAND_TRACE); + assert(FirmwareBot::parseCommand("!tracer", 7, &command)); + assert(command.id == BOT_COMMAND_TRACER); + assert(FirmwareBot::parseCommand("!prefix 01020304", 16, &command)); + assert(command.id == BOT_COMMAND_PREFIX); + assert(strcmp(command.args, "01020304") == 0); + assert(FirmwareBot::parseCommand("!weather", 8, &command)); + assert(command.id == BOT_COMMAND_UNSUPPORTED); + assert(FirmwareBot::parseCommand("!reboot now", 11, &command)); + assert(command.id == BOT_COMMAND_UNSUPPORTED); + assert(strcmp(command.name, "reboot") == 0); + assert(strcmp(command.args, "now") == 0); + assert(!FirmwareBot::parseCommand("weather", 7, &command, true)); + assert(!FirmwareBot::parseCommand("reboot", 6, &command, true)); assert(FirmwareBot::parseCommand("!wat", 4, &command)); assert(command.id == BOT_COMMAND_UNKNOWN); @@ -375,8 +486,75 @@ static void test_command_outputs() { result = run_command("!help", out, sizeof(out)); assert(result.code == BOT_COMMAND_RESULT_OK); - assert(strstr(out, "ping") != NULL); - assert(strstr(out, "path") != NULL); + assert(strstr(out, "Commands: help cmd ping") == out); + assert(strstr(out, "roll") != NULL); + assert(strstr(out, "dice") != NULL); + assert(strstr(out, "trace") != NULL); + assert(strstr(out, "tracer") != NULL); + assert(strstr(out, "prefix") != NULL); + assert(strstr(out, "help ") != NULL); + assert(strstr(out, "weather") == NULL); + + result = run_command("!cmd", out, sizeof(out)); + assert(result.code == BOT_COMMAND_RESULT_OK); + assert(strstr(out, "help cmd ping test hello about roll dice") == out); + assert(strstr(out, "trace") != NULL); + assert(strstr(out, "tracer") != NULL); + assert(strstr(out, "prefix") != NULL); + assert(strstr(out, "weather") == NULL); + + result = run_command("!help roll", out, sizeof(out)); + assert(result.code == BOT_COMMAND_RESULT_OK); + assert(strstr(out, "roll: Roll within a numeric range.") == out); + assert(strstr(out, "Usage: roll [max|low high]") != NULL); + + result = run_command("!help dice", out, sizeof(out)); + assert(result.code == BOT_COMMAND_RESULT_OK); + assert(strstr(out, "dice: Roll dice notation") == out); + assert(strstr(out, "Usage: dice [dN|NdN]") != NULL); + + result = run_command("!help trace", out, sizeof(out)); + assert(result.code == BOT_COMMAND_RESULT_OK); + assert(strstr(out, "trace: Send a bounded active trace request") == out); + assert(strstr(out, "Usage: trace [hex-path]") != NULL); + + result = run_command("!help tracer", out, sizeof(out)); + assert(result.code == BOT_COMMAND_RESULT_OK); + assert(strstr(out, "tracer: Show the current packet route hashes") == out); + assert(strstr(out, "Usage: tracer") != NULL); + + result = run_command("!help t", out, sizeof(out)); + assert(result.code == BOT_COMMAND_RESULT_OK); + assert(strstr(out, "test: Return a short firmware bot self-test response") == out); + + result = run_command("!help weather", out, sizeof(out)); + assert(result.code == BOT_COMMAND_RESULT_OK); + assert(strcmp(out, "weather is unavailable in firmware") == 0); + + result = run_command("!help prefix", out, sizeof(out)); + assert(result.code == BOT_COMMAND_RESULT_OK); + assert(strstr(out, "prefix: Look up a local contact by public-key prefix") == out); + assert(strstr(out, "Usage: prefix ") != NULL); + + result = run_command("!prefix 01020304", out, sizeof(out)); + assert(result.code == BOT_COMMAND_RESULT_OK); + assert(strcmp(out, "Prefix lookup unavailable") == 0); + + result = run_command("!help nope", out, sizeof(out)); + assert(result.code == BOT_COMMAND_RESULT_OK); + assert(strcmp(out, "No help for nope") == 0); + + result = run_command("!weather", out, sizeof(out)); + assert(result.code == BOT_COMMAND_RESULT_OK); + assert(strcmp(out, "weather is unavailable in firmware") == 0); + + result = run_command("!reboot now", out, sizeof(out)); + assert(result.code == BOT_COMMAND_RESULT_OK); + assert(strcmp(out, "reboot is unavailable in firmware") == 0); + + result = run_command("!nodes", out, sizeof(out)); + assert(result.code == BOT_COMMAND_RESULT_OK); + assert(strcmp(out, "nodes is unavailable in firmware") == 0); result = run_command("!version", out, sizeof(out)); assert(result.code == BOT_COMMAND_RESULT_OK); @@ -395,6 +573,22 @@ static void test_command_outputs() { assert(result.code == BOT_COMMAND_RESULT_OK); assert(strcmp(out, "Path unavailable") == 0); + result = run_command("!trace", out, sizeof(out)); + assert(result.code == BOT_COMMAND_RESULT_OK); + assert(strcmp(out, "Trace route unavailable") == 0); + + result = run_command("!trace zz", out, sizeof(out)); + assert(result.code == BOT_COMMAND_RESULT_OK); + assert(strcmp(out, "Usage: trace [hex-path]") == 0); + + result = run_command("!tracer", out, sizeof(out)); + assert(result.code == BOT_COMMAND_RESULT_OK); + assert(strcmp(out, "Tracer unavailable") == 0); + + result = run_command("!tracer 1234", out, sizeof(out)); + assert(result.code == BOT_COMMAND_RESULT_OK); + assert(strcmp(out, "Usage: tracer") == 0); + result = run_command("!channels", out, sizeof(out)); assert(result.code == BOT_COMMAND_RESULT_OK); assert(strcmp(out, "Channels: #bot #testing emergency=#emergency public=Public (4 configured)") == 0); @@ -425,47 +619,95 @@ static void test_path_command() { assert(result.code == BOT_COMMAND_RESULT_OK); assert(strcmp(out, "Path 3h x 2B snr 5.75: 1234abcd0001") == 0); + assert(FirmwareBot::parseCommand("!tracer", 7, &command)); + result = BotCommands::executeCommand(command, context, out, sizeof(out)); + assert(result.code == BOT_COMMAND_RESULT_OK); + assert(strcmp(out, "Tracer 3h x 2B snr 5.75: 1234abcd0001") == 0); + + assert(FirmwareBot::parseCommand("!tracer 1234", 12, &command)); + result = BotCommands::executeCommand(command, context, out, sizeof(out)); + assert(result.code == BOT_COMMAND_RESULT_OK); + assert(strcmp(out, "Usage: tracer") == 0); + + assert(FirmwareBot::parseCommand("!path", 5, &command)); context.path = NULL; result = BotCommands::executeCommand(command, context, out, sizeof(out)); assert(result.code == BOT_COMMAND_RESULT_OK); assert(strcmp(out, "Path unavailable") == 0); + + assert(FirmwareBot::parseCommand("!tracer", 7, &command)); + result = BotCommands::executeCommand(command, context, out, sizeof(out)); + assert(result.code == BOT_COMMAND_RESULT_OK); + assert(strcmp(out, "Tracer unavailable") == 0); } static void test_dice_command() { char out[BOT_MAX_RESPONSE_LEN + 1]; BotCommandResult result = run_command("!roll", out, sizeof(out)); assert(result.code == BOT_COMMAND_RESULT_OK); - assert(strncmp(out, "Rolled d6: ", 11) == 0); - - result = run_command("!roll d20", out, sizeof(out)); - assert(result.code == BOT_COMMAND_RESULT_OK); - assert(strncmp(out, "Rolled d20: ", 12) == 0); - - result = run_command("!roll 2d6", out, sizeof(out)); - assert(result.code == BOT_COMMAND_RESULT_OK); - assert(strncmp(out, "Rolled 2d6: ", 12) == 0); - assert(strchr(out, '+') != NULL); - assert(strchr(out, '=') != NULL); + assert(strncmp(out, "Rolled 1-100: ", 14) == 0); result = run_command("!roll 20", out, sizeof(out)); assert(result.code == BOT_COMMAND_RESULT_OK); - assert(strncmp(out, "Rolled d20: ", 12) == 0); + assert(strncmp(out, "Rolled 1-20: ", 13) == 0); - result = run_command("!roll d100", out, sizeof(out)); + result = run_command("!roll 5 10", out, sizeof(out)); assert(result.code == BOT_COMMAND_RESULT_OK); - assert(strncmp(out, "Rolled d100: ", 13) == 0); + assert(strncmp(out, "Rolled 5-10: ", 13) == 0); - result = run_command("!roll d1", out, sizeof(out)); + result = run_command("!roll 1 1", out, sizeof(out)); + assert(result.code == BOT_COMMAND_RESULT_OK); + assert(strcmp(out, "Rolled 1-1: 1") == 0); + + result = run_command("!roll 2d6", out, sizeof(out)); + assert(result.code == BOT_COMMAND_RESULT_OK); + assert(strcmp(out, "Usage: roll [max|low high], range 1-1000") == 0); + + result = run_command("!roll 0", out, sizeof(out)); assert(result.code == BOT_COMMAND_RESULT_OK); assert(strncmp(out, "Usage: roll", 11) == 0); - result = run_command("!roll 0d6", out, sizeof(out)); + result = run_command("!roll 10 1", out, sizeof(out)); assert(result.code == BOT_COMMAND_RESULT_OK); assert(strncmp(out, "Usage: roll", 11) == 0); - result = run_command("!roll 11d6", out, sizeof(out)); + result = run_command("!roll 100000", out, sizeof(out)); assert(result.code == BOT_COMMAND_RESULT_OK); assert(strncmp(out, "Usage: roll", 11) == 0); + + result = run_command("!dice", out, sizeof(out)); + assert(result.code == BOT_COMMAND_RESULT_OK); + assert(strncmp(out, "Dice d6: ", 9) == 0); + + result = run_command("!dice d20", out, sizeof(out)); + assert(result.code == BOT_COMMAND_RESULT_OK); + assert(strncmp(out, "Dice d20: ", 10) == 0); + + result = run_command("!dice 2d6", out, sizeof(out)); + assert(result.code == BOT_COMMAND_RESULT_OK); + assert(strncmp(out, "Dice 2d6: ", 10) == 0); + assert(strchr(out, '+') != NULL); + assert(strchr(out, '=') != NULL); + + result = run_command("!dice 20", out, sizeof(out)); + assert(result.code == BOT_COMMAND_RESULT_OK); + assert(strcmp(out, "Usage: dice [dN|NdN], max 10 dice, sides 2-1000") == 0); + + result = run_command("!dice d1", out, sizeof(out)); + assert(result.code == BOT_COMMAND_RESULT_OK); + assert(strncmp(out, "Usage: dice", 11) == 0); + + result = run_command("!dice 0d6", out, sizeof(out)); + assert(result.code == BOT_COMMAND_RESULT_OK); + assert(strncmp(out, "Usage: dice", 11) == 0); + + result = run_command("!dice 11d6", out, sizeof(out)); + assert(result.code == BOT_COMMAND_RESULT_OK); + assert(strncmp(out, "Usage: dice", 11) == 0); + + result = run_command("!dice 2d1001", out, sizeof(out)); + assert(result.code == BOT_COMMAND_RESULT_OK); + assert(strncmp(out, "Usage: dice", 11) == 0); } static void test_command_truncation() { @@ -852,7 +1094,15 @@ static void test_response_coordinator_delay_biases() { BotFingerprint request = { 0x0123456789ABCDEFULL }; uint32_t first = ResponseCoordinator::responseDelayMillis(message, BOT_COMMAND_PING, request, 0x01020304UL, 0, 0); uint32_t second = ResponseCoordinator::responseDelayMillis(message, BOT_COMMAND_PING, request, 0x05060708UL, 0, 0); + uint32_t roll = ResponseCoordinator::responseDelayMillis(message, BOT_COMMAND_ROLL, request, 0x01020304UL, 0, 0); + uint32_t dice = ResponseCoordinator::responseDelayMillis(message, BOT_COMMAND_DICE, request, 0x01020304UL, 0, 0); + uint32_t trace = ResponseCoordinator::responseDelayMillis(message, BOT_COMMAND_TRACE, request, 0x01020304UL, 0, 0); + uint32_t tracer = ResponseCoordinator::responseDelayMillis(message, BOT_COMMAND_TRACER, request, 0x01020304UL, 0, 0); assert(first != second); + assert(roll == dice); + assert(roll == first + 200); + assert(trace == tracer); + assert(trace == first + 800); assert(ResponseCoordinator::responseDelayMillis(message, BOT_COMMAND_PING, request, 0x01020304UL, 2, BOT_RESPONSE_DELAY_JITTER_MILLIS + 17) == first + 300 + 17); } @@ -897,11 +1147,13 @@ static void test_response_coordinator_rejects_non_normal() { } int main() { + test_command_registry(); test_channel_policy(); test_bot_prefs_defaults(); test_bot_prefs_serialization_round_trip(); test_bot_prefs_rejects_corrupt_and_wrong_version(); test_bot_prefs_validation_and_command_mask(); + test_unknown_command_runtime_gate(); test_bot_prefs_known_bot_helpers(); test_prefs_aware_channel_policy(); test_normalize_text(); diff --git a/vendor/MeshCore b/vendor/MeshCore index 910b1be..43a831d 160000 --- a/vendor/MeshCore +++ b/vendor/MeshCore @@ -1 +1 @@ -Subproject commit 910b1bee5b0ccffc472c7684d4165fc85c681896 +Subproject commit 43a831df10ffadf1c1bc44515453e666c95beed6