Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions client/js/i18n/call-sites.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
// a key assembled at runtime translates unpredictably, even when it
// happens to resolve.
export const STATIC_CALL_SITES = new Set<string>([
"bouncer.networksAdded",
"browse.all",
"browse.aria",
"browse.ask",
Expand Down
179 changes: 179 additions & 0 deletions client/js/irc/bouncer.ts
Original file line number Diff line number Diff line change
@@ -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 <id>`
* 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 <id> <attributes>` 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 <id>`, 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 <id> <attributes>`; 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<string>,
have: (net: BouncerNetwork) => boolean
): BouncerNetwork[] {
const fresh: BouncerNetwork[] = [];
const ids = new Set<string>();
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]);
}
}
13 changes: 12 additions & 1 deletion client/js/irc/caps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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",
],
};

Expand All @@ -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=<url-safe base64>`, nefarious2's), undefined when the value does
Expand Down
Loading
Loading