refactor: extract MODE and fold AWAY/ACCOUNT member-notifies into membership

This commit is contained in:
Jean Chevronnet 2026-07-06 05:00:59 +00:00
parent ff33c4acf7
commit ae8232afa6
No known key found for this signature in database
5 changed files with 221 additions and 88 deletions

View file

@ -6,10 +6,11 @@ import { makeBatch } from './batch';
import { makeTagmsg } from './tagmsg';
import { makeMsgState } from './msgstate';
import { makeMessaging } from './messaging';
import { makeMode } from './mode';
import type { IrcMessage, Member, MessageKind } from '../irc/types';
import { NUMERICS, ERROR_NUMERICS } from '../irc/numerics';
import { buildModeContext, parseModeChanges, applyChannelFlag, applyUserModes } from '../irc/modes';
import { hostmask, maskMatches } from './text';
import { hostmask } from './text';
import { SERVER, isupport, canon, isChannelName, openBatches, historyCollect, inHistoryBatch } from './context';
import type { StoreApi } from 'zustand';
import type { ChatState } from '../store';
@ -32,7 +33,7 @@ const HANDLED_NUMERICS = new Set(['005', '332', '333', '353', '366', '396', '366
// `handle` function; the store wires it to client.on('message', handle).
export function makeHandler(ctx: HandlerCtx) {
const { set, get, helpers, closedChannels, knownServices, lastCantSend, lastAwayNotice, filehost } = ctx;
const { ensureBuffer, patchBuffer, dropBuffer, patchMemberEverywhere, tsOf, sysLine, serverLine, patchWhois } = helpers;
const { ensureBuffer, patchBuffer, dropBuffer, tsOf, sysLine, serverLine, patchWhois } = helpers;
// WHOIS/WHOWAS → the profile panel (and yomirc text WHOIS). See ./whois.
const { handleWhois, clearWhois } = makeWhois({ get, set, patchWhois, sysLine });
@ -46,6 +47,8 @@ export function makeHandler(ctx: HandlerCtx) {
const { handleMsgState } = makeMsgState({ ensureBuffer, patchBuffer });
// PRIVMSG/NOTICE hot path (routing, services, notify, batch collection). See ./messaging.
const { handleMessaging } = makeMessaging({ get, set, knownServices, filehost, helpers });
// MODE changes (user + channel modes, prefixes, ban lists). See ./mode.
const { handleMode } = makeMode({ get, set, helpers });
// ---- IRC event -> state ------------------------------------------------
function handle(msg: IrcMessage): void {
@ -318,6 +321,7 @@ export function makeHandler(ctx: HandlerCtx) {
if (handleTagmsg(msg, me)) return;
if (handleMsgState(msg)) return;
if (handleMessaging(msg, me)) return;
if (handleMode(msg, me)) return;
switch (msg.command) {
case 'CAP': {
@ -362,21 +366,6 @@ export function makeHandler(ctx: HandlerCtx) {
}
break;
}
case 'AWAY': {
// away-notify: ":nick AWAY :<reason>" = away, ":nick AWAY" = back. Keeps
// away state live in every common channel — no WHO poll needed.
patchMemberEverywhere(msg.nick, { away: msg.params.length > 0 });
break;
}
case 'ACCOUNT': {
// account-notify: ":nick ACCOUNT <account>" ('*' = logged out). Live account
// = live avatar; no WHOX re-poll.
const acct = msg.params[0];
const account = acct && acct !== '*' ? acct : undefined;
patchMemberEverywhere(msg.nick, { account });
if (msg.nick === me) set({ account: account ?? '' });
break;
}
case 'TOPIC': { // :<nick> TOPIC <channel> :<new topic>
const ch = msg.params[0];
const topic = msg.params[1] ?? '';
@ -386,72 +375,6 @@ export function makeHandler(ctx: HandlerCtx) {
sysLine(ch, topic, 'topic', msg.nick);
break;
}
case 'MODE': {
const chan = msg.params[0];
if (!isChannelName(chan)) {
// User mode change. User modes are global per-user and take no params;
// we only track our own (target === our nick).
if (chan === me) {
const change = msg.params[1] ?? '';
const next = applyUserModes(get().umodes, change);
set({ umodes: next });
const named = change.replace(/[+-]/g, '').split('').map((c) => i18n.t(`umodes.${c}`, '')).filter(Boolean);
serverLine(named.length
? i18n.t('system.yourModesNamed', { modes: next, change, names: named.join(', ') })
: i18n.t('system.yourModes', { modes: next, change }));
}
break;
}
const client = get().client;
const order = client?.server.prefixModes ?? '~&@%+';
const ctx = buildModeContext(client?.server.isupport ?? {}, client?.server.prefixModeToChar ?? {});
const changes = parseModeChanges(msg.params[1] ?? '', msg.params.slice(2), ctx);
const banLines: string[] = []; // +b/-b shown as their own clear lines (like mIRC)
let showCombined = false; // any prefix/flag/param change → show the mode line
for (const c of changes) {
if (c.kind === 'prefix' && c.param && c.prefix) {
// membership grant (+o/+v/…) → update that member's held prefixes
showCombined = true;
const sym = c.prefix;
patchBuffer(chan, (b) => {
const m = b.members[c.param!];
if (!m) return b;
const held = (m.prefixes ?? m.prefix ?? '').split('').filter((x) => x !== sym);
if (c.add) held.push(sym);
held.sort((a, z) => order.indexOf(a) - order.indexOf(z));
const prefixes = held.join('');
return { ...b, members: { ...b.members, [c.param!]: { ...m, prefixes, prefix: prefixes[0] ?? '' } } };
});
} else if (c.kind === 'list') {
// type A list mode. A ban (+b/-b) gets its own clear lines + who it hits;
// other list modes (+e/+I) ride along in the combined line.
if (c.mode === 'b' && c.param) {
const mask = c.param;
banLines.push(c.add ? `🔨 ${i18n.t('system.banned', { nick: msg.nick, mask })}` : `♻️ ${i18n.t('system.unbanned', { nick: msg.nick, mask })}`);
const members = get().buffers[canon(chan)]?.members ?? {};
const hit = Object.values(members)
.filter((m) => maskMatches(mask, `${m.nick}!${m.user || '*'}@${m.host || '*'}`))
.map((m) => m.nick);
if (hit.length) banLines.push(i18n.t(c.add ? 'system.bansAdded' : 'system.bansRemoved', { list: hit.join(', ') }));
} else showCombined = true;
} else {
// type B/C param mode or type D flag → maintain the channel mode string
showCombined = true;
patchBuffer(chan, (b) => ({ ...b, ...applyChannelFlag(b.modes || '', b.modeParams || {}, c) }));
}
}
for (const line of banLines) sysLine(chan, line, 'ban');
// The combined mode line is shown for everything except a pure ban change
// (those are already covered by the dedicated ban lines above).
if (showCombined) {
const argStr = msg.params.length > 2 ? ' ' + msg.params.slice(2).join(' ') : '';
sysLine(chan, `${msg.params[1] ?? ''}${argStr}`, 'mode', msg.nick);
}
break;
}
case '332': // RPL_TOPIC
ensureBuffer(msg.params[1]);
patchBuffer(msg.params[1], (b) => ({ ...b, topic: msg.params[2] ?? '' }));

View file

@ -8,8 +8,8 @@ import type { StoreHelpers } from './helpers';
// membership handler mutates through the helper stubs.
function setup() {
const state = {
nick: 'me', active: '', order: [] as string[],
buffers: {} as Record<string, { name: string; isChannel: boolean; members: Record<string, { nick: string; user?: string; host?: string; realname?: string; account?: string; prefix?: string }>; joined: boolean }>,
nick: 'me', account: '', active: '', order: [] as string[],
buffers: {} as Record<string, { name: string; isChannel: boolean; members: Record<string, { nick: string; user?: string; host?: string; realname?: string; account?: string; prefix?: string; away?: boolean }>; joined: boolean }>,
whois: {} as Record<string, { nick: string; loading: boolean; user?: string; host?: string; realname?: string }>,
prefs: { sound: false }, client: null as unknown,
kicked: null as unknown, profileUser: 'x',
@ -28,6 +28,12 @@ function setup() {
if (state.buffers[k(name)]) state.buffers[k(name)] = fn(state.buffers[k(name)]);
},
dropBuffer: (name: string) => { delete state.buffers[k(name)]; state.order = state.order.filter((x) => x !== k(name)); },
patchMemberEverywhere: (nick: string, patch: Record<string, unknown>) => {
for (const key of Object.keys(state.buffers)) {
const b = state.buffers[key];
if (b.members[nick]) b.members = { ...b.members, [nick]: { ...b.members[nick], ...patch } };
}
},
patchWhois: (nick: string, fn: (w: typeof state.whois[string]) => typeof state.whois[string]) => {
state.whois = { ...state.whois, [nick]: fn(state.whois[nick] ?? { nick, loading: true }) };
},
@ -89,6 +95,27 @@ describe('membership handler', () => {
expect(state.kicked).toMatchObject({ channel: '#x', by: 'op', kind: 'kick' });
});
it('AWAY marks/clears a member as away across shared channels', () => {
const { on, state, seed } = setup();
seed('#a', ['bob']); seed('#b', ['bob']);
on(':bob!u@h AWAY :lunch');
expect(state.buffers['#a'].members['bob']).toMatchObject({ away: true });
expect(state.buffers['#b'].members['bob']).toMatchObject({ away: true });
on(':bob!u@h AWAY');
expect(state.buffers['#a'].members['bob']).toMatchObject({ away: false });
});
it('ACCOUNT updates a member account; our own login updates state.account', () => {
const { on, state, seed } = setup();
seed('#a', ['bob']);
on(':bob!u@h ACCOUNT bobacct');
expect(state.buffers['#a'].members['bob']).toMatchObject({ account: 'bobacct' });
on(':me!u@h ACCOUNT myacct');
expect(state.account).toBe('myacct');
on(':me!u@h ACCOUNT *'); // logged out
expect(state.account).toBe('');
});
it('returns false for a non-membership command', () => {
const { on } = setup();
expect(on(':bob!u@h PRIVMSG #x :hi')).toBe(false);

View file

@ -21,10 +21,11 @@ interface MembershipDeps {
}
export function makeMembership({ get, set, closedChannels, helpers }: MembershipDeps) {
const { ensureBuffer, patchBuffer, dropBuffer, patchWhois, sysLine } = helpers;
const { ensureBuffer, patchBuffer, dropBuffer, patchMemberEverywhere, patchWhois, sysLine } = helpers;
// Handle a membership event (JOIN/PART/KICK/QUIT/NICK/CHGHOST/SETNAME). Returns
// true when handled; `me` is the client's current nick.
// Handle a membership event (JOIN/PART/KICK/QUIT/NICK/CHGHOST/SETNAME) or a live
// member-state notify (AWAY/ACCOUNT). Returns true when handled; `me` is the
// client's current nick.
function handleMembership(msg: IrcMessage, me: string): boolean {
switch (msg.command) {
case 'JOIN': {
@ -154,6 +155,21 @@ export function makeMembership({ get, set, closedChannels, helpers }: Membership
if (get().whois[msg.nick]) patchWhois(msg.nick, (w) => ({ ...w, realname: newReal }));
return true;
}
case 'AWAY': {
// away-notify: ":nick AWAY :<reason>" = away, ":nick AWAY" = back. Keeps
// away state live in every common channel — no WHO poll needed.
patchMemberEverywhere(msg.nick, { away: msg.params.length > 0 });
return true;
}
case 'ACCOUNT': {
// account-notify: ":nick ACCOUNT <account>" ('*' = logged out). Live account
// = live avatar; no WHOX re-poll.
const acct = msg.params[0];
const account = acct && acct !== '*' ? acct : undefined;
patchMemberEverywhere(msg.nick, { account });
if (msg.nick === me) set({ account: account ?? '' });
return true;
}
default:
return false;
}

View file

@ -0,0 +1,71 @@
import { describe, it, expect } from 'vitest';
import { makeMode } from './mode';
import { parseLine } from '../irc/parser';
import type { ChatState } from '../store';
import type { StoreHelpers } from './helpers';
type Member = { nick: string; user?: string; host?: string; prefix?: string; prefixes?: string };
type Buf = { name: string; members: Record<string, Member>; modes?: string; modeParams?: Record<string, string> };
function setup() {
const state = {
umodes: '', account: '',
client: { server: { prefixModes: '~&@%+', isupport: { CHANMODES: 'beI,k,l,imnpst' }, prefixModeToChar: { q: '~', a: '&', o: '@', h: '%', v: '+' } } },
buffers: {} as Record<string, Buf>,
};
const k = (n: string) => n.toLowerCase();
const lines: { name: string; text: string; kind: string }[] = [];
const serverLines: string[] = [];
const get = () => state as unknown as ChatState;
const set = (p: Partial<typeof state>) => Object.assign(state, p);
const helpers = {
patchBuffer: (name: string, fn: (b: Buf) => Buf) => { if (state.buffers[k(name)]) state.buffers[k(name)] = fn(state.buffers[k(name)]); },
sysLine: (name: string, text: string, kind: string) => { lines.push({ name, text, kind }); },
serverLine: (text: string) => { serverLines.push(text); },
} as unknown as StoreHelpers;
const seedChan = (chan: string, members: string[]) => {
state.buffers[k(chan)] = { name: chan, modes: '', modeParams: {}, members: Object.fromEntries(members.map((n) => [n, { nick: n, user: 'u', host: 'h', prefix: '' }])) };
};
const { handleMode } = makeMode({ get, set, helpers } as Parameters<typeof makeMode>[0]);
const on = (line: string, me = 'me') => handleMode(parseLine(line), me);
return { on, state, lines, serverLines, seedChan };
}
describe('MODE handler', () => {
it('tracks our own user modes', () => {
const { on, state, serverLines } = setup();
on(':srv MODE me +iw', 'me');
expect(state.umodes).toContain('i');
expect(state.umodes).toContain('w');
expect(serverLines).toHaveLength(1);
});
it('applies a +o membership grant to the member prefix', () => {
const { on, state, seedChan } = setup();
seedChan('#x', ['bob']);
on(':op!u@h MODE #x +o bob', 'me');
expect(state.buffers['#x'].members['bob'].prefix).toBe('@');
expect(state.buffers['#x'].members['bob'].prefixes).toBe('@');
});
it('renders a +b ban as its own line, listing the present members it hits', () => {
const { on, lines, seedChan } = setup();
seedChan('#x', ['bob']); // bob!u@h
on(':op!u@h MODE #x +b *!*@h', 'me');
const bans = lines.filter((l) => l.kind === 'ban');
expect(bans.length).toBeGreaterThanOrEqual(1);
expect(bans.some((l) => l.text.includes('bob'))).toBe(true); // hit list
});
it('shows a combined mode line for a channel flag change', () => {
const { on, lines, seedChan } = setup();
seedChan('#x', []);
on(':op!u@h MODE #x +m', 'me');
expect(lines.some((l) => l.kind === 'mode')).toBe(true);
});
it('returns false for a non-MODE command', () => {
const { on } = setup();
expect(on(':bob!u@h JOIN #x')).toBe(false);
});
});

96
src/core/store/mode.ts Normal file
View file

@ -0,0 +1,96 @@
// MODE sub-handler — user- and channel-mode changes.
//
// User modes: track our own umode string. Channel modes: apply membership grants
// (+o/+v/… → a member's held prefixes), keep the channel mode string current for
// flag/param modes, and render +b/-b as their own mIRC-style ban lines (with the
// present members each ban hits). Split out of handler.ts; the dispatcher calls
// handleMode(msg, me) before its command switch.
import i18n from '../i18n';
import { canon, isChannelName } from './context';
import { maskMatches } from './text';
import { buildModeContext, parseModeChanges, applyChannelFlag, applyUserModes } from '../irc/modes';
import type { IrcMessage } from '../irc/types';
import type { StoreApi } from 'zustand';
import type { ChatState } from '../store';
import type { StoreHelpers } from './helpers';
interface ModeDeps {
get: StoreApi<ChatState>['getState'];
set: StoreApi<ChatState>['setState'];
helpers: StoreHelpers;
}
export function makeMode({ get, set, helpers }: ModeDeps) {
const { patchBuffer, sysLine, serverLine } = helpers;
// Handle a MODE change. Returns true when it was one; `me` is our current nick.
function handleMode(msg: IrcMessage, me: string): boolean {
if (msg.command !== 'MODE') return false;
const chan = msg.params[0];
if (!isChannelName(chan)) {
// User mode change. User modes are global per-user and take no params;
// we only track our own (target === our nick).
if (chan === me) {
const change = msg.params[1] ?? '';
const next = applyUserModes(get().umodes, change);
set({ umodes: next });
const named = change.replace(/[+-]/g, '').split('').map((c) => i18n.t(`umodes.${c}`, '')).filter(Boolean);
serverLine(named.length
? i18n.t('system.yourModesNamed', { modes: next, change, names: named.join(', ') })
: i18n.t('system.yourModes', { modes: next, change }));
}
return true;
}
const client = get().client;
const order = client?.server.prefixModes ?? '~&@%+';
const ctx = buildModeContext(client?.server.isupport ?? {}, client?.server.prefixModeToChar ?? {});
const changes = parseModeChanges(msg.params[1] ?? '', msg.params.slice(2), ctx);
const banLines: string[] = []; // +b/-b shown as their own clear lines (like mIRC)
let showCombined = false; // any prefix/flag/param change → show the mode line
for (const c of changes) {
if (c.kind === 'prefix' && c.param && c.prefix) {
// membership grant (+o/+v/…) → update that member's held prefixes
showCombined = true;
const sym = c.prefix;
patchBuffer(chan, (b) => {
const m = b.members[c.param!];
if (!m) return b;
const held = (m.prefixes ?? m.prefix ?? '').split('').filter((x) => x !== sym);
if (c.add) held.push(sym);
held.sort((a, z) => order.indexOf(a) - order.indexOf(z));
const prefixes = held.join('');
return { ...b, members: { ...b.members, [c.param!]: { ...m, prefixes, prefix: prefixes[0] ?? '' } } };
});
} else if (c.kind === 'list') {
// type A list mode. A ban (+b/-b) gets its own clear lines + who it hits;
// other list modes (+e/+I) ride along in the combined line.
if (c.mode === 'b' && c.param) {
const mask = c.param;
banLines.push(c.add ? `🔨 ${i18n.t('system.banned', { nick: msg.nick, mask })}` : `♻️ ${i18n.t('system.unbanned', { nick: msg.nick, mask })}`);
const members = get().buffers[canon(chan)]?.members ?? {};
const hit = Object.values(members)
.filter((m) => maskMatches(mask, `${m.nick}!${m.user || '*'}@${m.host || '*'}`))
.map((m) => m.nick);
if (hit.length) banLines.push(i18n.t(c.add ? 'system.bansAdded' : 'system.bansRemoved', { list: hit.join(', ') }));
} else showCombined = true;
} else {
// type B/C param mode or type D flag → maintain the channel mode string
showCombined = true;
patchBuffer(chan, (b) => ({ ...b, ...applyChannelFlag(b.modes || '', b.modeParams || {}, c) }));
}
}
for (const line of banLines) sysLine(chan, line, 'ban');
// The combined mode line is shown for everything except a pure ban change
// (those are already covered by the dedicated ban lines above).
if (showCombined) {
const argStr = msg.params.length > 2 ? ' ' + msg.params.slice(2).join(' ') : '';
sysLine(chan, `${msg.params[1] ?? ''}${argStr}`, 'mode', msg.nick);
}
return true;
}
return { handleMode };
}