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