refactor: extract the IRCv3 layer into its own module behind client.ircv3
This commit is contained in:
parent
85251c537d
commit
2ba5d884ee
7 changed files with 321 additions and 143 deletions
|
|
@ -511,7 +511,7 @@ function CapabilitiesSection() {
|
|||
const client = useActiveChat((s) => s.client);
|
||||
const status = useActiveChat((s) => s.status);
|
||||
const connected = status === 'registered' && !!client;
|
||||
const caps = client ? client.listCaps() : CAP_KEYS.map((name) => ({ name, available: false, enabled: false }));
|
||||
const caps = client ? client.ircv3.listCaps() : CAP_KEYS.map((name) => ({ name, available: false, enabled: false }));
|
||||
const active = caps.filter((c) => c.enabled).length;
|
||||
|
||||
return (
|
||||
|
|
@ -614,7 +614,7 @@ function LoginTab() {
|
|||
// to enable it AND the server to advertise draft/account-registration; otherwise
|
||||
// REGISTER would just come back as an unknown command (e.g. a leaner ircd).
|
||||
const canRegister = getConfig().features.register
|
||||
&& status === 'registered' && !!client?.hasCap('draft/account-registration');
|
||||
&& status === 'registered' && !!client?.ircv3.hasCap('draft/account-registration');
|
||||
return (
|
||||
<>
|
||||
{canRegister && (
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// Tchatou IRC client — IRCv3-aware connection over WebSocket to InspIRCd.
|
||||
import { parseLine } from './parser';
|
||||
import { casefold } from './casemap';
|
||||
import { WANTED_CAPS } from './caps';
|
||||
import { Ircv3 } from './ircv3';
|
||||
import { CTCP_REPLIES } from './ctcp';
|
||||
import type { ConnectOptions, IrcMessage } from './types';
|
||||
|
||||
|
|
@ -23,8 +23,15 @@ export class IrcClient {
|
|||
private listeners: Record<string, Listener[]> = {};
|
||||
private opts!: ConnectOptions;
|
||||
|
||||
private availableCaps = new Set<string>();
|
||||
private ackedCaps = new Set<string>();
|
||||
// The IRCv3 capability layer: negotiation, the negotiated cap set, and every
|
||||
// cap-gated extended-feature command. The rest of the app reaches these via
|
||||
// `client.ircv3`. It calls back through this tiny transport so the send queues
|
||||
// below stay private.
|
||||
readonly ircv3 = new Ircv3({
|
||||
send: (l) => this.send(l),
|
||||
lowSend: (l) => this.lowSend(l),
|
||||
isupport: () => this.isupport,
|
||||
});
|
||||
private capEnded = false;
|
||||
|
||||
nick = '';
|
||||
|
|
@ -34,7 +41,6 @@ export class IrcClient {
|
|||
prefixModeToChar: Record<string, string> = {}; // mode letter -> prefix symbol (o->@, v->+, …)
|
||||
chantypes = '#&'; // valid channel-name prefixes (ISUPPORT CHANTYPES)
|
||||
casemapping = 'rfc1459'; // ISUPPORT CASEMAPPING — how names are compared/folded
|
||||
multilineMaxLines = 20; // draft/multiline cap limit (max-lines)
|
||||
vapid = ''; // ISUPPORT VAPID — server's Web Push public key (base64url P-256)
|
||||
network = ''; // ISUPPORT NETWORK (network name, for the UI)
|
||||
nicklen = 30; // ISUPPORT NICKLEN
|
||||
|
|
@ -183,7 +189,7 @@ export class IrcClient {
|
|||
this.ws = undefined;
|
||||
this.clearConnectTimer();
|
||||
// Reset per-connection state so a reconnect starts clean.
|
||||
this.availableCaps.clear(); this.ackedCaps.clear(); this.capEnded = false;
|
||||
this.ircv3.reset(); this.capEnded = false;
|
||||
this.registered = false; this.rxBuf = ''; this.sendQueue = []; this.tokens = this.burst;
|
||||
this.lowQueue = []; if (this.lowTimer) { clearTimeout(this.lowTimer); this.lowTimer = null; }
|
||||
this.emit('status', 'connecting');
|
||||
|
|
@ -432,60 +438,18 @@ export class IrcClient {
|
|||
}
|
||||
|
||||
private handleCap(msg: IrcMessage): void {
|
||||
// The subcommand is case-insensitive and InspIRCd echoes back the exact case
|
||||
// the client sent (`cap ls` → `ls`), so normalise before matching.
|
||||
const sub = (msg.params[1] || '').toUpperCase();
|
||||
// A CAP LS / CAP LIST arriving AFTER registration is a manual query (the user
|
||||
// typed `cap ls` in the status window), not negotiation — forward it so the
|
||||
// handler can print it, and DON'T re-run REQ/END, which would needlessly churn
|
||||
// the negotiated cap set. cap-notify NEW/DEL and ACK/NAK still flow below.
|
||||
if (this.registered && (sub === 'LS' || sub === 'LIST')) {
|
||||
this.emit('message', msg);
|
||||
return;
|
||||
}
|
||||
if (sub === 'LS' || sub === 'NEW') {
|
||||
// CAP * LS * :cap1 cap2 (the '*' before ':' means more lines follow)
|
||||
const more = msg.params[2] === '*';
|
||||
const capStr = msg.params[more ? 3 : 2] ?? '';
|
||||
for (const tok of capStr.split(' ').filter(Boolean)) {
|
||||
const name = tok.split('=')[0];
|
||||
this.availableCaps.add(name);
|
||||
if (name === 'draft/multiline') {
|
||||
const m = tok.match(/max-lines=(\d+)/);
|
||||
if (m) this.multilineMaxLines = parseInt(m[1], 10) || this.multilineMaxLines;
|
||||
}
|
||||
}
|
||||
if (!more) {
|
||||
const req = WANTED_CAPS.filter((c) => this.availableCaps.has(c));
|
||||
// Work around servers that drop a capability from CAP LS at a line-split
|
||||
// boundary (InspIRCd m_cap loses the overflowing cap). message-tags is the
|
||||
// usual victim, yet it's mandatory for msgid/labels/reactions and is implied
|
||||
// by server-time/echo-message/batch/etc. — so request it explicitly when any
|
||||
// tag-dependent cap IS advertised. The server still ACKs it.
|
||||
if (!req.includes('message-tags') &&
|
||||
['server-time', 'echo-message', 'batch', 'account-tag', 'labeled-response', 'message-redaction'].some((c) => this.availableCaps.has(c))) {
|
||||
req.push('message-tags');
|
||||
}
|
||||
if (req.length) this.send(`CAP REQ :${req.join(' ')}`);
|
||||
else this.endCap();
|
||||
}
|
||||
} else if (sub === 'ACK') {
|
||||
for (const c of (msg.params[2] ?? '').split(' ').filter(Boolean)) {
|
||||
this.ackedCaps.add(c);
|
||||
}
|
||||
if (this.ackedCaps.has('sasl') && this.opts.password) {
|
||||
this.send('AUTHENTICATE PLAIN');
|
||||
} else {
|
||||
this.endCap();
|
||||
}
|
||||
} else if (sub === 'DEL') {
|
||||
// cap-notify: server withdrew capabilities — forget them.
|
||||
for (const c of (msg.params[2] ?? '').split(' ').filter(Boolean)) {
|
||||
this.availableCaps.delete(c);
|
||||
this.ackedCaps.delete(c);
|
||||
}
|
||||
} else if (sub === 'NAK') {
|
||||
this.endCap();
|
||||
// Negotiation logic + the cap set live in the IRCv3 layer; it returns the
|
||||
// action to take so the registration/SASL ordering stays here.
|
||||
const action = this.ircv3.handleCap(msg, {
|
||||
registered: this.registered,
|
||||
hasPassword: !!this.opts.password,
|
||||
});
|
||||
switch (action.do) {
|
||||
case 'req': this.send(`CAP REQ :${action.caps.join(' ')}`); break;
|
||||
case 'sasl': this.send('AUTHENTICATE PLAIN'); break;
|
||||
case 'end': this.endCap(); break;
|
||||
case 'forward': this.emit('message', msg); break; // post-registration manual `cap ls`
|
||||
// 'none': nothing to do
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -522,7 +486,7 @@ export class IrcClient {
|
|||
this.capEnded = true;
|
||||
// draft/pre-away: if we're (re)connecting while marked away, set it DURING
|
||||
// registration so we're away from the instant we're connected — before CAP END.
|
||||
if (this.awayMessage && this.ackedCaps.has('draft/pre-away'))
|
||||
if (this.awayMessage && this.ircv3.hasCap('draft/pre-away'))
|
||||
this.send(`AWAY :${this.awayMessage}`);
|
||||
this.send('CAP END');
|
||||
}
|
||||
|
|
@ -551,19 +515,6 @@ export class IrcClient {
|
|||
}
|
||||
}
|
||||
|
||||
hasCap(name: string): boolean { return this.ackedCaps.has(name); }
|
||||
|
||||
// Snapshot of every capability Orbit wants, with its negotiated state — for the
|
||||
// Settings "IRCv3 capabilities" panel. `enabled` = the server ACK'd it and we're
|
||||
// using it; `available` = the server advertised it in CAP LS.
|
||||
listCaps(): { name: string; available: boolean; enabled: boolean }[] {
|
||||
return WANTED_CAPS.map((name) => ({
|
||||
name,
|
||||
available: this.availableCaps.has(name),
|
||||
enabled: this.ackedCaps.has(name),
|
||||
}));
|
||||
}
|
||||
|
||||
// ---- line-length handling (the 512-byte IRC line limit) -----------------
|
||||
private enc = new TextEncoder();
|
||||
private byteLen(s: string): number { return this.enc.encode(s).length; }
|
||||
|
|
@ -606,9 +557,9 @@ export class IrcClient {
|
|||
// marking this DM as relating to that channel (IRCv3 client-tags/channel-context).
|
||||
privmsg(target: string, text: string, context?: string): void {
|
||||
const ctx = context ? `+draft/channel-context=${context}` : '';
|
||||
if (this.useMultiline && /\r?\n/.test(text) && this.hasCap('draft/multiline') && this.hasCap('batch')) {
|
||||
if (this.useMultiline && /\r?\n/.test(text) && this.ircv3.hasCap('draft/multiline') && this.ircv3.hasCap('batch')) {
|
||||
const lines = text.split(/\r?\n/);
|
||||
if (lines.length <= this.multilineMaxLines) { this.multilineMsg(target, lines, ctx); return; }
|
||||
if (lines.length <= this.ircv3.multilineMaxLines) { this.multilineMsg(target, lines, ctx); return; }
|
||||
}
|
||||
const pre = ctx ? `@${ctx} ` : '';
|
||||
for (const part of this.splitForLine('PRIVMSG', target, text)) this.send(`${pre}PRIVMSG ${target} :${part}`);
|
||||
|
|
@ -656,61 +607,18 @@ export class IrcClient {
|
|||
setChannelMode(channel: string, mode: string, add: boolean): void {
|
||||
this.send(`MODE ${channel} ${add ? '+' : '-'}${mode}`);
|
||||
}
|
||||
// draft/account-registration: create + confirm a Swaygo/Tchatou account.
|
||||
register(account: string, email: string, password: string): void {
|
||||
this.send(`REGISTER ${account} ${email} :${password}`);
|
||||
}
|
||||
verify(account: string, code: string): void { this.send(`VERIFY ${account} ${code}`); }
|
||||
// draft/webpush: register/remove a Web Push subscription. <keys> is the
|
||||
// message-tag-format "p256dh=<b64url>;auth=<b64url>".
|
||||
webpushRegister(endpoint: string, keys: string): void { this.send(`WEBPUSH REGISTER ${endpoint} ${keys}`); }
|
||||
webpushUnregister(endpoint: string): void { this.send(`WEBPUSH UNREGISTER ${endpoint}`); }
|
||||
resend(account: string): void { this.send(`RESEND ${account}`); }
|
||||
list(): void { this.send('LIST'); }
|
||||
whois(nick: string): void { this.send(`WHOIS ${nick} ${nick}`); }
|
||||
whowas(nick: string): void { this.send(`WHOWAS ${nick}`); }
|
||||
invite(nick: string, channel: string): void { this.send(`INVITE ${nick} ${channel}`); }
|
||||
names(channel: string): void { this.send(`NAMES ${channel}`); }
|
||||
// draft/chathistory — load up to `limit` messages older than the given ISO time.
|
||||
chathistoryBefore(target: string, beforeIso: string, limit: number): void {
|
||||
this.send(`CHATHISTORY BEFORE ${target} timestamp=${beforeIso} ${limit}`);
|
||||
}
|
||||
// draft/chathistory — most recent `limit` items (messages + events w/ event-playback).
|
||||
// Low priority: prefetched on join for every channel, must not block typing.
|
||||
chathistoryLatest(target: string, limit: number): void {
|
||||
this.lowSend(`CHATHISTORY LATEST ${target} * ${limit}`);
|
||||
}
|
||||
// Remember the away message so draft/pre-away can re-apply it during the next
|
||||
// (re)connect's registration. '' = back (clears it).
|
||||
awayMessage = '';
|
||||
setAway(reason: string): void { this.awayMessage = reason; this.send(reason ? `AWAY :${reason}` : 'AWAY'); }
|
||||
// MONITOR (online-notify): + add, - remove, L list, C clear.
|
||||
monitor(op: '+' | '-' | 'L' | 'C', targets = ''): void {
|
||||
if (this.isupport['MONITOR'] === undefined) return; // server doesn't advertise MONITOR → don't send it
|
||||
this.send(`MONITOR ${op}${targets ? ` ${targets}` : ''}`);
|
||||
}
|
||||
// Query a channel's ban/except/invex list (replies via 367/348/346).
|
||||
modeList(channel: string, mode: 'b' | 'e' | 'I'): void { this.send(`MODE ${channel} ${mode}`); }
|
||||
// WHOX: request token(t)/channel(c)/nick(n)/flags(f)/account(a) so we can map
|
||||
// members → their services account (for real avatars). Token 152 echoes back.
|
||||
who(target: string): void { this.send(`WHO ${target} %tcnfa,152`); }
|
||||
react(target: string, msgid: string, emoji: string): void {
|
||||
// Reaction rides on TAGMSG (message-tags). Without the cap the server rejects
|
||||
// TAGMSG as an unknown command, so don't send it.
|
||||
if (!this.hasCap('message-tags')) return;
|
||||
this.send(`@+draft/reply=${msgid};+draft/react=${emoji} TAGMSG ${target}`);
|
||||
}
|
||||
redact(target: string, msgid: string, reason = ''): void {
|
||||
if (!this.hasCap('draft/message-redaction')) return;
|
||||
this.send(`REDACT ${target} ${msgid}${reason ? ` :${reason}` : ''}`);
|
||||
}
|
||||
markRead(target: string, ts: string): void {
|
||||
if (!this.hasCap('draft/read-marker')) return; // MARKREAD is unknown without the cap
|
||||
this.send(`MARKREAD ${target} timestamp=${ts}`);
|
||||
}
|
||||
sendTyping(target: string, state: 'active' | 'done'): void {
|
||||
// +typing rides on TAGMSG (message-tags); skip entirely if the server lacks it.
|
||||
if (!this.hasCap('message-tags')) return;
|
||||
this.send(`@+typing=${state} TAGMSG ${target}`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
103
src/core/irc/ircv3.test.ts
Normal file
103
src/core/irc/ircv3.test.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { Ircv3 } from './ircv3';
|
||||
import { parseLine } from './parser';
|
||||
|
||||
// A fake transport that records what the IRCv3 layer would put on the wire.
|
||||
function make(isup: Record<string, string> = {}) {
|
||||
const sent: string[] = [];
|
||||
const low: string[] = [];
|
||||
const ircv3 = new Ircv3({
|
||||
send: (l) => sent.push(l),
|
||||
lowSend: (l) => low.push(l),
|
||||
isupport: () => isup,
|
||||
});
|
||||
const cap = (line: string, ctx = { registered: false, hasPassword: false }) =>
|
||||
ircv3.handleCap(parseLine(line), ctx);
|
||||
return { ircv3, sent, low, cap };
|
||||
}
|
||||
|
||||
describe('Ircv3 capability negotiation', () => {
|
||||
it('requests the intersection of wanted and advertised caps', () => {
|
||||
const { cap } = make();
|
||||
const a = cap('CAP * LS :message-tags server-time sasl bogus-cap');
|
||||
expect(a.do).toBe('req');
|
||||
if (a.do === 'req') {
|
||||
expect(a.caps).toEqual(expect.arrayContaining(['message-tags', 'server-time', 'sasl']));
|
||||
expect(a.caps).not.toContain('bogus-cap');
|
||||
}
|
||||
});
|
||||
|
||||
it('ends negotiation when nothing wanted is advertised', () => {
|
||||
expect(make().cap('CAP * LS :bogus-cap another-bogus').do).toBe('end');
|
||||
});
|
||||
|
||||
it('buffers a multi-line CAP LS and only requests on the final line', () => {
|
||||
const { cap } = make();
|
||||
expect(cap('CAP * LS * :message-tags').do).toBe('none'); // trailing '*' → more to come
|
||||
expect(cap('CAP * LS :server-time').do).toBe('req');
|
||||
});
|
||||
|
||||
it('starts SASL only when acked and a password is present', () => {
|
||||
expect(make().cap('CAP * ACK :sasl', { registered: false, hasPassword: true }).do).toBe('sasl');
|
||||
expect(make().cap('CAP * ACK :sasl', { registered: false, hasPassword: false }).do).toBe('end');
|
||||
});
|
||||
|
||||
it('forwards a post-registration manual CAP LS instead of renegotiating', () => {
|
||||
expect(make().cap('CAP * LS :message-tags', { registered: true, hasPassword: false }).do).toBe('forward');
|
||||
});
|
||||
|
||||
it('NAK ends negotiation', () => {
|
||||
expect(make().cap('CAP * NAK :sasl').do).toBe('end');
|
||||
});
|
||||
|
||||
it('tracks acked caps; DEL and reset() forget them', () => {
|
||||
const { ircv3, cap } = make();
|
||||
cap('CAP * ACK :message-tags draft/read-marker');
|
||||
expect(ircv3.hasCap('message-tags')).toBe(true);
|
||||
expect(ircv3.hasCap('draft/read-marker')).toBe(true);
|
||||
cap('CAP * DEL :draft/read-marker');
|
||||
expect(ircv3.hasCap('draft/read-marker')).toBe(false);
|
||||
ircv3.reset();
|
||||
expect(ircv3.hasCap('message-tags')).toBe(false);
|
||||
});
|
||||
|
||||
it('learns draft/multiline max-lines from CAP LS', () => {
|
||||
const { ircv3, cap } = make();
|
||||
cap('CAP * LS :draft/multiline=max-lines=42');
|
||||
expect(ircv3.multilineMaxLines).toBe(42);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Ircv3 cap-gated commands', () => {
|
||||
it('skips MARKREAD without the cap, sends it with', () => {
|
||||
const { ircv3, sent, cap } = make();
|
||||
ircv3.markRead('#x', '2020-01-01T00:00:00Z');
|
||||
expect(sent).toHaveLength(0);
|
||||
cap('CAP * ACK :draft/read-marker');
|
||||
ircv3.markRead('#x', '2020-01-01T00:00:00Z');
|
||||
expect(sent).toEqual(['MARKREAD #x timestamp=2020-01-01T00:00:00Z']);
|
||||
});
|
||||
|
||||
it('skips typing and reactions without message-tags', () => {
|
||||
const { ircv3, sent } = make();
|
||||
ircv3.sendTyping('#x', 'active');
|
||||
ircv3.react('#x', 'abc', '\u{1F600}');
|
||||
expect(sent).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('only sends MONITOR when ISUPPORT advertises it', () => {
|
||||
const off = make();
|
||||
off.ircv3.monitor('+', 'bob');
|
||||
expect(off.sent).toHaveLength(0);
|
||||
const on = make({ MONITOR: '100' });
|
||||
on.ircv3.monitor('+', 'bob');
|
||||
expect(on.sent).toEqual(['MONITOR + bob']); // per spec: "MONITOR + <nicklist>"
|
||||
});
|
||||
|
||||
it('routes chathistory prefetch through the low-priority queue', () => {
|
||||
const { ircv3, sent, low } = make();
|
||||
ircv3.chathistoryLatest('#x', 50);
|
||||
expect(sent).toHaveLength(0);
|
||||
expect(low).toEqual(['CHATHISTORY LATEST #x * 50']);
|
||||
});
|
||||
});
|
||||
167
src/core/irc/ircv3.ts
Normal file
167
src/core/irc/ircv3.ts
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
// IRCv3 capability layer for the IRC client.
|
||||
//
|
||||
// Split out of client.ts so the transport (WebSocket, reconnect, send queues) and
|
||||
// the core IRC verbs stay separate from the IRCv3 feature surface. This owns:
|
||||
// * capability negotiation (CAP LS/REQ/ACK/NAK/NEW/DEL) + the negotiated cap set,
|
||||
// * the cap-gated extended-feature commands (reactions, read markers, typing,
|
||||
// redaction, chathistory, account registration, web push, MONITOR).
|
||||
//
|
||||
// The client holds one instance and exposes it as `client.ircv3`; the rest of the
|
||||
// app reaches every IRCv3 feature through that API rather than the raw socket.
|
||||
import { WANTED_CAPS } from './caps';
|
||||
import type { IrcMessage } from './types';
|
||||
|
||||
// The minimal slice of the client the IRCv3 layer needs. Kept as an interface
|
||||
// (not a client reference) so the client's send queues stay private and this
|
||||
// module can be unit-tested against a fake transport.
|
||||
export interface Ircv3Transport {
|
||||
/** Throttled send (token bucket). */
|
||||
send(line: string): void;
|
||||
/** Low-priority background queue — history prefetch, must yield to live traffic. */
|
||||
lowSend(line: string): void;
|
||||
/** Live view of the parsed ISUPPORT (005) tokens. */
|
||||
isupport(): Record<string, string>;
|
||||
}
|
||||
|
||||
// What the client should do in response to a CAP line. The negotiation *logic*
|
||||
// lives here (pure + testable); the client performs the actual I/O so the
|
||||
// registration/SASL ordering stays in one place.
|
||||
export type CapAction =
|
||||
| { do: 'req'; caps: string[] } // send CAP REQ for these caps
|
||||
| { do: 'sasl' } // begin SASL (AUTHENTICATE PLAIN)
|
||||
| { do: 'end' } // finish negotiation (CAP END)
|
||||
| { do: 'forward' } // a post-registration manual `cap ls` → let the UI print it
|
||||
| { do: 'none' }; // nothing to do
|
||||
|
||||
export class Ircv3 {
|
||||
private available = new Set<string>();
|
||||
private acked = new Set<string>();
|
||||
/** draft/multiline max-lines, learned from CAP LS (`draft/multiline=max-lines=N`). */
|
||||
multilineMaxLines = 20;
|
||||
private readonly tx: Ircv3Transport;
|
||||
|
||||
constructor(tx: Ircv3Transport) { this.tx = tx; }
|
||||
|
||||
/** Forget every negotiated cap — called when a socket (re)opens. */
|
||||
reset(): void {
|
||||
this.available.clear();
|
||||
this.acked.clear();
|
||||
this.multilineMaxLines = 20;
|
||||
}
|
||||
|
||||
/** True once the server ACK'd `name` and we're using it. */
|
||||
hasCap(name: string): boolean { return this.acked.has(name); }
|
||||
/** True if the server advertised `name` in CAP LS (whether or not we requested it). */
|
||||
isAvailable(name: string): boolean { return this.available.has(name); }
|
||||
|
||||
// Snapshot for the Settings "IRCv3 capabilities" panel. `enabled` = ACK'd and in
|
||||
// use; `available` = advertised in CAP LS.
|
||||
listCaps(): { name: string; available: boolean; enabled: boolean }[] {
|
||||
return WANTED_CAPS.map((name) => ({
|
||||
name,
|
||||
available: this.available.has(name),
|
||||
enabled: this.acked.has(name),
|
||||
}));
|
||||
}
|
||||
|
||||
// Capability negotiation. Mutates the cap sets and returns the action the client
|
||||
// should perform next.
|
||||
handleCap(msg: IrcMessage, ctx: { registered: boolean; hasPassword: boolean }): CapAction {
|
||||
// The subcommand is case-insensitive and InspIRCd echoes back the exact case
|
||||
// the client sent (`cap ls` → `ls`), so normalise before matching.
|
||||
const sub = (msg.params[1] || '').toUpperCase();
|
||||
// A CAP LS / LIST arriving AFTER registration is a manual query the user typed
|
||||
// in the status window — forward it to the UI; don't re-run REQ/END.
|
||||
if (ctx.registered && (sub === 'LS' || sub === 'LIST')) return { do: 'forward' };
|
||||
|
||||
if (sub === 'LS' || sub === 'NEW') {
|
||||
// CAP * LS * :cap1 cap2 (the '*' before the trailing arg means more follow)
|
||||
const more = msg.params[2] === '*';
|
||||
const capStr = msg.params[more ? 3 : 2] ?? '';
|
||||
for (const tok of capStr.split(' ').filter(Boolean)) {
|
||||
const name = tok.split('=')[0];
|
||||
this.available.add(name);
|
||||
if (name === 'draft/multiline') {
|
||||
const m = tok.match(/max-lines=(\d+)/);
|
||||
if (m) this.multilineMaxLines = parseInt(m[1], 10) || this.multilineMaxLines;
|
||||
}
|
||||
}
|
||||
if (more) return { do: 'none' };
|
||||
const req = WANTED_CAPS.filter((c) => this.available.has(c));
|
||||
// Work around servers that drop a capability from CAP LS at a line-split
|
||||
// boundary (InspIRCd m_cap loses the overflowing cap). message-tags is the
|
||||
// usual victim, yet it's mandatory for msgid/labels/reactions and is implied
|
||||
// by server-time/echo-message/batch/etc. — so request it explicitly when any
|
||||
// tag-dependent cap IS advertised. The server still ACKs it.
|
||||
if (!req.includes('message-tags') &&
|
||||
['server-time', 'echo-message', 'batch', 'account-tag', 'labeled-response', 'message-redaction'].some((c) => this.available.has(c))) {
|
||||
req.push('message-tags');
|
||||
}
|
||||
return req.length ? { do: 'req', caps: req } : { do: 'end' };
|
||||
}
|
||||
|
||||
if (sub === 'ACK') {
|
||||
for (const c of (msg.params[2] ?? '').split(' ').filter(Boolean)) this.acked.add(c);
|
||||
// SASL if the server ACK'd it and we have a password, else we're done.
|
||||
return this.acked.has('sasl') && ctx.hasPassword ? { do: 'sasl' } : { do: 'end' };
|
||||
}
|
||||
|
||||
if (sub === 'DEL') { // cap-notify: server withdrew capabilities — forget them.
|
||||
for (const c of (msg.params[2] ?? '').split(' ').filter(Boolean)) {
|
||||
this.available.delete(c);
|
||||
this.acked.delete(c);
|
||||
}
|
||||
return { do: 'none' };
|
||||
}
|
||||
|
||||
if (sub === 'NAK') return { do: 'end' };
|
||||
return { do: 'none' };
|
||||
}
|
||||
|
||||
// ---- cap-gated extended-feature commands --------------------------------
|
||||
// Each guards on the negotiated cap/ISUPPORT: a leaner ircd rejects an
|
||||
// unsupported command as 421 "Unknown command", so we skip it entirely.
|
||||
|
||||
// draft/chathistory — load up to `limit` messages older than the given ISO time.
|
||||
chathistoryBefore(target: string, beforeIso: string, limit: number): void {
|
||||
this.tx.send(`CHATHISTORY BEFORE ${target} timestamp=${beforeIso} ${limit}`);
|
||||
}
|
||||
// draft/chathistory — most recent `limit` items (messages + events w/ event-playback).
|
||||
// Low priority: prefetched on join for every channel, must not block typing.
|
||||
chathistoryLatest(target: string, limit: number): void {
|
||||
this.tx.lowSend(`CHATHISTORY LATEST ${target} * ${limit}`);
|
||||
}
|
||||
// MONITOR (online-notify): + add, - remove, L list, C clear.
|
||||
monitor(op: '+' | '-' | 'L' | 'C', targets = ''): void {
|
||||
if (this.tx.isupport()['MONITOR'] === undefined) return; // server doesn't advertise MONITOR
|
||||
this.tx.send(`MONITOR ${op}${targets ? ` ${targets}` : ''}`);
|
||||
}
|
||||
// Reaction rides on TAGMSG (message-tags); without the cap the server rejects it.
|
||||
react(target: string, msgid: string, emoji: string): void {
|
||||
if (!this.acked.has('message-tags')) return;
|
||||
this.tx.send(`@+draft/reply=${msgid};+draft/react=${emoji} TAGMSG ${target}`);
|
||||
}
|
||||
redact(target: string, msgid: string, reason = ''): void {
|
||||
if (!this.acked.has('draft/message-redaction')) return;
|
||||
this.tx.send(`REDACT ${target} ${msgid}${reason ? ` :${reason}` : ''}`);
|
||||
}
|
||||
markRead(target: string, ts: string): void {
|
||||
if (!this.acked.has('draft/read-marker')) return; // MARKREAD is unknown without the cap
|
||||
this.tx.send(`MARKREAD ${target} timestamp=${ts}`);
|
||||
}
|
||||
sendTyping(target: string, state: 'active' | 'done'): void {
|
||||
if (!this.acked.has('message-tags')) return; // +typing rides on TAGMSG
|
||||
this.tx.send(`@+typing=${state} TAGMSG ${target}`);
|
||||
}
|
||||
// draft/account-registration: create + confirm a Swaygo/Tchatou account. Gated at
|
||||
// the UI (the register form only shows when the cap is present), so no guard here.
|
||||
register(account: string, email: string, password: string): void {
|
||||
this.tx.send(`REGISTER ${account} ${email} :${password}`);
|
||||
}
|
||||
verify(account: string, code: string): void { this.tx.send(`VERIFY ${account} ${code}`); }
|
||||
resend(account: string): void { this.tx.send(`RESEND ${account}`); }
|
||||
// draft/webpush: register/remove a Web Push subscription (gated at the call site on
|
||||
// the VAPID ISUPPORT key). <keys> is "p256dh=<b64url>;auth=<b64url>".
|
||||
webpushRegister(endpoint: string, keys: string): void { this.tx.send(`WEBPUSH REGISTER ${endpoint} ${keys}`); }
|
||||
webpushUnregister(endpoint: string): void { this.tx.send(`WEBPUSH UNREGISTER ${endpoint}`); }
|
||||
}
|
||||
|
|
@ -209,7 +209,7 @@ export function createChatStore(ns = '') {
|
|||
client.queryUserModes();
|
||||
// Watch our friends via MONITOR (server pushes 730/731 on presence change).
|
||||
const fr = get().friends;
|
||||
if (fr.length) client.monitor('+', fr.join(','));
|
||||
if (fr.length) client.ircv3.monitor('+', fr.join(','));
|
||||
// On a RECONNECT, rejoin every channel we still have open (the client
|
||||
// only auto-joins the initial set; this restores ones joined later).
|
||||
if (wasReconnect) {
|
||||
|
|
@ -294,7 +294,7 @@ export function createChatStore(ns = '') {
|
|||
if (s.historyLoading[key] || s.historyDone[key]) return;
|
||||
const client = s.client;
|
||||
const buf = s.buffers[key];
|
||||
if (!client || !buf || !client.hasCap('draft/chathistory')) return;
|
||||
if (!client || !buf || !client.ircv3.hasCap('draft/chathistory')) return;
|
||||
// A channel we've parted is no longer a valid CHATHISTORY target — the server
|
||||
// would answer FAIL INVALID_TARGET, so don't ask.
|
||||
if (isChannelName(buf.name) && !buf.joined) return;
|
||||
|
|
@ -302,7 +302,7 @@ export function createChatStore(ns = '') {
|
|||
const oldest = buf.messages.find((m) => m.kind === 'privmsg' || m.kind === 'action' || m.kind === 'notice');
|
||||
if (!oldest) return;
|
||||
set({ historyLoading: { ...s.historyLoading, [key]: true } });
|
||||
client.chathistoryBefore(buf.name, new Date(oldest.ts).toISOString(), 50);
|
||||
client.ircv3.chathistoryBefore(buf.name, new Date(oldest.ts).toISOString(), 50);
|
||||
// safety: clear the spinner if the server never answers.
|
||||
setTimeout(() => set({ historyLoading: { ...get().historyLoading, [key]: false } }), 8000);
|
||||
},
|
||||
|
|
@ -320,7 +320,7 @@ export function createChatStore(ns = '') {
|
|||
const next = [...cur, n];
|
||||
saveFriends(next); syncGlobal();
|
||||
set({ friends: next });
|
||||
get().client?.monitor('+', n); // start watching
|
||||
get().client?.ircv3.monitor('+', n); // start watching
|
||||
},
|
||||
|
||||
removeFriend(nick) {
|
||||
|
|
@ -328,7 +328,7 @@ export function createChatStore(ns = '') {
|
|||
saveFriends(next); syncGlobal();
|
||||
const online = { ...get().friendsOnline }; delete online[nick.toLowerCase()];
|
||||
set({ friends: next, friendsOnline: online });
|
||||
get().client?.monitor('-', nick);
|
||||
get().client?.ircv3.monitor('-', nick);
|
||||
},
|
||||
|
||||
loadBanList(channel) {
|
||||
|
|
@ -397,7 +397,7 @@ export function createChatStore(ns = '') {
|
|||
if (pb && pb.messages.length) {
|
||||
const latest = pb.messages[pb.messages.length - 1].ts;
|
||||
if (latest > pb.readTs) {
|
||||
get().client?.markRead(pb.name, new Date(latest).toISOString());
|
||||
get().client?.ircv3.markRead(pb.name, new Date(latest).toISOString());
|
||||
patchBuffer(prev, (b) => ({ ...b, readTs: latest }));
|
||||
}
|
||||
}
|
||||
|
|
@ -412,7 +412,7 @@ export function createChatStore(ns = '') {
|
|||
const b = s.buffers[name];
|
||||
if (b?.messages.length) {
|
||||
const latest = b.messages[b.messages.length - 1].ts;
|
||||
if (latest > b.readTs) s.client?.markRead(b.name, new Date(latest).toISOString());
|
||||
if (latest > b.readTs) s.client?.ircv3.markRead(b.name, new Date(latest).toISOString());
|
||||
}
|
||||
}
|
||||
// …then clear unread/highlight everywhere in one update.
|
||||
|
|
@ -430,7 +430,7 @@ export function createChatStore(ns = '') {
|
|||
const { client, active } = get();
|
||||
if (!client || !active || !isChannelName(active)) return;
|
||||
const now = Date.now();
|
||||
if (now - lastTypingSent > 3000) { lastTypingSent = now; client.sendTyping(active, 'active'); }
|
||||
if (now - lastTypingSent > 3000) { lastTypingSent = now; client.ircv3.sendTyping(active, 'active'); }
|
||||
},
|
||||
|
||||
sendInput(text) {
|
||||
|
|
@ -485,7 +485,7 @@ export function createChatStore(ns = '') {
|
|||
if (!t || !body) break;
|
||||
client.privmsg(t, body);
|
||||
// Optimistic echo (masked for services) so the user sees feedback.
|
||||
if (!client.hasCap('echo-message')) {
|
||||
if (!client.ircv3.hasCap('echo-message')) {
|
||||
const dest = isChannelName(t) ? t : t;
|
||||
addMessage(dest, {
|
||||
id: newId(), bufferName: dest, from: get().nick,
|
||||
|
|
@ -501,7 +501,7 @@ export function createChatStore(ns = '') {
|
|||
if (!t || !body) break;
|
||||
client.send(`NOTICE ${t} :${body}`);
|
||||
// No optimistic echo if the server will echo it back to us.
|
||||
if (!client.hasCap('echo-message')) {
|
||||
if (!client.ircv3.hasCap('echo-message')) {
|
||||
const dest = isChannelName(t) ? t : (active || SERVER);
|
||||
addMessage(dest, {
|
||||
id: newId(), bufferName: dest, from: get().nick,
|
||||
|
|
@ -539,7 +539,7 @@ export function createChatStore(ns = '') {
|
|||
i18n.t('security.leakGuard', { channel: active, service: leak.service }),
|
||||
'warning');
|
||||
ensureBuffer(leak.service);
|
||||
if (!client.hasCap('echo-message')) {
|
||||
if (!client.ircv3.hasCap('echo-message')) {
|
||||
addMessage(leak.service, {
|
||||
id: newId(), bufferName: leak.service, from: get().nick,
|
||||
text: maskSecret(leak.command), ts: Date.now(), kind: 'privmsg', self: true,
|
||||
|
|
@ -557,9 +557,9 @@ export function createChatStore(ns = '') {
|
|||
const ctx = !isChannelName(active) ? get().pmContext[canon(active)] : undefined;
|
||||
if (reply) { client.privmsgReply(active, body, reply.id, ctx); set({ replyTarget: null }); }
|
||||
else client.privmsg(active, body, ctx);
|
||||
if (isChannelName(active)) { client.sendTyping(active, 'done'); lastTypingSent = 0; }
|
||||
if (isChannelName(active)) { client.ircv3.sendTyping(active, 'done'); lastTypingSent = 0; }
|
||||
// Optimistic echo only if the server won't echo it back to us.
|
||||
if (!client.hasCap('echo-message')) {
|
||||
if (!client.ircv3.hasCap('echo-message')) {
|
||||
addMessage(active, {
|
||||
id: newId(), bufferName: active, from: get().nick,
|
||||
text: isService(active) ? maskSecret(text) : body,
|
||||
|
|
@ -624,12 +624,12 @@ export function createChatStore(ns = '') {
|
|||
|
||||
toggleReaction(msgid, emoji) {
|
||||
const { client, active } = get();
|
||||
client?.react(active, msgid, emoji);
|
||||
client?.ircv3.react(active, msgid, emoji);
|
||||
},
|
||||
|
||||
redact(msgid) {
|
||||
const { client, active } = get();
|
||||
client?.redact(active, msgid);
|
||||
client?.ircv3.redact(active, msgid);
|
||||
},
|
||||
|
||||
async uploadImage(file) {
|
||||
|
|
@ -660,7 +660,7 @@ export function createChatStore(ns = '') {
|
|||
// "* Mik 📷 partage une image : <url>" (mIRC/irssi etc.); web renders the card.
|
||||
const caption = `📷 ${i18n.t('system.shareImage', { url: data.url })}`;
|
||||
client.action(active, caption);
|
||||
if (!client.hasCap('echo-message')) {
|
||||
if (!client.ircv3.hasCap('echo-message')) {
|
||||
addMessage(active, { id: newId(), bufferName: active, from: get().nick, text: caption, ts: Date.now(), kind: 'action', self: true });
|
||||
}
|
||||
} catch (e) {
|
||||
|
|
@ -711,7 +711,7 @@ export function createChatStore(ns = '') {
|
|||
const data = await res.json() as { url: string };
|
||||
const caption = `🎤 ${i18n.t('system.shareVoice', { url: data.url })}`;
|
||||
client.action(active, caption);
|
||||
if (!client.hasCap('echo-message')) {
|
||||
if (!client.ircv3.hasCap('echo-message')) {
|
||||
addMessage(active, { id: newId(), bufferName: active, from: get().nick, text: caption, ts: Date.now(), kind: 'action', self: true });
|
||||
}
|
||||
} catch (e) {
|
||||
|
|
@ -739,19 +739,19 @@ export function createChatStore(ns = '') {
|
|||
const { client } = get();
|
||||
if (!client) return;
|
||||
set({ reg: { step: 'idle', account, busy: true, error: '', info: '', challengeUrl: '' } });
|
||||
client.register(account, email, password);
|
||||
client.ircv3.register(account, email, password);
|
||||
},
|
||||
accountVerify(code) {
|
||||
const { client, reg } = get();
|
||||
if (!client || !reg.account) return;
|
||||
set({ reg: { ...reg, busy: true, error: '' } });
|
||||
client.verify(reg.account, code);
|
||||
client.ircv3.verify(reg.account, code);
|
||||
},
|
||||
accountResend() {
|
||||
const { client, reg } = get();
|
||||
if (!client || !reg.account) return;
|
||||
set({ reg: { ...reg, error: '', info: i18n.t('reg.codeResent') } });
|
||||
client.resend(reg.account);
|
||||
client.ircv3.resend(reg.account);
|
||||
},
|
||||
// Change the account password via Django (same-origin proxy → swaygo). The
|
||||
// server updates BOTH Anope (IRC login) AND Django (website) so they never
|
||||
|
|
|
|||
|
|
@ -565,7 +565,7 @@ export function makeHandler(ctx: HandlerCtx) {
|
|||
// Pull full history (messages + JOIN/PART/KICK/MODE/TOPIC events via event-playback)
|
||||
// from m_ircv3_chathistory — the +H auto-replay only carries messages. Deduped by id.
|
||||
const cl = get().client;
|
||||
if (cl?.hasCap('draft/chathistory')) cl.chathistoryLatest(ch, 50);
|
||||
if (cl?.ircv3.hasCap('draft/chathistory')) cl.ircv3.chathistoryLatest(ch, 50);
|
||||
}
|
||||
// extended-join: ":nick JOIN #chan <account> :<realname>" — '*'/'0' = none.
|
||||
// Gives us account + realname up front, so no WHO needed for joiners.
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ function registerWithServer(client: IrcClient, sub: PushSubscription): void {
|
|||
const p256dh = bytesToUrlB64(sub.getKey('p256dh'));
|
||||
const auth = bytesToUrlB64(sub.getKey('auth'));
|
||||
if (!p256dh || !auth) return;
|
||||
client.webpushRegister(sub.endpoint, `p256dh=${p256dh};auth=${auth}`);
|
||||
client.ircv3.webpushRegister(sub.endpoint, `p256dh=${p256dh};auth=${auth}`);
|
||||
}
|
||||
|
||||
// Turn push on: ask permission, subscribe, hand the endpoint to the ircd.
|
||||
|
|
@ -87,7 +87,7 @@ export async function disablePush(client: IrcClient): Promise<void> {
|
|||
const reg = await navigator.serviceWorker.ready;
|
||||
const sub = await reg.pushManager.getSubscription();
|
||||
if (sub) {
|
||||
client.webpushUnregister(sub.endpoint);
|
||||
client.ircv3.webpushUnregister(sub.endpoint);
|
||||
await sub.unsubscribe();
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue