kokoirc 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/README.md +227 -0
  2. package/docs/commands/alias.md +42 -0
  3. package/docs/commands/ban.md +26 -0
  4. package/docs/commands/close.md +25 -0
  5. package/docs/commands/connect.md +26 -0
  6. package/docs/commands/deop.md +24 -0
  7. package/docs/commands/devoice.md +24 -0
  8. package/docs/commands/disconnect.md +26 -0
  9. package/docs/commands/help.md +28 -0
  10. package/docs/commands/ignore.md +47 -0
  11. package/docs/commands/items.md +95 -0
  12. package/docs/commands/join.md +25 -0
  13. package/docs/commands/kb.md +26 -0
  14. package/docs/commands/kick.md +25 -0
  15. package/docs/commands/log.md +82 -0
  16. package/docs/commands/me.md +24 -0
  17. package/docs/commands/mode.md +29 -0
  18. package/docs/commands/msg.md +26 -0
  19. package/docs/commands/nick.md +24 -0
  20. package/docs/commands/notice.md +24 -0
  21. package/docs/commands/op.md +24 -0
  22. package/docs/commands/part.md +25 -0
  23. package/docs/commands/quit.md +24 -0
  24. package/docs/commands/reload.md +19 -0
  25. package/docs/commands/script.md +126 -0
  26. package/docs/commands/server.md +61 -0
  27. package/docs/commands/set.md +37 -0
  28. package/docs/commands/topic.md +24 -0
  29. package/docs/commands/unalias.md +22 -0
  30. package/docs/commands/unban.md +25 -0
  31. package/docs/commands/unignore.md +25 -0
  32. package/docs/commands/voice.md +25 -0
  33. package/docs/commands/whois.md +24 -0
  34. package/docs/commands/wii.md +23 -0
  35. package/package.json +38 -0
  36. package/src/app/App.tsx +205 -0
  37. package/src/core/commands/docs.ts +183 -0
  38. package/src/core/commands/execution.ts +114 -0
  39. package/src/core/commands/help-formatter.ts +185 -0
  40. package/src/core/commands/helpers.ts +168 -0
  41. package/src/core/commands/index.ts +7 -0
  42. package/src/core/commands/parser.ts +33 -0
  43. package/src/core/commands/registry.ts +1394 -0
  44. package/src/core/commands/types.ts +19 -0
  45. package/src/core/config/defaults.ts +66 -0
  46. package/src/core/config/loader.ts +209 -0
  47. package/src/core/constants.ts +20 -0
  48. package/src/core/init.ts +32 -0
  49. package/src/core/irc/antiflood.ts +244 -0
  50. package/src/core/irc/client.ts +145 -0
  51. package/src/core/irc/events.ts +1031 -0
  52. package/src/core/irc/formatting.ts +132 -0
  53. package/src/core/irc/ignore.ts +84 -0
  54. package/src/core/irc/index.ts +2 -0
  55. package/src/core/irc/netsplit.ts +292 -0
  56. package/src/core/scripts/api.ts +240 -0
  57. package/src/core/scripts/event-bus.ts +82 -0
  58. package/src/core/scripts/index.ts +26 -0
  59. package/src/core/scripts/manager.ts +154 -0
  60. package/src/core/scripts/types.ts +256 -0
  61. package/src/core/state/selectors.ts +39 -0
  62. package/src/core/state/sorting.ts +30 -0
  63. package/src/core/state/store.ts +242 -0
  64. package/src/core/storage/crypto.ts +78 -0
  65. package/src/core/storage/db.ts +107 -0
  66. package/src/core/storage/index.ts +80 -0
  67. package/src/core/storage/query.ts +204 -0
  68. package/src/core/storage/types.ts +37 -0
  69. package/src/core/storage/writer.ts +130 -0
  70. package/src/core/theme/index.ts +3 -0
  71. package/src/core/theme/loader.ts +45 -0
  72. package/src/core/theme/parser.ts +518 -0
  73. package/src/core/theme/renderer.tsx +25 -0
  74. package/src/index.tsx +17 -0
  75. package/src/types/config.ts +126 -0
  76. package/src/types/index.ts +107 -0
  77. package/src/types/irc-framework.d.ts +569 -0
  78. package/src/types/theme.ts +37 -0
  79. package/src/ui/ErrorBoundary.tsx +42 -0
  80. package/src/ui/chat/ChatView.tsx +39 -0
  81. package/src/ui/chat/MessageLine.tsx +92 -0
  82. package/src/ui/hooks/useStatusbarColors.ts +23 -0
  83. package/src/ui/input/CommandInput.tsx +273 -0
  84. package/src/ui/layout/AppLayout.tsx +126 -0
  85. package/src/ui/layout/TopicBar.tsx +46 -0
  86. package/src/ui/sidebar/BufferList.tsx +55 -0
  87. package/src/ui/sidebar/NickList.tsx +96 -0
  88. package/src/ui/splash/SplashScreen.tsx +100 -0
  89. package/src/ui/statusbar/StatusLine.tsx +205 -0
  90. package/themes/.gitkeep +0 -0
  91. package/themes/default.theme +57 -0
  92. package/tsconfig.json +19 -0
@@ -0,0 +1,1031 @@
1
+ import type { Client } from "kofany-irc-framework"
2
+ import { useStore } from "@/core/state/store"
3
+ import { makeBufferId, BufferType, ActivityLevel } from "@/types"
4
+ import type { Message } from "@/types"
5
+ import { formatDuration, formatDate, buildModeString, buildPrefixMap, buildModeOrder, getHighestPrefix, getNickMode } from "./formatting"
6
+ import { handleNetsplitQuit, handleNetsplitJoin, destroyNetsplitState } from "./netsplit"
7
+ import { shouldSuppressNickFlood, destroyAntifloodState } from "./antiflood"
8
+ import { shouldIgnore } from "./ignore"
9
+ import { eventBus } from "@/core/scripts/event-bus"
10
+
11
+ function isChannelTarget(target: string): boolean {
12
+ return target.startsWith("#") || target.startsWith("&") || target.startsWith("+") || target.startsWith("!")
13
+ }
14
+
15
+ /** Get list modes (CHANMODES category A) from ISUPPORT, fallback to beIR. */
16
+ function getListModes(connectionId: string): Set<string> {
17
+ const conn = useStore.getState().connections.get(connectionId)
18
+ const chanmodes = conn?.isupport?.CHANMODES
19
+ if (chanmodes) return new Set(chanmodes.split(",")[0])
20
+ return new Set(["b", "e", "I", "R"])
21
+ }
22
+
23
+ export function bindEvents(client: Client, connectionId: string) {
24
+ const getStore = () => useStore.getState()
25
+ const statusId = makeBufferId(connectionId, "Status")
26
+
27
+ /** Safely add a message to the Status buffer (must exist). */
28
+ function statusMsg(text: string) {
29
+ getStore().addMessage(statusId, makeEventMessage(text))
30
+ }
31
+
32
+ // ─── Socket-level events ─────────────────────────────────
33
+ client.on("socket connected", () => {
34
+ statusMsg("%Z9ece6aSocket connected, registering...%N")
35
+ })
36
+
37
+ client.on("socket error", (err) => {
38
+ console.error(`[${connectionId}] Socket error:`, err)
39
+ getStore().updateConnection(connectionId, { status: "error" })
40
+ statusMsg(`%Zf7768eSocket error: ${err?.message ?? err}%N`)
41
+ })
42
+
43
+ client.on("socket close", (hadError) => {
44
+ if (hadError) {
45
+ statusMsg(`%Zf7768eSocket closed with error%N`)
46
+ }
47
+ })
48
+
49
+ // ─── Registration ─────────────────────────────────────────
50
+ client.on("registered", (event) => {
51
+ eventBus.emit("connected", { connectionId, nick: event.nick })
52
+
53
+ const s = getStore()
54
+ s.updateConnection(connectionId, { status: "connected", nick: event.nick })
55
+ statusMsg(`%Z9ece6aRegistered as %Zc0caf5${event.nick}%N`)
56
+ // Auto-join channels from config
57
+ const config = s.config
58
+ if (config) {
59
+ const serverConfig = Object.entries(config.servers).find(([id]) => id === connectionId)?.[1]
60
+ if (serverConfig) {
61
+ for (const channel of serverConfig.channels) {
62
+ client.join(channel)
63
+ }
64
+ }
65
+ }
66
+ })
67
+
68
+ client.on("join", (event) => {
69
+ if (!eventBus.emit("irc.join", {
70
+ connectionId, nick: event.nick, ident: event.ident,
71
+ hostname: event.hostname, channel: event.channel, account: event.account,
72
+ })) return
73
+
74
+ const s = getStore()
75
+ const bufferId = makeBufferId(connectionId, event.channel)
76
+ const conn = s.connections.get(connectionId)
77
+
78
+ if (event.nick === conn?.nick) {
79
+ s.addBuffer({
80
+ id: bufferId,
81
+ connectionId,
82
+ type: BufferType.Channel,
83
+ name: event.channel,
84
+ messages: [],
85
+ activity: ActivityLevel.None,
86
+ unreadCount: 0,
87
+ lastRead: new Date(),
88
+ users: new Map(),
89
+ listModes: new Map(),
90
+ })
91
+ // Switch to the newly joined channel
92
+ getStore().setActiveBuffer(bufferId)
93
+ // Request channel modes so we get RPL_CHANNELMODEIS (324)
94
+ client.raw(`MODE ${event.channel}`)
95
+ } else {
96
+ s.addNick(bufferId, { nick: event.nick, prefix: "", modes: "", away: false, account: event.account })
97
+
98
+ // If this join is from a netsplit healing, batch it instead of showing individually
99
+ if (handleNetsplitJoin(connectionId, event.nick, bufferId)) {
100
+ return
101
+ }
102
+
103
+ if (shouldIgnore(event.nick, event.ident, event.hostname, "JOINS", event.channel)) return
104
+
105
+ s.addMessage(bufferId, makeFormattedEvent("join", [
106
+ event.nick, event.ident || "", event.hostname || "", event.channel,
107
+ ]))
108
+ }
109
+ })
110
+
111
+ client.on("part", (event) => {
112
+ if (!eventBus.emit("irc.part", {
113
+ connectionId, nick: event.nick, ident: event.ident,
114
+ hostname: event.hostname, channel: event.channel, message: event.message,
115
+ })) return
116
+
117
+ const s = getStore()
118
+ const bufferId = makeBufferId(connectionId, event.channel)
119
+ const conn = s.connections.get(connectionId)
120
+
121
+ if (event.nick === conn?.nick) {
122
+ s.removeBuffer(bufferId)
123
+ } else {
124
+ s.removeNick(bufferId, event.nick)
125
+ if (shouldIgnore(event.nick, event.ident, event.hostname, "PARTS", event.channel)) return
126
+ s.addMessage(bufferId, makeFormattedEvent("part", [
127
+ event.nick, event.ident || "", event.hostname || "", event.channel, event.message || "",
128
+ ]))
129
+ }
130
+ })
131
+
132
+ client.on("quit", (event) => {
133
+ if (!eventBus.emit("irc.quit", {
134
+ connectionId, nick: event.nick, ident: event.ident,
135
+ hostname: event.hostname, message: event.message,
136
+ })) return
137
+
138
+ const s = getStore()
139
+ const affected = Array.from(s.buffers.entries())
140
+ .filter(([_, buf]) => buf.connectionId === connectionId && buf.users.has(event.nick))
141
+ .map(([id]) => id)
142
+
143
+ // Remove nick from all affected channels
144
+ for (const id of affected) {
145
+ getStore().removeNick(id, event.nick)
146
+ }
147
+
148
+ // Check if this is a netsplit — if so, batch it instead of showing individual quits
149
+ if (handleNetsplitQuit(connectionId, event.nick, event.message || "", affected)) {
150
+ return
151
+ }
152
+
153
+ if (shouldIgnore(event.nick, event.ident, event.hostname, "QUITS")) return
154
+
155
+ for (const id of affected) {
156
+ getStore().addMessage(id, makeFormattedEvent("quit", [
157
+ event.nick, event.ident || "", event.hostname || "", event.message || "",
158
+ ]))
159
+ }
160
+ })
161
+
162
+ client.on("kick", (event) => {
163
+ if (!eventBus.emit("irc.kick", {
164
+ connectionId, nick: event.nick, ident: event.ident, hostname: event.hostname,
165
+ channel: event.channel, kicked: event.kicked, message: event.message,
166
+ })) return
167
+
168
+ const s = getStore()
169
+ const bufferId = makeBufferId(connectionId, event.channel)
170
+ const conn = s.connections.get(connectionId)
171
+
172
+ if (event.kicked === conn?.nick) {
173
+ s.addMessage(bufferId, makeEventMessage(
174
+ `%Zf7768eYou were kicked from ${event.channel} by %Za9b1d6${event.nick}%Zf7768e (${event.message || ""})%N`
175
+ ))
176
+ } else {
177
+ s.removeNick(bufferId, event.kicked)
178
+ if (shouldIgnore(event.nick, event.ident, event.hostname, "KICKS", event.channel)) return
179
+ s.addMessage(bufferId, makeEventMessage(
180
+ `%Ze0af68${event.kicked}%Z565f89 was kicked by %Za9b1d6${event.nick}%Z565f89 (${event.message || ""})%N`
181
+ ))
182
+ }
183
+ })
184
+
185
+ client.on("privmsg", (event) => {
186
+ const isChannel = isChannelTarget(event.target)
187
+ if (!eventBus.emit("irc.privmsg", {
188
+ connectionId, nick: event.nick, ident: event.ident, hostname: event.hostname,
189
+ target: event.target, message: event.message, tags: event.tags, time: event.time, isChannel,
190
+ })) return
191
+
192
+ const s = getStore()
193
+ const bufferName = isChannel ? event.target : event.nick
194
+ const bufferId = makeBufferId(connectionId, bufferName)
195
+
196
+ // Create query buffer if it doesn't exist
197
+ if (!isChannel && !s.buffers.has(bufferId)) {
198
+ const host = event.ident && event.hostname ? `${event.ident}@${event.hostname}` : undefined
199
+ s.addBuffer({
200
+ id: bufferId,
201
+ connectionId,
202
+ type: BufferType.Query,
203
+ name: event.nick,
204
+ messages: [],
205
+ activity: ActivityLevel.None,
206
+ unreadCount: 0,
207
+ lastRead: new Date(),
208
+ users: new Map(),
209
+ topic: host,
210
+ listModes: new Map(),
211
+ })
212
+ } else if (!isChannel) {
213
+ // Update hostname if we got new info
214
+ const host = event.ident && event.hostname ? `${event.ident}@${event.hostname}` : undefined
215
+ if (host) {
216
+ const buf = s.buffers.get(bufferId)
217
+ if (buf && buf.type === BufferType.Query && buf.topic !== host) {
218
+ s.updateBufferTopic(bufferId, host)
219
+ }
220
+ }
221
+ }
222
+
223
+ const conn = s.connections.get(connectionId)
224
+ const isOwnMsg = event.nick === conn?.nick
225
+ const isMention = !isOwnMsg && conn?.nick
226
+ ? event.message.toLowerCase().includes(conn.nick.toLowerCase())
227
+ : false
228
+
229
+ s.addMessage(bufferId, {
230
+ id: crypto.randomUUID(),
231
+ timestamp: new Date(event.time || Date.now()),
232
+ type: "message",
233
+ nick: event.nick,
234
+ nickMode: getNickMode(s.buffers, bufferId, event.nick),
235
+ text: event.message,
236
+ highlight: isMention,
237
+ tags: event.tags,
238
+ })
239
+
240
+ if (s.activeBufferId !== bufferId && !isOwnMsg) {
241
+ const level = !isChannel ? ActivityLevel.Mention
242
+ : isMention ? ActivityLevel.Mention
243
+ : ActivityLevel.Activity
244
+ s.updateBufferActivity(bufferId, level)
245
+ }
246
+ })
247
+
248
+ client.on("action", (event) => {
249
+ const isChannel = isChannelTarget(event.target)
250
+ if (!eventBus.emit("irc.action", {
251
+ connectionId, nick: event.nick, ident: event.ident, hostname: event.hostname,
252
+ target: event.target, message: event.message, tags: event.tags, time: event.time, isChannel,
253
+ })) return
254
+
255
+ const s = getStore()
256
+ const bufferName = isChannel ? event.target : event.nick
257
+ const bufferId = makeBufferId(connectionId, bufferName)
258
+
259
+ s.addMessage(bufferId, {
260
+ id: crypto.randomUUID(),
261
+ timestamp: new Date(event.time || Date.now()),
262
+ type: "action",
263
+ nick: event.nick,
264
+ text: event.message,
265
+ highlight: false,
266
+ tags: event.tags,
267
+ })
268
+ })
269
+
270
+ client.on("notice", (event) => {
271
+ if (!eventBus.emit("irc.notice", {
272
+ connectionId, nick: event.nick, target: event.target,
273
+ message: event.message, from_server: event.from_server,
274
+ })) return
275
+
276
+ const s = getStore()
277
+ // Server notices go to server buffer
278
+ const bufferId = event.from_server
279
+ ? makeBufferId(connectionId, "Status")
280
+ : makeBufferId(connectionId, event.target && isChannelTarget(event.target) ? event.target : event.nick || "Status")
281
+
282
+ if (!s.buffers.has(bufferId)) {
283
+ // Fallback to server status buffer
284
+ const statusId = makeBufferId(connectionId, "Status")
285
+ s.addMessage(statusId, {
286
+ id: crypto.randomUUID(),
287
+ timestamp: new Date(event.time || Date.now()),
288
+ type: "notice",
289
+ nick: event.nick,
290
+ text: event.message,
291
+ highlight: false,
292
+ })
293
+ return
294
+ }
295
+
296
+ s.addMessage(bufferId, {
297
+ id: crypto.randomUUID(),
298
+ timestamp: new Date(event.time || Date.now()),
299
+ type: "notice",
300
+ nick: event.nick,
301
+ text: event.message,
302
+ highlight: false,
303
+ })
304
+ })
305
+
306
+ client.on("nick", (event) => {
307
+ if (!eventBus.emit("irc.nick", {
308
+ connectionId, nick: event.nick, new_nick: event.new_nick,
309
+ ident: event.ident, hostname: event.hostname,
310
+ })) return
311
+
312
+ const s = getStore()
313
+ const conn = s.connections.get(connectionId)
314
+
315
+ // If it's us, update connection nick
316
+ if (event.nick === conn?.nick) {
317
+ getStore().updateConnection(connectionId, { nick: event.new_nick })
318
+ }
319
+
320
+ // Collect affected buffer IDs first, then mutate with fresh state
321
+ const affected = Array.from(s.buffers.entries())
322
+ .filter(([_, buf]) => buf.connectionId === connectionId && buf.users.has(event.nick))
323
+ .map(([id]) => id)
324
+
325
+ const nickIgnored = shouldIgnore(event.nick, event.ident, event.hostname, "NICKS")
326
+ for (const id of affected) {
327
+ getStore().updateNick(id, event.nick, event.new_nick)
328
+ if (nickIgnored) continue
329
+ if (shouldSuppressNickFlood(connectionId, id)) continue
330
+ getStore().addMessage(id, makeFormattedEvent("nick_change", [
331
+ event.nick, event.new_nick,
332
+ ]))
333
+ }
334
+ })
335
+
336
+ client.on("topic", (event) => {
337
+ if (!eventBus.emit("irc.topic", {
338
+ connectionId, nick: event.nick, channel: event.channel, topic: event.topic,
339
+ })) return
340
+
341
+ const s = getStore()
342
+ const bufferId = makeBufferId(connectionId, event.channel)
343
+ s.updateBufferTopic(bufferId, event.topic, event.nick)
344
+ if (event.nick) {
345
+ getStore().addMessage(bufferId, makeFormattedEvent("topic", [
346
+ event.nick, event.topic,
347
+ ]))
348
+ }
349
+ })
350
+
351
+ client.on("topicsetby", (event) => {
352
+ const bufferId = makeBufferId(connectionId, event.channel)
353
+ const when = event.when ? formatDate(new Date(event.when * 1000)) : ""
354
+ getStore().addMessage(bufferId, makeEventMessage(
355
+ `%Z565f89Topic set by %Za9b1d6${event.nick}%Z565f89${when ? " on " + when : ""}%N`
356
+ ))
357
+ })
358
+
359
+ client.on("userlist", (event) => {
360
+ const bufferId = makeBufferId(connectionId, event.channel)
361
+ if (!getStore().buffers.has(bufferId)) return
362
+
363
+ const conn = getStore().connections.get(connectionId)
364
+ const prefixMap = buildPrefixMap(conn?.isupport?.PREFIX)
365
+ const modeOrder = buildModeOrder(conn?.isupport?.PREFIX)
366
+
367
+ for (const user of event.users) {
368
+ // irc-framework gives modes as array of chars (["o","v"]) or prefix symbols (["@","+"])
369
+ // Normalize to mode chars and store all of them
370
+ const rawModes = (user.modes ?? [])
371
+ .map((m) => {
372
+ // If it's already a mode char in the order list, keep it
373
+ if (modeOrder.includes(m)) return m
374
+ // Otherwise it's a prefix symbol — reverse-lookup
375
+ for (const [modeChar, sym] of Object.entries(prefixMap)) {
376
+ if (sym === m && modeOrder.includes(modeChar)) return modeChar
377
+ }
378
+ return ""
379
+ })
380
+ .filter(Boolean)
381
+ .join("")
382
+ const prefix = getHighestPrefix(rawModes, modeOrder, prefixMap)
383
+ getStore().addNick(bufferId, {
384
+ nick: user.nick,
385
+ prefix,
386
+ modes: rawModes,
387
+ away: !!user.away,
388
+ account: user.account,
389
+ })
390
+ }
391
+ })
392
+
393
+ client.on("mode", (event) => {
394
+ if (!eventBus.emit("irc.mode", {
395
+ connectionId, nick: event.nick, target: event.target,
396
+ modes: Array.isArray(event.modes) ? event.modes : [],
397
+ })) return
398
+
399
+ const s = getStore()
400
+ const bufferId = makeBufferId(connectionId, event.target)
401
+ if (!s.buffers.has(bufferId)) return
402
+
403
+ // Build displayable mode string
404
+ const modeStr = buildModeString(event)
405
+ getStore().addMessage(bufferId, makeFormattedEvent("mode", [
406
+ event.nick || "server", modeStr, event.target,
407
+ ]))
408
+
409
+ // Update nick prefixes for user prefix modes (+o, +v, etc.)
410
+ if (!Array.isArray(event.modes)) return
411
+ const conn = getStore().connections.get(connectionId)
412
+ const prefixMap = buildPrefixMap(conn?.isupport?.PREFIX)
413
+ const modeOrder = buildModeOrder(conn?.isupport?.PREFIX)
414
+
415
+ for (const mc of event.modes) {
416
+ if (!mc.param) continue
417
+ const isAdding = mc.mode.startsWith("+")
418
+ const modeChar = mc.mode.replace(/[+-]/, "")
419
+ if (!modeOrder.includes(modeChar)) continue // not a nick prefix mode
420
+
421
+ const buf = getStore().buffers.get(bufferId)
422
+ const entry = buf?.users.get(mc.param)
423
+ if (!entry) continue
424
+
425
+ // Add or remove this specific mode char from the user's modes string
426
+ let modes = entry.modes ?? ""
427
+ if (isAdding && !modes.includes(modeChar)) {
428
+ modes += modeChar
429
+ } else if (!isAdding) {
430
+ modes = modes.replace(modeChar, "")
431
+ }
432
+
433
+ getStore().addNick(bufferId, {
434
+ ...entry,
435
+ modes,
436
+ prefix: getHighestPrefix(modes, modeOrder, prefixMap),
437
+ })
438
+ }
439
+
440
+ // Update channel modes (non-nick-prefix, non-list modes)
441
+ const listModes = getListModes(connectionId)
442
+ const buf = getStore().buffers.get(bufferId)
443
+ if (buf) {
444
+ let chanModes = buf.modes ?? ""
445
+ const params: Record<string, string> = { ...buf.modeParams }
446
+ for (const mc of event.modes) {
447
+ const isAdding = mc.mode.startsWith("+")
448
+ const modeChar = mc.mode.replace(/[+-]/, "")
449
+ if (modeOrder.includes(modeChar)) continue // nick prefix mode
450
+ if (listModes.has(modeChar)) {
451
+ // Track list mode changes in store
452
+ if (isAdding && mc.param) {
453
+ getStore().addListEntry(bufferId, modeChar, {
454
+ mask: mc.param,
455
+ setBy: event.nick || "server",
456
+ setAt: Date.now() / 1000,
457
+ })
458
+ } else if (!isAdding && mc.param) {
459
+ getStore().removeListEntry(bufferId, modeChar, mc.param)
460
+ }
461
+ continue // don't add to channel modes string
462
+ }
463
+ if (isAdding && !chanModes.includes(modeChar)) {
464
+ chanModes += modeChar
465
+ } else if (!isAdding) {
466
+ chanModes = chanModes.replace(modeChar, "")
467
+ delete params[modeChar]
468
+ }
469
+ if (isAdding && mc.param) {
470
+ params[modeChar] = mc.param
471
+ }
472
+ }
473
+ getStore().updateBufferModes(bufferId, chanModes, params)
474
+ }
475
+ })
476
+
477
+ // Lag measurement via CTCP PING or PONG
478
+ let lastPingSent = 0
479
+ const lagPingInterval = setInterval(() => {
480
+ if (getStore().connections.get(connectionId)?.status === "connected") {
481
+ lastPingSent = Date.now()
482
+ client.raw("PING " + lastPingSent)
483
+ }
484
+ }, 30000)
485
+
486
+ client.on("pong", () => {
487
+ if (lastPingSent > 0) {
488
+ const lag = Date.now() - lastPingSent
489
+ getStore().updateConnection(connectionId, { lag })
490
+ }
491
+ })
492
+
493
+ client.on("close", () => {
494
+ eventBus.emit("disconnected", { connectionId })
495
+
496
+ clearInterval(lagPingInterval)
497
+ destroyNetsplitState(connectionId)
498
+ destroyAntifloodState(connectionId)
499
+ getStore().updateConnection(connectionId, { status: "disconnected" })
500
+ statusMsg("%Zf7768eDisconnected from server%N")
501
+ })
502
+
503
+ client.on("reconnecting", (event) => {
504
+ getStore().updateConnection(connectionId, { status: "connecting" })
505
+ const attempt = event?.attempt ?? "?"
506
+ const max = event?.max_retries ?? "?"
507
+ statusMsg(`%Ze0af68Reconnecting (attempt ${attempt}/${max})...%N`)
508
+ })
509
+
510
+ client.on("error", (event) => {
511
+ console.error(`[${connectionId}] IRC error:`, event)
512
+ statusMsg(`%Zf7768eError: ${event.message || event.error || JSON.stringify(event)}%N`)
513
+ })
514
+
515
+ client.on("irc error", (event) => {
516
+ const s = getStore()
517
+
518
+ // Route to channel buffer if available
519
+ let targetBuffer = statusId
520
+ if (event.channel) {
521
+ const chanBufferId = makeBufferId(connectionId, event.channel)
522
+ if (s.buffers.has(chanBufferId)) {
523
+ targetBuffer = chanBufferId
524
+ }
525
+ }
526
+
527
+ // Build message with context
528
+ const prefix = event.nick ? `${event.nick}: `
529
+ : event.channel ? `${event.channel}: `
530
+ : ""
531
+ const reason = event.reason
532
+ || event.message
533
+ || (event.error ? event.error.replace(/_/g, " ") : "Unknown error")
534
+
535
+ s.addMessage(targetBuffer, makeEventMessage(`%Zf7768e${prefix}${reason}%N`))
536
+ })
537
+
538
+ // Nick in use — irc-framework does NOT auto-retry, we must send alternate nick
539
+ let nickRetries = 0
540
+ client.on("nick in use", (event) => {
541
+ nickRetries++
542
+ if (nickRetries > 5) {
543
+ statusMsg(`%Zf7768eCould not find available nick after 5 attempts%N`)
544
+ return
545
+ }
546
+ const newNick = event.nick + "_"
547
+ statusMsg(`%Ze0af68Nick ${event.nick} is already in use, trying ${newNick}...%N`)
548
+ client.changeNick(newNick)
549
+ getStore().updateConnection(connectionId, { nick: newNick })
550
+ })
551
+
552
+ // Invalid nick
553
+ client.on("nick invalid", (event) => {
554
+ statusMsg(`%Zf7768eNick ${event.nick} is invalid: ${event.reason}%N`)
555
+ })
556
+
557
+ // SASL failure
558
+ client.on("sasl failed", (event) => {
559
+ statusMsg(`%Zf7768eSASL authentication failed: ${event.reason}${event.message ? " — " + event.message : ""}%N`)
560
+ })
561
+
562
+ // Store ISUPPORT/server options
563
+ client.on("server options", (event) => {
564
+ const s = getStore()
565
+ s.updateConnection(connectionId, { isupport: event.options || {} })
566
+ })
567
+
568
+ // RPL_UMODEIS — user's own modes (emitted on connect and after MODE <nick>)
569
+ client.on("user info", (event) => {
570
+ const modes = (event.raw_modes || "").replace(/^\+/, "")
571
+ getStore().updateConnection(connectionId, { userModes: modes })
572
+ })
573
+
574
+ // ─── MOTD ──────────────────────────────────────────────────
575
+ client.on("motd", (event) => {
576
+ if (event.error) {
577
+ statusMsg(`%Z565f89${event.error}%N`)
578
+ return
579
+ }
580
+ if (!event.motd) return
581
+ for (const line of event.motd.split("\n")) {
582
+ if (line.trim()) statusMsg(`%Z565f89${line}%N`)
583
+ }
584
+ })
585
+
586
+ // ─── Away / Back ───────────────────────────────────────────
587
+ client.on("away", (event) => {
588
+ const s = getStore()
589
+ if (event.self) {
590
+ statusMsg(`%Z565f89You are now marked as away${event.message ? ": " + event.message : ""}%N`)
591
+ return
592
+ }
593
+ if (!event.nick) return
594
+
595
+ // Update nick away status in all shared channels
596
+ for (const [bufId, buf] of s.buffers) {
597
+ if (buf.connectionId === connectionId && buf.users.has(event.nick)) {
598
+ const entry = buf.users.get(event.nick)!
599
+ getStore().addNick(bufId, { ...entry, away: true })
600
+ }
601
+ }
602
+
603
+ // Show in query buffer if we have one open (RPL_AWAY response to messaging)
604
+ const queryId = makeBufferId(connectionId, event.nick)
605
+ if (s.buffers.has(queryId)) {
606
+ s.addMessage(queryId, makeEventMessage(
607
+ `%Z565f89${event.nick} is away${event.message ? ": " + event.message : ""}%N`
608
+ ))
609
+ }
610
+ })
611
+
612
+ client.on("back", (event) => {
613
+ const s = getStore()
614
+ if (event.self) {
615
+ statusMsg(`%Z565f89You are no longer marked as away%N`)
616
+ return
617
+ }
618
+ if (!event.nick) return
619
+
620
+ // Update nick away status in all shared channels
621
+ for (const [bufId, buf] of s.buffers) {
622
+ if (buf.connectionId === connectionId && buf.users.has(event.nick)) {
623
+ const entry = buf.users.get(event.nick)!
624
+ getStore().addNick(bufId, { ...entry, away: false })
625
+ }
626
+ }
627
+ })
628
+
629
+ // ─── Channel redirect ─────────────────────────────────────
630
+ client.on("channel_redirect", (event) => {
631
+ statusMsg(`%Ze0af68${event.from} is redirecting to ${event.to}%N`)
632
+ })
633
+
634
+ // ─── Invite ────────────────────────────────────────────────
635
+ client.on("invite", (event) => {
636
+ if (!eventBus.emit("irc.invite", {
637
+ connectionId, nick: event.nick, channel: event.channel,
638
+ })) return
639
+
640
+ const s = getStore()
641
+ const target = s.activeBufferId ?? statusId
642
+ s.addMessage(target, makeEventMessage(
643
+ `%Zbb9af7${event.nick} invites you to ${event.channel}%N`
644
+ ))
645
+ })
646
+
647
+ client.on("invited", (event) => {
648
+ const s = getStore()
649
+ const bufferId = makeBufferId(connectionId, event.channel)
650
+ const target = s.buffers.has(bufferId) ? bufferId : statusId
651
+ s.addMessage(target, makeEventMessage(
652
+ `%Z9ece6aInviting ${event.nick} to ${event.channel}%N`
653
+ ))
654
+ })
655
+
656
+ // ─── Ban list ──────────────────────────────────────────────
657
+ client.on("banlist", (event) => {
658
+ const s = getStore()
659
+ const bufferId = makeBufferId(connectionId, event.channel)
660
+ const target = s.buffers.has(bufferId) ? bufferId : statusId
661
+
662
+ // Convert to ListEntry[] and store
663
+ const entries = event.bans.map((ban: any) => ({
664
+ mask: ban.banned,
665
+ setBy: ban.banned_by || "",
666
+ setAt: ban.banned_at || 0,
667
+ }))
668
+ getStore().setListEntries(bufferId, "b", entries)
669
+
670
+ displayNumberedList(target, "Ban list", event.channel, entries)
671
+ })
672
+
673
+ // ─── Exception list (irc-framework "exceptlist" event) ──────
674
+ client.on("exceptlist", (event: any) => {
675
+ const s = getStore()
676
+ const bufferId = makeBufferId(connectionId, event.channel)
677
+ const target = s.buffers.has(bufferId) ? bufferId : statusId
678
+
679
+ const entries = (event.excepts ?? []).map((e: any) => ({
680
+ mask: e.except || "",
681
+ setBy: e.except_by || "",
682
+ setAt: e.except_at ? parseInt(e.except_at, 10) : 0,
683
+ }))
684
+ getStore().setListEntries(bufferId, "e", entries)
685
+ displayNumberedList(target, "Exception list", event.channel, entries)
686
+ })
687
+
688
+ // ─── Invite list (irc-framework "inviteList" event) ────────
689
+ client.on("inviteList", (event: any) => {
690
+ const s = getStore()
691
+ const bufferId = makeBufferId(connectionId, event.channel)
692
+ const target = s.buffers.has(bufferId) ? bufferId : statusId
693
+
694
+ const entries = (event.invites ?? []).map((e: any) => ({
695
+ mask: e.invited || "",
696
+ setBy: e.invited_by || "",
697
+ setAt: e.invited_at ? parseInt(e.invited_at, 10) : 0,
698
+ }))
699
+ getStore().setListEntries(bufferId, "I", entries)
700
+ displayNumberedList(target, "Invite exception list", event.channel, entries)
701
+ })
702
+
703
+ // ─── Login / Account ───────────────────────────────────────
704
+ client.on("loggedin", (event) => {
705
+ statusMsg(`%Z9ece6aLogged in as %Zc0caf5${event.account}%N`)
706
+ })
707
+
708
+ client.on("loggedout", () => {
709
+ statusMsg(`%Ze0af68Logged out from account%N`)
710
+ })
711
+
712
+ // ACCOUNT-NOTIFY — a user's account changed (requires account-notify cap)
713
+ client.on("account", (event) => {
714
+ const s = getStore()
715
+ for (const [bufId, buf] of s.buffers) {
716
+ if (buf.connectionId === connectionId && buf.users.has(event.nick)) {
717
+ const entry = buf.users.get(event.nick)!
718
+ getStore().addNick(bufId, {
719
+ ...entry,
720
+ account: event.account === false ? undefined : event.account,
721
+ })
722
+ }
723
+ }
724
+ })
725
+
726
+ // ─── Displayed host ────────────────────────────────────────
727
+ client.on("displayed host", (event) => {
728
+ statusMsg(`%Z565f89Your displayed host is now %Za9b1d6${event.hostname}%N`)
729
+ })
730
+
731
+ // ─── Channel info (324 modes, 329 creation time, 328 URL) ─
732
+ client.on("channel info", (event) => {
733
+ const bufferId = makeBufferId(connectionId, event.channel)
734
+
735
+ // 324 — RPL_CHANNELMODEIS
736
+ if (event.raw_modes) {
737
+ const listModeSet = getListModes(connectionId)
738
+ // Filter out list mode chars from the displayed modes
739
+ const modeChars = event.raw_modes.replace(/^\+/, "")
740
+ .split("").filter((ch) => !listModeSet.has(ch)).join("")
741
+ const params: Record<string, string> = {}
742
+ if (event.modes) {
743
+ for (const mc of event.modes) {
744
+ const ch = mc.mode.replace(/[+-]/, "")
745
+ if (listModeSet.has(ch)) continue
746
+ if (mc.param) params[ch] = mc.param
747
+ }
748
+ }
749
+ getStore().updateBufferModes(bufferId, modeChars, params)
750
+ }
751
+
752
+ // 329 — RPL_CREATIONTIME
753
+ if (event.created_at) {
754
+ const buf = getStore().buffers.get(bufferId)
755
+ if (buf) {
756
+ getStore().addMessage(bufferId, makeEventMessage(
757
+ `%Z565f89Channel created: ${formatDate(new Date(event.created_at * 1000))}%N`
758
+ ))
759
+ }
760
+ }
761
+ })
762
+
763
+ // ─── Wallops ───────────────────────────────────────────────
764
+ client.on("wallops", (event) => {
765
+ if (!eventBus.emit("irc.wallops", {
766
+ connectionId, nick: event.nick, message: event.message, from_server: event.from_server,
767
+ })) return
768
+
769
+ const from = event.from_server ? "Server" : event.nick
770
+ statusMsg(`%Zbb9af7[Wallops/${from}] ${event.message}%N`)
771
+ })
772
+
773
+ // ─── CTCP ─────────────────────────────────────────────────
774
+ client.on("ctcp response", (event) => {
775
+ if (!eventBus.emit("irc.ctcp_response", {
776
+ connectionId, nick: event.nick, type: event.type, message: event.message,
777
+ })) return
778
+
779
+ const s = getStore()
780
+ const target = s.activeBufferId ?? statusId
781
+ s.addMessage(target, makeEventMessage(
782
+ `%Z565f89CTCP %Za9b1d6${event.type}%Z565f89 reply from %Zc0caf5${event.nick}%Z565f89: ${event.message}%N`
783
+ ))
784
+ })
785
+
786
+ client.on("ctcp request", (event) => {
787
+ // VERSION is handled internally by irc-framework unless version: null
788
+ // Show other CTCP requests (ACTION is handled separately)
789
+ if (event.type === "ACTION" || event.type === "VERSION") return
790
+ if (!eventBus.emit("irc.ctcp_request", {
791
+ connectionId, nick: event.nick, type: event.type, message: event.message,
792
+ })) return
793
+ const s = getStore()
794
+ const target = s.activeBufferId ?? statusId
795
+ s.addMessage(target, makeEventMessage(
796
+ `%Z565f89CTCP %Za9b1d6${event.type}%Z565f89 from %Zc0caf5${event.nick}%Z565f89${event.message ? ": " + event.message : ""}%N`
797
+ ))
798
+ })
799
+
800
+ // ─── Reop list (344/345) ─────────────────────────────────────
801
+ // 344 is mapped to RPL_WHOISCOUNTRY in irc-framework's numerics and gets
802
+ // consumed by the WHOIS handler — it never reaches "unknown command".
803
+ // We intercept it via raw middleware which fires before handler dispatch.
804
+ // 345 (end-of-list) is NOT mapped, so it arrives via "unknown command".
805
+ const reopCollector = new Map<string, { mask: string; setBy: string; setAt: number }[]>()
806
+
807
+ client.use(function reopMiddleware(_client: any, rawEvents: any, _parsedEvents: any) {
808
+ rawEvents.use(function reopHandler(command: string, message: any, _rawLine: string, __client: any, next: () => void) {
809
+ if (command !== "344") { next(); return }
810
+ const params = message.params ?? []
811
+ const channel = params[1]
812
+ // Disambiguate: RPL_REOPLIST has a channel (#...) in param[1],
813
+ // RPL_WHOISCOUNTRY has a nick. Only collect if it's a channel.
814
+ if (channel && isChannelTarget(channel)) {
815
+ if (!reopCollector.has(channel)) reopCollector.set(channel, [])
816
+ reopCollector.get(channel)!.push({
817
+ mask: params[2] || "",
818
+ setBy: params[3] || "",
819
+ setAt: params[4] ? parseInt(params[4], 10) : 0,
820
+ })
821
+ }
822
+ next()
823
+ })
824
+ })
825
+
826
+ // ─── Catch-all for unhandled numerics ──────────────────────
827
+ client.on("unknown command", (command) => {
828
+ // Only handle IRC numerics (3-digit codes)
829
+ if (!/^\d{3}$/.test(command.command)) return
830
+
831
+ const numeric = parseInt(command.command, 10)
832
+ const params = [...command.params]
833
+
834
+ // 345 RPL_ENDOFREOPLIST: <nick> <channel> :End of Channel Reop List
835
+ if (numeric === 345) {
836
+ const channel = params[1]
837
+ const entries = reopCollector.get(channel) ?? []
838
+ reopCollector.delete(channel)
839
+ const bufferId = makeBufferId(connectionId, channel)
840
+ const s = getStore()
841
+ const target = s.buffers.has(bufferId) ? bufferId : statusId
842
+ getStore().setListEntries(bufferId, "R", entries)
843
+ displayNumberedList(target, "Reop list", channel, entries)
844
+ return
845
+ }
846
+
847
+ // First param is usually our nick — skip it
848
+ if (params.length > 1) params.shift()
849
+
850
+ const text = params.join(" ")
851
+ if (!text.trim()) return
852
+
853
+ const isError = numeric >= 400 && numeric < 600
854
+ const s = getStore()
855
+
856
+ if (isError) {
857
+ // Route channel errors to the channel buffer
858
+ let targetBuffer = statusId
859
+ for (const p of params) {
860
+ if (isChannelTarget(p)) {
861
+ const chanBufferId = makeBufferId(connectionId, p)
862
+ if (s.buffers.has(chanBufferId)) {
863
+ targetBuffer = chanBufferId
864
+ break
865
+ }
866
+ }
867
+ }
868
+ s.addMessage(targetBuffer, makeEventMessage(`%Zf7768e${text}%N`))
869
+ } else {
870
+ statusMsg(`%Z565f89${text}%N`)
871
+ }
872
+ })
873
+
874
+ // ─── Whois response ──────────────────────────────────────
875
+ client.on("whois", (event) => {
876
+ const s = getStore()
877
+ const targetBuffer = s.activeBufferId
878
+ if (!targetBuffer) return
879
+
880
+ if (event.error) {
881
+ s.addMessage(targetBuffer, makeEventMessage(
882
+ `%Zf7768e${event.nick || "?"}: No such nick/channel%N`
883
+ ))
884
+ return
885
+ }
886
+
887
+ const lines: string[] = []
888
+ lines.push(`%Z7aa2f7───── WHOIS ${event.nick} ──────────────────────────%N`)
889
+
890
+ if (event.ident && event.hostname) {
891
+ lines.push(`%Zc0caf5${event.nick}%Z565f89 (${event.ident}@${event.hostname})%N`)
892
+ }
893
+
894
+ if (event.real_name) {
895
+ lines.push(` %Za9b1d6${event.real_name}%N`)
896
+ }
897
+
898
+ if (event.channels) {
899
+ lines.push(`%Z565f89 channels: %Za9b1d6${event.channels}%N`)
900
+ }
901
+
902
+ if (event.server) {
903
+ const info = event.server_info ? ` (${event.server_info})` : ""
904
+ lines.push(`%Z565f89 server: %Za9b1d6${event.server}${info}%N`)
905
+ }
906
+
907
+ if (event.account) {
908
+ lines.push(`%Z565f89 account: %Z9ece6a${event.account}%N`)
909
+ }
910
+
911
+ if (event.idle != null) {
912
+ let line = `%Z565f89 idle: %Za9b1d6${formatDuration(event.idle)}`
913
+ if (event.logon) {
914
+ line += `%Z565f89, signon: %Za9b1d6${formatDate(new Date(event.logon * 1000))}`
915
+ }
916
+ lines.push(line + `%N`)
917
+ }
918
+
919
+ if (event.away) {
920
+ lines.push(`%Z565f89 away: %Ze0af68${event.away}%N`)
921
+ }
922
+
923
+ if (event.operator) {
924
+ lines.push(` %Zbb9af7${event.operator}%N`)
925
+ }
926
+
927
+ if (event.secure || event.actually_secure) {
928
+ lines.push(` %Z9ece6ais using a secure connection%N`)
929
+ }
930
+
931
+ if (event.bot) {
932
+ lines.push(` %Z7dcfffis a bot%N`)
933
+ }
934
+
935
+ // RPL_WHOISSPECIAL — can be array or string
936
+ if (event.special) {
937
+ const specials = Array.isArray(event.special) ? event.special : [event.special]
938
+ for (const line of specials) {
939
+ lines.push(` %Zbb9af7${line}%N`)
940
+ }
941
+ }
942
+
943
+ lines.push(`%Z7aa2f7─────────────────────────────────────────────%N`)
944
+
945
+ for (const line of lines) {
946
+ getStore().addMessage(targetBuffer, makeEventMessage(line))
947
+ }
948
+ })
949
+
950
+ // ─── Whowas response ──────────────────────────────────────
951
+ client.on("whowas", (event) => {
952
+ const s = getStore()
953
+ const targetBuffer = s.activeBufferId ?? statusId
954
+
955
+ if (event.error) {
956
+ s.addMessage(targetBuffer, makeEventMessage(
957
+ `%Zf7768e${event.nick}: No such nick in history%N`
958
+ ))
959
+ return
960
+ }
961
+
962
+ const lines: string[] = []
963
+ lines.push(`%Z7aa2f7───── WHOWAS ${event.nick} ──────────────────────────%N`)
964
+
965
+ if (event.ident && event.hostname) {
966
+ lines.push(`%Zc0caf5${event.nick}%Z565f89 was (${event.ident}@${event.hostname})%N`)
967
+ }
968
+
969
+ if (event.real_name) {
970
+ lines.push(` %Za9b1d6${event.real_name}%N`)
971
+ }
972
+
973
+ lines.push(`%Z7aa2f7─────────────────────────────────────────────%N`)
974
+
975
+ for (const line of lines) {
976
+ getStore().addMessage(targetBuffer, makeEventMessage(line))
977
+ }
978
+ })
979
+ }
980
+
981
+ /** Display a numbered list of ListEntry items. */
982
+ function displayNumberedList(
983
+ target: string,
984
+ label: string,
985
+ channel: string,
986
+ entries: { mask: string; setBy: string; setAt: number }[],
987
+ ) {
988
+ const s = useStore.getState()
989
+ if (entries.length === 0) {
990
+ s.addMessage(target, makeEventMessage(
991
+ `%Z565f89${channel}: ${label} is empty%N`
992
+ ))
993
+ return
994
+ }
995
+ s.addMessage(target, makeEventMessage(
996
+ `%Z7aa2f7───── ${label} for ${channel} ─────%N`
997
+ ))
998
+ for (let i = 0; i < entries.length; i++) {
999
+ const e = entries[i]
1000
+ const by = e.setBy ? ` set by ${e.setBy}` : ""
1001
+ const at = e.setAt ? ` [${formatDate(new Date(e.setAt * 1000))}]` : ""
1002
+ s.addMessage(target, makeEventMessage(
1003
+ `%Ze0af68${(i + 1).toString().padStart(2)}.%N %Za9b1d6${e.mask}%Z565f89${by}${at}%N`
1004
+ ))
1005
+ }
1006
+ }
1007
+
1008
+ /** System/inline event — text may contain %Z color codes. */
1009
+ function makeEventMessage(text: string): Message {
1010
+ return {
1011
+ id: crypto.randomUUID(),
1012
+ timestamp: new Date(),
1013
+ type: "event",
1014
+ text,
1015
+ highlight: false,
1016
+ }
1017
+ }
1018
+
1019
+ /** IRC event with theme format key — rendered via [formats.events] in MessageLine. */
1020
+ function makeFormattedEvent(key: string, params: string[]): Message {
1021
+ return {
1022
+ id: crypto.randomUUID(),
1023
+ timestamp: new Date(),
1024
+ type: "event",
1025
+ text: params.join(" "),
1026
+ eventKey: key,
1027
+ eventParams: params,
1028
+ highlight: false,
1029
+ }
1030
+ }
1031
+