IRC: join after NickServ login, keyed auto-join, SASL fallback

- With NickServ, auto-join waits for the logged-in reply (900) or 2 s
  at most, so registered-only IRC channels let us in
- A failed SASL login falls back to NickServ (its own password, or the
  SASL account and password)
- IDENTIFY names the account explicitly, and once logged in on a
  fallback nick, REGAIN takes ours back from a stale session
- Auto-join entries take keys ("#private key, #public"); keys from
  /join are reused when rejoining; keyed channels go first in JOIN
- Serial irc dump: whole Buffers, and which login is configured
  (never the secrets)

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-03 19:48:16 +02:00
co-authored by Claude Opus 5.5
parent 83eb791924
commit 1f0c1b2e14
9 changed files with 263 additions and 41 deletions
+59 -12
View File
@@ -37,6 +37,13 @@ std::pair<std::string, std::string> firstWord(const std::string& text) {
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 {
@@ -108,6 +115,8 @@ void IrcSession::add(int b, IrcLine::Kind kind, const std::string& nick, const s
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");
@@ -130,18 +139,49 @@ 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}));
// 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<std::string> 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) {
@@ -163,11 +203,17 @@ void IrcSession::receive(const std::string& raw, int64_t utc) {
} 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), utc);
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");
@@ -284,6 +330,7 @@ void IrcSession::command(int b, const std::string& text, int64_t utc) {
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 : "";