From e591c866bd91d94f0968ff0371da78ad62051c3f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 19:23:39 +0000 Subject: [PATCH 1/3] Bouncers: ZNC's buffer replay, soju's push, search and networks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ZNC and soju each left a gap Seance did not cover. Each fix goes by what the server offers, not by which bouncer it is. - ZNC's buffer replay (`BATCH +x znc.in/playback `, on every attach) was unwrapped and handled as live lines. It now goes the catch-up's way: appended, counted as unread but silent. ZNC has no msgids, so a line not newer than the conversation's newest message is dropped, and a buffer ZNC keeps is not shown twice. - Behind a bouncer (BOUNCER_NETID, soju.im/bouncer-networks, or a znc.in/* cap) the autojoin waits for the bouncer's own JOINs. If it gave any back it owns the list: a saved channel it did not give back was left from another client and is not JOINed again. A bouncer that gave none back gets the saved channels, as before. - soju offers web push only as soju.im/webpush: the same command, VAPID token and payload as draft/webpush. Seance now asks for it too. - Search: soju refuses a search over every channel without in= (`FAIL SEARCH INVALID_PARAMS in`). Seance then asks each joined channel (up to 30) and answers once, for the rest of the connection too. soju takes only timestamp references, so the window around a hit is asked for by the hit's time. - soju.im/bouncer-networks: a login without a network is soju's control connection, which has no channels. It no longer JOINs there; it lists the networks and adds each new one as a network of its own, the same server and login bound with `BOUNCER BIND ` before CAP END. A network the user removes is not added back. With -notify, a network added on the bouncer later is added the same way. Tests: test/irc/bouncer.ts (new), and the soju cases in historysearch, webpush and caps. Docs: irc-layer.md § Bouncers, client-features.md § Searching history. --- client/js/i18n/call-sites.ts | 1 + client/js/irc/bouncer.ts | 125 ++++++++++++++++ client/js/irc/caps.ts | 13 +- client/js/irc/channel.ts | 8 + client/js/irc/client.ts | 79 ++++++++-- client/js/irc/handlers/bouncer.ts | 15 ++ client/js/irc/handlers/index.ts | 7 +- client/js/irc/history.ts | 32 ++++ client/js/irc/historysearch.ts | 217 +++++++++++++++++++++++++-- client/js/irc/manager.ts | 60 ++++++++ client/js/irc/saved-networks.ts | 20 +++ client/js/irc/types.ts | 6 + client/locales/en.json | 1 + client/locales/messages.pot | 6 + client/locales/qqx.json | 1 + docs/resources/client-features.md | 2 +- docs/resources/irc-layer.md | 10 ++ test/irc/bouncer.ts | 234 ++++++++++++++++++++++++++++++ test/irc/caps.ts | 10 +- test/irc/historysearch.ts | 84 +++++++++++ test/irc/webpush.ts | 12 ++ 21 files changed, 918 insertions(+), 25 deletions(-) create mode 100644 client/js/irc/bouncer.ts create mode 100644 client/js/irc/handlers/bouncer.ts create mode 100644 test/irc/bouncer.ts diff --git a/client/js/i18n/call-sites.ts b/client/js/i18n/call-sites.ts index a9b279f50..f242182fa 100644 --- a/client/js/i18n/call-sites.ts +++ b/client/js/i18n/call-sites.ts @@ -5,6 +5,7 @@ // a key assembled at runtime translates unpredictably, even when it // happens to resolve. export const STATIC_CALL_SITES = new Set([ + "bouncer.networksAdded", "browse.all", "browse.aria", "browse.ask", diff --git a/client/js/irc/bouncer.ts b/client/js/irc/bouncer.ts new file mode 100644 index 000000000..793454e11 --- /dev/null +++ b/client/js/irc/bouncer.ts @@ -0,0 +1,125 @@ +/** + * A bouncer's networks (`soju.im/bouncer-networks`, + * https://codeberg.org/emersion/soju/src/branch/master/doc/ext/bouncer-networks.md). + * + * A soju login without a network (`alice`, not `alice/libera`) is the + * bouncer's control connection: it has no channels (`CHANTYPES=` empty, and a + * JOIN is answered `400 … bouncer connection`). There Seance asks for the + * account's networks (`BOUNCER LISTNETWORKS`) and hands them to the manager + * (`IrcClientOptions.onBouncerNetworks`), which adds each new one as a network + * of its own: the same server and login, bound to it with `BOUNCER BIND ` + * before `CAP END` (client.ts `finalLines`). That is how gamja and senpai work + * too: one connection per bound network. With `-notify`, networks added on + * the bouncer later (from another client) arrive as single `BOUNCER NETWORK` + * lines and are added the same way. + * + * Kept free of store and DOM imports: it runs under mocha. + */ + +import {t} from "../i18n/core"; +import type {IrcClient} from "./client"; +import type {BatchHandler} from "./handlers/batch"; +import {parseTags, type IrcMessage} from "./message"; + +export const BOUNCER_NETWORKS_CAP = "soju.im/bouncer-networks"; +export const BOUNCER_NETWORKS_BATCH = "soju.im/bouncer-networks"; + +/** One of a bouncer's networks, as `BOUNCER NETWORK ` gives it. */ +export interface BouncerNetwork { + id: string; + /** The `name` attribute, else the `host`, else the id. */ + name: string; + host?: string; + /** `connected`, `connecting` or `disconnected`, when the bouncer says. */ + state?: string; + /** The attributes were `*`: the network was removed from the bouncer. */ + removed?: boolean; +} + +/** The bouncer's control connection: it offers the networks and is bound to none. */ +export function isBouncerControl(client: IrcClient): boolean { + return ( + client.caps.hasCapability(BOUNCER_NETWORKS_CAP) && + !client.isupport.tokens.has("BOUNCER_NETID") + ); +} + +/** `BOUNCER BIND `, for the registration flush, when this connection is bound to one. */ +export function bindLine(client: IrcClient): string | undefined { + const id = client.options.bouncerNetId; + return id && client.caps.available.has(BOUNCER_NETWORKS_CAP) ? `BOUNCER BIND ${id}` : undefined; +} + +/** Ask the control connection for the account's networks (after 001). */ +export function listNetworks(client: IrcClient): void { + if (!isBouncerControl(client)) { + return; + } + + client.background.add({ + key: "bouncer:listnetworks", + cost: client.fakeLag.cost("BOUNCER LISTNETWORKS"), + run: () => void client.send("BOUNCER LISTNETWORKS"), + }); +} + +/** `BOUNCER NETWORK `; undefined for any other `BOUNCER` line. */ +export function parseNetwork(msg: IrcMessage): BouncerNetwork | undefined { + const [sub, id, attrs] = msg.params; + + if (sub?.toUpperCase() !== "NETWORK" || !id || attrs === undefined) { + return undefined; + } + + if (attrs === "*") { + return {id, name: id, removed: true}; + } + + const tags = parseTags(attrs); + const host = tags.get("host") || undefined; + return {id, name: tags.get("name") || host || id, host, state: tags.get("state") || undefined}; +} + +/** Give the manager what the bouncer listed, and say in the lobby what was added. */ +function offer(client: IrcClient, networks: BouncerNetwork[]): void { + const added = client.options.onBouncerNetworks?.(networks) ?? []; + + if (added.length > 0) { + client.pushMessage( + client.lobby, + {text: t("bouncer.networksAdded", {names: added.join(", ")})}, + true + ); + } +} + +/** The answer to `BOUNCER LISTNETWORKS`: every network of the account. */ +export const bouncerNetworksBatch: BatchHandler = (client, batch) => { + // A bound connection hears the list too (`-notify`); only the control one adds. + if (!isBouncerControl(client)) { + return; + } + + const networks: BouncerNetwork[] = []; + + for (const line of batch.messages) { + const net = line.command === "BOUNCER" ? parseNetwork(line) : undefined; + + if (net) { + networks.push(net); + } else { + client.handleMessage(line); + } + } + + offer(client, networks); +}; + +/** A single `BOUNCER NETWORK` (`-notify`: a network added or changed elsewhere). */ +export function bouncerLine(client: IrcClient, msg: IrcMessage): void { + const net = parseNetwork(msg); + + if (net && isBouncerControl(client)) { + offer(client, [net]); + } +} diff --git a/client/js/irc/caps.ts b/client/js/irc/caps.ts index 8579fcaf8..432af845e 100644 --- a/client/js/irc/caps.ts +++ b/client/js/irc/caps.ts @@ -63,7 +63,8 @@ export interface CapResult { * disturb)), and * `draft/pre-away` lets the away state be declared before `CAP END`, so a * resumed bouncer session is never announced as back (presence.ts - * `registrationAwayLine`). Deliberately + * `registrationAwayLine`). `soju.im/bouncer-networks` lists a bouncer's + * networks and binds a connection to one (bouncer.ts). Deliberately * absent: `draft/bouncer`, `no-implicit-names`. */ export const SEANCE_CAPS: CapNegotiatorOptions = { @@ -91,11 +92,14 @@ export const SEANCE_CAPS: CapNegotiatorOptions = { "draft/read-marker", "draft/message-redaction", "draft/webpush", + "soju.im/webpush", "draft/authtoken", "draft/metadata-2", "draft/persistence", "draft/pre-away", "soju.im/search", + "soju.im/bouncer-networks", + "soju.im/bouncer-networks-notify", ], }; @@ -112,6 +116,13 @@ const REQ_PREFIX = "CAP REQ :"; /** The cap name for the Web Push draft (draft/webpush). */ export const WEBPUSH_CAP = "draft/webpush"; +/** + * soju's name for the same extension, from before the draft was named: the + * same `WEBPUSH` command, the `VAPID` 005 token and a one-line payload. A + * server offering both is used through {@link WEBPUSH_CAP}. + */ +export const SOJU_WEBPUSH_CAP = "soju.im/webpush"; + /** * The VAPID public key a `draft/webpush` CAP 302 value advertises * (`vapid=`, nefarious2's), undefined when the value does diff --git a/client/js/irc/channel.ts b/client/js/irc/channel.ts index 4fe2bdd73..69e4468d9 100644 --- a/client/js/irc/channel.ts +++ b/client/js/irc/channel.ts @@ -130,6 +130,13 @@ export class Channel { * clock, so they never move it. */ newestServerAt: number | undefined = undefined; + /** + * Unix ms of the newest message or action shown here, whatever stamped it: + * a bouncer's buffer replay without msgids (ZNC, history.ts + * `zncPlaybackBatch`) skips what is not newer, so an attach that replays + * the same buffer again shows nothing twice. + */ + newestChatAt: number | undefined = undefined; /** History has been requested at least once (so `newestRef` is a valid catch-up reference). */ historyRequested = false; /** @@ -225,6 +232,7 @@ export class Channel { if (msg.type === MessageType.MESSAGE || msg.type === MessageType.ACTION) { ref.reactable = true; + this.newestChatAt = Math.max(this.newestChatAt ?? 0, ref.time.getTime()); } this.msgRefs.set(msg.id, ref); diff --git a/client/js/irc/client.ts b/client/js/irc/client.ts index c28ce3589..587270779 100644 --- a/client/js/irc/client.ts +++ b/client/js/irc/client.ts @@ -29,7 +29,8 @@ import type { SharedServerOptions, } from "../../../shared/types/network"; import type {PushSession, ServerToClientEvents} from "../../../shared/types/socket-events"; -import {CapNegotiator, SEANCE_CAPS, webpushVapidOf, WEBPUSH_CAP} from "./caps"; +import {bindLine, isBouncerControl, listNetworks, type BouncerNetwork} from "./bouncer"; +import {CapNegotiator, SEANCE_CAPS, SOJU_WEBPUSH_CAP, webpushVapidOf, WEBPUSH_CAP} from "./caps"; import {casefold, namesEqual} from "./casemap"; import {Channel, MsgRef, SAID_TYPES, countsAsUnread} from "./channel"; import {QueryLog} from "./querylog"; @@ -294,6 +295,12 @@ export interface IrcClientOptions extends ConnectOptions { * way back to the connect screen to fix them. */ onSaslRejected?: () => void; + /** + * A bouncer's control connection listed its networks (bouncer.ts). The + * manager adds the new ones as networks of their own and returns their + * names, for the lobby to say so. + */ + onBouncerNetworks?: (networks: BouncerNetwork[]) => string[]; } /** What to try after a SASL login the deploy insists on did not happen. */ @@ -1430,6 +1437,13 @@ export class IrcClient { lines.push(`METADATA * SUB ${MUTE_KEY} ${PRESENCE_KEY} ${MODE_KEY} ${CLOSED_KEY}`); } + // Bound to one of a bouncer's networks (bouncer.ts): said before CAP END. + const bind = bindLine(this); + + if (bind) { + lines.unshift(bind); + } + const line = registrationAwayLine(this); return line ? [...lines, line] : lines; }; @@ -1780,6 +1794,9 @@ export class IrcClient { // subscription and the Settings UI can tell the two cases apart. The // WEBPUSH REGISTER itself is emitted later through the bus — a // REGISTER sent before CAP END is silently dropped by nefarious2. + // A bouncer's control connection: what networks it has (bouncer.ts). + listNetworks(this); + this.dispatch("webpush:available", { network: this.uuid, vapid: this.webpushVapid(), @@ -1800,11 +1817,34 @@ export class IrcClient { // (bounce_setup_local_alias): it is already on the wire, and the // note extends the wait. Otherwise nothing comes, so a short wait. awaitRestoration(this, RESTORE_QUIET_MS); + } else if (this.behindBouncer) { + // soju and ZNC send the channels they hold right behind the MOTD: + // wait for them before deciding what is left to JOIN (autojoin). + awaitRestoration(this, RESTORE_QUIET_MS); } else { this.restorationSettled(); } } + /** + * This connection goes through a bouncer that keeps its own channel list: + * soju (`BOUNCER_NETID`, `soju.im/bouncer-networks`) or ZNC (its `znc.in/*` + * caps). What it holds is what the user is in, on every device. + */ + get behindBouncer(): boolean { + if (this.isupport.tokens.has("BOUNCER_NETID")) { + return true; + } + + for (const name of this.caps.available.keys()) { + if (name.startsWith("znc.in/") || name === "soju.im/bouncer-networks") { + return true; + } + } + + return false; + } + /** * Registration's restoration is in, or was never coming: opt into * session persistence if that still does something, then JOIN what the @@ -1836,16 +1876,32 @@ export class IrcClient { } /** - * JOIN every autojoin channel we are not in, the active one's catch-up + * JOIN every autojoin channel we are not in (behind a bouncer that holds + * channels, none: see {@link behindBouncer}), the active one's catch-up * pipelined behind it: the server processes lines in order, so by the * time it reads the history request we are a member. Saves a round trip * on the channel the user is looking at. */ autojoin(): void { - const joining = this.channels.filter( - (chan) => - chan.type === ChanType.CHANNEL && chan.autoJoin && chan.state === ChanState.PARTED - ); + // A bouncer that gave channels back owns the list: one it did not give + // back was left from another client, and stays left here. A bouncer + // that gave none back (a new one, or one not on the network yet) is + // sent the saved channels, as any server is. + // A bouncer's control connection has no channels at all (bouncer.ts). + const bouncerHolds = + isBouncerControl(this) || + (this.behindBouncer && + this.channels.some( + (chan) => chan.type === ChanType.CHANNEL && chan.state === ChanState.JOINED + )); + const joining = bouncerHolds + ? [] + : this.channels.filter( + (chan) => + chan.type === ChanType.CHANNEL && + chan.autoJoin && + chan.state === ChanState.PARTED + ); if (joining.length > 0) { this.joinChannels(joining.map((chan) => ({name: chan.name, key: chan.shared.key}))); @@ -1869,19 +1925,24 @@ export class IrcClient { /** * The VAPID public key this server advertises for Web Push, when the - * `draft/webpush` capability is enabled: the `VAPID` ISUPPORT token the + * `draft/webpush` capability (or soju's `soju.im/webpush`) is enabled: the `VAPID` ISUPPORT token the * spec has the server send once the cap is on, or nefarious2's cap 302 * value (`vapid=`). The cap is requested bare too, as the spec has * it offered; undefined when the network cannot push (cap missing or no * key), and nothing subscribes then. */ webpushVapid(): string | undefined { - if (!this.caps.hasCapability(WEBPUSH_CAP)) { + // soju offers only its own name for it (caps.ts SOJU_WEBPUSH_CAP). + const cap = [WEBPUSH_CAP, SOJU_WEBPUSH_CAP].find((name) => this.caps.hasCapability(name)); + + // A bouncer's control connection carries no conversations; its bound + // networks subscribe for theirs (bouncer.ts). + if (!cap || isBouncerControl(this)) { return undefined; } return ( - webpushVapidOf(this.caps.value(WEBPUSH_CAP) ?? "") ?? + webpushVapidOf(this.caps.value(cap) ?? "") ?? // The same checks and normal form for the 005 token. webpushVapidOf(`vapid=${this.isupport.vapid ?? ""}`) ); diff --git a/client/js/irc/handlers/bouncer.ts b/client/js/irc/handlers/bouncer.ts new file mode 100644 index 000000000..f7e6dd84f --- /dev/null +++ b/client/js/irc/handlers/bouncer.ts @@ -0,0 +1,15 @@ +/** + * BOUNCER (`soju.im/bouncer-networks`): the networks of a bouncer account, + * outside the `LISTNETWORKS` batch (see ../bouncer.ts). The `BIND` and + * `LISTNETWORKS` replies need nothing more; a `FAIL BOUNCER` shows in the lobby + * as any standard reply does. + */ + +import {bouncerLine} from "../bouncer"; +import type {Handler} from "../types"; + +const bouncer: Handler = (client, msg) => { + bouncerLine(client, msg); +}; + +export default {BOUNCER: bouncer}; diff --git a/client/js/irc/handlers/index.ts b/client/js/irc/handlers/index.ts index 09062623d..169a520c3 100644 --- a/client/js/irc/handlers/index.ts +++ b/client/js/irc/handlers/index.ts @@ -12,7 +12,7 @@ */ import {MessageType} from "../../../../shared/types/msg"; -import history, {chathistoryBatch} from "../history"; +import history, {chathistoryBatch, ZNC_PLAYBACK_BATCH, zncPlaybackBatch} from "../history"; import {searchBatch} from "../historysearch"; import {AUTHTOKEN_BATCH} from "../authtoken"; import {MULTILINE_CAP, multilineBatch} from "../multiline"; @@ -21,6 +21,8 @@ import {labeledBatch} from "../request"; import type {Handler} from "../types"; import account from "./account"; import away from "./away"; +import bouncer from "./bouncer"; +import {BOUNCER_NETWORKS_BATCH, bouncerNetworksBatch} from "../bouncer"; import batch, {registerBatchHandler} from "./batch"; import {targetsBatch} from "../catchup"; import cap from "./cap"; @@ -55,6 +57,7 @@ import whois from "./whois"; const modules: Record[] = [ account, away, + bouncer, batch, cap, chghost, @@ -91,6 +94,8 @@ export const handlers = new Map(); // Batch types delivered as a unit (everything else is unwrapped in order). registerBatchHandler("chathistory", chathistoryBatch); +registerBatchHandler(ZNC_PLAYBACK_BATCH, zncPlaybackBatch); +registerBatchHandler(BOUNCER_NETWORKS_BATCH, bouncerNetworksBatch); // The spec names the batch both ways; nefarious2 sends `search`. registerBatchHandler("search", searchBatch); registerBatchHandler("soju.im/search", searchBatch); diff --git a/client/js/irc/history.ts b/client/js/irc/history.ts index 79592882e..9a1c69872 100644 --- a/client/js/irc/history.ts +++ b/client/js/irc/history.ts @@ -159,6 +159,12 @@ export function takesMsgidRefs(client: IrcClient): boolean { return types === undefined || types.split(",").includes("msgid"); } +/** Whether the server takes `timestamp=` references (MSGREFTYPES; unlisted means any). */ +export function takesTimestampRefs(client: IrcClient): boolean { + const types = client.isupport.tokens.get("MSGREFTYPES"); + return types === undefined || types.split(",").includes("timestamp"); +} + /** `msgid=…` when we have one and the server takes it, else `timestamp=`. */ export function formatRef(client: IrcClient, ref: MsgRef): string { if (ref.msgid && takesMsgidRefs(client)) { @@ -705,6 +711,32 @@ export const chathistoryBatch: BatchHandler = (client, batch) => { } }; +/** ZNC's buffer replay on attach: `BATCH +x znc.in/playback `. */ +export const ZNC_PLAYBACK_BATCH = "znc.in/playback"; + +/** + * ZNC's buffer replay (`BATCH +x znc.in/playback `, sent on every + * attach, and by the `*playback` module). The lines are stored ones, so they + * go the catch-up's way: appended, counted as unread but silent, a closed + * query reopened only by what was said since. ZNC has no msgids, so what is + * not newer than the newest message already shown is dropped: a buffer ZNC + * does not clear (`AutoClearChanBuffer` off, or the `*playback` module) + * comes back on every attach. + */ +export const zncPlaybackBatch: BatchHandler = (client, batch) => { + const target = batch.params[0] ?? ""; + const chan = target ? client.findChannel(target) : undefined; + const newest = chan?.newestChatAt; + const lines = + newest === undefined + ? batch.messages + : batch.messages.filter((line) => client.timeOf(line).getTime() > newest); + + if (lines.length > 0) { + deliverCatchup(client, target, lines); + } +}; + /** * One inner batch of the server-driven catch-up (persistence.ts): appended * like the gathered pages of our own catch-up — counted as unread but silent diff --git a/client/js/irc/historysearch.ts b/client/js/irc/historysearch.ts index b70778e1c..e75cfe54a 100644 --- a/client/js/irc/historysearch.ts +++ b/client/js/irc/historysearch.ts @@ -35,7 +35,8 @@ import type {IrcClient} from "./client"; import type {BatchHandler, OpenBatch} from "./handlers/batch"; import {formatLine, MAX_LINE_BYTES, utf8ByteLength, type IrcMessage} from "./message"; import type {Channel} from "./channel"; -import {historyLimit, takesMsgidRefs} from "./history"; +import {historyLimit, takesMsgidRefs, takesTimestampRefs} from "./history"; +import {ChanState, ChanType} from "../../../shared/types/chan"; import {applyEdit, applyReaction, applyRedaction} from "../helpers/messageUpdates"; import {sameAuthor} from "../helpers/pastRows"; import {ignoreListFor} from "../ignore"; @@ -59,6 +60,10 @@ interface PendingSearch { requestId: number; /** Set on a context request for a reply's parent: its answer goes to the quote. */ parent?: {chan: Channel; msgid: string}; + /** A search's attributes, kept so one the server refuses for want of `in=` can be sent again. */ + attrs?: SearchAttrs; + /** One channel's part of a search over every channel ({@link fanOut}). */ + group?: SearchGroup; label?: string; timer: ReturnType; /** @@ -69,7 +74,86 @@ interface PendingSearch { } /** What a request is for: who gets its answer. */ -type Asked = Pick; +type Asked = Pick; + +/** + * A search over every channel, sent as one `SEARCH in=` each to a + * server that wants `in=` (soju: `FAIL SEARCH INVALID_PARAMS in`). Answered + * once, when every part has: the newest `limit` hits, oldest first, as one + * server would have sent them. + */ +interface SearchGroup { + requestId: number; + limit?: number; + waiting: number; + hits: SearchHit[]; + /** The first part's failure, the answer when no part found anything. */ + failure?: {status: SearchStatus; error?: string}; +} + +/** The most channels one search over every channel asks about, one `SEARCH` each. */ +export const SEARCH_FAN_OUT_MAX = 30; +/** + * Servers that answered a search without `in=` with `FAIL SEARCH INVALID_PARAMS in`: + * every later search over every channel on that connection goes out per channel. + */ +const needsIn = new WeakSet(); + +/** + * When each msgid seen in a search or context page on this connection was + * said, for a server that takes `timestamp=` references and not `msgid=` ones + * (soju: `MSGREFTYPES=timestamp`). Bounded: the oldest are let go. + */ +const timesByClient = new WeakMap>(); +const TIMES_MAX = 5000; + +function noteTimes(client: IrcClient, lines: IrcMessage[]): void { + let times = timesByClient.get(client); + + if (!times) { + times = new Map(); + timesByClient.set(client, times); + } + + for (const line of lines) { + const msgid = line.tags.get("msgid"); + + if (msgid) { + times.delete(msgid); + times.set(msgid, client.timeOf(line)); + } + } + + for (const key of times.keys()) { + if (times.size <= TIMES_MAX) { + break; + } + + times.delete(key); + } +} + +/** + * How to name `msgid` in `target` to this server: by msgid where it takes + * those, else by when it was said, if this connection knows (a search or + * context page brought it, or the channel shows it). Undefined when neither. + */ +function contextRef(client: IrcClient, target: string, msgid: string): string | undefined { + if (takesMsgidRefs(client)) { + return `msgid=${msgid}`; + } + + if (!takesTimestampRefs(client)) { + return undefined; + } + + const chan = client.findChannel(target); + const id = chan?.idOf(msgid); + const time = + (id === undefined ? undefined : chan?.msgRefs.get(id)?.time) ?? + timesByClient.get(client)?.get(msgid); + return time ? `timestamp=${time.toISOString()}` : undefined; +} const pendingByClient = new WeakMap(); /** Reply parents asked for on this connection, `:`: each is worth one command. */ @@ -173,6 +257,11 @@ function answer( hits: SearchHit[] = [], page: ContextPage = {} ): void { + if (request.group) { + answerPart(client, request.group, status, error, hits); + return; + } + if (request.parent) { const {chan, msgid} = request.parent; const row = hits.find((hit) => hit.msg.msgid === msgid)?.msg; @@ -212,6 +301,73 @@ function lineFor(input: Parameters[0]): string | undefined { } } +/** One channel's part of a search over every channel is in: the whole is answered with the last. */ +function answerPart( + client: IrcClient, + group: SearchGroup, + status: SearchStatus, + error: string | undefined, + hits: SearchHit[] +): void { + if (status === "ok") { + group.hits.push(...hits); + } else { + group.failure ??= {status, error}; + } + + if (--group.waiting > 0) { + return; + } + + const base = {network: client.uuid, requestId: group.requestId}; + + if (group.hits.length === 0 && group.failure) { + client.dispatchBus("search:results", {...base, ...group.failure, hits: []}); + return; + } + + const time = (hit: SearchHit) => new Date(hit.msg.time).getTime(); + const sorted = group.hits.toSorted((a, b) => time(a) - time(b)); + const kept = group.limit ? sorted.slice(-group.limit) : sorted; + client.dispatchBus("search:results", {...base, status: "ok", hits: kept}); +} + +/** + * Search every channel by asking about each (a server that wants `in=`). The + * channels are those joined; private conversations are left out, as a server's + * own search over every channel leaves them out. + */ +function fanOut(client: IrcClient, requestId: number, attrs: SearchAttrs): void { + const channels = client.channels + .filter((chan) => chan.type === ChanType.CHANNEL && chan.state === ChanState.JOINED) + .slice(0, SEARCH_FAN_OUT_MAX); + + if (channels.length === 0) { + client.dispatchBus("search:results", { + network: client.uuid, + requestId, + status: "ok", + hits: [], + }); + return; + } + + const group: SearchGroup = {requestId, limit: attrs.limit, waiting: channels.length, hits: []}; + + const {in: _all, ...rest} = attrs; + + for (const chan of channels) { + const part: SearchAttrs = {in: chan.name, ...rest}; + sendRequest( + client, + {kind: "results", requestId, attrs: part, group}, + true, + "SEARCH", + client.profile.searchParams?.(part) ?? specSearchParams(part) + ); + } +} + /** Send a request, which always gets its one answer, whatever happens. */ function sendRequest( client: IrcClient, @@ -251,16 +407,29 @@ function canAskContext(client: IrcClient): boolean { return ( client.caps.hasCapability("draft/chathistory") && client.caps.hasCapability("labeled-response") && - takesMsgidRefs(client) // the window and the parent are asked for by msgid + // The window is asked for by msgid, or by when it was said (soju). + (takesMsgidRefs(client) || takesTimestampRefs(client)) ); } +/** A reply's parent is known by its msgid alone. */ +function canAskParent(client: IrcClient): boolean { + return canAskContext(client) && takesMsgidRefs(client); +} + /** Send a `SEARCH`, answered by one `search:results`. */ export function requestSearch(client: IrcClient, requestId: number, attrs: SearchAttrs): void { + const available = client.caps.hasCapability(SEARCH_CAP) && client.caps.hasCapability("batch"); + + if (available && client.isWelcomed && !attrs.in && needsIn.has(client)) { + fanOut(client, requestId, attrs); + return; + } + sendRequest( client, - {kind: "results", requestId}, - client.caps.hasCapability(SEARCH_CAP) && client.caps.hasCapability("batch"), + {kind: "results", requestId, attrs}, + available, "SEARCH", client.profile.searchParams?.(attrs) ?? specSearchParams(attrs) ); @@ -275,12 +444,15 @@ export function requestSearchContext( msgid: string, limit: number ): void { - sendRequest(client, {kind: "context", requestId}, canAskContext(client), "CHATHISTORY", [ - direction, - target, - `msgid=${msgid}`, - String(historyLimit(client, limit)), - ]); + const ref = contextRef(client, target, msgid); + + sendRequest( + client, + {kind: "context", requestId}, + canAskContext(client) && ref !== undefined, + "CHATHISTORY", + [direction, target, ref ?? "", String(historyLimit(client, limit))] + ); } /** @@ -311,7 +483,7 @@ export function requestReplyParent(client: IrcClient, chan: Channel, msgid: stri sendRequest( client, {kind: "context", requestId: 0, parent: {chan, msgid}}, - canAskContext(client), + canAskParent(client), "CHATHISTORY", ["AROUND", chan.name, `msgid=${msgid}`, "1"] ); @@ -450,6 +622,7 @@ function settle( /** A closed `search` batch: the answer to the request it names, else the oldest. */ export const searchBatch: BatchHandler = (client, batch) => { + noteTimes(client, batch.messages); const request = searchFor(client, labelOf(batch)); if (request) { @@ -465,6 +638,7 @@ export function searchContextBatch(client: IrcClient, batch: OpenBatch): boolean return false; } + noteTimes(client, batch.messages); const changes: LineChange[] = []; settle( client, @@ -494,6 +668,25 @@ export function searchContextBatch(client: IrcClient, batch: OpenBatch): boolean export function searchFailed(client: IrcClient, msg: IrcMessage): void { const request = searchFor(client, msg.tags.get("label")); + // `FAIL SEARCH INVALID_PARAMS in`: this server searches one channel at a + // time (soju). Ask about each instead, now and for the rest of the connection. + if ( + request && + !request.group && + request.attrs && + !request.attrs.in && + msg.params[1] === "INVALID_PARAMS" && + msg.params[2]?.toLowerCase() === "in" + ) { + needsIn.add(client); + + if (take(client, request) && !request.expired) { + fanOut(client, request.requestId, request.attrs); + } + + return; + } + if (request) { settle(client, request, "fail", msg.params[msg.params.length - 1]); } diff --git a/client/js/irc/manager.ts b/client/js/irc/manager.ts index 6bc07a094..258656ded 100644 --- a/client/js/irc/manager.ts +++ b/client/js/irc/manager.ts @@ -28,6 +28,7 @@ import {leavesAwayToWorker, syncPresence} from "./presence"; import {claimMute, clearRemoteDnd} from "./pushMute"; import * as saved from "./saved-networks"; import type {SavedNetwork} from "./saved-networks"; +import type {BouncerNetwork} from "./bouncer"; import type {ConnectOptions} from "./types"; import type {GlobalModeState} from "../helpers/dnd"; @@ -132,6 +133,7 @@ export function createNetwork(options: CreateNetworkOptions): IrcClient { saved.save({...current, autoconnect: false}); } }, + onBouncerNetworks: (networks) => addBouncerNetworks(entry.uuid, networks), }); clients.set(entry.uuid, client); // Load the network's ignore list now, which mirrors it for the push @@ -171,6 +173,64 @@ export function createNetwork(options: CreateNetworkOptions): IrcClient { return client; } +/** + * A bouncer's control connection listed its networks (irc/bouncer.ts): add + * each one this entry has not offered before as a network of its own, with + * the same server and login, bound to it. Offered once: a network the user + * removes here is not added back at the next connect. Returns the names added. + */ +function addBouncerNetworks(controlUuid: string, networks: BouncerNetwork[]): string[] { + const control = saved.get(controlUuid); + const live = clients.get(controlUuid); + + if (!control || !live) { + return []; + } + + const seen = new Set(control.bouncerSeen ?? []); + const fresh = networks.filter((net) => !net.removed && !seen.has(net.id)); + + if (fresh.length === 0) { + return []; + } + + saved.save({...control, bouncerSeen: [...seen, ...fresh.map((net) => net.id)]}); + const added: string[] = []; + + for (const net of fresh) { + const already = saved + .list() + .some( + (other) => + other.bouncerNetId === net.id && + other.host === control.host && + other.port === control.port && + other.saslAccount === control.saslAccount + ); + + if (already) { + continue; + } + + createNetwork({ + ...saved.toConnectOptions(control), + // Kept in memory only, unless the bouncer's entry remembers it. + saslPassword: live.options.saslPassword, + uuid: saved.newUuid(), + name: net.name, + join: "", + bouncerNetId: net.id, + autoconnect: control.autoconnect, + rememberPassword: control.rememberPassword, + pushEnabled: control.pushEnabled, + notifyEnabled: control.notifyEnabled, + }); + added.push(net.name); + } + + return added; +} + /** * Connect every saved network flagged `autoconnect`. Runs once per page load * from boot; entries that need a SASL password that was not remembered are diff --git a/client/js/irc/saved-networks.ts b/client/js/irc/saved-networks.ts index 1bd6f80a5..d2899fe2c 100644 --- a/client/js/irc/saved-networks.ts +++ b/client/js/irc/saved-networks.ts @@ -63,6 +63,12 @@ export type SavedNetwork = ConnectOptions & { notifyEnabled?: boolean; /** Slash commands run in the lobby after every registration, one per entry. */ commands?: string[]; + /** + * A bouncer's control connection (irc/bouncer.ts): the ids of its networks + * already offered here. Each is added as a network of its own once; one the + * user removes is not added again. + */ + bouncerSeen?: string[]; /** Epoch ms of the last connect; the picker lists most recent first. */ lastUsed?: number; /** Newest message seen on this network (see {@link NetworkCursor}). */ @@ -270,6 +276,16 @@ export function normalize(raw: Record): SavedNetwork | undefine net.lastUsed = raw.lastUsed; } + const netId = asString(raw.bouncerNetId); + + if (netId) { + net.bouncerNetId = netId; + } + + if (Array.isArray(raw.bouncerSeen)) { + net.bouncerSeen = raw.bouncerSeen.map(asString).filter(Boolean); + } + const cursor = asCursor(raw.cursor); if (cursor) { @@ -397,6 +413,7 @@ export function toConnectOptions(net: SavedNetwork): ConnectOptions { sasl: net.sasl, saslAccount: net.saslAccount, saslPassword: net.saslPassword, + ...(net.bouncerNetId ? {bouncerNetId: net.bouncerNetId} : {}), }; } @@ -566,8 +583,11 @@ export function findMatching(host: string, port: number, nick: string): SavedNet const h = hostnameOf(host).toLowerCase(); const n = nick.trim().toLowerCase(); + // A network added from a bouncer's list shares its host, port and nick + // with the bouncer's own entry, which is the one the connect form means. return read().find( (net) => + !net.bouncerNetId && hostnameOf(net.host).toLowerCase() === h && net.port === port && net.nick.toLowerCase() === n diff --git a/client/js/irc/types.ts b/client/js/irc/types.ts index 66b2b6825..abd538646 100644 --- a/client/js/irc/types.ts +++ b/client/js/irc/types.ts @@ -54,6 +54,12 @@ export type ConnectOptions = { * true (see `client/js/branding.ts` and client.ts `saslRequired`). */ saslDisconnectOnFail?: boolean; + /** + * The bouncer network this connection binds to (`soju.im/bouncer-networks`: + * `BOUNCER BIND ` before `CAP END`). Set on the networks Seance added + * from a bouncer's list (irc/bouncer.ts); absent everywhere else. + */ + bouncerNetId?: string; }; /** Connection state as seen by the rest of the app. */ diff --git a/client/locales/en.json b/client/locales/en.json index 5f1fb734b..2400c25f8 100644 --- a/client/locales/en.json +++ b/client/locales/en.json @@ -1,4 +1,5 @@ { + "bouncer.networksAdded": "Added this bouncer's networks: {names}", "browse.all": "All channels", "browse.aria": "Channel browser for {network}", "browse.ask": "Load channels", diff --git a/client/locales/messages.pot b/client/locales/messages.pot index 0690faf14..6177bfa74 100644 --- a/client/locales/messages.pot +++ b/client/locales/messages.pot @@ -7,6 +7,12 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" +#. Lobby line on a bouncer's control connection (soju) after Seance added the networks the bouncer listed, each as a network of its own. {names} is a comma-separated list of network names. +#: client/js/irc/bouncer.ts:90 +msgctxt "bouncer.networksAdded" +msgid "Added this bouncer's networks: {names}" +msgstr "" + #. Channel browser (a window listing the channels a network has, from the server's LIST, searched on the device): heading of the full list. #: client/components/Windows/ChannelBrowser.vue msgctxt "browse.all" diff --git a/client/locales/qqx.json b/client/locales/qqx.json index 472cebd15..fcc3b1ba3 100644 --- a/client/locales/qqx.json +++ b/client/locales/qqx.json @@ -1,4 +1,5 @@ { + "bouncer.networksAdded": "‫àḋḋéḋ ţḣïś ḃöüñċéŕ'ś ñéţẃöŕķś: {names}àḋḋéḋ ţḣïś ḃöüñċéŕ'ś ñéţẃöŕķś: {names}‬", "browse.all": "‫àľľ ċḣàññéľśàľľ ċḣàññéľś‬", "browse.aria": "‫ċḣàññéľ ḃŕöẃśéŕ ḟöŕ {network}ċḣàññéľ ḃŕöẃśéŕ ḟöŕ {network}‬", "browse.ask": "‫ľöàḋ ċḣàññéľśľöàḋ ċḣàññéľś‬", diff --git a/docs/resources/client-features.md b/docs/resources/client-features.md index bff9bc475..98ce512d3 100644 --- a/docs/resources/client-features.md +++ b/docs/resources/client-features.md @@ -24,7 +24,7 @@ How the parts of the client behave and why, moved out of `CLAUDE.md` on 2026-11- ## Searching history -`Windows/SearchResults.vue` (`/search`, or the header's search button, which toggles it): a panel beside the chat (`RoutedChat.vue` shows both on `/chan-:id/search`), over it on a phone. The route's `?q=` is the only state, and the chips and `SearchFilterSheet.vue` edit that string (`client/js/searchQuery.ts`, which says what the syntax is). The server (`soju.im/search`, `irc/historysearch.ts`) takes one nick, one substring and a time window, newest first, and gives no total. Extra words, `-word`, `account:` and the kind of link are checked on the device against what came back, so a page can be thin or empty while older matches exist: the status line says how many are hidden and how far back the results reach, never a total, and "Search older" stays until a page adds nothing. Nothing from a search reaches a channel: no unread counts, no cursor, no query opened or logged. A server without the cap, or no connection, searches the messages loaded on this device under a banner, with the same filters. So does an empty answer, in any scope, since it may only be the server's: a scan that ran out, a conversation it does not store, case outside ASCII. Every-channel results also carry loaded lines of private conversations, which the server leaves out, marked on the row; paging goes on from the server's oldest row, never from one of those. Tapping a hit takes the chat beside it to the message, as a mention does (`docs/projects/inbox.md`), and the results stay: a hit in another conversation moves the route there with its scope spelt out in `q`, which asks nothing new. On a phone the panel tucks away (`js/searchPanel.ts`) until the header's magnifier brings it back. The ✕ (Cancel on a phone), Escape or the magnifier close it. Under the empty field are the last ten searches on this device (`helpers/searchRecents.ts`, `thelounge.search.recent`), kept on Enter or an opened hit, never on what typing searched by itself, with Clear. A hit in a conversation not joined says so in the results. Each hit's time carries its day as well as the day's header above it (`helpers/messageTime.ts` `datedTime`): Today, Yesterday, the weekday within the last week, then the date, on the reader's clock settings. Left out on purpose: oldest first (it would only flip the newest page), role filters (history keeps no roles), several chosen channels (one request each, ~2 s of fake lag), and ranking by relevance (only what is loaded could be ranked). Browser check: `tools/scenarios/history-search.mjs`. Typing searches once it pauses for 0.8 s, on a word of three letters or more with no filter half typed, one search at a time and at most one per 2 s, which is what a search costs in fake lag; Enter searches at once. nefarious2 scans up to 50000 rows for each, stalling every client meanwhile (~165 ms measured), so do not make this faster. +`Windows/SearchResults.vue` (`/search`, or the header's search button, which toggles it): a panel beside the chat (`RoutedChat.vue` shows both on `/chan-:id/search`), over it on a phone. The route's `?q=` is the only state, and the chips and `SearchFilterSheet.vue` edit that string (`client/js/searchQuery.ts`, which says what the syntax is). The server (`soju.im/search`, `irc/historysearch.ts`) takes one nick, one substring and a time window, newest first, and gives no total. Extra words, `-word`, `account:` and the kind of link are checked on the device against what came back, so a page can be thin or empty while older matches exist: the status line says how many are hidden and how far back the results reach, never a total, and "Search older" stays until a page adds nothing. Nothing from a search reaches a channel: no unread counts, no cursor, no query opened or logged. A server without the cap, or no connection, searches the messages loaded on this device under a banner, with the same filters. So does an empty answer, in any scope, since it may only be the server's: a scan that ran out, a conversation it does not store, case outside ASCII. Every-channel results also carry loaded lines of private conversations, which the server leaves out, marked on the row; paging goes on from the server's oldest row, never from one of those. Tapping a hit takes the chat beside it to the message, as a mention does (`docs/projects/inbox.md`), and the results stay: a hit in another conversation moves the route there with its scope spelt out in `q`, which asks nothing new. On a phone the panel tucks away (`js/searchPanel.ts`) until the header's magnifier brings it back. The ✕ (Cancel on a phone), Escape or the magnifier close it. Under the empty field are the last ten searches on this device (`helpers/searchRecents.ts`, `thelounge.search.recent`), kept on Enter or an opened hit, never on what typing searched by itself, with Clear. A hit in a conversation not joined says so in the results. Each hit's time carries its day as well as the day's header above it (`helpers/messageTime.ts` `datedTime`): Today, Yesterday, the weekday within the last week, then the date, on the reader's clock settings. Left out on purpose: oldest first (it would only flip the newest page), role filters (history keeps no roles), several chosen channels (one request each, ~2 s of fake lag), and ranking by relevance (only what is loaded could be ranked). A server that wants `in=` (soju answers `FAIL SEARCH INVALID_PARAMS in`) is asked again with one `SEARCH in=` per joined channel, up to 30, and so is every later search over every channel on that connection; the parts are answered as one page, the newest `limit` hits. A server that takes only `timestamp=` references (soju: `MSGREFTYPES=timestamp`) is asked for a hit's surroundings by the hit's time, which the search page brought; a reply's missing parent is known by msgid alone, so it is not asked for there. Browser check: `tools/scenarios/history-search.mjs`. Typing searches once it pauses for 0.8 s, on a word of three letters or more with no filter half typed, one search at a time and at most one per 2 s, which is what a search costs in fake lag; Enter searches at once. nefarious2 scans up to 50000 rows for each, stalling every client meanwhile (~165 ms measured), so do not make this faster. ## Channel browser diff --git a/docs/resources/irc-layer.md b/docs/resources/irc-layer.md index a85913213..615ccf456 100644 --- a/docs/resources/irc-layer.md +++ b/docs/resources/irc-layer.md @@ -40,6 +40,16 @@ Replies, reactions, deletion and edits (bus-contract §1.4): `Channel.idByMsgid` ## A login can give its dial back +## Bouncers: soju and ZNC + +Neither needs a profile; each is told by what it offers. `IrcClient.behindBouncer` is true with `BOUNCER_NETID` (soju bound to a network), `soju.im/bouncer-networks` or any `znc.in/*` cap. + +- **The bouncer owns the channel list.** Behind a bouncer the autojoin waits for the bouncer's own JOINs after the MOTD (`awaitRestoration`, as for nefarious2's held session). If it gave any back, nothing else is JOINed: a saved channel it did not give back was left from another client and stays a parted placeholder. A bouncer that gave none back (new, or not on the network yet) gets the saved list as any server does. Tests: `test/irc/bouncer.ts`. +- **ZNC's buffer replay** (`BATCH +x znc.in/playback `, on every attach) goes the catch-up's way (`history.ts` `zncPlaybackBatch` → `deliverCatchup`): appended, unread but silent, a closed query reopened only by what was said since. ZNC has no msgids, so a line not newer than the conversation's newest message or action (`Channel.newestChatAt`) is dropped: a buffer ZNC keeps (`AutoClearChanBuffer` off) comes back on every attach. +- **soju's networks** (`bouncer.ts`, `soju.im/bouncer-networks` and `-notify`): a login without a network is the control connection (the cap, and no `BOUNCER_NETID`). It JOINs nothing and asks `BOUNCER LISTNETWORKS` through the background queue; the manager (`addBouncerNetworks`) adds each network the entry has not offered before (`SavedNetwork.bouncerSeen`, so one the user removes is not added back) as a saved network of its own: the same server and login, `join` empty, `bouncerNetId` set. A bound entry sends `BOUNCER BIND ` in the `CAP END` flush (`finalLines`). The control connection does not subscribe to push; the bound ones do. `findMatching` skips bound entries, so the connect form finds the bouncer's own. +- **Push** through soju's `soju.im/webpush`: the same command, `VAPID` token and one-line payload as `draft/webpush` (`caps.ts` `SOJU_WEBPUSH_CAP`). +- Search on soju: `docs/resources/client-features.md` § Searching history. + ## `/who` **`/who`** (`commands/who.ts`, `handlers/who.ts`): one `who` message per query, rendered as a table by `MessageTypes/who.vue` in the channel the mask names (or where it was typed). With `WHOX` in ISUPPORT the query asks for `%tcuhsnfdar,` (account names included; the token is checked on every 354), otherwise a plain `WHO` and 352 rows; queries are matched to replies in order, a user's own `%fields` spec is sent as typed and answered as plain text, and a reply nobody asked for lands in the lobby as text. The table reads only the base tokens (`--body-color-muted`, `--link-color`, `--window-heading-color`) so every theme colours it; the server is a tooltip on the host, not a column. Tests: `test/irc/who.ts`; browser check: `tools/scenarios/who-list.mjs` (also `--mobile --width=390 --height=844`). diff --git a/test/irc/bouncer.ts b/test/irc/bouncer.ts new file mode 100644 index 000000000..02da1d294 --- /dev/null +++ b/test/irc/bouncer.ts @@ -0,0 +1,234 @@ +import {expect} from "chai"; +import sinon from "sinon"; +import {RESTORE_MAX_WAIT_MS} from "../../client/js/irc/persistence"; +import {isBouncerControl, type BouncerNetwork} from "../../client/js/irc/bouncer"; +import {setup, register, Harness} from "./support"; +import type {SharedMsg} from "../../shared/types/msg"; + +/** What ZNC 1.9 offers a client (its own caps plus the legacy `znc.in/*` names). */ +const ZNC_CAPS = + "batch cap-notify echo-message multi-prefix server-time userhost-in-names znc.in/batch znc.in/self-message znc.in/server-time-iso"; + +type MsgPayload = {chan: number; msg: SharedMsg; replay?: boolean}; + +/** ZNC's attach: our JOIN, NAMES, then the buffer in a `znc.in/playback` batch. */ +function attach(h: Harness, lines: string[], target = "#seance", ref = "p1"): void { + if (target.startsWith("#") && !h.client.findChannel(target)) { + h.transport.lines( + `:alice!alice@znc.host JOIN ${target}`, + `:irc.test 353 alice = ${target} :@alice bob`, + `:irc.test 366 alice ${target} :End of /NAMES list.` + ); + } + + h.transport.line(`:znc.in BATCH +${ref} znc.in/playback ${target}`); + + for (const line of lines) { + h.transport.line(`@batch=${ref};${line.slice(1)}`); + } + + h.transport.line(`:znc.in BATCH -${ref}`); +} + +function said(n: number, text = `buffered ${n}`, target = "#seance"): string { + return `@time=2026-11-08T10:0${n}:00.000Z :bob!bob@host PRIVMSG ${target} :${text}`; +} + +describe("Bouncers: ZNC buffer playback", function () { + it("delivers the buffer as replay: unread, but no live notification", function () { + const h = setup(); + register(h, ZNC_CAPS); + attach(h, [said(1), said(2, "alice: are you there?")]); + + const chan = h.client.findChannel("#seance")!; + const msgs = h + .payloads("msg") + .filter((p) => p.chan === chan.id && p.msg.from?.nick === "bob"); + expect(msgs.map((p) => p.msg.text)).to.deep.equal(["buffered 1", "alice: are you there?"]); + expect(msgs.every((p) => p.replay)).to.equal(true); + expect(new Date(msgs[0].msg.time).toISOString()).to.equal("2026-11-08T10:01:00.000Z"); + expect(chan.shared.unread).to.equal(2); + }); + + it("shows nothing twice when ZNC replays a buffer it did not clear", function () { + const h = setup(); + register(h, ZNC_CAPS); + attach(h, [said(1), said(2)]); + const chan = h.client.findChannel("#seance")!; + const count = () => + h.messages(chan.id).filter((m) => m.text?.startsWith("buffered")).length; + expect(count()).to.equal(2); + + // A reconnect: same buffer again, plus one newer line. + attach(h, [said(1), said(2), said(3)], "#seance", "p2"); + expect(count()).to.equal(3); + expect(h.messages(chan.id).filter((m) => m.text === "buffered 3")).to.have.length(1); + }); + + it("opens a query from a buffered private message", function () { + const h = setup(); + register(h, ZNC_CAPS); + attach(h, ["@time=2026-11-08T10:05:00.000Z :bob!bob@host PRIVMSG alice :hi there"], "bob"); + + const query = h.client.findChannel("bob"); + expect(query, "query opened").to.not.equal(undefined); + expect(h.messages(query!.id).map((m) => m.text)).to.include("hi there"); + }); +}); + +describe("Bouncers: the bouncer's channel list", function () { + let clock: sinon.SinonFakeTimers; + + beforeEach(function () { + clock = sinon.useFakeTimers({toFake: ["setTimeout", "clearTimeout", "Date"]}); + }); + + afterEach(function () { + clock.restore(); + }); + + const joins = (h: Harness) => h.transport.sent.filter((line) => /^JOIN /.test(line)); + + it("does not JOIN again a channel the bouncer no longer holds", function () { + const h = setup({join: "#seance,#gone"}); + register(h, ZNC_CAPS); + h.transport.lines( + ":alice!alice@znc.host JOIN #seance", + ":irc.test 353 alice = #seance :@alice bob", + ":irc.test 366 alice #seance :End of /NAMES list." + ); + clock.tick(RESTORE_MAX_WAIT_MS); + + expect(joins(h)).to.deep.equal([]); + expect(h.client.findChannel("#seance")?.state).to.not.equal(undefined); + }); + + it("sends the saved channels to a bouncer that holds none yet", function () { + const h = setup({join: "#seance,#new"}); + register(h, ZNC_CAPS); + expect(joins(h), "waits for the bouncer's own JOINs first").to.deep.equal([]); + clock.tick(RESTORE_MAX_WAIT_MS); + + expect(joins(h)).to.have.length(1); + expect(joins(h)[0]).to.match(/^JOIN #seance,#new$|^JOIN #new,#seance$/); + }); + + it("knows soju by its BOUNCER_NETID", function () { + const h = setup({join: "#seance,#gone"}); + register(h, "batch server-time message-tags echo-message", [ + ":irc.test 005 alice BOUNCER_NETID=3 :are supported by this server", + ]); + h.transport.line(":alice!alice@soju JOIN #seance"); + clock.tick(RESTORE_MAX_WAIT_MS); + + expect(h.client.behindBouncer).to.equal(true); + expect(joins(h)).to.deep.equal([]); + }); + + it("leaves a plain server alone: the saved channels go out at once", function () { + const h = setup({join: "#seance"}); + register(h, "batch server-time message-tags echo-message"); + + expect(h.client.behindBouncer).to.equal(false); + expect(joins(h)).to.have.length(1); + }); +}); + +describe("Bouncers: soju's networks (soju.im/bouncer-networks)", function () { + let clock: sinon.SinonFakeTimers; + + beforeEach(function () { + clock = sinon.useFakeTimers({toFake: ["setTimeout", "clearTimeout", "Date"]}); + }); + + afterEach(function () { + clock.restore(); + }); + + const SOJU_CAPS = + "batch server-time message-tags echo-message labeled-response soju.im/bouncer-networks soju.im/bouncer-networks-notify"; + + it("asks the control connection for the networks, and JOINs nothing there", function () { + const offered: BouncerNetwork[][] = []; + const h = setup({ + join: "#seance", + onBouncerNetworks(networks) { + offered.push(networks); + return networks.map((net) => net.name); + }, + }); + register(h, SOJU_CAPS, [":irc.test 005 alice CHANTYPES= :are supported by this server"]); + clock.tick(RESTORE_MAX_WAIT_MS); + + expect(isBouncerControl(h.client)).to.equal(true); + expect(h.transport.sent).to.include("BOUNCER LISTNETWORKS"); + expect(h.transport.sent.filter((line) => line.startsWith("JOIN "))).to.deep.equal([]); + + h.transport.lines( + ":irc.test BATCH +n soju.im/bouncer-networks", + "@batch=n :irc.test BOUNCER NETWORK 1 name=Libera;host=irc.libera.chat;state=connected", + "@batch=n :irc.test BOUNCER NETWORK 2 host=irc.oftc.net", + ":irc.test BATCH -n" + ); + + expect(offered).to.have.length(1); + expect(offered[0]).to.deep.equal([ + {id: "1", name: "Libera", host: "irc.libera.chat", state: "connected"}, + {id: "2", name: "irc.oftc.net", host: "irc.oftc.net", state: undefined}, + ]); + expect(h.messages(h.client.lobby.id).map((m) => m.text)).to.include( + "Added this bouncer's networks: Libera, irc.oftc.net" + ); + }); + + it("offers a network added on the bouncer later (-notify), and knows a removed one", function () { + const offered: BouncerNetwork[] = []; + const h = setup({ + onBouncerNetworks(networks) { + offered.push(...networks); + return []; + }, + }); + register(h, SOJU_CAPS); + h.transport.lines( + ":irc.test BOUNCER NETWORK 7 name=EFnet;state=connecting", + ":irc.test BOUNCER NETWORK 1 *" + ); + + expect(offered).to.deep.equal([ + {id: "7", name: "EFnet", host: undefined, state: "connecting"}, + {id: "1", name: "1", removed: true}, + ]); + }); + + it("binds a network added from the bouncer before CAP END", function () { + const h = setup({bouncerNetId: "42"}); + h.client.connect(); + h.transport.open(); + h.transport.line(`:irc.test CAP * LS :${SOJU_CAPS}`); + const req = h.transport.sent.findIndex((line) => line.startsWith("CAP REQ :")); + const tail = h.transport.sent.slice(req); + + expect(tail.indexOf("BOUNCER BIND 42")).to.be.greaterThan(0); + expect(tail.indexOf("BOUNCER BIND 42")).to.be.lessThan(tail.indexOf("CAP END")); + }); + + it("binds nothing on a server without the extension", function () { + const h = setup({bouncerNetId: "42"}); + h.client.connect(); + h.transport.open(); + h.transport.line(":irc.test CAP * LS :batch server-time"); + + expect(h.transport.sent.some((line) => line.startsWith("BOUNCER"))).to.equal(false); + }); + + it("is not a control connection once bound (BOUNCER_NETID)", function () { + const h = setup({onBouncerNetworks: () => []}); + register(h, SOJU_CAPS, [ + ":irc.test 005 alice BOUNCER_NETID=1 :are supported by this server", + ]); + + expect(isBouncerControl(h.client)).to.equal(false); + expect(h.transport.sent).to.not.include("BOUNCER LISTNETWORKS"); + }); +}); diff --git a/test/irc/caps.ts b/test/irc/caps.ts index fa1b072d9..0e428065d 100644 --- a/test/irc/caps.ts +++ b/test/irc/caps.ts @@ -60,7 +60,15 @@ describe("irc/caps", function () { expect(neg.available.has("tls")).to.equal(true); const requested = reqCaps(second.send[0]); - expect(requested).to.deep.equal(SEANCE_CAPS.wanted); + // Everything wanted that nefarious2 offers: all but soju's own names. + const sojuOnly = [ + "soju.im/webpush", + "soju.im/bouncer-networks", + "soju.im/bouncer-networks-notify", + ]; + expect(requested).to.deep.equal( + SEANCE_CAPS.wanted.filter((cap) => !sojuOnly.includes(cap)) + ); expect(requested).to.not.include("tls"); expect(requested).to.include("draft/multiline"); expect(requested).to.not.include("draft/bouncer"); diff --git a/test/irc/historysearch.ts b/test/irc/historysearch.ts index a307f9aa6..d5704a61c 100644 --- a/test/irc/historysearch.ts +++ b/test/irc/historysearch.ts @@ -522,6 +522,22 @@ describe("historysearch", function () { expect(payloads("msg")).to.have.length(0); }); + it("asks by when it was said a server that takes only timestamps (soju)", function () { + const {client, transport} = setup(); + transport.line( + ":irc.test 005 alice MSGREFTYPES=timestamp :are supported by this server" + ); + requestSearch(client, 1, {in: "#seance", text: "fake"}); + batch(transport, "soju.im/search", labelOf(transport.sent[0]), [ROW1, ROW2]); + transport.sent.length = 0; + + requestSearchContext(client, 4, "#seance", "AROUND", "m2", 21); + + expect(transport.sent[0]).to.match( + / CHATHISTORY AROUND #seance timestamp=2026-10-20T11:00:00\.000Z 21$/ + ); + }); + it("does not ask by msgid a server whose MSGREFTYPES leaves it out", function () { const {client, transport} = setup(); transport.line( @@ -800,4 +816,72 @@ describe("historysearch", function () { status: "offline", }); }); + describe("a server that wants in= (soju)", function () { + const ROW3 = + "@time=2026-10-20T10:30:00.000Z;msgid=m3 :bob!bob@host PRIVMSG #linux :fake lag again"; + + function refuse(transport: FakeTransport, line: string): void { + transport.line( + `@label=${labelOf( + line + )} :irc.test FAIL SEARCH INVALID_PARAMS in :Missing "in" attribute` + ); + } + + it("asks each joined channel instead, and answers once with the newest, oldest first", function () { + const {client, transport} = setup(); + transport.line("@time=2026-08-25T12:00:00.000Z :alice!alice@host JOIN #linux"); + transport.sent.length = 0; + + requestSearch(client, 7, {text: "fake", limit: 2}); + expect(transport.sent).to.have.length(1); + expect(transport.sent[0]).to.not.contain("in="); + refuse(transport, transport.sent[0]); + + const parts = transport.sent.slice(1); + const partFor = (chan: string) => parts.find((line) => line.includes(`in=${chan};`))!; + expect(parts.map((line) => line.match(/in=([^;\s]+)/)?.[1])).to.have.members([ + "#seance", + "#linux", + ]); + expect(payloads("search:results")).to.have.length(0); + + batch(transport, "soju.im/search", labelOf(partFor("#seance")), [ROW1, ROW2]); + expect(payloads("search:results")).to.have.length(0); + batch(transport, "soju.im/search", labelOf(partFor("#linux")), [ROW3]); + + const answers = payloads("search:results"); + expect(answers).to.have.length(1); + expect(answers[0]).to.include({requestId: 7, status: "ok"}); + expect(answers[0].hits.map((hit) => hit.msg.msgid)).to.deep.equal(["m3", "m2"]); + expect(answers[0].hits.map((hit) => hit.target)).to.deep.equal(["#linux", "#seance"]); + }); + + it("remembers it: the next search over every channel goes per channel at once", function () { + const {client, transport} = setup(); + requestSearch(client, 1, {text: "fake"}); + refuse(transport, transport.sent[0]); + batch(transport, "soju.im/search", labelOf(transport.sent[1]), []); + transport.sent.length = 0; + + requestSearch(client, 2, {text: "lag"}); + + expect(transport.sent).to.have.length(1); + expect(transport.sent[0]).to.contain("in=#seance"); + }); + + it("reports a failure only when no channel found anything", function () { + const {client, transport} = setup(); + requestSearch(client, 3, {text: "fake"}); + refuse(transport, transport.sent[0]); + transport.line( + `@label=${labelOf( + transport.sent[1] + )} :irc.test FAIL SEARCH INTERNAL_ERROR :Database is down` + ); + + const [answer] = payloads("search:results"); + expect(answer).to.include({requestId: 3, status: "fail", error: "Database is down"}); + }); + }); }); diff --git a/test/irc/webpush.ts b/test/irc/webpush.ts index 90a8d6c63..44f209393 100644 --- a/test/irc/webpush.ts +++ b/test/irc/webpush.ts @@ -98,6 +98,18 @@ describe("Web Push subscription (draft/webpush)", function () { expect(payloads[0].vapid).to.be.undefined; }); + it("announces soju's VAPID key under its own cap name (soju.im/webpush)", function () { + const h = setup(); + register(h, `${ALL_CAPS} soju.im/webpush`, [ + `:irc.test 005 alice VAPID=${VAPID} :are supported by this server`, + ]); + + const sent = h.transport.sent.find((l) => l.startsWith("CAP REQ :")); + expect(sent).to.include("soju.im/webpush"); + const payloads = h.payloads<{vapid: string | undefined}>("webpush:available"); + expect(payloads.map((p) => p.vapid)).to.deep.equal([VAPID]); + }); + it("announces the VAPID ISUPPORT key of a server that lists the cap bare (the spec)", function () { const h = setup(); register(h, `${ALL_CAPS} draft/webpush`, [ From 37e7e8308eaeae201b0c2c4d47d71bb005096535 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 19:47:48 +0000 Subject: [PATCH 2/3] Bouncers: review fixes - ZNC playback: know a buffered line by its time, sender, target and text, counted per conversation, instead of dropping everything not newer than the newest message. Lines seen live are skipped, two equal lines in one second both stay. - Behind a bouncer the saved channels are never JOINed: the bouncer's own JOINs are the list. - soju search without in=: ask the channels one after another (0.7 has no labeled-response), uncharged behind a bouncer, no cap, and a part that fails fails the search. - soju networks: add only after a SASL login (soju binds no PASS login), only from a connection that is not itself bound, at most 20 per list, ids checked before they are stored or sent, entries added by hand as account/name count as saved; with -notify the list is not asked for again. - The service worker binds a soju network's own connection too, so a reply from a notification reaches that network. --- client/js/irc/bouncer.ts | 82 ++++++++++++++---- client/js/irc/channel.ts | 8 -- client/js/irc/client.ts | 68 ++++++++------- client/js/irc/history.ts | 102 +++++++++++++++++++--- client/js/irc/historysearch.ts | 100 +++++++++++----------- client/js/irc/manager.ts | 41 +++++---- client/js/irc/saved-networks.ts | 9 +- client/js/webpush.ts | 2 + client/service-worker.js | 16 +++- docs/resources/client-features.md | 2 +- docs/resources/irc-layer.md | 11 ++- test/irc/bouncer.ts | 135 ++++++++++++++++++++++++++---- test/irc/historysearch.ts | 42 +++++++--- 13 files changed, 439 insertions(+), 179 deletions(-) diff --git a/client/js/irc/bouncer.ts b/client/js/irc/bouncer.ts index 793454e11..322e2590d 100644 --- a/client/js/irc/bouncer.ts +++ b/client/js/irc/bouncer.ts @@ -22,23 +22,32 @@ import type {BatchHandler} from "./handlers/batch"; import {parseTags, type IrcMessage} from "./message"; export const BOUNCER_NETWORKS_CAP = "soju.im/bouncer-networks"; +export const BOUNCER_NOTIFY_CAP = "soju.im/bouncer-networks-notify"; export const BOUNCER_NETWORKS_BATCH = "soju.im/bouncer-networks"; +/** The most networks one list adds: a runaway list must not open hundreds of sockets. */ +export const BOUNCER_NETWORKS_MAX = 20; + +/** + * A network id Seance will store and send back in `BOUNCER BIND`. soju's + * are numbers; anything else that could break the line (a space, a `:`, + * a control character) is refused rather than sent. + */ +export function isNetworkId(id: unknown): id is string { + return typeof id === "string" && /^[A-Za-z0-9._~-]{1,64}$/.test(id); +} + /** One of a bouncer's networks, as `BOUNCER NETWORK ` gives it. */ export interface BouncerNetwork { id: string; /** The `name` attribute, else the `host`, else the id. */ name: string; - host?: string; - /** `connected`, `connecting` or `disconnected`, when the bouncer says. */ - state?: string; - /** The attributes were `*`: the network was removed from the bouncer. */ - removed?: boolean; } /** The bouncer's control connection: it offers the networks and is bound to none. */ export function isBouncerControl(client: IrcClient): boolean { return ( + !client.options.bouncerNetId && client.caps.hasCapability(BOUNCER_NETWORKS_CAP) && !client.isupport.tokens.has("BOUNCER_NETID") ); @@ -47,12 +56,18 @@ export function isBouncerControl(client: IrcClient): boolean { /** `BOUNCER BIND `, for the registration flush, when this connection is bound to one. */ export function bindLine(client: IrcClient): string | undefined { const id = client.options.bouncerNetId; - return id && client.caps.available.has(BOUNCER_NETWORKS_CAP) ? `BOUNCER BIND ${id}` : undefined; + return isNetworkId(id) && client.caps.available.has(BOUNCER_NETWORKS_CAP) + ? `BOUNCER BIND ${id}` + : undefined; } -/** Ask the control connection for the account's networks (after 001). */ +/** + * Ask the control connection for the account's networks (after 001). With + * `-notify` on, soju sends the list by itself at registration, so asking + * again would only cost fake lag. + */ export function listNetworks(client: IrcClient): void { - if (!isBouncerControl(client)) { + if (!isBouncerControl(client) || client.caps.hasCapability(BOUNCER_NOTIFY_CAP)) { return; } @@ -63,25 +78,59 @@ export function listNetworks(client: IrcClient): void { }); } -/** `BOUNCER NETWORK `; undefined for any other `BOUNCER` line. */ +/** + * `BOUNCER NETWORK `; undefined for any other `BOUNCER` + * line, a removal (`*`), or an id that could not be sent back safely. + */ export function parseNetwork(msg: IrcMessage): BouncerNetwork | undefined { const [sub, id, attrs] = msg.params; - if (sub?.toUpperCase() !== "NETWORK" || !id || attrs === undefined) { + if (sub?.toUpperCase() !== "NETWORK" || !isNetworkId(id) || !attrs || attrs === "*") { return undefined; } - if (attrs === "*") { - return {id, name: id, removed: true}; + const tags = parseTags(attrs); + return {id, name: tags.get("name") || tags.get("host") || id}; +} + +/** + * Which of the listed networks to add: each one not offered before + * (`seen`) and not already a saved network (`have`), at most + * {@link BOUNCER_NETWORKS_MAX}. Pure, so the manager's rule is tested here. + */ +export function networksToAdd( + listed: BouncerNetwork[], + seen: ReadonlySet, + have: (net: BouncerNetwork) => boolean +): BouncerNetwork[] { + const fresh: BouncerNetwork[] = []; + const ids = new Set(); + + for (const net of listed) { + if (seen.has(net.id) || ids.has(net.id) || have(net)) { + continue; + } + + ids.add(net.id); + fresh.push(net); + + if (fresh.length >= BOUNCER_NETWORKS_MAX) { + break; + } } - const tags = parseTags(attrs); - const host = tags.get("host") || undefined; - return {id, name: tags.get("name") || host || id, host, state: tags.get("state") || undefined}; + return fresh; } /** Give the manager what the bouncer listed, and say in the lobby what was added. */ function offer(client: IrcClient, networks: BouncerNetwork[]): void { + // A bound network logs in the way the control connection did. soju + // binds only an account it authenticated with SASL (a PASS login is + // answered ACCOUNT_REQUIRED), so without SASL nothing is added. + if (networks.length === 0 || !client.saslOk) { + return; + } + const added = client.options.onBouncerNetworks?.(networks) ?? []; if (added.length > 0) { @@ -95,7 +144,6 @@ function offer(client: IrcClient, networks: BouncerNetwork[]): void { /** The answer to `BOUNCER LISTNETWORKS`: every network of the account. */ export const bouncerNetworksBatch: BatchHandler = (client, batch) => { - // A bound connection hears the list too (`-notify`); only the control one adds. if (!isBouncerControl(client)) { return; } @@ -107,7 +155,7 @@ export const bouncerNetworksBatch: BatchHandler = (client, batch) => { if (net) { networks.push(net); - } else { + } else if (line.command !== "BOUNCER") { client.handleMessage(line); } } diff --git a/client/js/irc/channel.ts b/client/js/irc/channel.ts index 69e4468d9..4fe2bdd73 100644 --- a/client/js/irc/channel.ts +++ b/client/js/irc/channel.ts @@ -130,13 +130,6 @@ export class Channel { * clock, so they never move it. */ newestServerAt: number | undefined = undefined; - /** - * Unix ms of the newest message or action shown here, whatever stamped it: - * a bouncer's buffer replay without msgids (ZNC, history.ts - * `zncPlaybackBatch`) skips what is not newer, so an attach that replays - * the same buffer again shows nothing twice. - */ - newestChatAt: number | undefined = undefined; /** History has been requested at least once (so `newestRef` is a valid catch-up reference). */ historyRequested = false; /** @@ -232,7 +225,6 @@ export class Channel { if (msg.type === MessageType.MESSAGE || msg.type === MessageType.ACTION) { ref.reactable = true; - this.newestChatAt = Math.max(this.newestChatAt ?? 0, ref.time.getTime()); } this.msgRefs.set(msg.id, ref); diff --git a/client/js/irc/client.ts b/client/js/irc/client.ts index 587270779..3cc800b72 100644 --- a/client/js/irc/client.ts +++ b/client/js/irc/client.ts @@ -29,7 +29,13 @@ import type { SharedServerOptions, } from "../../../shared/types/network"; import type {PushSession, ServerToClientEvents} from "../../../shared/types/socket-events"; -import {bindLine, isBouncerControl, listNetworks, type BouncerNetwork} from "./bouncer"; +import { + BOUNCER_NETWORKS_CAP, + bindLine, + isBouncerControl, + listNetworks, + type BouncerNetwork, +} from "./bouncer"; import {CapNegotiator, SEANCE_CAPS, SOJU_WEBPUSH_CAP, webpushVapidOf, WEBPUSH_CAP} from "./caps"; import {casefold, namesEqual} from "./casemap"; import {Channel, MsgRef, SAID_TYPES, countsAsUnread} from "./channel"; @@ -82,7 +88,7 @@ import { setAttended, syncPresence, } from "./presence"; -import {abortHistory, retryLostHistory} from "./history"; +import {abortHistory, noteZncLine, retryLostHistory} from "./history"; import {resetSearches} from "./historysearch"; import {globalUntil, MUTE_KEY, MUTE_READ_DELAY_MS, type PendingMute, releaseMute} from "./pushMute"; import {MODE_KEY, PRESENCE_KEY, syncShared, type Stamped} from "./presenceShare"; @@ -512,6 +518,8 @@ export class IrcClient { attachCursor: string | undefined; /** The server took that cursor: it replays the gap, so catchup.ts stands down. */ serverReplay = false; + /** This connection goes through ZNC (its `znc.in/*` caps): see history.ts `noteZncLine`. */ + behindZnc = false; private saslTimer: ReturnType | null = null; private readonly bus: Pick; @@ -1750,6 +1758,7 @@ export class IrcClient { } this.connected = true; + this.behindZnc = [...this.caps.available.keys()].some((name) => name.startsWith("znc.in/")); this._state = "registered"; const first = this.channels.find((chan) => chan.type === ChanType.CHANNEL); @@ -1817,10 +1826,6 @@ export class IrcClient { // (bounce_setup_local_alias): it is already on the wire, and the // note extends the wait. Otherwise nothing comes, so a short wait. awaitRestoration(this, RESTORE_QUIET_MS); - } else if (this.behindBouncer) { - // soju and ZNC send the channels they hold right behind the MOTD: - // wait for them before deciding what is left to JOIN (autojoin). - awaitRestoration(this, RESTORE_QUIET_MS); } else { this.restorationSettled(); } @@ -1832,17 +1837,11 @@ export class IrcClient { * caps). What it holds is what the user is in, on every device. */ get behindBouncer(): boolean { - if (this.isupport.tokens.has("BOUNCER_NETID")) { - return true; - } - - for (const name of this.caps.available.keys()) { - if (name.startsWith("znc.in/") || name === "soju.im/bouncer-networks") { - return true; - } - } - - return false; + return ( + this.behindZnc || + this.isupport.tokens.has("BOUNCER_NETID") || + this.caps.available.has(BOUNCER_NETWORKS_CAP) + ); } /** @@ -1883,18 +1882,12 @@ export class IrcClient { * on the channel the user is looking at. */ autojoin(): void { - // A bouncer that gave channels back owns the list: one it did not give - // back was left from another client, and stays left here. A bouncer - // that gave none back (a new one, or one not on the network yet) is - // sent the saved channels, as any server is. - // A bouncer's control connection has no channels at all (bouncer.ts). - const bouncerHolds = - isBouncerControl(this) || - (this.behindBouncer && - this.channels.some( - (chan) => chan.type === ChanType.CHANNEL && chan.state === ChanState.JOINED - )); - const joining = bouncerHolds + // A bouncer owns the channel list: it JOINs us to what it holds right + // after the MOTD, leaves out what was parted or detached from another + // client, and sends nothing while it is off the network. Saved channels + // it did not give back stay parted here until the user joins them (as + // gamja does). A soju control connection has no channels at all. + const joining = this.behindBouncer ? [] : this.channels.filter( (chan) => @@ -2058,7 +2051,7 @@ export class IrcClient { } /** Send one raw line. Reports an ERROR message in the lobby instead of throwing. */ - send(line: string): boolean { + send(line: string, charge = true): boolean { if (this.transport.state !== "open") { this.pushMessage(this.lobby, {type: MessageType.ERROR, text: t("send.notConnected")}); return false; @@ -2066,7 +2059,11 @@ export class IrcClient { try { this.transport.send(line); - this.fakeLag.charge(line); + + if (charge) { + this.fakeLag.charge(line); + } + return true; } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); @@ -2941,6 +2938,15 @@ export class IrcClient { return; } + // Behind ZNC, what we saw live is what its next buffer replay repeats. + if ( + this.behindZnc && + !this.replaying && + (msg.command === "PRIVMSG" || msg.command === "NOTICE") + ) { + noteZncLine(this, msg); + } + // An answer to one of our labeled requests (request.ts): its asker // renders it, the handlers never see it. if (interceptRequestLine(this, msg)) { diff --git a/client/js/irc/history.ts b/client/js/irc/history.ts index 9a1c69872..21c389338 100644 --- a/client/js/irc/history.ts +++ b/client/js/irc/history.ts @@ -714,23 +714,101 @@ export const chathistoryBatch: BatchHandler = (client, batch) => { /** ZNC's buffer replay on attach: `BATCH +x znc.in/playback `. */ export const ZNC_PLAYBACK_BATCH = "znc.in/playback"; +/** + * What ZNC has shown each conversation on this page, by identity: its own + * `time` tag, the sender, the command and the text, with how many times each + * came. ZNC has no msgids, and a buffer it does not clear + * (`AutoClearChanBuffer` off, or the `*playback` module) comes back on every + * attach, along with lines we already saw live. Bounded per conversation. + */ +const zncSeen = new WeakMap>>(); +const ZNC_SEEN_MAX = 2000; + +/** A line's identity, from the bouncer's own time tag; none without one. */ +function zncKey(line: IrcMessage): string | undefined { + const time = line.tags.get("time"); + return time + ? [time, line.source?.name ?? "", line.command, ...line.params.slice(1)].join("\0") + : undefined; +} + +function zncSeenFor(client: IrcClient, target: string): Map { + let byTarget = zncSeen.get(client); + + if (!byTarget) { + byTarget = new Map(); + zncSeen.set(client, byTarget); + } + + const key = client.casefold(target); + let seen = byTarget.get(key); + + if (!seen) { + seen = new Map(); + byTarget.set(key, seen); + } + + return seen; +} + +function trimSeen(seen: Map): void { + for (const key of seen.keys()) { + if (seen.size <= ZNC_SEEN_MAX) { + break; + } + + seen.delete(key); + } +} + +/** A live PRIVMSG or NOTICE behind ZNC: remembered, so its replay is known. */ +export function noteZncLine(client: IrcClient, line: IrcMessage): void { + const key = zncKey(line); + const target = line.params[0]; + + if (!key || !target) { + return; + } + + const conversation = client.isSelf(target) ? line.source?.name ?? target : target; + const seen = zncSeenFor(client, conversation); + seen.set(key, (seen.get(key) ?? 0) + 1); + trimSeen(seen); +} + /** * ZNC's buffer replay (`BATCH +x znc.in/playback `, sent on every - * attach, and by the `*playback` module). The lines are stored ones, so they - * go the catch-up's way: appended, counted as unread but silent, a closed - * query reopened only by what was said since. ZNC has no msgids, so what is - * not newer than the newest message already shown is dropped: a buffer ZNC - * does not clear (`AutoClearChanBuffer` off, or the `*playback` module) - * comes back on every attach. + * attach). The lines are stored ones, so they go the catch-up's way: + * appended, counted as unread but silent, a closed query reopened only by + * what was said since. A line this page has already shown, as often as it + * showed it, is dropped ({@link zncSeen}); a line without a time tag is kept. */ export const zncPlaybackBatch: BatchHandler = (client, batch) => { const target = batch.params[0] ?? ""; - const chan = target ? client.findChannel(target) : undefined; - const newest = chan?.newestChatAt; - const lines = - newest === undefined - ? batch.messages - : batch.messages.filter((line) => client.timeOf(line).getTime() > newest); + + if (!target) { + return; + } + + const seen = zncSeenFor(client, target); + const inBatch = new Map(); + const lines = batch.messages.filter((line) => { + const key = zncKey(line); + + if (!key) { + return true; + } + + const count = (inBatch.get(key) ?? 0) + 1; + inBatch.set(key, count); + return count > (seen.get(key) ?? 0); + }); + + for (const [key, count] of inBatch) { + seen.set(key, Math.max(count, seen.get(key) ?? 0)); + } + + trimSeen(seen); if (lines.length > 0) { deliverCatchup(client, target, lines); diff --git a/client/js/irc/historysearch.ts b/client/js/irc/historysearch.ts index e75cfe54a..06247684f 100644 --- a/client/js/irc/historysearch.ts +++ b/client/js/irc/historysearch.ts @@ -35,7 +35,7 @@ import type {IrcClient} from "./client"; import type {BatchHandler, OpenBatch} from "./handlers/batch"; import {formatLine, MAX_LINE_BYTES, utf8ByteLength, type IrcMessage} from "./message"; import type {Channel} from "./channel"; -import {historyLimit, takesMsgidRefs, takesTimestampRefs} from "./history"; +import {formatRef, historyLimit, takesMsgidRefs, takesTimestampRefs} from "./history"; import {ChanState, ChanType} from "../../../shared/types/chan"; import {applyEdit, applyReaction, applyRedaction} from "../helpers/messageUpdates"; import {sameAuthor} from "../helpers/pastRows"; @@ -78,21 +78,20 @@ type Asked = Pick` each to a - * server that wants `in=` (soju: `FAIL SEARCH INVALID_PARAMS in`). Answered - * once, when every part has: the newest `limit` hits, oldest first, as one - * server would have sent them. + * server that wants `in=` (soju: `FAIL SEARCH INVALID_PARAMS in`). One part at + * a time, so an unlabeled answer is always the part's; answered once, when the + * last part has: the newest `limit` hits, oldest first, as one server would + * have sent them. A part that fails fails the whole: results that leave + * channels out are not every channel's. */ interface SearchGroup { requestId: number; - limit?: number; - waiting: number; + /** Every attribute but `in`. */ + attrs: SearchAttrs; + /** Channels not asked about yet. */ + queue: string[]; hits: SearchHit[]; - /** The first part's failure, the answer when no part found anything. */ - failure?: {status: SearchStatus; error?: string}; } - -/** The most channels one search over every channel asks about, one `SEARCH` each. */ -export const SEARCH_FAN_OUT_MAX = 30; /** * Servers that answered a search without `in=` with `FAIL SEARCH INVALID_PARAMS in`: * every later search over every channel on that connection goes out per channel. @@ -108,6 +107,10 @@ const timesByClient = new WeakMap>(); const TIMES_MAX = 5000; function noteTimes(client: IrcClient, lines: IrcMessage[]): void { + if (takesMsgidRefs(client)) { + return; // asked by msgid: nothing to look up + } + let times = timesByClient.get(client); if (!times) { @@ -140,11 +143,7 @@ function noteTimes(client: IrcClient, lines: IrcMessage[]): void { */ function contextRef(client: IrcClient, target: string, msgid: string): string | undefined { if (takesMsgidRefs(client)) { - return `msgid=${msgid}`; - } - - if (!takesTimestampRefs(client)) { - return undefined; + return formatRef(client, {msgid, time: new Date(0)}); } const chan = client.findChannel(target); @@ -152,7 +151,7 @@ function contextRef(client: IrcClient, target: string, msgid: string): string | const time = (id === undefined ? undefined : chan?.msgRefs.get(id)?.time) ?? timesByClient.get(client)?.get(msgid); - return time ? `timestamp=${time.toISOString()}` : undefined; + return time ? formatRef(client, {time}) : undefined; } const pendingByClient = new WeakMap(); @@ -301,7 +300,7 @@ function lineFor(input: Parameters[0]): string | undefined { } } -/** One channel's part of a search over every channel is in: the whole is answered with the last. */ +/** One channel's part of a search over every channel is in: ask the next, or answer the whole. */ function answerPart( client: IrcClient, group: SearchGroup, @@ -309,63 +308,62 @@ function answerPart( error: string | undefined, hits: SearchHit[] ): void { - if (status === "ok") { - group.hits.push(...hits); - } else { - group.failure ??= {status, error}; - } + const base = {network: client.uuid, requestId: group.requestId}; - if (--group.waiting > 0) { + if (status !== "ok") { + client.dispatchBus("search:results", {...base, status, error, hits: []}); return; } - const base = {network: client.uuid, requestId: group.requestId}; + group.hits.push(...hits); - if (group.hits.length === 0 && group.failure) { - client.dispatchBus("search:results", {...base, ...group.failure, hits: []}); + if (group.queue.length > 0) { + askNext(client, group); return; } const time = (hit: SearchHit) => new Date(hit.msg.time).getTime(); const sorted = group.hits.toSorted((a, b) => time(a) - time(b)); - const kept = group.limit ? sorted.slice(-group.limit) : sorted; + const kept = group.attrs.limit ? sorted.slice(-group.attrs.limit) : sorted; client.dispatchBus("search:results", {...base, status: "ok", hits: kept}); } +function askNext(client: IrcClient, group: SearchGroup): void { + const part: SearchAttrs = {in: group.queue.shift(), ...group.attrs}; + sendRequest( + client, + {kind: "results", requestId: group.requestId, attrs: part, group}, + true, + "SEARCH", + client.profile.searchParams?.(part) ?? specSearchParams(part), + // A bouncer answers SEARCH from its own store: no ircd charges for it. + !client.behindBouncer + ); +} + /** * Search every channel by asking about each (a server that wants `in=`). The * channels are those joined; private conversations are left out, as a server's - * own search over every channel leaves them out. + * own search over every channel leaves them out. None joined (soju's control + * connection): searched on the device instead. */ function fanOut(client: IrcClient, requestId: number, attrs: SearchAttrs): void { - const channels = client.channels + const queue = client.channels .filter((chan) => chan.type === ChanType.CHANNEL && chan.state === ChanState.JOINED) - .slice(0, SEARCH_FAN_OUT_MAX); + .map((chan) => chan.name); - if (channels.length === 0) { + if (queue.length === 0) { client.dispatchBus("search:results", { network: client.uuid, requestId, - status: "ok", + status: "unsupported", hits: [], }); return; } - const group: SearchGroup = {requestId, limit: attrs.limit, waiting: channels.length, hits: []}; - const {in: _all, ...rest} = attrs; - - for (const chan of channels) { - const part: SearchAttrs = {in: chan.name, ...rest}; - sendRequest( - client, - {kind: "results", requestId, attrs: part, group}, - true, - "SEARCH", - client.profile.searchParams?.(part) ?? specSearchParams(part) - ); - } + askNext(client, {requestId, attrs: rest, queue, hits: []}); } /** Send a request, which always gets its one answer, whatever happens. */ @@ -374,7 +372,8 @@ function sendRequest( request: Asked, available: boolean, command: string, - params: string[] + params: string[], + charge = true ): void { if (!client.isWelcomed) { answer(client, request, "offline"); @@ -394,7 +393,7 @@ function sendRequest( return; } - if (!client.send(line)) { + if (!client.send(line, charge)) { answer(client, request, "offline"); return; } @@ -722,6 +721,9 @@ export function resetSearches(client: IrcClient): void { settle(client, request, "offline"); } - // The queue they waited in is gone with the socket; a new one asks afresh. + // The queue they waited in is gone with the socket; a new one asks afresh, + // and the next server may be another (an edited network). parentsAsked.delete(client); + needsIn.delete(client); + timesByClient.delete(client); } diff --git a/client/js/irc/manager.ts b/client/js/irc/manager.ts index 258656ded..b4ca966fa 100644 --- a/client/js/irc/manager.ts +++ b/client/js/irc/manager.ts @@ -28,7 +28,7 @@ import {leavesAwayToWorker, syncPresence} from "./presence"; import {claimMute, clearRemoteDnd} from "./pushMute"; import * as saved from "./saved-networks"; import type {SavedNetwork} from "./saved-networks"; -import type {BouncerNetwork} from "./bouncer"; +import {networksToAdd, type BouncerNetwork} from "./bouncer"; import type {ConnectOptions} from "./types"; import type {GlobalModeState} from "../helpers/dnd"; @@ -187,31 +187,31 @@ function addBouncerNetworks(controlUuid: string, networks: BouncerNetwork[]): st return []; } - const seen = new Set(control.bouncerSeen ?? []); - const fresh = networks.filter((net) => !net.removed && !seen.has(net.id)); + const sameBouncer = (other: SavedNetwork): boolean => + other.host === control.host && other.port === control.port && other.tls === control.tls; + // Bound here before, or added by hand the soju way (`alice/libera`). + const have = (net: BouncerNetwork): boolean => + saved + .list() + .some( + (other) => + sameBouncer(other) && + ((other.bouncerNetId === net.id && other.saslAccount === control.saslAccount) || + other.saslAccount === `${control.saslAccount}/${net.name}`) + ); + const fresh = networksToAdd(networks, new Set(control.bouncerSeen ?? []), have); if (fresh.length === 0) { return []; } - saved.save({...control, bouncerSeen: [...seen, ...fresh.map((net) => net.id)]}); - const added: string[] = []; + // Offered once: a network the user removes here is not added back. + saved.save({ + ...control, + bouncerSeen: [...(control.bouncerSeen ?? []), ...fresh.map((net) => net.id)], + }); for (const net of fresh) { - const already = saved - .list() - .some( - (other) => - other.bouncerNetId === net.id && - other.host === control.host && - other.port === control.port && - other.saslAccount === control.saslAccount - ); - - if (already) { - continue; - } - createNetwork({ ...saved.toConnectOptions(control), // Kept in memory only, unless the bouncer's entry remembers it. @@ -225,10 +225,9 @@ function addBouncerNetworks(controlUuid: string, networks: BouncerNetwork[]): st pushEnabled: control.pushEnabled, notifyEnabled: control.notifyEnabled, }); - added.push(net.name); } - return added; + return fresh.map((net) => net.name); } /** diff --git a/client/js/irc/saved-networks.ts b/client/js/irc/saved-networks.ts index d2899fe2c..d5d3ddbad 100644 --- a/client/js/irc/saved-networks.ts +++ b/client/js/irc/saved-networks.ts @@ -14,6 +14,7 @@ * backend with {@link useStorageBackend}. */ +import {isNetworkId} from "./bouncer"; import storage from "../localStorage"; import {collator} from "../i18n/collation"; import {forgetNetworkLog} from "./querylog"; @@ -276,14 +277,12 @@ export function normalize(raw: Record): SavedNetwork | undefine net.lastUsed = raw.lastUsed; } - const netId = asString(raw.bouncerNetId); - - if (netId) { - net.bouncerNetId = netId; + if (isNetworkId(raw.bouncerNetId)) { + net.bouncerNetId = raw.bouncerNetId; } if (Array.isArray(raw.bouncerSeen)) { - net.bouncerSeen = raw.bouncerSeen.map(asString).filter(Boolean); + net.bouncerSeen = raw.bouncerSeen.filter(isNetworkId); } const cursor = asCursor(raw.cursor); diff --git a/client/js/webpush.ts b/client/js/webpush.ts index 39c2e6bbe..dc76e179f 100644 --- a/client/js/webpush.ts +++ b/client/js/webpush.ts @@ -1572,6 +1572,8 @@ async function writeStash(): Promise { tls: net.tls, saslAccount: net.saslAccount, saslPassword: net.rememberPassword === true ? net.saslPassword : undefined, + // One of a soju account's networks: the worker binds to it (irc/bouncer.ts). + bouncerNetId: net.bouncerNetId, vapid: subs[net.uuid]?.vapid ?? announcedKey(net.uuid) ?? null, })); diff --git a/client/service-worker.js b/client/service-worker.js index e9e2d3e5e..a29d5304d 100644 --- a/client/service-worker.js +++ b/client/service-worker.js @@ -805,6 +805,8 @@ function randomNick() { * through done(ok): false on any SASL/registration failure, a close, or the * timeout. */ +const SW_BOUNCER_CAP = "soju.im/bouncer-networks"; + function swIrcOpen(net, pre, onReady, optional = []) { return new Promise((resolve) => { const scheme = net.tls ? "wss://" : "ws://"; @@ -905,7 +907,10 @@ function swIrcOpen(net, pre, onReady, optional = []) { } stage = 1; - asked = optional.filter((cap) => offered.includes(cap)); + // One of a soju account's networks (irc/bouncer.ts): bound with + // BOUNCER BIND before CAP END, or this is the control connection. + const wanted = net.bouncerNetId ? optional.concat([SW_BOUNCER_CAP]) : optional; + asked = wanted.filter((cap) => offered.includes(cap)); ws.send("CAP REQ :" + ["sasl"].concat(asked).join(" ")); return; } @@ -945,6 +950,15 @@ function swIrcOpen(net, pre, onReady, optional = []) { if (stage === 3 && / 903 /.test(l)) { stage = 4; + if (net.bouncerNetId) { + if (!ctx.caps.has(SW_BOUNCER_CAP)) { + done(false); + return; + } + + ws.send("BOUNCER BIND " + net.bouncerNetId); + } + for (const line of typeof pre === "function" ? pre(ctx) : pre) { ws.send(line); } diff --git a/docs/resources/client-features.md b/docs/resources/client-features.md index 98ce512d3..4c81035bc 100644 --- a/docs/resources/client-features.md +++ b/docs/resources/client-features.md @@ -24,7 +24,7 @@ How the parts of the client behave and why, moved out of `CLAUDE.md` on 2026-11- ## Searching history -`Windows/SearchResults.vue` (`/search`, or the header's search button, which toggles it): a panel beside the chat (`RoutedChat.vue` shows both on `/chan-:id/search`), over it on a phone. The route's `?q=` is the only state, and the chips and `SearchFilterSheet.vue` edit that string (`client/js/searchQuery.ts`, which says what the syntax is). The server (`soju.im/search`, `irc/historysearch.ts`) takes one nick, one substring and a time window, newest first, and gives no total. Extra words, `-word`, `account:` and the kind of link are checked on the device against what came back, so a page can be thin or empty while older matches exist: the status line says how many are hidden and how far back the results reach, never a total, and "Search older" stays until a page adds nothing. Nothing from a search reaches a channel: no unread counts, no cursor, no query opened or logged. A server without the cap, or no connection, searches the messages loaded on this device under a banner, with the same filters. So does an empty answer, in any scope, since it may only be the server's: a scan that ran out, a conversation it does not store, case outside ASCII. Every-channel results also carry loaded lines of private conversations, which the server leaves out, marked on the row; paging goes on from the server's oldest row, never from one of those. Tapping a hit takes the chat beside it to the message, as a mention does (`docs/projects/inbox.md`), and the results stay: a hit in another conversation moves the route there with its scope spelt out in `q`, which asks nothing new. On a phone the panel tucks away (`js/searchPanel.ts`) until the header's magnifier brings it back. The ✕ (Cancel on a phone), Escape or the magnifier close it. Under the empty field are the last ten searches on this device (`helpers/searchRecents.ts`, `thelounge.search.recent`), kept on Enter or an opened hit, never on what typing searched by itself, with Clear. A hit in a conversation not joined says so in the results. Each hit's time carries its day as well as the day's header above it (`helpers/messageTime.ts` `datedTime`): Today, Yesterday, the weekday within the last week, then the date, on the reader's clock settings. Left out on purpose: oldest first (it would only flip the newest page), role filters (history keeps no roles), several chosen channels (one request each, ~2 s of fake lag), and ranking by relevance (only what is loaded could be ranked). A server that wants `in=` (soju answers `FAIL SEARCH INVALID_PARAMS in`) is asked again with one `SEARCH in=` per joined channel, up to 30, and so is every later search over every channel on that connection; the parts are answered as one page, the newest `limit` hits. A server that takes only `timestamp=` references (soju: `MSGREFTYPES=timestamp`) is asked for a hit's surroundings by the hit's time, which the search page brought; a reply's missing parent is known by msgid alone, so it is not asked for there. Browser check: `tools/scenarios/history-search.mjs`. Typing searches once it pauses for 0.8 s, on a word of three letters or more with no filter half typed, one search at a time and at most one per 2 s, which is what a search costs in fake lag; Enter searches at once. nefarious2 scans up to 50000 rows for each, stalling every client meanwhile (~165 ms measured), so do not make this faster. +`Windows/SearchResults.vue` (`/search`, or the header's search button, which toggles it): a panel beside the chat (`RoutedChat.vue` shows both on `/chan-:id/search`), over it on a phone. The route's `?q=` is the only state, and the chips and `SearchFilterSheet.vue` edit that string (`client/js/searchQuery.ts`, which says what the syntax is). The server (`soju.im/search`, `irc/historysearch.ts`) takes one nick, one substring and a time window, newest first, and gives no total. Extra words, `-word`, `account:` and the kind of link are checked on the device against what came back, so a page can be thin or empty while older matches exist: the status line says how many are hidden and how far back the results reach, never a total, and "Search older" stays until a page adds nothing. Nothing from a search reaches a channel: no unread counts, no cursor, no query opened or logged. A server without the cap, or no connection, searches the messages loaded on this device under a banner, with the same filters. So does an empty answer, in any scope, since it may only be the server's: a scan that ran out, a conversation it does not store, case outside ASCII. Every-channel results also carry loaded lines of private conversations, which the server leaves out, marked on the row; paging goes on from the server's oldest row, never from one of those. Tapping a hit takes the chat beside it to the message, as a mention does (`docs/projects/inbox.md`), and the results stay: a hit in another conversation moves the route there with its scope spelt out in `q`, which asks nothing new. On a phone the panel tucks away (`js/searchPanel.ts`) until the header's magnifier brings it back. The ✕ (Cancel on a phone), Escape or the magnifier close it. Under the empty field are the last ten searches on this device (`helpers/searchRecents.ts`, `thelounge.search.recent`), kept on Enter or an opened hit, never on what typing searched by itself, with Clear. A hit in a conversation not joined says so in the results. Each hit's time carries its day as well as the day's header above it (`helpers/messageTime.ts` `datedTime`): Today, Yesterday, the weekday within the last week, then the date, on the reader's clock settings. Left out on purpose: oldest first (it would only flip the newest page), role filters (history keeps no roles), several chosen channels (one request each, ~2 s of fake lag), and ranking by relevance (only what is loaded could be ranked). A server that wants `in=` (soju answers `FAIL SEARCH INVALID_PARAMS in`) is asked again with one `SEARCH in=` per joined channel, one after another (soju 0.7 has no `labeled-response` to tell concurrent answers apart, and behind a bouncer the parts are not charged as the server's fake lag), and so is every later search over every channel on that connection; the parts are answered as one page, the newest `limit` hits, and a part that fails fails the search. A server that takes only `timestamp=` references (soju: `MSGREFTYPES=timestamp`) is asked for a hit's surroundings by the hit's time, which the search page brought; a reply's missing parent is known by msgid alone, so it is not asked for there. Browser check: `tools/scenarios/history-search.mjs`. Typing searches once it pauses for 0.8 s, on a word of three letters or more with no filter half typed, one search at a time and at most one per 2 s, which is what a search costs in fake lag; Enter searches at once. nefarious2 scans up to 50000 rows for each, stalling every client meanwhile (~165 ms measured), so do not make this faster. ## Channel browser diff --git a/docs/resources/irc-layer.md b/docs/resources/irc-layer.md index 615ccf456..e18be03d4 100644 --- a/docs/resources/irc-layer.md +++ b/docs/resources/irc-layer.md @@ -38,18 +38,17 @@ Replies, reactions, deletion and edits (bus-contract §1.4): `Channel.idByMsgid` **The catch-up cursor is offered exactly once per connection, and only then.** `IrcClient.cursor` (the newest msgid shown on the network, by `@time`, persisted in `saved-networks.ts`) goes out as `PERSISTENCE ATTACH default ` from `saslProgress` on SASL _success_, before `CAP END` in the same flush — never without SASL (the server answers `FAIL PERSISTENCE ACCOUNT_REQUIRED`), never after 001, never without the `attach-cursor` token in the `draft/persistence` CAP 302 value, and never with a msgid of 64 bytes or more. While the server accepts it (`client.serverReplay`) the restoration JOINs must not schedule a catch-up of their own (`persistence.ts` `serverReplayCovers`) — the server's replay is that catch-up, and it arrives as an appended `chathistory` batch, not as `more`. -## A login can give its dial back - ## Bouncers: soju and ZNC Neither needs a profile; each is told by what it offers. `IrcClient.behindBouncer` is true with `BOUNCER_NETID` (soju bound to a network), `soju.im/bouncer-networks` or any `znc.in/*` cap. -- **The bouncer owns the channel list.** Behind a bouncer the autojoin waits for the bouncer's own JOINs after the MOTD (`awaitRestoration`, as for nefarious2's held session). If it gave any back, nothing else is JOINed: a saved channel it did not give back was left from another client and stays a parted placeholder. A bouncer that gave none back (new, or not on the network yet) gets the saved list as any server does. Tests: `test/irc/bouncer.ts`. -- **ZNC's buffer replay** (`BATCH +x znc.in/playback `, on every attach) goes the catch-up's way (`history.ts` `zncPlaybackBatch` → `deliverCatchup`): appended, unread but silent, a closed query reopened only by what was said since. ZNC has no msgids, so a line not newer than the conversation's newest message or action (`Channel.newestChatAt`) is dropped: a buffer ZNC keeps (`AutoClearChanBuffer` off) comes back on every attach. -- **soju's networks** (`bouncer.ts`, `soju.im/bouncer-networks` and `-notify`): a login without a network is the control connection (the cap, and no `BOUNCER_NETID`). It JOINs nothing and asks `BOUNCER LISTNETWORKS` through the background queue; the manager (`addBouncerNetworks`) adds each network the entry has not offered before (`SavedNetwork.bouncerSeen`, so one the user removes is not added back) as a saved network of its own: the same server and login, `join` empty, `bouncerNetId` set. A bound entry sends `BOUNCER BIND ` in the `CAP END` flush (`finalLines`). The control connection does not subscribe to push; the bound ones do. `findMatching` skips bound entries, so the connect form finds the bouncer's own. -- **Push** through soju's `soju.im/webpush`: the same command, `VAPID` token and one-line payload as `draft/webpush` (`caps.ts` `SOJU_WEBPUSH_CAP`). +- **The bouncer owns the channel list.** Behind a bouncer the saved channels are never JOINed (`autojoin`): the bouncer JOINs us to what it holds, and a channel parted from another client stays parted here. A new channel is joined by hand once, and the bouncer keeps it from then on. That is gamja's rule too. Tests: `test/irc/bouncer.ts`. +- **ZNC's buffer replay** (`BATCH +x znc.in/playback `, on every attach) goes the catch-up's way (`history.ts` `zncPlaybackBatch` → `deliverCatchup`): appended, unread but silent, a closed query reopened only by what was said since. ZNC has no msgids, so a line is known by its time, sender, command, target and text (`noteZncLine` counts the live ones per conversation, at most 2000). A buffered line is kept only when the batch holds more copies of it than this connection already saw, so a buffer ZNC keeps (`AutoClearChanBuffer` off) is not shown twice, and two equal lines in the same second both stay. +- **soju's networks** (`bouncer.ts`, `soju.im/bouncer-networks` and `-notify`): a login without a network is the control connection (the cap, no `BOUNCER_NETID`, and no `bouncerNetId` of its own). It JOINs nothing. Without `-notify` it asks `BOUNCER LISTNETWORKS` through the background queue; with it, soju sends the list by itself. Networks are added only when the control connection logged in with SASL, since soju refuses `BIND` to a PASS login. The manager (`addBouncerNetworks`, choosing with the pure `networksToAdd`) adds each network the entry has not offered before (`SavedNetwork.bouncerSeen`, so one the user removes is not added back), that is not saved already (bound to it, or added by hand as `account/name`), at most 20 from one list, as a saved network of its own: the same server and login, `join` empty, `bouncerNetId` set. Ids must match `[A-Za-z0-9._~-]{1,64}` (`isNetworkId`) or they are dropped. A bound entry sends `BOUNCER BIND ` in the `CAP END` flush (`finalLines`), and so does the service worker's own connection for a notification's reply or mark read (`swIrcOpen`, the stash's `bouncerNetId`; it gives up if the cap is not offered). The control connection does not subscribe to push; the bound ones do. `findMatching` skips bound entries, so the connect form finds the bouncer's own. - Search on soju: `docs/resources/client-features.md` § Searching history. +## A login can give its dial back + ## `/who` **`/who`** (`commands/who.ts`, `handlers/who.ts`): one `who` message per query, rendered as a table by `MessageTypes/who.vue` in the channel the mask names (or where it was typed). With `WHOX` in ISUPPORT the query asks for `%tcuhsnfdar,` (account names included; the token is checked on every 354), otherwise a plain `WHO` and 352 rows; queries are matched to replies in order, a user's own `%fields` spec is sent as typed and answered as plain text, and a reply nobody asked for lands in the lobby as text. The table reads only the base tokens (`--body-color-muted`, `--link-color`, `--window-heading-color`) so every theme colours it; the server is a tooltip on the host, not a column. Tests: `test/irc/who.ts`; browser check: `tools/scenarios/who-list.mjs` (also `--mobile --width=390 --height=844`). diff --git a/test/irc/bouncer.ts b/test/irc/bouncer.ts index 02da1d294..498f2c166 100644 --- a/test/irc/bouncer.ts +++ b/test/irc/bouncer.ts @@ -1,7 +1,13 @@ import {expect} from "chai"; import sinon from "sinon"; import {RESTORE_MAX_WAIT_MS} from "../../client/js/irc/persistence"; -import {isBouncerControl, type BouncerNetwork} from "../../client/js/irc/bouncer"; +import { + BOUNCER_NETWORKS_MAX, + isBouncerControl, + isNetworkId, + networksToAdd, + type BouncerNetwork, +} from "../../client/js/irc/bouncer"; import {setup, register, Harness} from "./support"; import type {SharedMsg} from "../../shared/types/msg"; @@ -65,6 +71,28 @@ describe("Bouncers: ZNC buffer playback", function () { expect(h.messages(chan.id).filter((m) => m.text === "buffered 3")).to.have.length(1); }); + it("skips a buffered line this connection already saw live", function () { + const h = setup(); + register(h, ZNC_CAPS); + attach(h, []); + h.transport.line(said(4, "live one")); + attach(h, [said(4, "live one"), said(5, "missed")], "#seance", "p3"); + + const chan = h.client.findChannel("#seance")!; + const texts = h.messages(chan.id).map((m) => m.text); + expect(texts.filter((t) => t === "live one")).to.have.length(1); + expect(texts).to.include("missed"); + }); + + it("keeps two identical lines in the same second when both are new", function () { + const h = setup(); + register(h, ZNC_CAPS); + attach(h, [said(6, "ok"), said(6, "ok")]); + + const chan = h.client.findChannel("#seance")!; + expect(h.messages(chan.id).filter((m) => m.text === "ok")).to.have.length(2); + }); + it("opens a query from a buffered private message", function () { const h = setup(); register(h, ZNC_CAPS); @@ -103,14 +131,12 @@ describe("Bouncers: the bouncer's channel list", function () { expect(h.client.findChannel("#seance")?.state).to.not.equal(undefined); }); - it("sends the saved channels to a bouncer that holds none yet", function () { + it("never JOINs the saved channels behind a bouncer, even one that holds none", function () { const h = setup({join: "#seance,#new"}); register(h, ZNC_CAPS); - expect(joins(h), "waits for the bouncer's own JOINs first").to.deep.equal([]); clock.tick(RESTORE_MAX_WAIT_MS); - expect(joins(h)).to.have.length(1); - expect(joins(h)[0]).to.match(/^JOIN #seance,#new$|^JOIN #new,#seance$/); + expect(joins(h)).to.deep.equal([]); }); it("knows soju by its BOUNCER_NETID", function () { @@ -146,7 +172,13 @@ describe("Bouncers: soju's networks (soju.im/bouncer-networks)", function () { }); const SOJU_CAPS = - "batch server-time message-tags echo-message labeled-response soju.im/bouncer-networks soju.im/bouncer-networks-notify"; + "batch server-time message-tags echo-message soju.im/bouncer-networks soju.im/bouncer-networks-notify"; + + /** The control connection, logged in with SASL as soju requires for BIND. */ + function control(h: Harness, caps = SOJU_CAPS, before: string[] = []): void { + register(h, caps, before); + h.client.saslOk = true; + } it("asks the control connection for the networks, and JOINs nothing there", function () { const offered: BouncerNetwork[][] = []; @@ -157,7 +189,9 @@ describe("Bouncers: soju's networks (soju.im/bouncer-networks)", function () { return networks.map((net) => net.name); }, }); - register(h, SOJU_CAPS, [":irc.test 005 alice CHANTYPES= :are supported by this server"]); + control(h, "batch server-time message-tags echo-message soju.im/bouncer-networks", [ + ":irc.test 005 alice CHANTYPES= :are supported by this server", + ]); clock.tick(RESTORE_MAX_WAIT_MS); expect(isBouncerControl(h.client)).to.equal(true); @@ -168,20 +202,30 @@ describe("Bouncers: soju's networks (soju.im/bouncer-networks)", function () { ":irc.test BATCH +n soju.im/bouncer-networks", "@batch=n :irc.test BOUNCER NETWORK 1 name=Libera;host=irc.libera.chat;state=connected", "@batch=n :irc.test BOUNCER NETWORK 2 host=irc.oftc.net", + "@batch=n :irc.test BOUNCER NETWORK bad:id name=Evil", ":irc.test BATCH -n" ); - expect(offered).to.have.length(1); - expect(offered[0]).to.deep.equal([ - {id: "1", name: "Libera", host: "irc.libera.chat", state: "connected"}, - {id: "2", name: "irc.oftc.net", host: "irc.oftc.net", state: undefined}, + expect(offered).to.deep.equal([ + [ + {id: "1", name: "Libera"}, + {id: "2", name: "irc.oftc.net"}, + ], ]); expect(h.messages(h.client.lobby.id).map((m) => m.text)).to.include( "Added this bouncer's networks: Libera, irc.oftc.net" ); }); - it("offers a network added on the bouncer later (-notify), and knows a removed one", function () { + it("does not ask again when -notify sends the list by itself", function () { + const h = setup({onBouncerNetworks: () => []}); + control(h); + clock.tick(RESTORE_MAX_WAIT_MS); + + expect(h.transport.sent).to.not.include("BOUNCER LISTNETWORKS"); + }); + + it("offers a network added on the bouncer later (-notify), and ignores a removed one", function () { const offered: BouncerNetwork[] = []; const h = setup({ onBouncerNetworks(networks) { @@ -189,16 +233,27 @@ describe("Bouncers: soju's networks (soju.im/bouncer-networks)", function () { return []; }, }); - register(h, SOJU_CAPS); + control(h); h.transport.lines( ":irc.test BOUNCER NETWORK 7 name=EFnet;state=connecting", ":irc.test BOUNCER NETWORK 1 *" ); - expect(offered).to.deep.equal([ - {id: "7", name: "EFnet", host: undefined, state: "connecting"}, - {id: "1", name: "1", removed: true}, - ]); + expect(offered).to.deep.equal([{id: "7", name: "EFnet"}]); + }); + + it("adds nothing for a login without SASL: soju binds only a SASL account", function () { + const offered: BouncerNetwork[] = []; + const h = setup({ + onBouncerNetworks(networks) { + offered.push(...networks); + return []; + }, + }); + register(h, SOJU_CAPS); + h.transport.line(":irc.test BOUNCER NETWORK 7 name=EFnet"); + + expect(offered).to.deep.equal([]); }); it("binds a network added from the bouncer before CAP END", function () { @@ -224,11 +279,55 @@ describe("Bouncers: soju's networks (soju.im/bouncer-networks)", function () { it("is not a control connection once bound (BOUNCER_NETID)", function () { const h = setup({onBouncerNetworks: () => []}); - register(h, SOJU_CAPS, [ + control(h, "batch server-time soju.im/bouncer-networks", [ ":irc.test 005 alice BOUNCER_NETID=1 :are supported by this server", ]); expect(isBouncerControl(h.client)).to.equal(false); expect(h.transport.sent).to.not.include("BOUNCER LISTNETWORKS"); }); + + it("is not a control connection when it asked to bind, even if soju refused", function () { + const offered: BouncerNetwork[] = []; + const h = setup({ + bouncerNetId: "42", + onBouncerNetworks(networks) { + offered.push(...networks); + return []; + }, + }); + control(h); + h.transport.line(":irc.test BOUNCER NETWORK 7 name=EFnet"); + + expect(isBouncerControl(h.client)).to.equal(false); + expect(offered).to.deep.equal([]); + }); +}); + +describe("Bouncers: which listed networks to add (networksToAdd)", function () { + const net = (id: string, name = `net${id}`): BouncerNetwork => ({id, name}); + + it("skips the ones offered before, the ones saved already, and repeats", function () { + const picked = networksToAdd( + [net("1"), net("2"), net("3"), net("3")], + new Set(["1"]), + (n) => n.id === "2" + ); + expect(picked.map((n) => n.id)).to.deep.equal(["3"]); + }); + + it("adds at most BOUNCER_NETWORKS_MAX from one list", function () { + const many = Array.from({length: BOUNCER_NETWORKS_MAX + 5}, (_, i) => net(String(i))); + expect(networksToAdd(many, new Set(), () => false)).to.have.length(BOUNCER_NETWORKS_MAX); + }); + + it("takes only ids that are safe to send back in BOUNCER BIND", function () { + expect(isNetworkId("42")).to.equal(true); + expect(isNetworkId("a.b_c~d-e")).to.equal(true); + expect(isNetworkId("")).to.equal(false); + expect(isNetworkId("4 2")).to.equal(false); + expect(isNetworkId(":42")).to.equal(false); + expect(isNetworkId("x".repeat(65))).to.equal(false); + expect(isNetworkId(42)).to.equal(false); + }); }); diff --git a/test/irc/historysearch.ts b/test/irc/historysearch.ts index d5704a61c..33ec5f882 100644 --- a/test/irc/historysearch.ts +++ b/test/irc/historysearch.ts @@ -828,7 +828,7 @@ describe("historysearch", function () { ); } - it("asks each joined channel instead, and answers once with the newest, oldest first", function () { + it("asks each joined channel in turn, and answers once with the newest, oldest first", function () { const {client, transport} = setup(); transport.line("@time=2026-08-25T12:00:00.000Z :alice!alice@host JOIN #linux"); transport.sent.length = 0; @@ -838,17 +838,16 @@ describe("historysearch", function () { expect(transport.sent[0]).to.not.contain("in="); refuse(transport, transport.sent[0]); - const parts = transport.sent.slice(1); - const partFor = (chan: string) => parts.find((line) => line.includes(`in=${chan};`))!; - expect(parts.map((line) => line.match(/in=([^;\s]+)/)?.[1])).to.have.members([ - "#seance", - "#linux", - ]); + // One channel at a time: soju 0.7 has no labeled-response to + // tell concurrent answers apart. + expect(transport.sent).to.have.length(2); + expect(transport.sent[1]).to.contain("in=#linux;"); + batch(transport, "soju.im/search", labelOf(transport.sent[1]), [ROW3]); expect(payloads("search:results")).to.have.length(0); - batch(transport, "soju.im/search", labelOf(partFor("#seance")), [ROW1, ROW2]); - expect(payloads("search:results")).to.have.length(0); - batch(transport, "soju.im/search", labelOf(partFor("#linux")), [ROW3]); + expect(transport.sent).to.have.length(3); + expect(transport.sent[2]).to.contain("in=#seance;"); + batch(transport, "soju.im/search", labelOf(transport.sent[2]), [ROW1, ROW2]); const answers = payloads("search:results"); expect(answers).to.have.length(1); @@ -857,6 +856,29 @@ describe("historysearch", function () { expect(answers[0].hits.map((hit) => hit.target)).to.deep.equal(["#linux", "#seance"]); }); + it("fails the whole search when one channel's part fails", function () { + const {client, transport} = setup(); + transport.line("@time=2026-08-25T12:00:00.000Z :alice!alice@host JOIN #linux"); + transport.sent.length = 0; + + requestSearch(client, 8, {text: "fake"}); + refuse(transport, transport.sent[0]); + batch(transport, "soju.im/search", labelOf(transport.sent[1]), [ROW3]); + transport.line( + `@label=${labelOf( + transport.sent[2] + )} :irc.test FAIL SEARCH INTERNAL_ERROR :Database is down` + ); + + const answers = payloads("search:results"); + expect(answers).to.have.length(1); + expect(answers[0]).to.include({ + requestId: 8, + status: "fail", + error: "Database is down", + }); + }); + it("remembers it: the next search over every channel goes per channel at once", function () { const {client, transport} = setup(); requestSearch(client, 1, {text: "fake"}); From 9949abd69ad08df24a1bd90ae30e544c9d955495 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 19:59:05 +0000 Subject: [PATCH 3/3] Bouncers: cap the networks one bouncer entry adds in all The cap of 20 counted one list at a time, and each -notify line is a list of one, so a bouncer announcing new ids one by one got all of them added. Count what the entry offered before (bouncerSeen) against it, which also bounds bouncerSeen. --- client/js/irc/bouncer.ts | 20 +++++++++++++------- docs/resources/irc-layer.md | 2 +- test/irc/bouncer.ts | 11 +++++++++++ 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/client/js/irc/bouncer.ts b/client/js/irc/bouncer.ts index 322e2590d..4849d40e3 100644 --- a/client/js/irc/bouncer.ts +++ b/client/js/irc/bouncer.ts @@ -25,7 +25,11 @@ export const BOUNCER_NETWORKS_CAP = "soju.im/bouncer-networks"; export const BOUNCER_NOTIFY_CAP = "soju.im/bouncer-networks-notify"; export const BOUNCER_NETWORKS_BATCH = "soju.im/bouncer-networks"; -/** The most networks one list adds: a runaway list must not open hundreds of sockets. */ +/** + * The most networks one bouncer entry ever adds, across every list and + * `-notify` line: a bouncer that keeps announcing new ids must not open + * hundreds of sockets. It also bounds `SavedNetwork.bouncerSeen`. + */ export const BOUNCER_NETWORKS_MAX = 20; /** @@ -95,8 +99,9 @@ export function parseNetwork(msg: IrcMessage): BouncerNetwork | undefined { /** * Which of the listed networks to add: each one not offered before - * (`seen`) and not already a saved network (`have`), at most - * {@link BOUNCER_NETWORKS_MAX}. Pure, so the manager's rule is tested here. + * (`seen`) and not already a saved network (`have`), while the entry has + * offered fewer than {@link BOUNCER_NETWORKS_MAX} in all. Pure, so the + * manager's rule is tested here. */ export function networksToAdd( listed: BouncerNetwork[], @@ -105,18 +110,19 @@ export function networksToAdd( ): BouncerNetwork[] { const fresh: BouncerNetwork[] = []; const ids = new Set(); + const room = BOUNCER_NETWORKS_MAX - seen.size; for (const net of listed) { + if (fresh.length >= room) { + break; + } + if (seen.has(net.id) || ids.has(net.id) || have(net)) { continue; } ids.add(net.id); fresh.push(net); - - if (fresh.length >= BOUNCER_NETWORKS_MAX) { - break; - } } return fresh; diff --git a/docs/resources/irc-layer.md b/docs/resources/irc-layer.md index e18be03d4..511985379 100644 --- a/docs/resources/irc-layer.md +++ b/docs/resources/irc-layer.md @@ -44,7 +44,7 @@ Neither needs a profile; each is told by what it offers. `IrcClient.behindBounce - **The bouncer owns the channel list.** Behind a bouncer the saved channels are never JOINed (`autojoin`): the bouncer JOINs us to what it holds, and a channel parted from another client stays parted here. A new channel is joined by hand once, and the bouncer keeps it from then on. That is gamja's rule too. Tests: `test/irc/bouncer.ts`. - **ZNC's buffer replay** (`BATCH +x znc.in/playback `, on every attach) goes the catch-up's way (`history.ts` `zncPlaybackBatch` → `deliverCatchup`): appended, unread but silent, a closed query reopened only by what was said since. ZNC has no msgids, so a line is known by its time, sender, command, target and text (`noteZncLine` counts the live ones per conversation, at most 2000). A buffered line is kept only when the batch holds more copies of it than this connection already saw, so a buffer ZNC keeps (`AutoClearChanBuffer` off) is not shown twice, and two equal lines in the same second both stay. -- **soju's networks** (`bouncer.ts`, `soju.im/bouncer-networks` and `-notify`): a login without a network is the control connection (the cap, no `BOUNCER_NETID`, and no `bouncerNetId` of its own). It JOINs nothing. Without `-notify` it asks `BOUNCER LISTNETWORKS` through the background queue; with it, soju sends the list by itself. Networks are added only when the control connection logged in with SASL, since soju refuses `BIND` to a PASS login. The manager (`addBouncerNetworks`, choosing with the pure `networksToAdd`) adds each network the entry has not offered before (`SavedNetwork.bouncerSeen`, so one the user removes is not added back), that is not saved already (bound to it, or added by hand as `account/name`), at most 20 from one list, as a saved network of its own: the same server and login, `join` empty, `bouncerNetId` set. Ids must match `[A-Za-z0-9._~-]{1,64}` (`isNetworkId`) or they are dropped. A bound entry sends `BOUNCER BIND ` in the `CAP END` flush (`finalLines`), and so does the service worker's own connection for a notification's reply or mark read (`swIrcOpen`, the stash's `bouncerNetId`; it gives up if the cap is not offered). The control connection does not subscribe to push; the bound ones do. `findMatching` skips bound entries, so the connect form finds the bouncer's own. +- **soju's networks** (`bouncer.ts`, `soju.im/bouncer-networks` and `-notify`): a login without a network is the control connection (the cap, no `BOUNCER_NETID`, and no `bouncerNetId` of its own). It JOINs nothing. Without `-notify` it asks `BOUNCER LISTNETWORKS` through the background queue; with it, soju sends the list by itself. Networks are added only when the control connection logged in with SASL, since soju refuses `BIND` to a PASS login. The manager (`addBouncerNetworks`, choosing with the pure `networksToAdd`) adds each network the entry has not offered before (`SavedNetwork.bouncerSeen`, so one the user removes is not added back), that is not saved already (bound to it, or added by hand as `account/name`), at most 20 per bouncer entry in all (lists and `-notify` lines together), as a saved network of its own: the same server and login, `join` empty, `bouncerNetId` set. Ids must match `[A-Za-z0-9._~-]{1,64}` (`isNetworkId`) or they are dropped. A bound entry sends `BOUNCER BIND ` in the `CAP END` flush (`finalLines`), and so does the service worker's own connection for a notification's reply or mark read (`swIrcOpen`, the stash's `bouncerNetId`; it gives up if the cap is not offered). The control connection does not subscribe to push; the bound ones do. `findMatching` skips bound entries, so the connect form finds the bouncer's own. - Search on soju: `docs/resources/client-features.md` § Searching history. ## A login can give its dial back diff --git a/test/irc/bouncer.ts b/test/irc/bouncer.ts index 498f2c166..5d2420e77 100644 --- a/test/irc/bouncer.ts +++ b/test/irc/bouncer.ts @@ -321,6 +321,17 @@ describe("Bouncers: which listed networks to add (networksToAdd)", function () { expect(networksToAdd(many, new Set(), () => false)).to.have.length(BOUNCER_NETWORKS_MAX); }); + it("counts what the entry offered before, so -notify lines one by one stop at the cap too", function () { + const seen = new Set(); + + for (let i = 0; i < BOUNCER_NETWORKS_MAX + 5; i++) { + networksToAdd([net(`n${i}`)], seen, () => false).forEach((n) => seen.add(n.id)); + } + + expect(seen.size).to.equal(BOUNCER_NETWORKS_MAX); + expect(networksToAdd([net("late")], seen, () => false)).to.deep.equal([]); + }); + it("takes only ids that are safe to send back in BOUNCER BIND", function () { expect(isNetworkId("42")).to.equal(true); expect(isNetworkId("a.b_c~d-e")).to.equal(true);