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..4849d40e3 --- /dev/null +++ b/client/js/irc/bouncer.ts @@ -0,0 +1,179 @@ +/** + * 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_NOTIFY_CAP = "soju.im/bouncer-networks-notify"; +export const BOUNCER_NETWORKS_BATCH = "soju.im/bouncer-networks"; + +/** + * 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; + +/** + * 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; +} + +/** 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") + ); +} + +/** `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 isNetworkId(id) && client.caps.available.has(BOUNCER_NETWORKS_CAP) + ? `BOUNCER BIND ${id}` + : undefined; +} + +/** + * 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) || client.caps.hasCapability(BOUNCER_NOTIFY_CAP)) { + 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, 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" || !isNetworkId(id) || !attrs || attrs === "*") { + return undefined; + } + + 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`), 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[], + seen: ReadonlySet, + have: (net: BouncerNetwork) => boolean +): 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); + } + + 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) { + 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) => { + 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 if (line.command !== "BOUNCER") { + 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/client.ts b/client/js/irc/client.ts index c28ce3589..3cc800b72 100644 --- a/client/js/irc/client.ts +++ b/client/js/irc/client.ts @@ -29,7 +29,14 @@ 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 { + 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"; import {QueryLog} from "./querylog"; @@ -81,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"; @@ -294,6 +301,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. */ @@ -505,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; @@ -1430,6 +1445,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; }; @@ -1736,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); @@ -1780,6 +1803,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(), @@ -1805,6 +1831,19 @@ export class IrcClient { } } + /** + * 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 { + return ( + this.behindZnc || + this.isupport.tokens.has("BOUNCER_NETID") || + this.caps.available.has(BOUNCER_NETWORKS_CAP) + ); + } + /** * Registration's restoration is in, or was never coming: opt into * session persistence if that still does something, then JOIN what the @@ -1836,16 +1875,26 @@ 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 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) => + 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 +1918,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 ?? ""}`) ); @@ -1997,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; @@ -2005,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); @@ -2880,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/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..21c389338 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,110 @@ 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). 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] ?? ""; + + 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); + } +}; + /** * 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..06247684f 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 {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"; 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,85 @@ 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`). 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; + /** Every attribute but `in`. */ + attrs: SearchAttrs; + /** Channels not asked about yet. */ + queue: string[]; + hits: SearchHit[]; +} +/** + * 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 { + if (takesMsgidRefs(client)) { + return; // asked by msgid: nothing to look up + } + + 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 formatRef(client, {msgid, time: new Date(0)}); + } + + 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 ? formatRef(client, {time}) : undefined; +} const pendingByClient = new WeakMap(); /** Reply parents asked for on this connection, `:`: each is worth one command. */ @@ -173,6 +256,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,13 +300,80 @@ function lineFor(input: Parameters[0]): string | undefined { } } +/** 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, + status: SearchStatus, + error: string | undefined, + hits: SearchHit[] +): void { + const base = {network: client.uuid, requestId: group.requestId}; + + if (status !== "ok") { + client.dispatchBus("search:results", {...base, status, error, hits: []}); + return; + } + + group.hits.push(...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.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. None joined (soju's control + * connection): searched on the device instead. + */ +function fanOut(client: IrcClient, requestId: number, attrs: SearchAttrs): void { + const queue = client.channels + .filter((chan) => chan.type === ChanType.CHANNEL && chan.state === ChanState.JOINED) + .map((chan) => chan.name); + + if (queue.length === 0) { + client.dispatchBus("search:results", { + network: client.uuid, + requestId, + status: "unsupported", + hits: [], + }); + return; + } + + const {in: _all, ...rest} = attrs; + askNext(client, {requestId, attrs: rest, queue, hits: []}); +} + /** Send a request, which always gets its one answer, whatever happens. */ function sendRequest( client: IrcClient, request: Asked, available: boolean, command: string, - params: string[] + params: string[], + charge = true ): void { if (!client.isWelcomed) { answer(client, request, "offline"); @@ -238,7 +393,7 @@ function sendRequest( return; } - if (!client.send(line)) { + if (!client.send(line, charge)) { answer(client, request, "offline"); return; } @@ -251,16 +406,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 +443,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 +482,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 +621,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 +637,7 @@ export function searchContextBatch(client: IrcClient, batch: OpenBatch): boolean return false; } + noteTimes(client, batch.messages); const changes: LineChange[] = []; settle( client, @@ -494,6 +667,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]); } @@ -529,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 6bc07a094..b4ca966fa 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 {networksToAdd, 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,63 @@ 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 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 []; + } + + // 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) { + 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, + }); + } + + return fresh.map((net) => net.name); +} + /** * 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..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"; @@ -63,6 +64,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 +277,14 @@ export function normalize(raw: Record): SavedNetwork | undefine net.lastUsed = raw.lastUsed; } + if (isNetworkId(raw.bouncerNetId)) { + net.bouncerNetId = raw.bouncerNetId; + } + + if (Array.isArray(raw.bouncerSeen)) { + net.bouncerSeen = raw.bouncerSeen.filter(isNetworkId); + } + const cursor = asCursor(raw.cursor); if (cursor) { @@ -397,6 +412,7 @@ export function toConnectOptions(net: SavedNetwork): ConnectOptions { sasl: net.sasl, saslAccount: net.saslAccount, saslPassword: net.saslPassword, + ...(net.bouncerNetId ? {bouncerNetId: net.bouncerNetId} : {}), }; } @@ -566,8 +582,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/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/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/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 bff9bc475..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). 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 a85913213..511985379 100644 --- a/docs/resources/irc-layer.md +++ b/docs/resources/irc-layer.md @@ -38,6 +38,15 @@ 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`. +## 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 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 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 ## `/who` diff --git a/test/irc/bouncer.ts b/test/irc/bouncer.ts new file mode 100644 index 000000000..5d2420e77 --- /dev/null +++ b/test/irc/bouncer.ts @@ -0,0 +1,344 @@ +import {expect} from "chai"; +import sinon from "sinon"; +import {RESTORE_MAX_WAIT_MS} from "../../client/js/irc/persistence"; +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"; + +/** 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("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); + 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("never JOINs the saved channels behind a bouncer, even one that holds none", function () { + const h = setup({join: "#seance,#new"}); + register(h, ZNC_CAPS); + clock.tick(RESTORE_MAX_WAIT_MS); + + expect(joins(h)).to.deep.equal([]); + }); + + 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 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[][] = []; + const h = setup({ + join: "#seance", + onBouncerNetworks(networks) { + offered.push(networks); + return networks.map((net) => net.name); + }, + }); + 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); + 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", + "@batch=n :irc.test BOUNCER NETWORK bad:id name=Evil", + ":irc.test BATCH -n" + ); + + 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("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) { + offered.push(...networks); + return []; + }, + }); + 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"}]); + }); + + 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 () { + 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: () => []}); + 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("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); + 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/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..33ec5f882 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,94 @@ 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 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; + + 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]); + + // 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); + + 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); + 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("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"}); + 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`, [