diff --git a/lib/irc/src/base64.h b/lib/irc/src/base64.h new file mode 100644 index 0000000..62665ad --- /dev/null +++ b/lib/irc/src/base64.h @@ -0,0 +1,31 @@ +#pragma once + +#include + +namespace roro { + +inline std::string base64Encode(const std::string& in) { + static const char* kAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + std::string out; + size_t i = 0; + while (i + 2 < in.size()) { + uint32_t n = (static_cast(in[i]) << 16) | (static_cast(in[i + 1]) << 8) | static_cast(in[i + 2]); + out += kAlphabet[n >> 18]; + out += kAlphabet[(n >> 12) & 63]; + out += kAlphabet[(n >> 6) & 63]; + out += kAlphabet[n & 63]; + i += 3; + } + size_t rest = in.size() - i; + if (rest) { + uint32_t n = static_cast(in[i]) << 16; + if (rest == 2) n |= static_cast(in[i + 1]) << 8; + out += kAlphabet[n >> 18]; + out += kAlphabet[(n >> 12) & 63]; + out += rest == 2 ? kAlphabet[(n >> 6) & 63] : '='; + out += '='; + } + return out; +} + +} // namespace roro diff --git a/lib/irc/src/irc_config.cpp b/lib/irc/src/irc_config.cpp new file mode 100644 index 0000000..6a380f1 --- /dev/null +++ b/lib/irc/src/irc_config.cpp @@ -0,0 +1,79 @@ +#include "irc_config.h" + +#include +#include + +namespace roro { + +namespace { +bool validNick(const std::string& n) { + if (n.empty() || n.size() > 30) return false; + if (std::isdigit(static_cast(n[0])) || n[0] == '-') return false; + for (char c : n) + if (!std::isalnum(static_cast(c)) && !std::strchr("[]\\`_^{|}-", c)) return false; + return true; +} +} // namespace + +void IrcConfig::load(const std::string& defaultNick) { + nick = defaultNick; + std::string s; + int32_t i; + if (store_.getString("irc_host", s)) host = s; + if (store_.getInt("irc_port", i)) port = i; + if (store_.getInt("irc_tls", i)) tls = i != 0; + if (store_.getInt("irc_selfsign", i)) allowSelfSigned = i != 0; + store_.getString("irc_pin", pinnedSha256); + if (store_.getString("irc_nick", s)) nick = s; + store_.getString("irc_sasl_user", saslUser); + store_.getString("irc_sasl_pass", saslPassword); + store_.getString("irc_ns_pass", nickservPassword); + if (store_.getString("irc_join", s)) autojoin = parseChannels(s); +} + +std::string IrcConfig::validate() const { + if (host.empty() || host.size() > 63) return "Server must be 1 to 63 characters"; + if (port < 1 || port > 65535) return "Port must be 1 to 65535"; + if (!validNick(nick)) return "Nick: letters, digits and []\\`_^{|}- only, not starting with a digit"; + for (auto& c : autojoin) + if (c.size() < 2 || (c[0] != '#' && c[0] != '&')) return "IRC channels start with # or &"; + return ""; +} + +std::string IrcConfig::save() { + std::string error = validate(); + if (!error.empty()) return error; + store_.putString("irc_host", host); + store_.putInt("irc_port", port); + store_.putInt("irc_tls", tls ? 1 : 0); + store_.putInt("irc_selfsign", allowSelfSigned ? 1 : 0); + store_.putString("irc_pin", pinnedSha256); + store_.putString("irc_nick", nick); + store_.putString("irc_sasl_user", saslUser); + store_.putString("irc_sasl_pass", saslPassword); + store_.putString("irc_ns_pass", nickservPassword); + store_.putString("irc_join", formatChannels(autojoin)); + return ""; +} + +std::vector IrcConfig::parseChannels(const std::string& text) { + std::vector out; + std::string current; + for (char c : text + " ") { + if (c == ' ' || c == ',') { + if (!current.empty()) out.push_back(current); + current.clear(); + } else { + current += c; + } + } + return out; +} + +std::string IrcConfig::formatChannels(const std::vector& channels) { + std::string out; + for (auto& c : channels) out += (out.empty() ? "" : " ") + c; + return out; +} + +} // namespace roro diff --git a/lib/irc/src/irc_config.h b/lib/irc/src/irc_config.h new file mode 100644 index 0000000..924218f --- /dev/null +++ b/lib/irc/src/irc_config.h @@ -0,0 +1,36 @@ +#pragma once + +#include +#include + +#include "key_value_store.h" + +namespace roro { + +// The one IRC server the IRC Service connects to, persisted in internal flash. +class IrcConfig { + public: + explicit IrcConfig(KeyValueStore& store) : store_(store) {} + + std::string host = "irc.libera.chat"; + int port = 6697; + bool tls = true; + bool allowSelfSigned = false; // pin the certificate on first use instead of checking a CA + std::string pinnedSha256; // hex fingerprint once pinned + std::string nick; + std::string saslUser, saslPassword; // SASL PLAIN when both are set + std::string nickservPassword; // otherwise IDENTIFY with NickServ, if set + std::vector autojoin; + + void load(const std::string& defaultNick); + std::string save(); // empty on success, otherwise why it was refused + std::string validate() const; + + static std::vector parseChannels(const std::string& text); + static std::string formatChannels(const std::vector& channels); + + private: + KeyValueStore& store_; +}; + +} // namespace roro diff --git a/lib/irc/src/irc_message.cpp b/lib/irc/src/irc_message.cpp new file mode 100644 index 0000000..9bdf229 --- /dev/null +++ b/lib/irc/src/irc_message.cpp @@ -0,0 +1,43 @@ +#include "irc_message.h" + +namespace roro { + +IrcMessage IrcMessage::parse(const std::string& raw) { + IrcMessage m; + std::string line = raw; + while (!line.empty() && (line.back() == '\r' || line.back() == '\n')) line.pop_back(); + + size_t pos = 0; + auto word = [&]() { + size_t end = line.find(' ', pos); + std::string w = line.substr(pos, end == std::string::npos ? std::string::npos : end - pos); + pos = end == std::string::npos ? line.size() : end + 1; + while (pos < line.size() && line[pos] == ' ') pos++; + return w; + }; + if (pos < line.size() && line[pos] == '@') word(); + if (pos < line.size() && line[pos] == ':') m.prefix = word().substr(1); + m.command = word(); + while (pos < line.size()) { + if (line[pos] == ':') { + m.params.push_back(line.substr(pos + 1)); + break; + } + m.params.push_back(word()); + } + return m; +} + +std::string IrcMessage::serialize(const std::string& command, std::initializer_list params) { + std::string out = command; + size_t i = 0; + for (auto& p : params) { + bool last = ++i == params.size(); + out += ' '; + if (last && (p.empty() || p[0] == ':' || p.find(' ') != std::string::npos)) out += ':'; + out += p; + } + return out; +} + +} // namespace roro diff --git a/lib/irc/src/irc_message.h b/lib/irc/src/irc_message.h new file mode 100644 index 0000000..9f4e768 --- /dev/null +++ b/lib/irc/src/irc_message.h @@ -0,0 +1,23 @@ +#pragma once + +#include +#include +#include + +namespace roro { + +// One IRC protocol line: [@tags] [:prefix] COMMAND params... [:trailing] (tags are ignored). +struct IrcMessage { + std::string prefix; + std::string command; + std::vector params; + + std::string nick() const { return prefix.substr(0, prefix.find('!')); } + std::string param(size_t i) const { return i < params.size() ? params[i] : ""; } + + static IrcMessage parse(const std::string& line); + // Builds a line; the last parameter gets a ':' when it needs one. + static std::string serialize(const std::string& command, std::initializer_list params); +}; + +} // namespace roro diff --git a/lib/irc/src/irc_session.cpp b/lib/irc/src/irc_session.cpp new file mode 100644 index 0000000..501c84c --- /dev/null +++ b/lib/irc/src/irc_session.cpp @@ -0,0 +1,318 @@ +#include "irc_session.h" + +#include +#include + +#include "base64.h" + +namespace roro { + +namespace { + +const char kCtcp = '\x01'; + +std::string lower(std::string s) { + for (auto& c : s) c = static_cast(std::tolower(static_cast(c))); + return s; +} + +bool isChannel(const std::string& name) { return !name.empty() && (name[0] == '#' || name[0] == '&'); } + +bool nickChar(char c) { return std::isalnum(static_cast(c)) || std::string("[]\\`_^{|}-").find(c) != std::string::npos; } + +std::string join(const std::vector& params, size_t from) { + std::string out; + for (size_t i = from; i < params.size(); i++) out += (out.empty() ? "" : " ") + params[i]; + return out; +} + +// Splits "word rest of text" into the first word and the rest. +std::pair firstWord(const std::string& text) { + size_t space = text.find(' '); + if (space == std::string::npos) return {text, ""}; + return {text.substr(0, space), text.substr(space + 1)}; +} + +} // namespace + +IrcSession::IrcSession(const IrcConfig& config) : config_(config), nick_(config.nick) { + buffers_.push_back({config.host, IrcBuffer::Type::Server, {}, 0, false, true, ""}); +} + +int IrcSession::totalUnread() const { + int n = 0; + for (auto& b : buffers_) n += b.unread; + return n; +} + +IrcEffects IrcSession::takeEffects() { + IrcEffects out; + std::swap(out, effects_); + return out; +} + +int IrcSession::findBuffer(const std::string& name) const { + for (int i = 0; i < bufferCount(); i++) + if (lower(buffers_[i].name) == lower(name)) return i; + return -1; +} + +int IrcSession::bufferFor(const std::string& name, IrcBuffer::Type type) { + int i = findBuffer(name); + if (i >= 0) return i; + if (bufferCount() >= kMaxBuffers) return 0; // full: fall back to the server Buffer + buffers_.push_back({name, type, {}, 0, false, false, ""}); + return bufferCount() - 1; +} + +void IrcSession::setViewing(int buffer) { + viewing_ = buffer; + if (buffer >= 0 && buffer < bufferCount()) { + buffers_[buffer].unread = 0; + buffers_[buffer].mentioned = false; + } +} + +bool IrcSession::mentionsMe(const std::string& text) const { + std::string hay = lower(text), needle = lower(nick_); + for (size_t pos = hay.find(needle); pos != std::string::npos; pos = hay.find(needle, pos + 1)) { + bool startOk = pos == 0 || !nickChar(hay[pos - 1]); + size_t end = pos + needle.size(); + bool endOk = end >= hay.size() || !nickChar(hay[end]); + if (startOk && endOk) return true; + } + return false; +} + +void IrcSession::add(int b, IrcLine::Kind kind, const std::string& nick, const std::string& text, int64_t utc, + bool mention) { + IrcBuffer& buf = buffers_[b]; + IrcLine line{utc, kind, nick, text}; + buf.lines.push_back(line); + if (buf.lines.size() > kLinesPerBuffer) buf.lines.pop_front(); + effects_.logs.push_back({buf.name, line}); + + bool fromOthers = kind == IrcLine::Kind::Message || kind == IrcLine::Kind::Action || kind == IrcLine::Kind::Notice; + if (b != viewing_ && fromOthers) { + buf.unread++; + if (mention) { + buf.mentioned = true; + std::string where = buf.type == IrcBuffer::Type::Query ? nick : nick + " in " + buf.name; + effects_.notifications.push_back(where + ": " + text); + } + } +} + +void IrcSession::connected(int64_t) { + registered_ = false; + quit_ = false; + nick_ = config_.nick; + if (!config_.saslUser.empty() && !config_.saslPassword.empty()) send("CAP REQ :sasl"); + send(IrcMessage::serialize("NICK", {nick_})); + send(IrcMessage::serialize("USER", {nick_, "0", "*", "roro9stack"})); +} + +void IrcSession::disconnected(int64_t utc, const std::string& reason) { + registered_ = false; + rejoin_.clear(); + for (int i = 0; i < bufferCount(); i++) { + if (buffers_[i].type == IrcBuffer::Type::Channel && buffers_[i].joined) rejoin_.push_back(buffers_[i].name); + if (buffers_[i].type == IrcBuffer::Type::Channel) buffers_[i].joined = false; + info(i, "-- " + reason + " --", utc); + } +} + +void IrcSession::onWelcome(const IrcMessage& m, int64_t utc) { + registered_ = true; + nick_ = m.param(0); + info(0, "Connected to " + config_.host + " as " + nick_, utc); + if (!config_.nickservPassword.empty() && config_.saslUser.empty()) + send(IrcMessage::serialize("PRIVMSG", {"NickServ", "IDENTIFY " + config_.nickservPassword})); + + std::vector channels = config_.autojoin; + for (auto& c : rejoin_) + if (std::find(channels.begin(), channels.end(), c) == channels.end()) channels.push_back(c); + rejoin_.clear(); + if (!channels.empty()) { + std::string list; + for (auto& c : channels) list += (list.empty() ? "" : ",") + c; + send(IrcMessage::serialize("JOIN", {list})); + } +} + +void IrcSession::receive(const std::string& raw, int64_t utc) { + IrcMessage m = IrcMessage::parse(raw); + const std::string& cmd = m.command; + bool fromMe = lower(m.nick()) == lower(nick_); + + if (cmd == "PING") { + send(IrcMessage::serialize("PONG", {m.param(0)})); + } else if (cmd == "001") { + onWelcome(m, utc); + } else if (cmd == "433" && !registered_) { + nick_ += "_"; + send(IrcMessage::serialize("NICK", {nick_})); + } else if (cmd == "CAP") { + std::string sub = m.param(1); + if (sub == "ACK" && m.param(2).find("sasl") != std::string::npos) send("AUTHENTICATE PLAIN"); + else if (sub == "NAK") send("CAP END"); + } else if (cmd == "AUTHENTICATE" && m.param(0) == "+") { + std::string user = config_.saslUser; + send("AUTHENTICATE " + base64Encode(user + '\0' + user + '\0' + config_.saslPassword)); + } else if (cmd == "903") { + info(0, "SASL login succeeded", utc); + send("CAP END"); + } else if (cmd == "904" || cmd == "905" || cmd == "906" || cmd == "902") { + info(0, "SASL login failed: " + m.param(m.params.size() - 1), utc); + send("CAP END"); + } else if (cmd == "PRIVMSG" || cmd == "NOTICE") { + onPrivmsg(m, utc, cmd == "NOTICE"); + } else if (cmd == "JOIN") { + if (fromMe) { + int b = bufferFor(m.param(0), IrcBuffer::Type::Channel); + buffers_[b].joined = true; + info(b, "Joined " + m.param(0), utc); + } + } else if (cmd == "PART") { + int b = findBuffer(m.param(0)); + if (fromMe && b > 0) { + buffers_[b].joined = false; + info(b, "Left " + m.param(0), utc); + } + } else if (cmd == "KICK") { + int b = findBuffer(m.param(0)); + if (lower(m.param(1)) == lower(nick_) && b > 0) { + buffers_[b].joined = false; + add(b, IrcLine::Kind::Notice, m.nick(), "kicked you: " + m.param(2), utc, true); + } + } else if (cmd == "NICK") { + if (fromMe) { + nick_ = m.param(0); + info(0, "You are now " + nick_, utc); + } else { + int b = findBuffer(m.nick()); + if (b > 0) { + buffers_[b].name = m.param(0); + info(b, m.nick() + " is now " + m.param(0), utc); + } + } + } else if (cmd == "TOPIC") { + int b = findBuffer(m.param(0)); + if (b > 0) { + buffers_[b].topic = m.param(1); + info(b, m.nick() + " set the topic: " + m.param(1), utc); + } + } else if (cmd == "332") { + int b = findBuffer(m.param(1)); + if (b > 0) { + buffers_[b].topic = m.param(2); + info(b, "Topic: " + m.param(2), utc); + } + } else if (cmd == "353") { + std::string channel = m.param(2); + int b = findBuffer(channel); + if (namesRequested_.count(lower(channel)) && b >= 0) info(b, "Names: " + m.param(3), utc); + } else if (cmd == "366") { + namesRequested_.erase(lower(m.param(1))); + } else if (cmd == "ERROR") { + info(0, "Server: " + m.param(0), utc); + } else if (cmd.size() == 3 && std::isdigit(static_cast(cmd[0])) && cmd != "333" && cmd != "353") { + info(0, join(m.params, 1), utc); // other numerics, minus our own nick + } +} + +void IrcSession::onPrivmsg(const IrcMessage& m, int64_t utc, bool notice) { + std::string target = m.param(0), text = m.param(1), from = m.nick(); + bool toMe = lower(target) == lower(nick_); + + // CTCP: ACTION is shown, VERSION answered, anything else ignored. + bool action = false; + if (!text.empty() && text[0] == kCtcp) { + std::string body = text.substr(1, text.size() >= 2 && text.back() == kCtcp ? text.size() - 2 : std::string::npos); + auto [verb, rest] = firstWord(body); + if (verb == "ACTION") { + action = true; + text = rest; + } else { + if (verb == "VERSION" && !notice) send(std::string("NOTICE ") + from + " :" + kCtcp + "VERSION roro9stack" + kCtcp); + return; + } + } + + int b; + if (notice && (from.empty() || from.find('.') != std::string::npos || !registered_ || target == "*")) { + b = 0; // server notices + } else if (toMe) { + b = notice ? (findBuffer(from) > 0 ? findBuffer(from) : 0) : bufferFor(from, IrcBuffer::Type::Query); + } else { + b = bufferFor(target, IrcBuffer::Type::Channel); + } + auto kind = notice ? IrcLine::Kind::Notice : action ? IrcLine::Kind::Action : IrcLine::Kind::Message; + bool mention = !notice && (toMe || mentionsMe(text)); + add(b, kind, from, text, utc, mention); +} + +void IrcSession::say(int b, const std::string& text, int64_t utc, bool action) { + const IrcBuffer& buf = buffers_[b]; + if (buf.type == IrcBuffer::Type::Server) { + info(b, "This is the server Buffer: /join #name an IRC channel, or /msg nick text", utc); + return; + } + std::string payload = action ? std::string(1, kCtcp) + "ACTION " + text + kCtcp : text; + send(IrcMessage::serialize("PRIVMSG", {buf.name, payload})); + add(b, action ? IrcLine::Kind::OwnAction : IrcLine::Kind::Own, nick_, text, utc); +} + +void IrcSession::input(int b, const std::string& text, int64_t utc) { + if (b < 0 || b >= bufferCount() || text.empty()) return; + if (text.size() >= 2 && text[0] == '/' && text[1] == '/') return say(b, text.substr(1), utc, false); + if (text[0] == '/') return command(b, text.substr(1), utc); + say(b, text, utc, false); +} + +void IrcSession::command(int b, const std::string& text, int64_t utc) { + auto [verb, rest] = firstWord(text); + verb = lower(verb); + const IrcBuffer& buf = buffers_[b]; + bool inChannel = buf.type == IrcBuffer::Type::Channel; + + if (verb == "join") { + auto [channel, key] = firstWord(rest); + if (channel.empty()) return info(b, "Usage: /join #channel", utc); + if (!isChannel(channel)) channel = "#" + channel; + send(key.empty() ? IrcMessage::serialize("JOIN", {channel}) : IrcMessage::serialize("JOIN", {channel, key})); + } else if (verb == "part") { + std::string channel = inChannel ? buf.name : ""; + std::string message = rest; + if (isChannel(rest)) std::tie(channel, message) = firstWord(rest); + if (channel.empty()) return info(b, "Usage: /part #channel", utc); + send(message.empty() ? IrcMessage::serialize("PART", {channel}) : IrcMessage::serialize("PART", {channel, message})); + } else if (verb == "msg" || verb == "query") { + auto [who, message] = firstWord(rest); + if (who.empty()) return info(b, "Usage: /msg nick text", utc); + int target = bufferFor(who, isChannel(who) ? IrcBuffer::Type::Channel : IrcBuffer::Type::Query); + if (!message.empty()) say(target, message, utc, false); + } else if (verb == "me") { + say(b, rest, utc, true); + } else if (verb == "nick") { + if (rest.empty()) return info(b, "Usage: /nick newnick", utc); + send(IrcMessage::serialize("NICK", {rest})); + } else if (verb == "topic") { + if (!inChannel) return info(b, "Use /topic in an IRC channel", utc); + send(rest.empty() ? IrcMessage::serialize("TOPIC", {buf.name}) : IrcMessage::serialize("TOPIC", {buf.name, rest})); + } else if (verb == "names") { + std::string channel = rest.empty() ? (inChannel ? buf.name : "") : rest; + if (channel.empty()) return info(b, "Usage: /names #channel", utc); + namesRequested_.insert(lower(channel)); + send(IrcMessage::serialize("NAMES", {channel})); + } else if (verb == "quit") { + quit_ = true; + send(IrcMessage::serialize("QUIT", {rest.empty() ? "roro9stack" : rest})); + } else if (verb == "raw" || verb == "quote") { + if (!rest.empty()) send(rest); + } else { + info(b, "Unknown command /" + verb + " (try /join /part /msg /me /nick /topic /names /quit /raw)", utc); + } +} + +} // namespace roro diff --git a/lib/irc/src/irc_session.h b/lib/irc/src/irc_session.h new file mode 100644 index 0000000..9c76311 --- /dev/null +++ b/lib/irc/src/irc_session.h @@ -0,0 +1,98 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "irc_config.h" +#include "irc_message.h" + +namespace roro { + +struct IrcLine { + enum class Kind { Message, Action, Notice, Info, Own, OwnAction }; + int64_t utc; // -1 if the clock wasn't set + Kind kind; + std::string nick; + std::string text; +}; + +// One IRC conversation (see Buffer in CONTEXT.md). +struct IrcBuffer { + enum class Type { Server, Channel, Query }; + std::string name; + Type type; + std::deque lines; // the most recent kLinesPerBuffer; full history goes to Logs + int unread = 0; + bool mentioned = false; + bool joined = false; + std::string topic; +}; + +struct IrcLogEntry { + std::string buffer; + IrcLine line; +}; + +// What the session wants done: lines to send, Log entries, Notification texts. +struct IrcEffects { + std::vector send; + std::vector logs; + std::vector notifications; +}; + +// The IRC protocol, without any networking: registration (SASL PLAIN or NickServ), Buffers, +// unread counts, Mentions, the user's commands, and rejoining after a pause. The IRC Service +// feeds it received lines and carries out the effects. +class IrcSession { + public: + static constexpr size_t kLinesPerBuffer = 50; + static constexpr int kMaxBuffers = 12; + + explicit IrcSession(const IrcConfig& config); + + void connected(int64_t utc); + void disconnected(int64_t utc, const std::string& reason); + void receive(const std::string& raw, int64_t utc); + void input(int buffer, const std::string& text, int64_t utc); + + // The Buffer the user is looking at (-1: none); it neither counts unread nor notifies. + void setViewing(int buffer); + + bool registered() const { return registered_; } + bool quitRequested() const { return quit_; } + const std::string& nick() const { return nick_; } + + int bufferCount() const { return static_cast(buffers_.size()); } + const IrcBuffer& buffer(int i) const { return buffers_[i]; } + int totalUnread() const; + + IrcEffects takeEffects(); + + private: + void send(const std::string& line) { effects_.send.push_back(line); } + int findBuffer(const std::string& name) const; + int bufferFor(const std::string& name, IrcBuffer::Type type); // creates if needed and possible + void add(int buffer, IrcLine::Kind kind, const std::string& nick, const std::string& text, int64_t utc, + bool countsAsMention = false); + void info(int buffer, const std::string& text, int64_t utc) { add(buffer, IrcLine::Kind::Info, "", text, utc); } + bool mentionsMe(const std::string& text) const; + void onPrivmsg(const IrcMessage& m, int64_t utc, bool notice); + void onWelcome(const IrcMessage& m, int64_t utc); + void command(int buffer, const std::string& text, int64_t utc); + void say(int buffer, const std::string& text, int64_t utc, bool action); + + const IrcConfig& config_; + std::vector buffers_; + std::string nick_; + bool registered_ = false; + bool quit_ = false; + int viewing_ = -1; + std::set namesRequested_; + std::vector rejoin_; // IRC channels to join again after a reconnect + IrcEffects effects_; +}; + +} // namespace roro diff --git a/lib/irc/src/reconnect_policy.h b/lib/irc/src/reconnect_policy.h new file mode 100644 index 0000000..b364313 --- /dev/null +++ b/lib/irc/src/reconnect_policy.h @@ -0,0 +1,21 @@ +#pragma once + +#include + +namespace roro { + +// Waits between IRC reconnection attempts: 5 s, 10 s, 30 s, 1 min, 2 min, then every 5 min. +class ReconnectPolicy { + public: + uint32_t nextDelayMs() { + static const uint32_t kDelays[] = {5000, 10000, 30000, 60000, 120000, 300000}; + const int n = sizeof(kDelays) / sizeof(kDelays[0]); + return kDelays[attempt_ < n ? attempt_++ : n - 1]; + } + void reset() { attempt_ = 0; } + + private: + int attempt_ = 0; +}; + +} // namespace roro diff --git a/test/test_irc_config/test_irc_config.cpp b/test/test_irc_config/test_irc_config.cpp new file mode 100644 index 0000000..142b6b2 --- /dev/null +++ b/test/test_irc_config/test_irc_config.cpp @@ -0,0 +1,74 @@ +#include + +#include "../memory_store.h" +#include "irc_config.h" + +using namespace roro; + +void setUp() {} +void tearDown() {} + +void test_defaults_point_at_libera_over_tls() { + MemoryStore store; + IrcConfig c(store); + c.load("roro_2fa4"); + TEST_ASSERT_EQUAL_STRING("irc.libera.chat", c.host.c_str()); + TEST_ASSERT_EQUAL(6697, c.port); + TEST_ASSERT_TRUE(c.tls); + TEST_ASSERT_FALSE(c.allowSelfSigned); + TEST_ASSERT_EQUAL_STRING("roro_2fa4", c.nick.c_str()); + TEST_ASSERT_TRUE(c.autojoin.empty()); +} + +void test_save_and_reload() { + MemoryStore store; + { + IrcConfig c(store); + c.load("x"); + c.nick = "clement"; + c.saslUser = "clement"; + c.saslPassword = "s3cret"; + c.autojoin = {"#roro", "#meshtastic"}; + TEST_ASSERT_TRUE(c.save().empty()); + } + IrcConfig again(store); + again.load("x"); + TEST_ASSERT_EQUAL_STRING("clement", again.nick.c_str()); + TEST_ASSERT_EQUAL_STRING("s3cret", again.saslPassword.c_str()); + TEST_ASSERT_EQUAL(2, again.autojoin.size()); + TEST_ASSERT_EQUAL_STRING("#meshtastic", again.autojoin[1].c_str()); +} + +void test_invalid_values_are_refused_with_a_reason() { + MemoryStore store; + IrcConfig c(store); + c.load("x"); + c.nick = "1abc"; // can't start with a digit + TEST_ASSERT_FALSE(c.save().empty()); + c.nick = "has space"; + TEST_ASSERT_FALSE(c.save().empty()); + c.nick = "ok_nick[1]"; + c.port = 0; + TEST_ASSERT_FALSE(c.save().empty()); + c.port = 6697; + c.host = ""; + TEST_ASSERT_FALSE(c.save().empty()); + c.host = "irc.libera.chat"; + c.autojoin = {"roro"}; // IRC channels start with # or & + TEST_ASSERT_FALSE(c.save().empty()); +} + +void test_autojoin_text_round_trip() { + TEST_ASSERT_EQUAL(2, IrcConfig::parseChannels("#a, #b").size()); + TEST_ASSERT_EQUAL(2, IrcConfig::parseChannels("#a #b").size()); + TEST_ASSERT_EQUAL_STRING("#a #b", IrcConfig::formatChannels({"#a", "#b"}).c_str()); +} + +int main() { + UNITY_BEGIN(); + RUN_TEST(test_defaults_point_at_libera_over_tls); + RUN_TEST(test_save_and_reload); + RUN_TEST(test_invalid_values_are_refused_with_a_reason); + RUN_TEST(test_autojoin_text_round_trip); + return UNITY_END(); +} diff --git a/test/test_irc_message/test_irc_message.cpp b/test/test_irc_message/test_irc_message.cpp new file mode 100644 index 0000000..3415d0b --- /dev/null +++ b/test/test_irc_message/test_irc_message.cpp @@ -0,0 +1,74 @@ +#include + +#include "base64.h" +#include "irc_message.h" +#include "reconnect_policy.h" + +using namespace roro; + +void setUp() {} +void tearDown() {} + +void test_parse_full_message() { + auto m = IrcMessage::parse(":alice!a@host PRIVMSG #roro :hello there"); + TEST_ASSERT_EQUAL_STRING("alice!a@host", m.prefix.c_str()); + TEST_ASSERT_EQUAL_STRING("alice", m.nick().c_str()); + TEST_ASSERT_EQUAL_STRING("PRIVMSG", m.command.c_str()); + TEST_ASSERT_EQUAL(2, m.params.size()); + TEST_ASSERT_EQUAL_STRING("#roro", m.params[0].c_str()); + TEST_ASSERT_EQUAL_STRING("hello there", m.params[1].c_str()); +} + +void test_parse_without_prefix_and_with_tags() { + auto m = IrcMessage::parse("@time=2026-10-02T12:00:00Z PING :abc"); + TEST_ASSERT_EQUAL_STRING("", m.prefix.c_str()); + TEST_ASSERT_EQUAL_STRING("PING", m.command.c_str()); + TEST_ASSERT_EQUAL_STRING("abc", m.params[0].c_str()); +} + +void test_parse_strips_line_endings_and_handles_empty_trailing() { + auto m = IrcMessage::parse(":srv 332 me #roro :\r\n"); + TEST_ASSERT_EQUAL(3, m.params.size()); + TEST_ASSERT_EQUAL_STRING("", m.params[2].c_str()); +} + +void test_parse_middle_params_only() { + auto m = IrcMessage::parse(":srv MODE #roro +nt"); + TEST_ASSERT_EQUAL(2, m.params.size()); + TEST_ASSERT_EQUAL_STRING("+nt", m.params[1].c_str()); +} + +void test_serialize_adds_colon_only_when_needed() { + TEST_ASSERT_EQUAL_STRING("NICK roro", IrcMessage::serialize("NICK", {"roro"}).c_str()); + TEST_ASSERT_EQUAL_STRING("PRIVMSG #roro :hello there", IrcMessage::serialize("PRIVMSG", {"#roro", "hello there"}).c_str()); + TEST_ASSERT_EQUAL_STRING("PRIVMSG #roro ::)", IrcMessage::serialize("PRIVMSG", {"#roro", ":)"}).c_str()); + TEST_ASSERT_EQUAL_STRING("TOPIC #roro :", IrcMessage::serialize("TOPIC", {"#roro", ""}).c_str()); +} + +void test_base64() { + TEST_ASSERT_EQUAL_STRING("", base64Encode("").c_str()); + TEST_ASSERT_EQUAL_STRING("Zg==", base64Encode("f").c_str()); + TEST_ASSERT_EQUAL_STRING("Zm8=", base64Encode("fo").c_str()); + TEST_ASSERT_EQUAL_STRING("Zm9v", base64Encode("foo").c_str()); + TEST_ASSERT_EQUAL_STRING("dXNlcgB1c2VyAHBhc3M=", base64Encode(std::string("user\0user\0pass", 14)).c_str()); +} + +void test_reconnect_delays_grow_then_cap_and_reset() { + ReconnectPolicy p; + uint32_t expected[] = {5000, 10000, 30000, 60000, 120000, 300000, 300000}; + for (uint32_t e : expected) TEST_ASSERT_EQUAL_UINT32(e, p.nextDelayMs()); + p.reset(); + TEST_ASSERT_EQUAL_UINT32(5000, p.nextDelayMs()); +} + +int main() { + UNITY_BEGIN(); + RUN_TEST(test_parse_full_message); + RUN_TEST(test_parse_without_prefix_and_with_tags); + RUN_TEST(test_parse_strips_line_endings_and_handles_empty_trailing); + RUN_TEST(test_parse_middle_params_only); + RUN_TEST(test_serialize_adds_colon_only_when_needed); + RUN_TEST(test_base64); + RUN_TEST(test_reconnect_delays_grow_then_cap_and_reset); + return UNITY_END(); +} diff --git a/test/test_irc_session/test_irc_session.cpp b/test/test_irc_session/test_irc_session.cpp new file mode 100644 index 0000000..7a170fc --- /dev/null +++ b/test/test_irc_session/test_irc_session.cpp @@ -0,0 +1,345 @@ +#include + +#include +#include + +#include "../memory_store.h" +#include "irc_session.h" + +using namespace roro; + +void setUp() {} +void tearDown() {} + +struct Fixture { + MemoryStore store; + IrcConfig config{store}; + std::unique_ptr session; + IrcEffects fx; + + explicit Fixture(bool sasl = false) { + config.load("roro"); + config.nick = "roro"; + config.autojoin = {"#roro"}; + if (sasl) { + config.saslUser = "acct"; + config.saslPassword = "pw"; + } + session.reset(new IrcSession(config)); + } + void recv(const std::string& line) { session->receive(line, 1000); } + IrcEffects& take() { + fx = session->takeEffects(); + return fx; + } + bool sent(const std::string& line) { + return std::find(fx.send.begin(), fx.send.end(), line) != fx.send.end(); + } + void registerNow() { + session->connected(0); + recv(":srv 001 roro :Welcome"); + take(); + } + int buffer(const std::string& name) { + for (int i = 0; i < session->bufferCount(); i++) + if (session->buffer(i).name == name) return i; + return -1; + } +}; + +void test_plain_registration_sends_nick_and_user() { + Fixture f; + f.session->connected(0); + f.take(); + TEST_ASSERT_TRUE(f.sent("NICK roro")); + TEST_ASSERT_TRUE(f.sent("USER roro 0 * roro9stack")); +} + +void test_welcome_registers_and_joins_autojoin_channels() { + Fixture f; + f.session->connected(0); + f.take(); + f.recv(":srv 001 roro :Welcome"); + f.take(); + TEST_ASSERT_TRUE(f.session->registered()); + TEST_ASSERT_TRUE(f.sent("JOIN #roro")); +} + +void test_nickserv_identify_after_welcome() { + Fixture f; + f.config.nickservPassword = "pw"; + f.session.reset(new IrcSession(f.config)); + f.registerNow(); + f.session->connected(0); + f.recv(":srv 001 roro :Welcome"); + f.take(); + TEST_ASSERT_TRUE(f.sent("PRIVMSG NickServ :IDENTIFY pw")); +} + +void test_sasl_plain_flow() { + Fixture f(true); + f.session->connected(0); + f.take(); + TEST_ASSERT_TRUE(f.sent("CAP REQ :sasl")); + f.recv(":srv CAP * ACK :sasl"); + f.take(); + TEST_ASSERT_TRUE(f.sent("AUTHENTICATE PLAIN")); + f.recv("AUTHENTICATE +"); + f.take(); + TEST_ASSERT_TRUE(f.sent("AUTHENTICATE YWNjdABhY2N0AHB3")); // acct\0acct\0pw + f.recv(":srv 903 roro :SASL authentication successful"); + f.take(); + TEST_ASSERT_TRUE(f.sent("CAP END")); +} + +void test_sasl_failure_still_ends_negotiation_and_says_so() { + Fixture f(true); + f.session->connected(0); + f.recv(":srv CAP * ACK :sasl"); + f.recv("AUTHENTICATE +"); + f.take(); + f.recv(":srv 904 roro :SASL authentication failed"); + f.take(); + TEST_ASSERT_TRUE(f.sent("CAP END")); + const auto& server = f.session->buffer(0); + TEST_ASSERT_TRUE(server.lines.back().text.find("SASL") != std::string::npos); +} + +void test_nick_in_use_during_registration_tries_another() { + Fixture f; + f.session->connected(0); + f.take(); + f.recv(":srv 433 * roro :Nickname is already in use"); + f.take(); + TEST_ASSERT_TRUE(f.sent("NICK roro_")); +} + +void test_ping_is_answered() { + Fixture f; + f.registerNow(); + f.recv("PING :tungsten.libera.chat"); + f.take(); + TEST_ASSERT_TRUE(f.sent("PONG tungsten.libera.chat")); +} + +void test_own_join_creates_a_channel_buffer() { + Fixture f; + f.registerNow(); + f.recv(":roro!u@h JOIN #roro"); + int b = f.buffer("#roro"); + TEST_ASSERT_TRUE(b > 0); + TEST_ASSERT_TRUE(f.session->buffer(b).joined); +} + +void test_others_joining_is_hidden() { + Fixture f; + f.registerNow(); + f.recv(":roro!u@h JOIN #roro"); + size_t before = f.session->buffer(f.buffer("#roro")).lines.size(); + f.recv(":bob!u@h JOIN #roro"); + f.recv(":bob!u@h PART #roro"); + f.recv(":bob!u@h QUIT :bye"); + TEST_ASSERT_EQUAL(before, f.session->buffer(f.buffer("#roro")).lines.size()); +} + +void test_channel_message_goes_to_its_buffer_and_is_logged() { + Fixture f; + f.registerNow(); + f.recv(":roro!u@h JOIN #roro"); + f.take(); + f.recv(":alice!u@h PRIVMSG #roro :hello all"); + auto& fx = f.take(); + const auto& b = f.session->buffer(f.buffer("#roro")); + TEST_ASSERT_EQUAL_STRING("alice", b.lines.back().nick.c_str()); + TEST_ASSERT_EQUAL_STRING("hello all", b.lines.back().text.c_str()); + TEST_ASSERT_EQUAL(1, fx.logs.size()); + TEST_ASSERT_EQUAL_STRING("#roro", fx.logs[0].buffer.c_str()); +} + +void test_unread_counts_unless_viewing_that_buffer() { + Fixture f; + f.registerNow(); + f.recv(":roro!u@h JOIN #roro"); + int b = f.buffer("#roro"); + f.recv(":alice!u@h PRIVMSG #roro :one"); + f.recv(":alice!u@h PRIVMSG #roro :two"); + TEST_ASSERT_EQUAL(2, f.session->buffer(b).unread); + TEST_ASSERT_EQUAL(2, f.session->totalUnread()); + f.session->setViewing(b); + TEST_ASSERT_EQUAL(0, f.session->buffer(b).unread); + f.recv(":alice!u@h PRIVMSG #roro :three"); + TEST_ASSERT_EQUAL(0, f.session->buffer(b).unread); +} + +void test_mention_notifies_ordinary_traffic_does_not() { + Fixture f; + f.registerNow(); + f.recv(":roro!u@h JOIN #roro"); + f.take(); + f.recv(":alice!u@h PRIVMSG #roro :lunch?"); + TEST_ASSERT_EQUAL(0, f.take().notifications.size()); + f.recv(":alice!u@h PRIVMSG #roro :Roro: ping"); + TEST_ASSERT_EQUAL(1, f.take().notifications.size()); + f.recv(":alice!u@h PRIVMSG #roro :rorojam is not you"); + TEST_ASSERT_EQUAL(0, f.take().notifications.size()); +} + +void test_private_message_opens_a_query_buffer_and_notifies() { + Fixture f; + f.registerNow(); + f.recv(":alice!u@h PRIVMSG roro :psst"); + TEST_ASSERT_TRUE(f.buffer("alice") > 0); + TEST_ASSERT_EQUAL(1, f.take().notifications.size()); +} + +void test_no_notification_while_viewing_that_buffer() { + Fixture f; + f.registerNow(); + f.recv(":alice!u@h PRIVMSG roro :first"); + f.take(); + f.session->setViewing(f.buffer("alice")); + f.recv(":alice!u@h PRIVMSG roro :second"); + TEST_ASSERT_EQUAL(0, f.take().notifications.size()); +} + +void test_action_and_ctcp_version() { + Fixture f; + f.registerNow(); + f.recv(":roro!u@h JOIN #roro"); + f.recv(":alice!u@h PRIVMSG #roro :\x01" "ACTION waves\x01"); + const auto& line = f.session->buffer(f.buffer("#roro")).lines.back(); + TEST_ASSERT_EQUAL(static_cast(IrcLine::Kind::Action), static_cast(line.kind)); + TEST_ASSERT_EQUAL_STRING("waves", line.text.c_str()); + f.take(); + f.recv(":alice!u@h PRIVMSG roro :\x01VERSION\x01"); + f.take(); + TEST_ASSERT_TRUE(f.sent("NOTICE alice :\x01VERSION roro9stack\x01")); +} + +void test_topic_is_kept() { + Fixture f; + f.registerNow(); + f.recv(":roro!u@h JOIN #roro"); + f.recv(":srv 332 roro #roro :Welcome to roro"); + TEST_ASSERT_EQUAL_STRING("Welcome to roro", f.session->buffer(f.buffer("#roro")).topic.c_str()); +} + +void test_input_message_and_commands() { + Fixture f; + f.registerNow(); + f.recv(":roro!u@h JOIN #roro"); + int b = f.buffer("#roro"); + f.take(); + f.session->input(b, "hello", 0); + f.session->input(b, "/me waves", 0); + f.session->input(b, "/topic New topic", 0); + f.session->input(b, "/join #other", 0); + f.session->input(b, "/part", 0); + f.session->input(b, "/nick roro2", 0); + f.session->input(b, "/names", 0); + f.session->input(b, "/raw MODE #roro +t", 0); + f.session->input(b, "//not a command", 0); + f.take(); + TEST_ASSERT_TRUE(f.sent("PRIVMSG #roro hello")); + TEST_ASSERT_TRUE(f.sent("PRIVMSG #roro :\x01" "ACTION waves\x01")); + TEST_ASSERT_TRUE(f.sent("TOPIC #roro :New topic")); + TEST_ASSERT_TRUE(f.sent("JOIN #other")); + TEST_ASSERT_TRUE(f.sent("PART #roro")); + TEST_ASSERT_TRUE(f.sent("NICK roro2")); + TEST_ASSERT_TRUE(f.sent("NAMES #roro")); + TEST_ASSERT_TRUE(f.sent("MODE #roro +t")); + TEST_ASSERT_TRUE(f.sent("PRIVMSG #roro :/not a command")); + // Own messages appear in the buffer + TEST_ASSERT_EQUAL_STRING("/not a command", f.session->buffer(b).lines.back().text.c_str()); +} + +void test_msg_opens_a_query_buffer() { + Fixture f; + f.registerNow(); + f.session->input(0, "/msg alice hi there", 0); + f.take(); + TEST_ASSERT_TRUE(f.sent("PRIVMSG alice :hi there")); + TEST_ASSERT_TRUE(f.buffer("alice") > 0); +} + +void test_text_in_the_server_buffer_explains_instead_of_sending() { + Fixture f; + f.registerNow(); + f.session->input(0, "hello", 0); + TEST_ASSERT_TRUE(f.take().send.empty()); + TEST_ASSERT_TRUE(f.session->buffer(0).lines.back().text.find("/join") != std::string::npos); +} + +void test_quit_sends_and_is_remembered() { + Fixture f; + f.registerNow(); + f.session->input(0, "/quit bye", 0); + f.take(); + TEST_ASSERT_TRUE(f.sent("QUIT bye")); + TEST_ASSERT_TRUE(f.session->quitRequested()); +} + +void test_names_reply_only_shown_when_asked() { + Fixture f; + f.registerNow(); + f.recv(":roro!u@h JOIN #roro"); + int b = f.buffer("#roro"); + size_t before = f.session->buffer(b).lines.size(); + f.recv(":srv 353 roro = #roro :roro alice bob"); + TEST_ASSERT_EQUAL(before, f.session->buffer(b).lines.size()); + f.session->input(b, "/names", 0); + f.recv(":srv 353 roro = #roro :roro alice bob"); + TEST_ASSERT_TRUE(f.session->buffer(b).lines.back().text.find("alice bob") != std::string::npos); +} + +void test_disconnect_marks_a_gap_and_reconnect_rejoins() { + Fixture f; + f.registerNow(); + f.recv(":roro!u@h JOIN #roro"); + f.session->input(1, "/join #extra", 0); + f.recv(":roro!u@h JOIN #extra"); + f.session->disconnected(5000, "paused for Wi-Fi monitoring"); + TEST_ASSERT_FALSE(f.session->registered()); + TEST_ASSERT_TRUE(f.session->buffer(f.buffer("#roro")).lines.back().text.find("paused") != std::string::npos); + f.take(); + f.session->connected(6000); + f.recv(":srv 001 roro :Welcome"); + f.take(); + TEST_ASSERT_TRUE(f.sent("JOIN #roro,#extra")); +} + +void test_buffer_keeps_only_recent_lines() { + Fixture f; + f.registerNow(); + f.recv(":roro!u@h JOIN #roro"); + for (int i = 0; i < IrcSession::kLinesPerBuffer + 10; i++) f.recv(":a!u@h PRIVMSG #roro :x"); + TEST_ASSERT_EQUAL(IrcSession::kLinesPerBuffer, f.session->buffer(f.buffer("#roro")).lines.size()); +} + +int main() { + UNITY_BEGIN(); + RUN_TEST(test_plain_registration_sends_nick_and_user); + RUN_TEST(test_welcome_registers_and_joins_autojoin_channels); + RUN_TEST(test_nickserv_identify_after_welcome); + RUN_TEST(test_sasl_plain_flow); + RUN_TEST(test_sasl_failure_still_ends_negotiation_and_says_so); + RUN_TEST(test_nick_in_use_during_registration_tries_another); + RUN_TEST(test_ping_is_answered); + RUN_TEST(test_own_join_creates_a_channel_buffer); + RUN_TEST(test_others_joining_is_hidden); + RUN_TEST(test_channel_message_goes_to_its_buffer_and_is_logged); + RUN_TEST(test_unread_counts_unless_viewing_that_buffer); + RUN_TEST(test_mention_notifies_ordinary_traffic_does_not); + RUN_TEST(test_private_message_opens_a_query_buffer_and_notifies); + RUN_TEST(test_no_notification_while_viewing_that_buffer); + RUN_TEST(test_action_and_ctcp_version); + RUN_TEST(test_topic_is_kept); + RUN_TEST(test_input_message_and_commands); + RUN_TEST(test_msg_opens_a_query_buffer); + RUN_TEST(test_text_in_the_server_buffer_explains_instead_of_sending); + RUN_TEST(test_quit_sends_and_is_remembered); + RUN_TEST(test_names_reply_only_shown_when_asked); + RUN_TEST(test_disconnect_marks_a_gap_and_reconnect_rejoins); + RUN_TEST(test_buffer_keeps_only_recent_lines); + return UNITY_END(); +}