Files
roro9stack/lib/irc/src/irc_session.cpp
T
twislaandClaude Opus 5.5 076baa77d1 M1 step 5: IRC App
- Chat: header (Buffer n/N, unread elsewhere, topic or connection
  state), wrapped hh:mm <nick> lines (own in accent, Mentions green,
  info grey), input line; Tab cycles Buffers, Fn+Up/Down scrolls back,
  Enter sends, Back leaves while the IRC Service keeps running
- /settings: server form (host, port, TLS, self-signed pinning, nick,
  SASL, NickServ, auto-join); Save & reconnect restarts the session
- Opening the App starts the IRC Service; viewing a Buffer clears its
  unread count; IrcSession gains a revision counter for redraws

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-02 15:14:59 +02:00

323 lines
13 KiB
C++

#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, ""});
revision_++;
return bufferCount() - 1;
}
void IrcSession::setViewing(int buffer) {
viewing_ = buffer;
revision_++;
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];
revision_++;
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.type != IrcBuffer::Type::Server) {
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;
quit_ = 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