refactor: extract BATCH handling from handler.ts into a tested sub-handler

This commit is contained in:
Jean Chevronnet 2026-07-06 04:25:02 +00:00
parent d4f0fb1a96
commit bfd5dbf94f
No known key found for this signature in database
3 changed files with 198 additions and 69 deletions

View file

@ -0,0 +1,96 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { makeBatch } from './batch';
import { parseLine } from '../irc/parser';
import { openBatches, historyCollect, multilineCollect, resetBatches } from './context';
import type { ChatState } from '../store';
import type { StoreHelpers } from './helpers';
import type { ChatMessage } from '../irc/types';
const mkMsg = (p: Partial<ChatMessage>): ChatMessage =>
({ id: 'x', bufferName: '#x', from: 'bob', text: 'hi', ts: 1000, kind: 'privmsg', self: false, ...p }) as ChatMessage;
function setup() {
const state = {
buffers: { '#x': { name: '#x', messages: [] as ChatMessage[] } } as Record<string, { name: string; messages: ChatMessage[] }>,
historyLoading: {} as Record<string, boolean>,
historyDone: {} as Record<string, boolean>,
};
const added: ChatMessage[] = [];
const server: string[] = [];
const get = () => state as unknown as ChatState;
const set = (p: Partial<typeof state>) => Object.assign(state, p);
const helpers = {
msgSig: (m: ChatMessage) => `${m.kind} ${m.from} ${Math.floor(m.ts / 1000)} ${m.text}`,
patchBuffer: (name: string, fn: (b: typeof state.buffers[string]) => typeof state.buffers[string]) => {
const k = name.toLowerCase(); if (state.buffers[k]) state.buffers[k] = fn(state.buffers[k]);
},
addMessage: (_name: string, m: ChatMessage) => { added.push(m); },
serverLine: (text: string) => { server.push(text); },
} as unknown as StoreHelpers;
const { handleBatch } = makeBatch({ get, set, helpers } as Parameters<typeof makeBatch>[0]);
const on = (line: string) => handleBatch(parseLine(line));
return { on, state, added, server };
}
beforeEach(() => resetBatches());
describe('BATCH handler', () => {
it('opens a chathistory batch and drains its collected messages on close', () => {
const { on, state } = setup();
on(':srv BATCH +abc chathistory #x');
expect(openBatches['abc']).toMatchObject({ type: 'chathistory', target: '#x' });
historyCollect['abc'].push(mkMsg({ id: 'm1', text: 'old1', ts: 1000 }));
historyCollect['abc'].push(mkMsg({ id: 'm2', text: 'old2', ts: 2000 }));
on(':srv BATCH -abc');
expect(state.buffers['#x'].messages.map((m) => m.id)).toEqual(['m1', 'm2']);
expect(state.historyLoading['#x']).toBe(false);
expect(openBatches['abc']).toBeUndefined();
expect(historyCollect['abc']).toBeUndefined();
});
it('de-dupes a replayed message that matches one already shown (by signature)', () => {
const { on, state } = setup();
state.buffers['#x'].messages = [mkMsg({ id: 'shown', from: 'bob', text: 'hi', ts: 1000 })];
on(':srv BATCH +abc chathistory #x');
historyCollect['abc'].push(mkMsg({ id: 'dup', from: 'bob', text: 'hi', ts: 1000 })); // same sig as 'shown'
on(':srv BATCH -abc');
expect(state.buffers['#x'].messages).toHaveLength(1); // not duplicated
});
it('upgrades an existing id-less copy with the msgid from the replay', () => {
const { on, state } = setup();
state.buffers['#x'].messages = [mkMsg({ id: 'local', from: 'bob', text: 'hi', ts: 1000 })];
on(':srv BATCH +abc chathistory #x');
historyCollect['abc'].push(mkMsg({ id: 'real', msgid: 'server-msgid', from: 'bob', text: 'hi', ts: 1000 }));
on(':srv BATCH -abc');
expect(state.buffers['#x'].messages[0]).toMatchObject({ id: 'real', msgid: 'server-msgid' });
});
it('merges draft/multiline lines into one message', () => {
const { on, added } = setup();
on(':srv BATCH +ml draft/multiline #x');
multilineCollect['ml'] = { base: mkMsg({ id: 'b', bufferName: '#x', from: 'me', text: '', self: true }), lines: [{ text: 'line1', concat: false }, { text: 'line2', concat: false }] };
on(':srv BATCH -ml');
expect(added).toHaveLength(1);
expect(added[0].text).toBe('line1\nline2');
});
it('drops a quiet marker for a netsplit batch', () => {
const { on, server } = setup();
on(':srv BATCH +ns netsplit');
expect(server.some((s) => s.includes('📡'))).toBe(true);
});
it('bounds the number of open batches (65th is declined)', () => {
const { on } = setup();
for (let i = 0; i < 64; i++) on(`:srv BATCH +b${i} chathistory #x`);
expect(Object.keys(openBatches).length).toBe(64);
on(':srv BATCH +over chathistory #x');
expect(openBatches['over']).toBeUndefined();
});
it('returns false for a non-BATCH command', () => {
const { on } = setup();
expect(on(':bob!u@h PRIVMSG #x :hi')).toBe(false);
});
});

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

@ -0,0 +1,96 @@
// IRCv3 BATCH sub-handler.
//
// Opens/closes server batches and merges what the other handlers collected into
// them: a `chathistory` batch's messages are prepended as older history (deduped
// against what's already shown), and a `draft/multiline` batch's lines become one
// message. netsplit/netjoin batches just drop a quiet marker. The per-batch
// collectors live in ./context (written by the PRIVMSG + event-playback paths);
// this handler drains them on BATCH close.
import i18n from '../i18n';
import { canon, openBatches, historyCollect, multilineCollect } from './context';
import type { ChatMessage, IrcMessage } from '../irc/types';
import type { StoreApi } from 'zustand';
import type { ChatState } from '../store';
import type { StoreHelpers } from './helpers';
interface BatchDeps {
get: StoreApi<ChatState>['getState'];
set: StoreApi<ChatState>['setState'];
helpers: StoreHelpers;
}
export function makeBatch({ get, set, helpers }: BatchDeps) {
const { msgSig, patchBuffer, addMessage, serverLine } = helpers;
// Handle a BATCH open/close. Returns true when it was a BATCH.
function handleBatch(msg: IrcMessage): boolean {
if (msg.command !== 'BATCH') return false;
// :src BATCH +<ref> <type> [params…] / :src BATCH -<ref>
const ref = msg.params[0] || '';
const id = ref.slice(1);
if (ref[0] === '+') {
if (Object.keys(openBatches).length >= 64) return true; // bound server-opened batches
const type = msg.params[1] || '';
// chathistory replays old messages → collect+prepend, don't append live.
const quiet = type === 'netsplit' || type === 'netjoin';
openBatches[id] = { type, quiet, target: msg.params[2] };
if (type === 'chathistory') historyCollect[id] = [];
else if (type === 'netsplit') serverLine(`📡 ${i18n.t('system.netsplit')}`, 'info');
else if (type === 'netjoin') serverLine(`📡 ${i18n.t('system.netjoin')}`, 'info');
} else if (ref[0] === '-') {
const b = openBatches[id];
if (b?.type === 'chathistory' && b.target) {
const items = historyCollect[id] || [];
const key = canon(b.target);
// Prepend older messages, keep buffer ordered oldest→newest.
// Dedup by id AND by a content signature: the legacy +H auto-replay and
// our CHATHISTORY response deliver the SAME message with different ids
// (+H carries no msgid → random id; CHATHISTORY carries the real msgid),
// so id-only dedup would double every recent message. The signature
// (kind+sender+second+text) matches them since both preserve the original
// server-time and text.
patchBuffer(b.target, (buf) => {
const base = [...buf.messages];
const sigIdx = new Map<string, number>();
base.forEach((m, i) => sigIdx.set(msgSig(m), i));
const haveId = new Set(base.map((m) => m.id));
const seen = new Set<string>();
const fresh: ChatMessage[] = [];
for (const m of items) {
const sig = msgSig(m);
const at = sigIdx.get(sig);
if (at !== undefined) {
// Same message already present (other replay source). Upgrade it to
// the copy that carries the real msgid so REDACT/react target it.
if (m.msgid && !base[at].msgid) base[at] = { ...base[at], id: m.id, msgid: m.msgid };
continue;
}
if (haveId.has(m.id) || seen.has(sig)) continue;
seen.add(sig);
fresh.push(m);
}
const merged = [...fresh, ...base].sort((a, z) => a.ts - z.ts);
return { ...buf, messages: merged.slice(-1000) };
});
set({
historyLoading: { ...get().historyLoading, [key]: false },
historyDone: { ...get().historyDone, [key]: items.length === 0 },
});
delete historyCollect[id];
} else if (b?.type === 'draft/multiline' && multilineCollect[id]) {
// Merge the batch's lines into ONE message (concat = no newline).
const { base, lines } = multilineCollect[id];
if (lines.length) {
let merged = lines[0].text;
for (let i = 1; i < lines.length; i++) merged += (lines[i].concat ? '' : '\n') + lines[i].text;
addMessage(base.bufferName, { ...base, text: merged });
}
delete multilineCollect[id];
}
delete openBatches[id];
}
return true;
}
return { handleBatch };
}

View file

@ -2,6 +2,7 @@ import i18n from '../i18n';
import { desktopNotify, blip } from '../../platform/notify';
import { makeWhois } from './whois';
import { makeMembership } from './membership';
import { makeBatch } from './batch';
import type { ChatMessage, IrcMessage, Member, MessageKind } from '../irc/types';
import { NUMERICS, ERROR_NUMERICS } from '../irc/numerics';
import { buildModeContext, parseModeChanges, applyChannelFlag, applyUserModes } from '../irc/modes';
@ -35,12 +36,14 @@ const KNOWN_SERVICES_CAP = 256;
// `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, addMessage, tsOf, msgSig, sysLine, serverLine, patchWhois } = helpers;
const { ensureBuffer, patchBuffer, dropBuffer, patchMemberEverywhere, addMessage, tsOf, sysLine, serverLine, patchWhois } = helpers;
// WHOIS/WHOWAS → the profile panel (and yomirc text WHOIS). See ./whois.
const { handleWhois, clearWhois } = makeWhois({ get, set, patchWhois, sysLine });
// Channel-membership events (JOIN/PART/KICK/QUIT/NICK/CHGHOST/SETNAME). See ./membership.
const { handleMembership } = makeMembership({ get, set, closedChannels, helpers });
// IRCv3 BATCH open/close (chathistory + multiline merge). See ./batch.
const { handleBatch } = makeBatch({ get, set, helpers });
// ---- IRC event -> state ------------------------------------------------
function handle(msg: IrcMessage): void {
@ -307,8 +310,9 @@ export function makeHandler(ctx: HandlerCtx) {
return;
}
// Channel-membership events are handled first (see ./membership).
// Channel-membership events + BATCH are handled first (see ./membership, ./batch).
if (handleMembership(msg, me)) return;
if (handleBatch(msg)) return;
switch (msg.command) {
case 'CAP': {
@ -478,73 +482,6 @@ export function makeHandler(ctx: HandlerCtx) {
}
break;
}
case 'BATCH': {
// :src BATCH +<ref> <type> [params…] / :src BATCH -<ref>
const ref = msg.params[0] || '';
const id = ref.slice(1);
if (ref[0] === '+') {
if (Object.keys(openBatches).length >= 64) break; // bound server-opened batches
const type = msg.params[1] || '';
// chathistory replays old messages → collect+prepend, don't append live.
const quiet = type === 'netsplit' || type === 'netjoin';
openBatches[id] = { type, quiet, target: msg.params[2] };
if (type === 'chathistory') historyCollect[id] = [];
else if (type === 'netsplit') serverLine(`📡 ${i18n.t('system.netsplit')}`, 'info');
else if (type === 'netjoin') serverLine(`📡 ${i18n.t('system.netjoin')}`, 'info');
} else if (ref[0] === '-') {
const b = openBatches[id];
if (b?.type === 'chathistory' && b.target) {
const items = historyCollect[id] || [];
const key = canon(b.target);
// Prepend older messages, keep buffer ordered oldest→newest.
// Dedup by id AND by a content signature: the legacy +H auto-replay and
// our CHATHISTORY response deliver the SAME message with different ids
// (+H carries no msgid → random id; CHATHISTORY carries the real msgid),
// so id-only dedup would double every recent message. The signature
// (kind+sender+second+text) matches them since both preserve the original
// server-time and text.
patchBuffer(b.target, (buf) => {
const base = [...buf.messages];
const sigIdx = new Map<string, number>();
base.forEach((m, i) => sigIdx.set(msgSig(m), i));
const haveId = new Set(base.map((m) => m.id));
const seen = new Set<string>();
const fresh: ChatMessage[] = [];
for (const m of items) {
const sig = msgSig(m);
const at = sigIdx.get(sig);
if (at !== undefined) {
// Same message already present (other replay source). Upgrade it to
// the copy that carries the real msgid so REDACT/react target it.
if (m.msgid && !base[at].msgid) base[at] = { ...base[at], id: m.id, msgid: m.msgid };
continue;
}
if (haveId.has(m.id) || seen.has(sig)) continue;
seen.add(sig);
fresh.push(m);
}
const merged = [...fresh, ...base].sort((a, z) => a.ts - z.ts);
return { ...buf, messages: merged.slice(-1000) };
});
set({
historyLoading: { ...get().historyLoading, [key]: false },
historyDone: { ...get().historyDone, [key]: items.length === 0 },
});
delete historyCollect[id];
} else if (b?.type === 'draft/multiline' && multilineCollect[id]) {
// Merge the batch's lines into ONE message (concat = no newline).
const { base, lines } = multilineCollect[id];
if (lines.length) {
let merged = lines[0].text;
for (let i = 1; i < lines.length; i++) merged += (lines[i].concat ? '' : '\n') + lines[i].text;
addMessage(base.bufferName, { ...base, text: merged });
}
delete multilineCollect[id];
}
delete openBatches[id];
}
break;
}
case 'ERROR': { // server is closing the link — show why
const reason = msg.params[msg.params.length - 1] || i18n.t('system.serverErrorDefault');
sysLine(SERVER, `${i18n.t('system.serverError', { reason })}`, 'system');