From 06f032476dab0cae11dd37262a177fb8221f7500 Mon Sep 17 00:00:00 2001 From: Ben Allfree Date: Wed, 18 Feb 2026 04:19:59 -0800 Subject: [PATCH] chore: formatting --- src/LoBBSModule.cpp | 1293 ++++++++++++++++++++++--------------------- 1 file changed, 659 insertions(+), 634 deletions(-) diff --git a/src/LoBBSModule.cpp b/src/LoBBSModule.cpp index d5a7534..840ea7b 100644 --- a/src/LoBBSModule.cpp +++ b/src/LoBBSModule.cpp @@ -11,681 +11,706 @@ #include // Static helper: case-insensitive substring search -static const char *stristr(const char *haystack, const char *needle) -{ - if (!*needle) - return haystack; +static const char *stristr(const char *haystack, const char *needle) { + if (!*needle) + return haystack; - for (; *haystack; haystack++) { - const char *h = haystack; - const char *n = needle; - while (*h && *n && tolower(*h) == tolower(*n)) { - h++; - n++; - } - if (!*n) - return haystack; + for (; *haystack; haystack++) { + const char *h = haystack; + const char *n = needle; + while (*h && *n && tolower(*h) == tolower(*n)) { + h++; + n++; } - return nullptr; + if (!*n) + return haystack; + } + return nullptr; } // Static helper: comparator for case-insensitive alphabetical username sorting -static int compareUsernames(const void *a, const void *b) -{ - const meshtastic_LoBBSUser *u1 = (const meshtastic_LoBBSUser *)a; - const meshtastic_LoBBSUser *u2 = (const meshtastic_LoBBSUser *)b; - return strcasecmp(u1->username, u2->username); +static int compareUsernames(const void *a, const void *b) { + const meshtastic_LoBBSUser *u1 = (const meshtastic_LoBBSUser *)a; + const meshtastic_LoBBSUser *u2 = (const meshtastic_LoBBSUser *)b; + return strcasecmp(u1->username, u2->username); } // Static helper: load a user by UUID -static bool loadUserByUuid(LoBBSDal *dal, uint64_t uuid, meshtastic_LoBBSUser *outUser) -{ - auto users = dal->getDb()->select( - "users", - [uuid](const void *rec) -> bool { - const meshtastic_LoBBSUser *u = (const meshtastic_LoBBSUser *)rec; - return u->uuid == uuid; - }, - nullptr); +static bool loadUserByUuid(LoBBSDal *dal, uint64_t uuid, + meshtastic_LoBBSUser *outUser) { + auto users = dal->getDb()->select( + "users", + [uuid](const void *rec) -> bool { + const meshtastic_LoBBSUser *u = (const meshtastic_LoBBSUser *)rec; + return u->uuid == uuid; + }, + nullptr); - bool found = false; - if (!users.empty()) { - *outUser = *(const meshtastic_LoBBSUser *)users[0]; - found = true; - } + bool found = false; + if (!users.empty()) { + *outUser = *(const meshtastic_LoBBSUser *)users[0]; + found = true; + } - LoDb::freeRecords(users); + LoDb::freeRecords(users); - return found; + return found; } // Static helper: format time ago (e.g., "2h ago", "5m ago", "1d ago") -static void formatTimeAgo(uint32_t timestamp, char *buffer, size_t bufferSize) -{ - uint32_t now = getTime(); - if (now < timestamp) { - snprintf(buffer, bufferSize, "now"); - return; - } +static void formatTimeAgo(uint32_t timestamp, char *buffer, size_t bufferSize) { + uint32_t now = getTime(); + if (now < timestamp) { + snprintf(buffer, bufferSize, "now"); + return; + } - uint32_t diff = now - timestamp; + uint32_t diff = now - timestamp; - if (diff < 60) { - snprintf(buffer, bufferSize, "%ds ago", diff); - } else if (diff < 3600) { - snprintf(buffer, bufferSize, "%dm ago", diff / 60); - } else if (diff < 86400) { - snprintf(buffer, bufferSize, "%dh ago", diff / 3600); - } else { - snprintf(buffer, bufferSize, "%dd ago", diff / 86400); - } + if (diff < 60) { + snprintf(buffer, bufferSize, "%ds ago", diff); + } else if (diff < 3600) { + snprintf(buffer, bufferSize, "%dm ago", diff / 60); + } else if (diff < 86400) { + snprintf(buffer, bufferSize, "%dh ago", diff / 3600); + } else { + snprintf(buffer, bufferSize, "%dd ago", diff / 86400); + } } // Static helper: truncate message for list view -static void truncateMessage(const char *message, char *buffer, size_t bufferSize, size_t maxLen) -{ - size_t msgLen = strlen(message); - if (msgLen <= maxLen) { - strncpy(buffer, message, bufferSize - 1); - buffer[bufferSize - 1] = '\0'; - } else { - size_t copyLen = maxLen < bufferSize - 4 ? maxLen : bufferSize - 4; - strncpy(buffer, message, copyLen); - buffer[copyLen] = '\0'; - strncat(buffer, "...", bufferSize - strlen(buffer) - 1); - } +static void truncateMessage(const char *message, char *buffer, + size_t bufferSize, size_t maxLen) { + size_t msgLen = strlen(message); + if (msgLen <= maxLen) { + strncpy(buffer, message, bufferSize - 1); + buffer[bufferSize - 1] = '\0'; + } else { + size_t copyLen = maxLen < bufferSize - 4 ? maxLen : bufferSize - 4; + strncpy(buffer, message, copyLen); + buffer[copyLen] = '\0'; + strncat(buffer, "...", bufferSize - strlen(buffer) - 1); + } } -static void freeMailMessages(std::vector &mailMessages) -{ - LoDb::freeRecords(mailMessages); +static void freeMailMessages(std::vector &mailMessages) { + LoDb::freeRecords(mailMessages); } -static void freeNewsEntries(std::vector &newsItems) -{ - for (auto &entry : newsItems) { - delete[] (uint8_t *)entry.news; - } - newsItems.clear(); +static void freeNewsEntries(std::vector &newsItems) { + for (auto &entry : newsItems) { + delete[] (uint8_t *)entry.news; + } + newsItems.clear(); } -LoBBSModule::LoBBSModule() : SinglePortModule("LoBBS", meshtastic_PortNum_TEXT_MESSAGE_APP) -{ - // Create data access layer with host node ID as salt - dal = new LoBBSDal(nodeDB->getNodeNum()); +LoBBSModule::LoBBSModule() + : SinglePortModule("LoBBS", meshtastic_PortNum_TEXT_MESSAGE_APP) { + // Create data access layer with host node ID as salt + dal = new LoBBSDal(nodeDB->getNodeNum()); } -ProcessMessage LoBBSModule::handleReceived(const meshtastic_MeshPacket &mp) -{ - // Only process direct messages to this node, ignore broadcasts - if (!isToUs(&mp)) { - return ProcessMessage::CONTINUE; +ProcessMessage LoBBSModule::handleReceived(const meshtastic_MeshPacket &mp) { + // Only process direct messages to this node, ignore broadcasts + if (!isToUs(&mp)) { + return ProcessMessage::CONTINUE; + } + + LOG_DEBUG("LoBBS received DM from=0x%0x, id=%d, msg=%.*s", mp.from, mp.id, + mp.decoded.payload.size, mp.decoded.payload.bytes); + + meshtastic_LoBBSUser existingUser = meshtastic_LoBBSUser_init_zero; + bool isAuthenticated = dal->loadUserByNodeId(mp.from, &existingUser); + if (isAuthenticated) { + LOG_DEBUG("User node ID: %08x", mp.from); + LOG_DEBUG("User UUID: " LODB_UUID_FMT, LODB_UUID_ARGS(existingUser.uuid)); + LOG_DEBUG("User: %s", existingUser.username); + LOG_DEBUG("User is authenticated: %d", isAuthenticated); + } else { + LOG_DEBUG("User node ID: %08x", mp.from); + LOG_DEBUG("User is not authenticated"); + } + + // Copy payload to mutable buffer and null terminate (there is no null + // terminator in the payload) + memcpy(msgBuffer, mp.decoded.payload.bytes, mp.decoded.payload.size); + msgBuffer[mp.decoded.payload.size] = '\0'; + + // Check for @mention mail sending BEFORE tokenizing (authenticated users + // only) This must happen before strtok destroys the buffer + if (isAuthenticated && strchr(msgBuffer, '@')) { + LOG_DEBUG("Processing @mention mail from node=0x%0x", mp.from); + + // Extract message content and find all @mentions + std::vector recipients; + std::string messageContent = std::string(msgBuffer); + + // Parse the message for @mentions + char *token = msgBuffer; + bool foundAny = false; + + while (*token) { + if (*token == '@') { + foundAny = true; + // Found a mention, extract username + token++; // Skip @ + char *usernameStart = token; + while (*token && (isalnum(*token) || *token == '_')) { + token++; + } + + // Save username (only if not already in list) + char username[LOBBS_USERNAME_BUFFER_SIZE]; + size_t usernameLen = token - usernameStart; + if (usernameLen > 0 && usernameLen < LOBBS_USERNAME_BUFFER_SIZE) { + strncpy(username, usernameStart, usernameLen); + username[usernameLen] = '\0'; + std::string usernameStr(username); + // Only add if not already in recipients list + if (std::find(recipients.begin(), recipients.end(), usernameStr) == + recipients.end()) { + recipients.push_back(usernameStr); + } + } + } else { + token++; + } } - LOG_DEBUG("LoBBS received DM from=0x%0x, id=%d, msg=%.*s", mp.from, mp.id, mp.decoded.payload.size, mp.decoded.payload.bytes); + if (foundAny && !recipients.empty()) { + // Validate and send to each recipient + int successCount = 0; + int failCount = 0; + std::string failedUsers; + for (const auto &recipient : recipients) { + // Get recipient UUID + uint64_t recipientUuid = dal->getUserUuidByUsername(recipient.c_str()); + if (recipientUuid == 0) { + LOG_WARN("User not found: %s", recipient.c_str()); + if (failCount > 0) + failedUsers += ", "; + failedUsers += "@" + recipient; + failCount++; + continue; + } + + // Send mail + if (dal->sendMail(existingUser.uuid, recipientUuid, + messageContent.c_str())) { + LOG_INFO("Sent mail from %s to %s", existingUser.username, + recipient.c_str()); + successCount++; + } else { + LOG_ERROR("Failed to send mail to %s", recipient.c_str()); + if (failCount > 0) + failedUsers += ", "; + failedUsers += "@" + recipient; + failCount++; + } + } + + // Send confirmation + if (successCount > 0 && failCount == 0) { + if (successCount == 1) { + snprintf(replyBuffer, sizeof(replyBuffer), "Mail sent to @%s", + recipients[0].c_str()); + } else { + snprintf(replyBuffer, sizeof(replyBuffer), "Mail sent to %d users", + successCount); + } + sendReply(mp.from, replyBuffer); + } else if (successCount > 0 && failCount > 0) { + snprintf(replyBuffer, sizeof(replyBuffer), + "Mail sent to %d users. Failed: %s", successCount, + failedUsers.c_str()); + sendReply(mp.from, replyBuffer); + } else { + snprintf(replyBuffer, sizeof(replyBuffer), + "Failed to send mail. Users not found: %s", + failedUsers.c_str()); + sendReply(mp.from, replyBuffer); + } + + return ProcessMessage::CONTINUE; + } + } + + // Tokenize for command processing (this modifies msgBuffer) + char *cmdName = strtok(msgBuffer, " "); + LOG_DEBUG("Token: %s", cmdName); + + if (strcasecmp(cmdName, "/hi") == 0) { + LOG_DEBUG("Processing /hi command from node=0x%0x", mp.from); + + // Get username + char *username = strtok(NULL, " "); + if (!username) { + sendReply(mp.from, "Usage: /hi "); + return ProcessMessage::CONTINUE; + } + + // Validate username length + size_t usernameLen = strlen(username); + if (usernameLen == 0 || usernameLen > LOBBS_MAX_USERNAME_LEN || + !dal->isValidUsername(username)) { + sendReply( + mp.from, + "Username not found or is invalid. Username must be 1-" LOBBS_XSTR( + LOBBS_MAX_USERNAME_LEN) " characters and contain only letters, " + "numbers, and common special " + "characters."); + return ProcessMessage::CONTINUE; + } + + // Get password (rest of the string) + char *password = strtok(NULL, ""); + if (!password) { + sendReply(mp.from, "Usage: /hi "); + return ProcessMessage::CONTINUE; + } + + // Validate password is at least 5 characters long + if (strlen(password) < 5 || strlen(password) > 50 || + !dal->isValidPassword(password)) { + sendReply(mp.from, "Password incorrect or invalid. Password must be " + "between 5 and 50 characters long and contain " + "only letters, numbers, and common " + "special characters."); + return ProcessMessage::CONTINUE; + } + + LOG_DEBUG("Processing /hi command: username=%s from node=0x%0x", username, + mp.from); + + // Try to load existing user meshtastic_LoBBSUser existingUser = meshtastic_LoBBSUser_init_zero; - bool isAuthenticated = dal->loadUserByNodeId(mp.from, &existingUser); - if (isAuthenticated) { - LOG_DEBUG("User node ID: %08x", mp.from); - LOG_DEBUG("User UUID: " LODB_UUID_FMT, LODB_UUID_ARGS(existingUser.uuid)); - LOG_DEBUG("User: %s", existingUser.username); - LOG_DEBUG("User is authenticated: %d", isAuthenticated); + bool userExists = dal->loadUserByUsername(username, &existingUser); + + if (userExists) { + // User exists - verify password + if (dal->verifyPassword(&existingUser, password)) { + LOG_DEBUG("User %s authenticated successfully from node 0x%0x", + username, mp.from); + + // Log in user (create/update session) + if (dal->loginUser(username, mp.from)) { + snprintf(replyBuffer, sizeof(replyBuffer), "Welcome back %s!", + username); + sendReply(mp.from, replyBuffer); + } else { + sendReply(mp.from, "Error creating session"); + } + } else { + LOG_WARN("Invalid password for user %s from node 0x%0x", username, + mp.from); + sendReply(mp.from, "Invalid password"); + } } else { - LOG_DEBUG("User node ID: %08x", mp.from); - LOG_DEBUG("User is not authenticated"); + // New user - create account + LOG_DEBUG("Creating new user: %s for node 0x%0x", username, mp.from); + + if (dal->createUser(username, password, mp.from)) { + snprintf(replyBuffer, sizeof(replyBuffer), "Welcome %s!", username); + sendReply(mp.from, replyBuffer); + } else { + sendReply(mp.from, "Error creating account"); + } } - // Copy payload to mutable buffer and null terminate (there is no null terminator in the payload) - memcpy(msgBuffer, mp.decoded.payload.bytes, mp.decoded.payload.size); - msgBuffer[mp.decoded.payload.size] = '\0'; + return ProcessMessage::CONTINUE; + } - // Check for @mention mail sending BEFORE tokenizing (authenticated users only) - // This must happen before strtok destroys the buffer - if (isAuthenticated && strchr(msgBuffer, '@')) { - LOG_DEBUG("Processing @mention mail from node=0x%0x", mp.from); - - // Extract message content and find all @mentions - std::vector recipients; - std::string messageContent = std::string(msgBuffer); - - // Parse the message for @mentions - char *token = msgBuffer; - bool foundAny = false; - - while (*token) { - if (*token == '@') { - foundAny = true; - // Found a mention, extract username - token++; // Skip @ - char *usernameStart = token; - while (*token && (isalnum(*token) || *token == '_')) { - token++; - } - - // Save username (only if not already in list) - char username[LOBBS_USERNAME_BUFFER_SIZE]; - size_t usernameLen = token - usernameStart; - if (usernameLen > 0 && usernameLen < LOBBS_USERNAME_BUFFER_SIZE) { - strncpy(username, usernameStart, usernameLen); - username[usernameLen] = '\0'; - std::string usernameStr(username); - // Only add if not already in recipients list - if (std::find(recipients.begin(), recipients.end(), usernameStr) == recipients.end()) { - recipients.push_back(usernameStr); - } - } - } else { - token++; - } - } - - if (foundAny && !recipients.empty()) { - // Validate and send to each recipient - int successCount = 0; - int failCount = 0; - std::string failedUsers; - - for (const auto &recipient : recipients) { - // Get recipient UUID - uint64_t recipientUuid = dal->getUserUuidByUsername(recipient.c_str()); - if (recipientUuid == 0) { - LOG_WARN("User not found: %s", recipient.c_str()); - if (failCount > 0) - failedUsers += ", "; - failedUsers += "@" + recipient; - failCount++; - continue; - } - - // Send mail - if (dal->sendMail(existingUser.uuid, recipientUuid, messageContent.c_str())) { - LOG_INFO("Sent mail from %s to %s", existingUser.username, recipient.c_str()); - successCount++; - } else { - LOG_ERROR("Failed to send mail to %s", recipient.c_str()); - if (failCount > 0) - failedUsers += ", "; - failedUsers += "@" + recipient; - failCount++; - } - } - - // Send confirmation - if (successCount > 0 && failCount == 0) { - if (successCount == 1) { - snprintf(replyBuffer, sizeof(replyBuffer), "Mail sent to @%s", recipients[0].c_str()); - } else { - snprintf(replyBuffer, sizeof(replyBuffer), "Mail sent to %d users", successCount); - } - sendReply(mp.from, replyBuffer); - } else if (successCount > 0 && failCount > 0) { - snprintf(replyBuffer, sizeof(replyBuffer), "Mail sent to %d users. Failed: %s", successCount, - failedUsers.c_str()); - sendReply(mp.from, replyBuffer); - } else { - snprintf(replyBuffer, sizeof(replyBuffer), "Failed to send mail. Users not found: %s", failedUsers.c_str()); - sendReply(mp.from, replyBuffer); - } - - return ProcessMessage::CONTINUE; - } - } - - // Tokenize for command processing (this modifies msgBuffer) - char *cmdName = strtok(msgBuffer, " "); - LOG_DEBUG("Token: %s", cmdName); - - if (strcasecmp(cmdName, "/hi") == 0) { - LOG_DEBUG("Processing /hi command from node=0x%0x", mp.from); - - // Get username - char *username = strtok(NULL, " "); - if (!username) { - sendReply(mp.from, "Usage: /hi "); - return ProcessMessage::CONTINUE; - } - - // Validate username length - size_t usernameLen = strlen(username); - if (usernameLen == 0 || usernameLen > LOBBS_MAX_USERNAME_LEN || !dal->isValidUsername(username)) { - sendReply( - mp.from, - "Username not found or is invalid. Username must be 1-" LOBBS_XSTR( - LOBBS_MAX_USERNAME_LEN) " characters and contain only letters, numbers, and common special characters."); - return ProcessMessage::CONTINUE; - } - - // Get password (rest of the string) - char *password = strtok(NULL, ""); - if (!password) { - sendReply(mp.from, "Usage: /hi "); - return ProcessMessage::CONTINUE; - } - - // Validate password is at least 5 characters long - if (strlen(password) < 5 || strlen(password) > 50 || !dal->isValidPassword(password)) { - sendReply(mp.from, "Password incorrect or invalid. Password must be between 5 and 50 characters long and contain " - "only letters, numbers, and common " - "special characters."); - return ProcessMessage::CONTINUE; - } - - LOG_DEBUG("Processing /hi command: username=%s from node=0x%0x", username, mp.from); - - // Try to load existing user - meshtastic_LoBBSUser existingUser = meshtastic_LoBBSUser_init_zero; - bool userExists = dal->loadUserByUsername(username, &existingUser); - - if (userExists) { - // User exists - verify password - if (dal->verifyPassword(&existingUser, password)) { - LOG_DEBUG("User %s authenticated successfully from node 0x%0x", username, mp.from); - - // Log in user (create/update session) - if (dal->loginUser(username, mp.from)) { - snprintf(replyBuffer, sizeof(replyBuffer), "Welcome back %s!", username); - sendReply(mp.from, replyBuffer); - } else { - sendReply(mp.from, "Error creating session"); - } - } else { - LOG_WARN("Invalid password for user %s from node 0x%0x", username, mp.from); - sendReply(mp.from, "Invalid password"); - } - } else { - // New user - create account - LOG_DEBUG("Creating new user: %s for node 0x%0x", username, mp.from); - - if (dal->createUser(username, password, mp.from)) { - snprintf(replyBuffer, sizeof(replyBuffer), "Welcome %s!", username); - sendReply(mp.from, replyBuffer); - } else { - sendReply(mp.from, "Error creating account"); - } - } - - return ProcessMessage::CONTINUE; - } - - if (!isAuthenticated) { - const char *helpMsg = LOBBS_HEADER "/hi - Login or create account\n"; - LOG_DEBUG("Help message: %s", helpMsg); - sendReply(mp.from, helpMsg); - return ProcessMessage::CONTINUE; - } - - /** - * ================================ - * Authenticated commands - * ================================ - */ - - if (strcasecmp(cmdName, "/bye") == 0) { - LOG_INFO("Processing /bye command from node=0x%0x", mp.from); - - dal->logoutUser(mp.from); - sendReply(mp.from, "Goodbye!"); - - return ProcessMessage::CONTINUE; - } - - if (strcasecmp(cmdName, "/users") == 0) { - LOG_INFO("Processing /users command from node=0x%0x", mp.from); - - char *filterStr = strtok(NULL, " "); - - // Parse optional filter argument - if (filterStr && !dal->isValidUsername(filterStr)) { - sendReply(mp.from, "Filter must contain only letters, numbers, and common special characters."); - return ProcessMessage::CONTINUE; - } - - // Build filter lambda for username matching (captures filterStr) - auto username_filter = [filterStr](const void *rec) -> bool { - const meshtastic_LoBBSUser *u = (const meshtastic_LoBBSUser *)rec; - return !filterStr || !filterStr[0] || stristr(u->username, filterStr) != nullptr; - }; - - // Execute synchronous select with filter and sort - auto users = dal->getDb()->select("users", username_filter, compareUsernames); - - // Build and send response - if (users.empty()) { - std::string reply; - LOG_DEBUG("No users match '%s'", filterStr); - if (filterStr && filterStr[0]) { - reply += "No users match '"; - reply += filterStr; - reply += "'"; - } else { - reply += "No users found"; - } - } else { - LOG_DEBUG("# users: %d", users.size()); - // Build user directory message - std::string userListMsg = "User directory:\n"; - for (size_t i = 0; i < users.size(); i++) { - const meshtastic_LoBBSUser *u = (const meshtastic_LoBBSUser *)users[i]; - if (i > 0) { - userListMsg += ", "; - } - userListMsg += u->username; - } - LOG_DEBUG("User list message: %s", userListMsg.c_str()); - - sendReply(mp.from, userListMsg.c_str()); - - // Free allocated records - LoDb::freeRecords(users); - } - - return ProcessMessage::CONTINUE; - } - - if (strcasecmp(cmdName, "/mail") == 0) { - LOG_INFO("Processing /mail command from node=0x%0x", mp.from); - - // Parse optional arguments - char *arg1 = strtok(NULL, " "); - - // Determine action: list (default), list with offset, or read specific message - uint32_t offset = 0; - int readMessageId = -1; - bool isReadCommand = false; - - if (arg1) { - if (!isdigit(static_cast(*arg1))) { - sendReply(mp.from, "Invalid mail command"); - return ProcessMessage::CONTINUE; - } - - char *argCopy = arg1; - size_t len = strlen(argCopy); - if (len > 0 && argCopy[len - 1] == '-') { - // "/mail -" format for listing from offset - argCopy[len - 1] = '\0'; - offset = atoi(argCopy); - if (offset > 0) - offset--; // Convert to 0-based - } else { - // "/mail " format for reading message - readMessageId = atoi(argCopy); - isReadCommand = true; - } - } - - // Get mail for current user - const uint32_t MAIL_PAGE_SIZE = 10; - auto mailMessages = dal->getMailForUser(existingUser.uuid, offset, MAIL_PAGE_SIZE); - - if (isReadCommand && readMessageId > 0) { - // Read specific message - if (readMessageId > (int)mailMessages.size()) { - sendReply(mp.from, "Invalid message number"); - freeMailMessages(mailMessages); - return ProcessMessage::CONTINUE; - } - - const meshtastic_LoBBSMail *mail = (const meshtastic_LoBBSMail *)mailMessages[readMessageId - 1]; - - // Get sender username - meshtastic_LoBBSUser sender = meshtastic_LoBBSUser_init_zero; - bool foundSender = loadUserByUuid(dal, mail->from_user_uuid, &sender); - - // Format time - char timeStr[32]; - formatTimeAgo(mail->timestamp, timeStr, sizeof(timeStr)); - - // Build message - std::string reply; - reply += "From: @"; - reply += foundSender ? sender.username : "unknown"; - reply += " ("; - reply += timeStr; - reply += ")\n"; - reply += mail->message; - - sendReply(mp.from, reply.c_str()); - - // Mark as read - dal->markMailAsRead(mail->uuid); - - freeMailMessages(mailMessages); - return ProcessMessage::CONTINUE; - } - - // List mail (default action) - if (mailMessages.empty()) { - sendReply(mp.from, "No mail"); - return ProcessMessage::CONTINUE; - } - - // Count unread messages - int unreadCount = 0; - for (auto *mailPtr : mailMessages) { - const meshtastic_LoBBSMail *mail = (const meshtastic_LoBBSMail *)mailPtr; - if (!mail->read) { - unreadCount++; - } - } - - // Build mail list - std::string mailList; - if (unreadCount > 0) { - char unreadStr[32]; - snprintf(unreadStr, sizeof(unreadStr), "(%d unread)\n", unreadCount); - mailList += unreadStr; - } - - for (size_t i = 0; i < mailMessages.size(); i++) { - const meshtastic_LoBBSMail *mail = (const meshtastic_LoBBSMail *)mailMessages[i]; - - // Get sender username - meshtastic_LoBBSUser sender = meshtastic_LoBBSUser_init_zero; - bool foundSender = loadUserByUuid(dal, mail->from_user_uuid, &sender); - - // Format message entry - char entryBuffer[256]; - char timeStr[32]; - char truncMsg[50]; - formatTimeAgo(mail->timestamp, timeStr, sizeof(timeStr)); - truncateMessage(mail->message, truncMsg, sizeof(truncMsg), 25); - - snprintf(entryBuffer, sizeof(entryBuffer), "[%d]%s @%s: %s (%s)\n", (int)(offset + i + 1), mail->read ? "" : "*", - foundSender ? sender.username : "unknown", truncMsg, timeStr); - - mailList += entryBuffer; - } - - sendReply(mp.from, mailList.c_str()); - - freeMailMessages(mailMessages); - - return ProcessMessage::CONTINUE; - } - - if (strcasecmp(cmdName, "/news") == 0) { - LOG_INFO("Processing /news command from node=0x%0x", mp.from); - - // Parse optional arguments - char *arg1 = strtok(NULL, " "); - char *arg2 = strtok(NULL, " "); - - uint32_t offset = 0; - int readNewsId = -1; - bool isReadCommand = false; - bool isPostCommand = false; - - if (arg1) { - if (strcasecmp(arg1, "r") == 0 && arg2) { - readNewsId = atoi(arg2); - isReadCommand = true; - } else if (strcasecmp(arg1, "l") == 0 && arg2) { - offset = atoi(arg2); - if (offset > 0) - offset--; // Convert to 0-based - } else if (isdigit((unsigned char)arg1[0])) { - // "/news " or "/news -" - char *argCopy = arg1; - size_t len = strlen(argCopy); - if (len > 0 && argCopy[len - 1] == '-') { - argCopy[len - 1] = '\0'; - offset = atoi(argCopy); - if (offset > 0) - offset--; // Convert to 0-based - } else { - readNewsId = atoi(argCopy); - isReadCommand = true; - } - } else { - isPostCommand = true; - } - } - - if (isPostCommand) { - // This is a news post - get the full message from original payload - const char *msgStart = (const char *)mp.decoded.payload.bytes; - size_t payloadSize = mp.decoded.payload.size; - - // Skip command prefix - const char *payloadEnd = msgStart + payloadSize; - while (msgStart < payloadEnd && *msgStart != ' ') - msgStart++; - while (msgStart < payloadEnd && *msgStart == ' ') - msgStart++; - - if (msgStart >= payloadEnd || *msgStart == '\0') { - sendReply(mp.from, "News message cannot be empty"); - return ProcessMessage::CONTINUE; - } - - if (!isalpha((unsigned char)*msgStart)) { - sendReply(mp.from, "News must start with a letter"); - return ProcessMessage::CONTINUE; - } - - std::string newsMessage(msgStart, payloadEnd - msgStart); - if (dal->postNews(existingUser.uuid, newsMessage.c_str())) { - sendReply(mp.from, "News posted"); - } else { - sendReply(mp.from, "Failed to post news"); - } - return ProcessMessage::CONTINUE; - } - - // Get news for current user - const uint32_t NEWS_PAGE_SIZE = 10; - auto newsItems = dal->getNewsForUser(existingUser.uuid, offset, NEWS_PAGE_SIZE); - - if (isReadCommand && readNewsId > 0) { - if (readNewsId > (int)newsItems.size()) { - sendReply(mp.from, "Invalid news number"); - freeNewsEntries(newsItems); - return ProcessMessage::CONTINUE; - } - - auto &entry = newsItems[readNewsId - 1]; - const meshtastic_LoBBSNews *news = entry.news; - - // Get author username - meshtastic_LoBBSUser author = meshtastic_LoBBSUser_init_zero; - bool foundAuthor = loadUserByUuid(dal, news->author_user_uuid, &author); - - char timeStr[32]; - formatTimeAgo(news->timestamp, timeStr, sizeof(timeStr)); - - std::string reply; - reply += "From: @"; - reply += foundAuthor ? author.username : "unknown"; - reply += " ("; - reply += timeStr; - reply += ")\n"; - reply += news->message; - - sendReply(mp.from, reply.c_str()); - - dal->markNewsAsRead(news->uuid, existingUser.uuid); - - freeNewsEntries(newsItems); - return ProcessMessage::CONTINUE; - } - - if (newsItems.empty()) { - sendReply(mp.from, "No news"); - return ProcessMessage::CONTINUE; - } - - int unreadCount = 0; - for (const auto &entry : newsItems) { - if (!entry.isRead) - unreadCount++; - } - - std::string newsList; - if (unreadCount > 0) { - char unreadStr[32]; - snprintf(unreadStr, sizeof(unreadStr), "(%d unread)\n", unreadCount); - newsList += unreadStr; - } - - for (size_t i = 0; i < newsItems.size(); i++) { - const auto &entry = newsItems[i]; - const meshtastic_LoBBSNews *news = entry.news; - - meshtastic_LoBBSUser author = meshtastic_LoBBSUser_init_zero; - bool foundAuthor = loadUserByUuid(dal, news->author_user_uuid, &author); - - char entryBuffer[256]; - char timeStr[32]; - char truncMsg[50]; - formatTimeAgo(news->timestamp, timeStr, sizeof(timeStr)); - truncateMessage(news->message, truncMsg, sizeof(truncMsg), 25); - - snprintf(entryBuffer, sizeof(entryBuffer), "[%d]%s @%s: %s (%s)\n", (int)(offset + i + 1), entry.isRead ? "" : "*", - foundAuthor ? author.username : "unknown", truncMsg, timeStr); - - newsList += entryBuffer; - } - - sendReply(mp.from, newsList.c_str()); - - freeNewsEntries(newsItems); - - return ProcessMessage::CONTINUE; - } - - std::string helpMsg = LOBBS_HEADER "/bye - Logout\n" - "/users [filter] - List users (optional filter)\n" - "/mail [|-] - List/read mail\n" - "@user - Send mail\n" - "/news [|-] - List/read news\n" - "/news - Post news"; - LOG_DEBUG("Help message: %s", helpMsg.c_str()); + if (!isAuthenticated) { + const char *helpMsg = + LOBBS_HEADER "/hi - Login or create account\n"; + LOG_DEBUG("Help message: %s", helpMsg); sendReply(mp.from, helpMsg); return ProcessMessage::CONTINUE; + } + + /** + * ================================ + * Authenticated commands + * ================================ + */ + + if (strcasecmp(cmdName, "/bye") == 0) { + LOG_INFO("Processing /bye command from node=0x%0x", mp.from); + + dal->logoutUser(mp.from); + sendReply(mp.from, "Goodbye!"); + + return ProcessMessage::CONTINUE; + } + + if (strcasecmp(cmdName, "/users") == 0) { + LOG_INFO("Processing /users command from node=0x%0x", mp.from); + + char *filterStr = strtok(NULL, " "); + + // Parse optional filter argument + if (filterStr && !dal->isValidUsername(filterStr)) { + sendReply(mp.from, "Filter must contain only letters, numbers, and " + "common special characters."); + return ProcessMessage::CONTINUE; + } + + // Build filter lambda for username matching (captures filterStr) + auto username_filter = [filterStr](const void *rec) -> bool { + const meshtastic_LoBBSUser *u = (const meshtastic_LoBBSUser *)rec; + return !filterStr || !filterStr[0] || + stristr(u->username, filterStr) != nullptr; + }; + + // Execute synchronous select with filter and sort + auto users = + dal->getDb()->select("users", username_filter, compareUsernames); + + // Build and send response + if (users.empty()) { + std::string reply; + LOG_DEBUG("No users match '%s'", filterStr); + if (filterStr && filterStr[0]) { + reply += "No users match '"; + reply += filterStr; + reply += "'"; + } else { + reply += "No users found"; + } + } else { + LOG_DEBUG("# users: %d", users.size()); + // Build user directory message + std::string userListMsg = "User directory:\n"; + for (size_t i = 0; i < users.size(); i++) { + const meshtastic_LoBBSUser *u = (const meshtastic_LoBBSUser *)users[i]; + if (i > 0) { + userListMsg += ", "; + } + userListMsg += u->username; + } + LOG_DEBUG("User list message: %s", userListMsg.c_str()); + + sendReply(mp.from, userListMsg.c_str()); + + // Free allocated records + LoDb::freeRecords(users); + } + + return ProcessMessage::CONTINUE; + } + + if (strcasecmp(cmdName, "/mail") == 0) { + LOG_INFO("Processing /mail command from node=0x%0x", mp.from); + + // Parse optional arguments + char *arg1 = strtok(NULL, " "); + + // Determine action: list (default), list with offset, or read specific + // message + uint32_t offset = 0; + int readMessageId = -1; + bool isReadCommand = false; + + if (arg1) { + if (!isdigit(static_cast(*arg1))) { + sendReply(mp.from, "Invalid mail command"); + return ProcessMessage::CONTINUE; + } + + char *argCopy = arg1; + size_t len = strlen(argCopy); + if (len > 0 && argCopy[len - 1] == '-') { + // "/mail -" format for listing from offset + argCopy[len - 1] = '\0'; + offset = atoi(argCopy); + if (offset > 0) + offset--; // Convert to 0-based + } else { + // "/mail " format for reading message + readMessageId = atoi(argCopy); + isReadCommand = true; + } + } + + // Get mail for current user + const uint32_t MAIL_PAGE_SIZE = 10; + auto mailMessages = + dal->getMailForUser(existingUser.uuid, offset, MAIL_PAGE_SIZE); + + if (isReadCommand && readMessageId > 0) { + // Read specific message + if (readMessageId > (int)mailMessages.size()) { + sendReply(mp.from, "Invalid message number"); + freeMailMessages(mailMessages); + return ProcessMessage::CONTINUE; + } + + const meshtastic_LoBBSMail *mail = + (const meshtastic_LoBBSMail *)mailMessages[readMessageId - 1]; + + // Get sender username + meshtastic_LoBBSUser sender = meshtastic_LoBBSUser_init_zero; + bool foundSender = loadUserByUuid(dal, mail->from_user_uuid, &sender); + + // Format time + char timeStr[32]; + formatTimeAgo(mail->timestamp, timeStr, sizeof(timeStr)); + + // Build message + std::string reply; + reply += "From: @"; + reply += foundSender ? sender.username : "unknown"; + reply += " ("; + reply += timeStr; + reply += ")\n"; + reply += mail->message; + + sendReply(mp.from, reply.c_str()); + + // Mark as read + dal->markMailAsRead(mail->uuid); + + freeMailMessages(mailMessages); + return ProcessMessage::CONTINUE; + } + + // List mail (default action) + if (mailMessages.empty()) { + sendReply(mp.from, "No mail"); + return ProcessMessage::CONTINUE; + } + + // Count unread messages + int unreadCount = 0; + for (auto *mailPtr : mailMessages) { + const meshtastic_LoBBSMail *mail = (const meshtastic_LoBBSMail *)mailPtr; + if (!mail->read) { + unreadCount++; + } + } + + // Build mail list + std::string mailList; + if (unreadCount > 0) { + char unreadStr[32]; + snprintf(unreadStr, sizeof(unreadStr), "(%d unread)\n", unreadCount); + mailList += unreadStr; + } + + for (size_t i = 0; i < mailMessages.size(); i++) { + const meshtastic_LoBBSMail *mail = + (const meshtastic_LoBBSMail *)mailMessages[i]; + + // Get sender username + meshtastic_LoBBSUser sender = meshtastic_LoBBSUser_init_zero; + bool foundSender = loadUserByUuid(dal, mail->from_user_uuid, &sender); + + // Format message entry + char entryBuffer[256]; + char timeStr[32]; + char truncMsg[50]; + formatTimeAgo(mail->timestamp, timeStr, sizeof(timeStr)); + truncateMessage(mail->message, truncMsg, sizeof(truncMsg), 25); + + snprintf(entryBuffer, sizeof(entryBuffer), "[%d]%s @%s: %s (%s)\n", + (int)(offset + i + 1), mail->read ? "" : "*", + foundSender ? sender.username : "unknown", truncMsg, timeStr); + + mailList += entryBuffer; + } + + sendReply(mp.from, mailList.c_str()); + + freeMailMessages(mailMessages); + + return ProcessMessage::CONTINUE; + } + + if (strcasecmp(cmdName, "/news") == 0) { + LOG_INFO("Processing /news command from node=0x%0x", mp.from); + + // Parse optional arguments + char *arg1 = strtok(NULL, " "); + char *arg2 = strtok(NULL, " "); + + uint32_t offset = 0; + int readNewsId = -1; + bool isReadCommand = false; + bool isPostCommand = false; + + if (arg1) { + if (strcasecmp(arg1, "r") == 0 && arg2) { + readNewsId = atoi(arg2); + isReadCommand = true; + } else if (strcasecmp(arg1, "l") == 0 && arg2) { + offset = atoi(arg2); + if (offset > 0) + offset--; // Convert to 0-based + } else if (isdigit((unsigned char)arg1[0])) { + // "/news " or "/news -" + char *argCopy = arg1; + size_t len = strlen(argCopy); + if (len > 0 && argCopy[len - 1] == '-') { + argCopy[len - 1] = '\0'; + offset = atoi(argCopy); + if (offset > 0) + offset--; // Convert to 0-based + } else { + readNewsId = atoi(argCopy); + isReadCommand = true; + } + } else { + isPostCommand = true; + } + } + + if (isPostCommand) { + // This is a news post - get the full message from original payload + const char *msgStart = (const char *)mp.decoded.payload.bytes; + size_t payloadSize = mp.decoded.payload.size; + + // Skip command prefix + const char *payloadEnd = msgStart + payloadSize; + while (msgStart < payloadEnd && *msgStart != ' ') + msgStart++; + while (msgStart < payloadEnd && *msgStart == ' ') + msgStart++; + + if (msgStart >= payloadEnd || *msgStart == '\0') { + sendReply(mp.from, "News message cannot be empty"); + return ProcessMessage::CONTINUE; + } + + if (!isalpha((unsigned char)*msgStart)) { + sendReply(mp.from, "News must start with a letter"); + return ProcessMessage::CONTINUE; + } + + std::string newsMessage(msgStart, payloadEnd - msgStart); + if (dal->postNews(existingUser.uuid, newsMessage.c_str())) { + sendReply(mp.from, "News posted"); + } else { + sendReply(mp.from, "Failed to post news"); + } + return ProcessMessage::CONTINUE; + } + + // Get news for current user + const uint32_t NEWS_PAGE_SIZE = 10; + auto newsItems = + dal->getNewsForUser(existingUser.uuid, offset, NEWS_PAGE_SIZE); + + if (isReadCommand && readNewsId > 0) { + if (readNewsId > (int)newsItems.size()) { + sendReply(mp.from, "Invalid news number"); + freeNewsEntries(newsItems); + return ProcessMessage::CONTINUE; + } + + auto &entry = newsItems[readNewsId - 1]; + const meshtastic_LoBBSNews *news = entry.news; + + // Get author username + meshtastic_LoBBSUser author = meshtastic_LoBBSUser_init_zero; + bool foundAuthor = loadUserByUuid(dal, news->author_user_uuid, &author); + + char timeStr[32]; + formatTimeAgo(news->timestamp, timeStr, sizeof(timeStr)); + + std::string reply; + reply += "From: @"; + reply += foundAuthor ? author.username : "unknown"; + reply += " ("; + reply += timeStr; + reply += ")\n"; + reply += news->message; + + sendReply(mp.from, reply.c_str()); + + dal->markNewsAsRead(news->uuid, existingUser.uuid); + + freeNewsEntries(newsItems); + return ProcessMessage::CONTINUE; + } + + if (newsItems.empty()) { + sendReply(mp.from, "No news"); + return ProcessMessage::CONTINUE; + } + + int unreadCount = 0; + for (const auto &entry : newsItems) { + if (!entry.isRead) + unreadCount++; + } + + std::string newsList; + if (unreadCount > 0) { + char unreadStr[32]; + snprintf(unreadStr, sizeof(unreadStr), "(%d unread)\n", unreadCount); + newsList += unreadStr; + } + + for (size_t i = 0; i < newsItems.size(); i++) { + const auto &entry = newsItems[i]; + const meshtastic_LoBBSNews *news = entry.news; + + meshtastic_LoBBSUser author = meshtastic_LoBBSUser_init_zero; + bool foundAuthor = loadUserByUuid(dal, news->author_user_uuid, &author); + + char entryBuffer[256]; + char timeStr[32]; + char truncMsg[50]; + formatTimeAgo(news->timestamp, timeStr, sizeof(timeStr)); + truncateMessage(news->message, truncMsg, sizeof(truncMsg), 25); + + snprintf(entryBuffer, sizeof(entryBuffer), "[%d]%s @%s: %s (%s)\n", + (int)(offset + i + 1), entry.isRead ? "" : "*", + foundAuthor ? author.username : "unknown", truncMsg, timeStr); + + newsList += entryBuffer; + } + + sendReply(mp.from, newsList.c_str()); + + freeNewsEntries(newsItems); + + return ProcessMessage::CONTINUE; + } + + std::string helpMsg = + LOBBS_HEADER "/bye - Logout\n" + "/users [filter] - List users (optional filter)\n" + "/mail [|-] - List/read mail\n" + "@user - Send mail\n" + "/news [|-] - List/read news\n" + "/news - Post news"; + LOG_DEBUG("Help message: %s", helpMsg.c_str()); + sendReply(mp.from, helpMsg); + return ProcessMessage::CONTINUE; } -void LoBBSModule::sendReply(NodeNum to, const std::string &msg) -{ - meshtastic_MeshPacket *reply = allocDataPacket(); - static constexpr char truncMarker[] = "[...]"; - static constexpr size_t truncMarkerLen = sizeof(truncMarker) - 1; +void LoBBSModule::sendReply(NodeNum to, const std::string &msg) { + meshtastic_MeshPacket *reply = allocDataPacket(); + static constexpr char truncMarker[] = "[...]"; + static constexpr size_t truncMarkerLen = sizeof(truncMarker) - 1; - bool isTruncated = msg.size() > MAX_REPLY_BYTES; - size_t payloadSize = isTruncated ? MAX_REPLY_BYTES : msg.size(); - reply->decoded.payload.size = payloadSize; + bool isTruncated = msg.size() > MAX_REPLY_BYTES; + size_t payloadSize = isTruncated ? MAX_REPLY_BYTES : msg.size(); + reply->decoded.payload.size = payloadSize; - if (isTruncated) { - size_t copyLen = MAX_REPLY_BYTES > truncMarkerLen ? MAX_REPLY_BYTES - truncMarkerLen : 0; - memcpy(reply->decoded.payload.bytes, msg.c_str(), copyLen); - memcpy(reply->decoded.payload.bytes + copyLen, truncMarker, truncMarkerLen); - } else { - memcpy(reply->decoded.payload.bytes, msg.c_str(), payloadSize); - } - reply->to = to; - reply->decoded.want_response = false; - service->sendToMesh(reply); + if (isTruncated) { + size_t copyLen = + MAX_REPLY_BYTES > truncMarkerLen ? MAX_REPLY_BYTES - truncMarkerLen : 0; + memcpy(reply->decoded.payload.bytes, msg.c_str(), copyLen); + memcpy(reply->decoded.payload.bytes + copyLen, truncMarker, truncMarkerLen); + } else { + memcpy(reply->decoded.payload.bytes, msg.c_str(), payloadSize); + } + reply->to = to; + reply->decoded.want_response = false; + service->sendToMesh(reply); } LoBBSModule *lobbsModule; \ No newline at end of file