#include "irc_session.h" #include #include #include "base64.h" namespace roro { namespace { const char kCtcp = '\x01'; std::string lower(std::string s) { for (auto& c : s) c = static_cast(std::tolower(static_cast(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(c)) || std::string("[]\\`_^{|}-").find(c) != std::string::npos; } std::string join(const std::vector& 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 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, ""}); for (auto& c : config.autojoin) if (!c.key.empty()) keys_[lower(c.channel)] = c.key; } void IrcSession::tick(uint32_t nowMs) { nowMs_ = nowMs; if (joinsHeld_ && nowMs - heldSinceMs_ >= kNickservWaitMs) joinChannels(); } 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; joinsHeld_ = false; saslFailed_ = 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); // NickServ when it's the configured login, or as the fallback when SASL failed. // The account is named explicitly: we may be on a fallback nick if a stale session holds ours. bool sasl = !config_.saslUser.empty() && !config_.saslPassword.empty(); std::string account = sasl ? config_.saslUser : config_.nick; std::string identify; if (!config_.nickservPassword.empty() && (!sasl || saslFailed_)) identify = account + " " + config_.nickservPassword; else if (sasl && saslFailed_) identify = account + " " + config_.saslPassword; if (!identify.empty()) { // IRC channels for registered users only would refuse us until NickServ has logged us in. send(IrcMessage::serialize("PRIVMSG", {"NickServ", "IDENTIFY " + identify})); joinsHeld_ = true; heldSinceMs_ = nowMs_; return; } joinChannels(); } void IrcSession::joinChannels() { joinsHeld_ = false; std::vector channels; auto addOnce = [&](const std::string& c) { for (auto& existing : channels) if (lower(existing) == lower(c)) return; channels.push_back(c); }; for (auto& c : config_.autojoin) addOnce(c.channel); for (auto& c : rejoin_) addOnce(c); rejoin_.clear(); if (channels.empty()) return; // JOIN #keyed,#open key: IRC pairs keys with the first channels listed. std::string keyed, open, keys; for (auto& c : channels) { auto k = keys_.find(lower(c)); if (k != keys_.end()) { keyed += (keyed.empty() ? "" : ",") + c; keys += (keys.empty() ? "" : ",") + k->second; } else { open += (open.empty() ? "" : ",") + c; } } std::string list = keyed + (!keyed.empty() && !open.empty() ? "," : "") + open; send(keys.empty() ? IrcMessage::serialize("JOIN", {list}) : IrcMessage::serialize("JOIN", {list, keys})); } 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 == "900") { // Logged in. Take our nick back from a stale session, then join. if (lower(nick_) != lower(config_.nick)) send(IrcMessage::serialize("PRIVMSG", {"NickServ", "REGAIN " + config_.nick})); if (joinsHeld_) joinChannels(); } 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) + " (trying NickServ instead)", utc); saslFailed_ = true; 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(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" || verb == "j") { auto [channel, key] = firstWord(rest); if (channel.empty()) return info(b, "Usage: /join #channel", utc); if (!isChannel(channel)) channel = "#" + channel; if (!key.empty()) keys_[lower(channel)] = key; // reused when rejoining 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 or /j, /part /msg /me /nick /topic /names /quit /raw)", utc); } } } // namespace roro