Files
thelounge/server/plugins/irc-events/monitor.ts
Max Leiter 78cadc5ff1 ircv3: monitor and extended-monitor (#5045)
Updated version of #4550. Closes #337, closes #872 (#337 can't be fully
addressed — tracking nick changes isn't really possible atm).

<img width="253" height="149" alt="image"
src="https://github.com/user-attachments/assets/8a961868-0aa9-406b-92e2-f9d4858e2d0d"
/>

Away support:
<img width="214" alt="image"
src="https://user-images.githubusercontent.com/8675906/165882385-6f928c8e-5a0b-4502-8bd3-3cd0bd70e0e0.png">

## Summary

Query windows show whether the person behind them is online, and whether
they are away, using
[MONITOR](https://ircv3.net/specs/extensions/monitor) rather than
polling. `Network` keeps the monitor list: queries are registered in
batched `MONITOR +` commands once RPL_ISUPPORT tells us the server
supports it, and one at a time as new query windows open. The limit
advertised in `MONITOR=<n>` is respected, with anything over it waiting
for a slot; `MONITOR=0` means no monitoring at all, a bare `MONITOR`
means no limit. A nick change on a monitored query swaps the target in
place so the query keeps its slot.

[extended-monitor](https://ircv3.net/specs/extensions/extended-monitor)
is requested too, so away, nick, account and chghost updates arrive for
monitored nicks we share no channel with. Away state for people we do
share a channel with comes from a WHO on join.

The indicator is a dot on the query in the sidebar and next to the nick
in the message bubble, with a tooltip and an `img` role so it isn't
colour-only, and it can be turned off under Appearance.

Known limitation: a server that enforces a monitor limit it doesn't
advertise will reject part of a batch with `ERR_MONLISTFULL`; the user
gets a message in the lobby, but the client's own list isn't reconciled
until reconnect.

## Test Plan

- Unit tests for the monitor list: add, case-insensitive dedupe,
queueing at the limit, removal draining the queue, batching, and the
rename path.
- Ran the server against a local IRCd and watched the wire: opening
queries sends `MONITOR + <nick>` each, a reconnect with four open
queries sends a single `MONITOR + bob,carol,dave,mallory`, 730/731 drive
the online/offline state, a rename sends `MONITOR - bob` then `MONITOR +
bob2`, `MONITOR=0` sends nothing at all, and a bare `MONITOR` behaves as
unlimited.
2026-07-22 09:46:58 -07:00

79 lines
2.1 KiB
TypeScript

import type {IrcEventHandler} from "../../client";
import Msg from "../../models/msg";
import {ChanType} from "../../../shared/types/chan";
import {MessageType} from "../../../shared/types/msg";
// https://ircv3.net/specs/extensions/monitor
// https://ircv3.net/specs/extensions/extended-monitor
export default <IrcEventHandler>function (irc, network) {
const client = this;
function getMonitoredQuery(nick: string) {
const channel = network.getChannel(nick);
return channel?.type === ChanType.QUERY ? channel : undefined;
}
// RPL_MONONLINE (730) / RPL_MONOFFLINE (731)
function handleMonitorStatus(nicks: string[], online: boolean) {
const changedChannels: string[] = [];
for (const nick of nicks) {
const channel = getMonitoredQuery(nick);
if (channel && channel.isOnline !== online) {
channel.isOnline = online;
changedChannels.push(channel.name);
}
}
if (changedChannels.length > 0) {
client.emit(online ? "users:online" : "users:offline", {
changedChannels,
networkId: network.uuid,
});
}
}
irc.on("users online", (data) => handleMonitorStatus(data.nicks, true));
irc.on("users offline", (data) => handleMonitorStatus(data.nicks, false));
// ERR_MONLISTFULL (734). Surface as a status message — the user didn't
// trigger MONITOR directly, so this is informational, not an error.
irc.on("irc error", function (data) {
if (data.error !== "monitor_list_full") {
return;
}
network.getLobby().pushMessage(
client,
new Msg({
text: "Your monitor list is full on this network; some queries may not show online/away status.",
}),
true
);
});
// account-notify for a monitored nick we don't share a channel with
irc.on("account", function (data) {
if (network.serverOptions.MONITOR === null) {
return;
}
const channel = getMonitoredQuery(data.nick);
if (!channel || channel.findUser(data.nick)) {
return;
}
channel.pushMessage(
client,
new Msg({
time: data.time,
type: data.account ? MessageType.LOGIN : MessageType.LOGOUT,
from: channel.getUser(data.nick),
text: data.account || "",
})
);
});
};