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:
2026-10-02 15:03:21 +02:00
co-authored by Claude Opus 5.5
parent d3948ccc3a
commit 311eb38723
11 changed files with 1142 additions and 0 deletions
+79
View File
@@ -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