Public Access
- 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
323 lines
13 KiB
C++
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
|