Public Access
M1 step 4a: IRC protocol core (host-tested)
lib/irc: IrcMessage parse/serialize, base64, ReconnectPolicy (5 s .. 5 min), IrcConfig (validated, persisted) and IrcSession: registration with SASL PLAIN or NickServ, nick fallback, PING, Buffers capped at 50 lines with unread counts, Mentions, CTCP ACTION/VERSION, topics, /names on request only, others' join/part/quit hidden, user commands, and rejoining after a pause. No networking: the session returns lines to send, Log entries and Notifications. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
This commit is contained in:
@@ -0,0 +1,31 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
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<uint8_t>(in[i]) << 16) | (static_cast<uint8_t>(in[i + 1]) << 8) | static_cast<uint8_t>(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<uint8_t>(in[i]) << 16;
|
||||||
|
if (rest == 2) n |= static_cast<uint8_t>(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
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
#include "irc_config.h"
|
||||||
|
|
||||||
|
#include <cctype>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
namespace roro {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
bool validNick(const std::string& n) {
|
||||||
|
if (n.empty() || n.size() > 30) return false;
|
||||||
|
if (std::isdigit(static_cast<unsigned char>(n[0])) || n[0] == '-') return false;
|
||||||
|
for (char c : n)
|
||||||
|
if (!std::isalnum(static_cast<unsigned char>(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<std::string> IrcConfig::parseChannels(const std::string& text) {
|
||||||
|
std::vector<std::string> 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<std::string>& channels) {
|
||||||
|
std::string out;
|
||||||
|
for (auto& c : channels) out += (out.empty() ? "" : " ") + c;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace roro
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#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<std::string> 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<std::string> parseChannels(const std::string& text);
|
||||||
|
static std::string formatChannels(const std::vector<std::string>& channels);
|
||||||
|
|
||||||
|
private:
|
||||||
|
KeyValueStore& store_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace roro
|
||||||
@@ -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<std::string> 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
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <initializer_list>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace roro {
|
||||||
|
|
||||||
|
// One IRC protocol line: [@tags] [:prefix] COMMAND params... [:trailing] (tags are ignored).
|
||||||
|
struct IrcMessage {
|
||||||
|
std::string prefix;
|
||||||
|
std::string command;
|
||||||
|
std::vector<std::string> 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<std::string> params);
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace roro
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
#include "irc_session.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cctype>
|
||||||
|
|
||||||
|
#include "base64.h"
|
||||||
|
|
||||||
|
namespace roro {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
const char kCtcp = '\x01';
|
||||||
|
|
||||||
|
std::string lower(std::string s) {
|
||||||
|
for (auto& c : s) c = static_cast<char>(std::tolower(static_cast<unsigned char>(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<unsigned char>(c)) || std::string("[]\\`_^{|}-").find(c) != std::string::npos; }
|
||||||
|
|
||||||
|
std::string join(const std::vector<std::string>& 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<std::string, std::string> 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<std::string> 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<unsigned char>(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
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <deque>
|
||||||
|
#include <set>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#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<IrcLine> 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<std::string> send;
|
||||||
|
std::vector<IrcLogEntry> logs;
|
||||||
|
std::vector<std::string> 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<int>(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<IrcBuffer> buffers_;
|
||||||
|
std::string nick_;
|
||||||
|
bool registered_ = false;
|
||||||
|
bool quit_ = false;
|
||||||
|
int viewing_ = -1;
|
||||||
|
std::set<std::string> namesRequested_;
|
||||||
|
std::vector<std::string> rejoin_; // IRC channels to join again after a reconnect
|
||||||
|
IrcEffects effects_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace roro
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
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
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
#include <unity.h>
|
||||||
|
|
||||||
|
#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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
#include <unity.h>
|
||||||
|
|
||||||
|
#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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
#include <unity.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
|
#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<IrcSession> 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<int>(IrcLine::Kind::Action), static_cast<int>(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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user