IRC: ask for history, and for the modes a channel already has

Closes #73.

The capability list was `server-time` and `multi-prefix`. It now also asks
for `message-tags`, `batch` and chathistory under both its spellings - the
capability was `draft/chathistory` for years and is `chathistory` now, and
a server offering one and not the other is ordinary.

`message-tags` is the one that makes the rest possible. IRC has no message
id, so this daemon generates one, and a generated id can never match the
message it is a second copy of - which means replayed history would have
arrived as a fresh copy of everything already on screen. With `msgid` the
dedup that every other protocol here relies on finally works for IRC too,
after a reconnect as much as after a backfill.

History is asked for on joining a channel rather than on connecting: a
channel nobody opens is history nobody reads. Paging further back happens
on scroll, and is watched for rather than awaited - a batch ending is not
something an RPC call can be handed, so the store is polled briefly. Crude
and honest: the wait is bounded, it ends the moment anything lands, and
being wrong costs a page that fills in a moment later rather than a page
that is missing.

None of it is asked for unless the server granted it. Capabilities are
requested without waiting for the answer, so what was actually granted is
recorded from the ACK, and cleared when the connection ends - a server
that gave history last time is not promising to this time.

Also: a bare MODE on join. Servers announce a change but never the state,
so a channel joined before anything changed showed no modes at all. Now
#ELITEWAREZ reports +mnt on sight, which is the difference between "why
did my message not appear" and knowing the channel is moderated.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-02 00:38:25 -04:00
co-authored by Claude Opus 5
parent cc2650067c
commit d106147041
3 changed files with 205 additions and 3 deletions
+144 -3
View File
@@ -114,6 +114,11 @@ pub fn spawn(state: AppState, config: IrcAccountConfig) {
// dead. Drop it before the wait, or a send during the gap
// goes into a socket nobody is reading.
state.runtime.remove_irc_handle(&account_id);
// The next connection negotiates its own. A server that
// granted history last time is not promising to this time,
// and asking on the strength of a stale answer is a command
// answered with an error.
state.runtime.clear_irc_caps(&account_id);
state.runtime.set_conn_state(&state, &account_id, ConnState::Connecting, None);
let because = detail.unwrap_or_else(|| "Connection closed".to_string());
state.runtime.report_progress(&state, &account_id, &format!("{because} - reconnecting in {}s...", backoff.as_secs()));
@@ -695,7 +700,24 @@ fn quit_message(config: &IrcAccountConfig) -> String {
pub const DEFAULT_QUIT_MESSAGE: &str = "moho";
const WANTED_CAPS: &[&str] = &["server-time", "multi-prefix"];
const WANTED_CAPS: &[&str] = &[
"server-time",
"multi-prefix",
// A message's own id, which is what makes replayed history recognisable
// as history rather than as new. Without it every backfill would arrive
// as a fresh copy of everything already on screen: IRC has no id of its
// own, so this daemon generates one, and a generated id can never match
// the message it is a second copy of.
"message-tags",
// How a server frames a replay. Not read for its own sake - the messages
// inside a batch are ordinary ones - but a server will not send
// chathistory to a client that cannot be told where a batch begins.
"batch",
// The history itself. Ergo and a few others have it; the ones that do not
// simply NAK it, which costs nothing.
"draft/chathistory",
"chathistory",
];
/// Closes capability negotiation and sends the ordinary NICK/USER pair.
///
@@ -941,6 +963,9 @@ async fn handle_message(
// text - which belongs at the moment it is shown rather than at
// whatever the server stamped the triggering event with.
let sent_at = server_time(&msg);
// Carried alongside, because everything below matches on `msg.command`
// and would otherwise have moved the message out from under it.
let msg_id = message_id(&msg);
match msg.command {
Command::PRIVMSG(target, body) => {
@@ -957,9 +982,9 @@ async fn handle_message(
return;
}
if let Some(action_body) = strip_action(&body) {
state.runtime.record_message_at(state, account_id, &buffer_name, kind, &from, action_body, true, "chat", None, None, false, None, Vec::new(), Vec::new(), None, sent_at, None);
state.runtime.record_message_at(state, account_id, &buffer_name, kind, &from, action_body, true, "chat", None, msg_id, false, None, Vec::new(), Vec::new(), None, sent_at, None);
} else {
state.runtime.record_message_at(state, account_id, &buffer_name, kind, &from, &body, false, "chat", None, None, false, None, Vec::new(), Vec::new(), None, sent_at, None);
state.runtime.record_message_at(state, account_id, &buffer_name, kind, &from, &body, false, "chat", None, msg_id, false, None, Vec::new(), Vec::new(), None, sent_at, None);
}
}
@@ -1001,6 +1026,26 @@ async fn handle_message(
members.insert(from.clone(), MemberRank::None);
if from == own_nick {
state.runtime.ensure_buffer(state, account_id, &channel, "channel");
// What was said before we arrived. Asked for on join rather
// than on connect, because a channel nobody opens is a request
// for history nobody reads - and asked for at all only where
// the server granted the capability, since otherwise it is a
// command answered with an error in the server tab.
//
// The messages come back as ordinary ones carrying their own
// ids, so anything already stored is recognised and stored
// once. That is what `message-tags` is for.
if let Some(sender) = state.runtime.irc_sender(account_id) {
// What the channel is set to. Servers announce a change
// but not the state, so a client that never asks shows
// nothing until somebody happens to alter it - which is
// why the header sat empty on every channel already
// joined.
let _ = sender.send(Command::ChannelMODE(channel.clone(), Vec::new()));
if state.runtime.irc_has_chathistory(account_id) {
let _ = sender.send(chathistory_latest(&channel));
}
}
}
emit_presence(state, account_id, &channel, members);
}
@@ -1211,6 +1256,13 @@ async fn handle_message(
// when there's no channel buffer to show them in (the join never
// succeeded), so the always-present server buffer is the only
// reliable place - same as HexChat's server tab.
// Which capabilities the server actually granted. Requested without
// waiting for the answer, so this is where we find out - and asking a
// server for history it never offered is an error in the server tab.
Command::CAP(_, CapSubCommand::ACK, _, Some(caps)) => {
state.runtime.grant_irc_caps(account_id, &caps);
}
// What modes a channel currently has, in answer to a bare /mode.
Command::Response(Response::RPL_CHANNELMODEIS, args) => {
if let Some(channel) = args.get(1) {
@@ -1387,6 +1439,68 @@ fn rank_word(rank: MemberRank) -> &'static str {
/// which is the ordinary case on an older server - hence an Option rather
/// than a default, so the caller can fall back to the clock rather than to
/// the epoch.
/// How many messages to ask for at a time.
///
/// Enough to fill a screen and then some, and small enough that a server
/// which caps the request silently truncates rather than refuses. Servers
/// commonly limit this to 100 anyway.
pub const CHATHISTORY_PAGE: u32 = 100;
/// `CHATHISTORY LATEST <target> * <n>` - the most recent messages there are.
///
/// Built as a raw message because the crate has no command for it: this is an
/// IRCv3 extension rather than part of the protocol the crate models.
pub fn chathistory_latest(target: &str) -> Message {
format!("CHATHISTORY LATEST {target} * {CHATHISTORY_PAGE}")
.parse()
.expect("a CHATHISTORY line built from a channel name is well formed")
}
/// `CHATHISTORY BEFORE <target> timestamp=<t> <n>` - what came before a point.
///
/// The timestamp is the server's own format, which is RFC 3339 with
/// milliseconds. Built from a unix second, which is what the store keeps.
pub fn chathistory_before(target: &str, before_unix: i64) -> Option<Message> {
let at = chrono::DateTime::from_timestamp(before_unix, 0)?.format("%Y-%m-%dT%H:%M:%S%.3fZ");
format!("CHATHISTORY BEFORE {target} timestamp={at} {CHATHISTORY_PAGE}").parse().ok()
}
/// Waits for a CHATHISTORY page to land, or gives up.
///
/// Polled rather than signalled. The alternative is threading a one-shot
/// channel from this call into the connection task so a batch ending can wake
/// it, which is more machinery than the problem deserves: the wait is bounded,
/// it ends the moment anything arrives, and the cost of being wrong is a page
/// that fills in a moment later as live messages rather than a page that is
/// missing.
pub async fn await_history(state: &AppState, buffer_id: &str, before: i64, limit: i64, had: usize) {
const PATIENCE: Duration = Duration::from_secs(3);
const CHECK: Duration = Duration::from_millis(100);
let deadline = tokio::time::Instant::now() + PATIENCE;
while tokio::time::Instant::now() < deadline {
tokio::time::sleep(CHECK).await;
if state.store.get_backlog(buffer_id, before, limit).map(|m| m.len()).unwrap_or(0) > had {
return;
}
}
}
/// A message's own id, where the server gives one.
///
/// Used as the message id rather than a generated one, so a message seen twice
/// - replayed history, a reconnect, a batch that overlaps what is already
/// stored - is stored once. That dedup already exists for every other protocol
/// here; IRC could not use it because it had no id to dedup on.
fn message_id(msg: &Message) -> Option<String> {
let tags = msg.tags.as_ref()?;
tags.iter()
.find(|tag| tag.0 == "msgid")?
.1
.as_deref()
.filter(|id| !id.is_empty())
.map(str::to_string)
}
fn server_time(msg: &Message) -> Option<i64> {
let tags = msg.tags.as_ref()?;
// Tag is a tuple struct of (name, value); matched by field rather than
@@ -1658,6 +1772,33 @@ mod quit_tests {
IrcAccountConfig { quit_message: message.map(String::from), ..Default::default() }
}
#[test]
fn builds_the_history_requests_the_spec_defines() {
assert_eq!(chathistory_latest("#channel").to_string().trim_end(), "CHATHISTORY LATEST #channel * 100");
let before = chathistory_before("#channel", 1_767_915_177).unwrap();
assert_eq!(
before.to_string().trim_end(),
"CHATHISTORY BEFORE #channel timestamp=2026-01-08T23:32:57.000Z 100"
);
}
#[test]
fn a_msgid_is_used_where_the_server_gives_one() {
// Without this every replayed message is a fresh copy of one already
// on screen: IRC has no id of its own, so the daemon generates one,
// and a generated id can never match the message it duplicates.
let with_id: Message = "@msgid=abc123 :nick!u@h PRIVMSG #chan :hello".parse().unwrap();
assert_eq!(message_id(&with_id).as_deref(), Some("abc123"));
let without: Message = ":nick!u@h PRIVMSG #chan :hello".parse().unwrap();
assert_eq!(message_id(&without), None);
// An empty tag is not an id, and using one would collapse every such
// message into a single row.
let empty: Message = "@msgid= :nick!u@h PRIVMSG #chan :hello".parse().unwrap();
assert_eq!(message_id(&empty), None);
}
#[test]
fn a_bare_nick_becomes_a_mask_and_a_mask_is_left_alone() {
assert_eq!(ban_mask("someone"), "someone!*@*");
+27
View File
@@ -150,6 +150,33 @@ pub async fn dispatch(
backend::discord::catch_up_channel(state, &cfg.token, &cfg.user_id, cfg.display_name.as_deref(), buffer_id, &channel_id).await;
}
}
// IRC pages back through CHATHISTORY, where the server
// has it. Unlike the other two this is not a request with
// a reply - the history arrives as ordinary messages on
// the same connection - so there is nothing to await, and
// the store is watched instead until the page lands or it
// becomes clear nothing is coming.
//
// Crude, and the honest shape of the problem: a batch
// ending is not something this call can be handed. What it
// buys is that the page returned below already contains
// the history, rather than the history arriving afterwards
// as live messages and being appended to the bottom of a
// conversation it belongs at the top of.
if before > 0
&& buffer.kind == "channel"
&& state.runtime.irc_has_chathistory(&buffer.account_id)
{
if let Some(sender) = state.runtime.irc_sender(&buffer.account_id) {
if let Some(request) = backend::irc::chathistory_before(&buffer.name, before) {
let had = state.store.get_backlog(buffer_id, before, limit).map(|m| m.len()).unwrap_or(0);
if sender.send(request).is_ok() {
backend::irc::await_history(state, buffer_id, before, limit, had).await;
}
}
}
}
// Matrix pages back through /messages the same way, and
// only when paginating: the initial open is served from
// what sync already delivered, and fetching history for
+34
View File
@@ -370,6 +370,13 @@ pub struct Runtime {
/// A `/list` in progress, by account. Gathered rather than announced a
/// line at a time - a network answers with tens of thousands of channels.
irc_channel_lists: Mutex<HashMap<String, Vec<IrcChannelListing>>>,
/// Which capabilities each IRC connection was actually granted.
///
/// Asked for is not the same as given: capabilities are requested without
/// waiting for the answer, and a server that does not know one simply
/// refuses that line. Asking for history from a server that never granted
/// it is a command it will answer with an error in the server tab.
irc_caps: Mutex<HashMap<String, std::collections::HashSet<String>>>,
/// Matrix-specific: buffer id -> room id. Matrix buffer *names* are
/// human-friendly (see backend/matrix/rooms.rs's naming fallback
/// chain), but sending/reacting/etc. needs the real `!opaque:server`
@@ -550,6 +557,7 @@ impl Runtime {
kick_senders: Mutex::new(HashMap::new()),
kick_channels: Mutex::new(HashMap::new()),
irc_channel_lists: Mutex::new(HashMap::new()),
irc_caps: Mutex::new(HashMap::new()),
matrix_rooms: Mutex::new(HashMap::new()),
matrix_room_names: Mutex::new(HashMap::new()),
matrix_space_parents: Mutex::new(HashMap::new()),
@@ -1986,6 +1994,32 @@ impl Runtime {
state.events.emit("bufferListChange", serde_json::to_value(&updated).unwrap());
}
pub fn grant_irc_caps(&self, account_id: &str, caps: &str) {
let mut all = self.irc_caps.lock().unwrap();
let granted = all.entry(account_id.to_string()).or_default();
for cap in caps.split_whitespace() {
// A server may answer with `cap=value`; the name is what matters.
granted.insert(cap.split('=').next().unwrap_or(cap).to_string());
}
}
pub fn clear_irc_caps(&self, account_id: &str) {
self.irc_caps.lock().unwrap().remove(account_id);
}
/// Whether this connection may be asked for history.
///
/// Either spelling counts: the capability was `draft/chathistory` for
/// years and is `chathistory` now, and a server offering one and not the
/// other is the ordinary case rather than an edge one.
pub fn irc_has_chathistory(&self, account_id: &str) -> bool {
self.irc_caps
.lock()
.unwrap()
.get(account_id)
.is_some_and(|caps| caps.contains("draft/chathistory") || caps.contains("chathistory"))
}
pub fn begin_irc_channel_list(&self, account_id: &str) {
self.irc_channel_lists.lock().unwrap().insert(account_id.to_string(), Vec::new());
}