refactor: extract BATCH handling from handler.ts into a tested sub-handler
This commit is contained in:
parent
d4f0fb1a96
commit
bfd5dbf94f
3 changed files with 198 additions and 69 deletions
96
src/core/store/batch.test.ts
Normal file
96
src/core/store/batch.test.ts
Normal 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
96
src/core/store/batch.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue