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
|
||||
Reference in New Issue
Block a user