kokoirc 0.2.2 → 0.2.4
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.
- package/LICENSE +21 -0
- package/README.md +68 -40
- package/docs/commands/clear.md +26 -0
- package/docs/commands/image.md +47 -0
- package/docs/commands/invite.md +23 -0
- package/docs/commands/kill.md +24 -0
- package/docs/commands/names.md +25 -0
- package/docs/commands/oper.md +24 -0
- package/docs/commands/preview.md +31 -0
- package/docs/commands/quote.md +29 -0
- package/docs/commands/server.md +6 -0
- package/docs/commands/stats.md +31 -0
- package/docs/commands/topic.md +12 -6
- package/docs/commands/version.md +23 -0
- package/docs/commands/wallops.md +24 -0
- package/package.json +46 -3
- package/src/app/App.tsx +11 -1
- package/src/core/commands/help-formatter.ts +1 -1
- package/src/core/commands/helpers.ts +3 -1
- package/src/core/commands/registry.ts +251 -6
- package/src/core/config/defaults.ts +11 -0
- package/src/core/config/loader.ts +5 -0
- package/src/core/constants.ts +3 -0
- package/src/core/image-preview/cache.ts +108 -0
- package/src/core/image-preview/detect.ts +105 -0
- package/src/core/image-preview/encode.ts +116 -0
- package/src/core/image-preview/fetch.ts +174 -0
- package/src/core/image-preview/index.ts +6 -0
- package/src/core/image-preview/render.ts +222 -0
- package/src/core/image-preview/stdin-guard.ts +33 -0
- package/src/core/init.ts +2 -1
- package/src/core/irc/antiflood.ts +2 -1
- package/src/core/irc/client.ts +3 -2
- package/src/core/irc/events.ts +121 -47
- package/src/core/irc/netsplit.ts +2 -1
- package/src/core/scripts/api.ts +3 -2
- package/src/core/state/store.ts +261 -3
- package/src/core/storage/index.ts +2 -2
- package/src/core/storage/writer.ts +12 -10
- package/src/core/theme/renderer.tsx +29 -1
- package/src/core/utils/id.ts +2 -0
- package/src/types/config.ts +14 -0
- package/src/types/index.ts +1 -2
- package/src/ui/chat/ChatView.tsx +11 -5
- package/src/ui/chat/MessageLine.tsx +18 -1
- package/src/ui/input/CommandInput.tsx +2 -1
- package/src/ui/layout/AppLayout.tsx +3 -1
- package/src/ui/overlay/ImagePreview.tsx +77 -0
package/src/core/irc/events.ts
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import type { Client } from "kofany-irc-framework"
|
|
2
2
|
import { useStore } from "@/core/state/store"
|
|
3
3
|
import { makeBufferId, BufferType, ActivityLevel } from "@/types"
|
|
4
|
-
import type { Message } from "@/types"
|
|
4
|
+
import type { Message, NickEntry } from "@/types"
|
|
5
|
+
import { nextMsgId } from "@/core/utils/id"
|
|
5
6
|
import { formatDuration, formatDate, buildModeString, buildPrefixMap, buildModeOrder, getHighestPrefix, getNickMode } from "./formatting"
|
|
6
7
|
import { handleNetsplitQuit, handleNetsplitJoin, destroyNetsplitState } from "./netsplit"
|
|
7
8
|
import { shouldSuppressNickFlood, destroyAntifloodState } from "./antiflood"
|
|
8
9
|
import { shouldIgnore } from "./ignore"
|
|
9
10
|
import { eventBus } from "@/core/scripts/event-bus"
|
|
11
|
+
import { parseCommand } from "@/core/commands/parser"
|
|
12
|
+
import { executeCommand } from "@/core/commands/execution"
|
|
10
13
|
|
|
11
14
|
function isChannelTarget(target: string): boolean {
|
|
12
15
|
return target.startsWith("#") || target.startsWith("&") || target.startsWith("+") || target.startsWith("!")
|
|
@@ -53,15 +56,18 @@ export function bindEvents(client: Client, connectionId: string) {
|
|
|
53
56
|
const s = getStore()
|
|
54
57
|
s.updateConnection(connectionId, { status: "connected", nick: event.nick })
|
|
55
58
|
statusMsg(`%Z9ece6aRegistered as %Zc0caf5${event.nick}%N`)
|
|
56
|
-
// Auto-join channels from config
|
|
57
59
|
const config = s.config
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
60
|
+
const serverConfig = config
|
|
61
|
+
? Object.entries(config.servers).find(([id]) => id === connectionId)?.[1]
|
|
62
|
+
: undefined
|
|
63
|
+
|
|
64
|
+
// Execute autosendcmd before autojoin (erssi-style: ";"-separated, WAIT <ms> for delays)
|
|
65
|
+
if (serverConfig?.autosendcmd) {
|
|
66
|
+
runAutosendcmd(serverConfig.autosendcmd, connectionId, event.nick, () => {
|
|
67
|
+
autojoinChannels(client, serverConfig)
|
|
68
|
+
})
|
|
69
|
+
} else {
|
|
70
|
+
if (serverConfig) autojoinChannels(client, serverConfig)
|
|
65
71
|
}
|
|
66
72
|
})
|
|
67
73
|
|
|
@@ -140,10 +146,8 @@ export function bindEvents(client: Client, connectionId: string) {
|
|
|
140
146
|
.filter(([_, buf]) => buf.connectionId === connectionId && buf.users.has(event.nick))
|
|
141
147
|
.map(([id]) => id)
|
|
142
148
|
|
|
143
|
-
//
|
|
144
|
-
|
|
145
|
-
getStore().removeNick(id, event.nick)
|
|
146
|
-
}
|
|
149
|
+
// Batch-remove nick from all affected channels (1 set() call)
|
|
150
|
+
getStore().batchRemoveNick(affected.map(id => ({ bufferId: id, nick: event.nick })))
|
|
147
151
|
|
|
148
152
|
// Check if this is a netsplit — if so, batch it instead of showing individual quits
|
|
149
153
|
if (handleNetsplitQuit(connectionId, event.nick, event.message || "", affected)) {
|
|
@@ -152,11 +156,12 @@ export function bindEvents(client: Client, connectionId: string) {
|
|
|
152
156
|
|
|
153
157
|
if (shouldIgnore(event.nick, event.ident, event.hostname, "QUITS")) return
|
|
154
158
|
|
|
155
|
-
|
|
156
|
-
|
|
159
|
+
getStore().batchAddMessage(affected.map(id => ({
|
|
160
|
+
bufferId: id,
|
|
161
|
+
message: makeFormattedEvent("quit", [
|
|
157
162
|
event.nick, event.ident || "", event.hostname || "", event.message || "",
|
|
158
|
-
])
|
|
159
|
-
}
|
|
163
|
+
]),
|
|
164
|
+
})))
|
|
160
165
|
})
|
|
161
166
|
|
|
162
167
|
client.on("kick", (event) => {
|
|
@@ -227,14 +232,13 @@ export function bindEvents(client: Client, connectionId: string) {
|
|
|
227
232
|
: false
|
|
228
233
|
|
|
229
234
|
s.addMessage(bufferId, {
|
|
230
|
-
id:
|
|
235
|
+
id: nextMsgId(),
|
|
231
236
|
timestamp: new Date(event.time || Date.now()),
|
|
232
237
|
type: "message",
|
|
233
238
|
nick: event.nick,
|
|
234
239
|
nickMode: getNickMode(s.buffers, bufferId, event.nick),
|
|
235
240
|
text: event.message,
|
|
236
241
|
highlight: isMention,
|
|
237
|
-
tags: event.tags,
|
|
238
242
|
})
|
|
239
243
|
|
|
240
244
|
if (s.activeBufferId !== bufferId && !isOwnMsg) {
|
|
@@ -257,13 +261,12 @@ export function bindEvents(client: Client, connectionId: string) {
|
|
|
257
261
|
const bufferId = makeBufferId(connectionId, bufferName)
|
|
258
262
|
|
|
259
263
|
s.addMessage(bufferId, {
|
|
260
|
-
id:
|
|
264
|
+
id: nextMsgId(),
|
|
261
265
|
timestamp: new Date(event.time || Date.now()),
|
|
262
266
|
type: "action",
|
|
263
267
|
nick: event.nick,
|
|
264
268
|
text: event.message,
|
|
265
269
|
highlight: false,
|
|
266
|
-
tags: event.tags,
|
|
267
270
|
})
|
|
268
271
|
})
|
|
269
272
|
|
|
@@ -283,7 +286,7 @@ export function bindEvents(client: Client, connectionId: string) {
|
|
|
283
286
|
// Fallback to server status buffer
|
|
284
287
|
const statusId = makeBufferId(connectionId, "Status")
|
|
285
288
|
s.addMessage(statusId, {
|
|
286
|
-
id:
|
|
289
|
+
id: nextMsgId(),
|
|
287
290
|
timestamp: new Date(event.time || Date.now()),
|
|
288
291
|
type: "notice",
|
|
289
292
|
nick: event.nick,
|
|
@@ -294,7 +297,7 @@ export function bindEvents(client: Client, connectionId: string) {
|
|
|
294
297
|
}
|
|
295
298
|
|
|
296
299
|
s.addMessage(bufferId, {
|
|
297
|
-
id:
|
|
300
|
+
id: nextMsgId(),
|
|
298
301
|
timestamp: new Date(event.time || Date.now()),
|
|
299
302
|
type: "notice",
|
|
300
303
|
nick: event.nick,
|
|
@@ -322,14 +325,24 @@ export function bindEvents(client: Client, connectionId: string) {
|
|
|
322
325
|
.filter(([_, buf]) => buf.connectionId === connectionId && buf.users.has(event.nick))
|
|
323
326
|
.map(([id]) => id)
|
|
324
327
|
|
|
328
|
+
// Batch-update nick across all affected buffers (1 set() call)
|
|
329
|
+
getStore().batchUpdateNick(affected.map(id => ({
|
|
330
|
+
bufferId: id, oldNick: event.nick, newNick: event.new_nick,
|
|
331
|
+
})))
|
|
332
|
+
|
|
325
333
|
const nickIgnored = shouldIgnore(event.nick, event.ident, event.hostname, "NICKS")
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
334
|
+
if (!nickIgnored) {
|
|
335
|
+
const msgEntries: Array<{ bufferId: string; message: Message }> = []
|
|
336
|
+
for (const id of affected) {
|
|
337
|
+
if (shouldSuppressNickFlood(connectionId, id)) continue
|
|
338
|
+
msgEntries.push({
|
|
339
|
+
bufferId: id,
|
|
340
|
+
message: makeFormattedEvent("nick_change", [event.nick, event.new_nick]),
|
|
341
|
+
})
|
|
342
|
+
}
|
|
343
|
+
if (msgEntries.length > 0) {
|
|
344
|
+
getStore().batchAddMessage(msgEntries)
|
|
345
|
+
}
|
|
333
346
|
}
|
|
334
347
|
})
|
|
335
348
|
|
|
@@ -364,11 +377,12 @@ export function bindEvents(client: Client, connectionId: string) {
|
|
|
364
377
|
const prefixMap = buildPrefixMap(conn?.isupport?.PREFIX)
|
|
365
378
|
const modeOrder = buildModeOrder(conn?.isupport?.PREFIX)
|
|
366
379
|
|
|
380
|
+
const nickEntries: Array<{ bufferId: string; entry: NickEntry }> = []
|
|
367
381
|
for (const user of event.users) {
|
|
368
382
|
// irc-framework gives modes as array of chars (["o","v"]) or prefix symbols (["@","+"])
|
|
369
383
|
// Normalize to mode chars and store all of them
|
|
370
384
|
const rawModes = (user.modes ?? [])
|
|
371
|
-
.map((m) => {
|
|
385
|
+
.map((m: string) => {
|
|
372
386
|
// If it's already a mode char in the order list, keep it
|
|
373
387
|
if (modeOrder.includes(m)) return m
|
|
374
388
|
// Otherwise it's a prefix symbol — reverse-lookup
|
|
@@ -380,14 +394,14 @@ export function bindEvents(client: Client, connectionId: string) {
|
|
|
380
394
|
.filter(Boolean)
|
|
381
395
|
.join("")
|
|
382
396
|
const prefix = getHighestPrefix(rawModes, modeOrder, prefixMap)
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
prefix,
|
|
386
|
-
modes: rawModes,
|
|
387
|
-
away: !!user.away,
|
|
388
|
-
account: user.account,
|
|
397
|
+
nickEntries.push({
|
|
398
|
+
bufferId,
|
|
399
|
+
entry: { nick: user.nick, prefix, modes: rawModes, away: !!user.away, account: user.account },
|
|
389
400
|
})
|
|
390
401
|
}
|
|
402
|
+
if (nickEntries.length > 0) {
|
|
403
|
+
getStore().batchAddNick(nickEntries)
|
|
404
|
+
}
|
|
391
405
|
})
|
|
392
406
|
|
|
393
407
|
client.on("mode", (event) => {
|
|
@@ -592,18 +606,22 @@ export function bindEvents(client: Client, connectionId: string) {
|
|
|
592
606
|
}
|
|
593
607
|
if (!event.nick) return
|
|
594
608
|
|
|
595
|
-
//
|
|
609
|
+
// Batch-update nick away status in all shared channels (1 set() call)
|
|
610
|
+
const nickEntries: Array<{ bufferId: string; entry: NickEntry }> = []
|
|
596
611
|
for (const [bufId, buf] of s.buffers) {
|
|
597
612
|
if (buf.connectionId === connectionId && buf.users.has(event.nick)) {
|
|
598
613
|
const entry = buf.users.get(event.nick)!
|
|
599
|
-
|
|
614
|
+
nickEntries.push({ bufferId: bufId, entry: { ...entry, away: true } })
|
|
600
615
|
}
|
|
601
616
|
}
|
|
617
|
+
if (nickEntries.length > 0) {
|
|
618
|
+
getStore().batchAddNick(nickEntries)
|
|
619
|
+
}
|
|
602
620
|
|
|
603
621
|
// Show in query buffer if we have one open (RPL_AWAY response to messaging)
|
|
604
622
|
const queryId = makeBufferId(connectionId, event.nick)
|
|
605
623
|
if (s.buffers.has(queryId)) {
|
|
606
|
-
|
|
624
|
+
getStore().addMessage(queryId, makeEventMessage(
|
|
607
625
|
`%Z565f89${event.nick} is away${event.message ? ": " + event.message : ""}%N`
|
|
608
626
|
))
|
|
609
627
|
}
|
|
@@ -617,13 +635,17 @@ export function bindEvents(client: Client, connectionId: string) {
|
|
|
617
635
|
}
|
|
618
636
|
if (!event.nick) return
|
|
619
637
|
|
|
620
|
-
//
|
|
638
|
+
// Batch-update nick away status in all shared channels (1 set() call)
|
|
639
|
+
const nickEntries: Array<{ bufferId: string; entry: NickEntry }> = []
|
|
621
640
|
for (const [bufId, buf] of s.buffers) {
|
|
622
641
|
if (buf.connectionId === connectionId && buf.users.has(event.nick)) {
|
|
623
642
|
const entry = buf.users.get(event.nick)!
|
|
624
|
-
|
|
643
|
+
nickEntries.push({ bufferId: bufId, entry: { ...entry, away: false } })
|
|
625
644
|
}
|
|
626
645
|
}
|
|
646
|
+
if (nickEntries.length > 0) {
|
|
647
|
+
getStore().batchAddNick(nickEntries)
|
|
648
|
+
}
|
|
627
649
|
})
|
|
628
650
|
|
|
629
651
|
// ─── Channel redirect ─────────────────────────────────────
|
|
@@ -712,15 +734,19 @@ export function bindEvents(client: Client, connectionId: string) {
|
|
|
712
734
|
// ACCOUNT-NOTIFY — a user's account changed (requires account-notify cap)
|
|
713
735
|
client.on("account", (event) => {
|
|
714
736
|
const s = getStore()
|
|
737
|
+
const nickEntries: Array<{ bufferId: string; entry: NickEntry }> = []
|
|
715
738
|
for (const [bufId, buf] of s.buffers) {
|
|
716
739
|
if (buf.connectionId === connectionId && buf.users.has(event.nick)) {
|
|
717
740
|
const entry = buf.users.get(event.nick)!
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
account: event.account === false ? undefined : event.account,
|
|
741
|
+
nickEntries.push({
|
|
742
|
+
bufferId: bufId,
|
|
743
|
+
entry: { ...entry, account: event.account === false ? undefined : event.account },
|
|
721
744
|
})
|
|
722
745
|
}
|
|
723
746
|
}
|
|
747
|
+
if (nickEntries.length > 0) {
|
|
748
|
+
getStore().batchAddNick(nickEntries)
|
|
749
|
+
}
|
|
724
750
|
})
|
|
725
751
|
|
|
726
752
|
// ─── Displayed host ────────────────────────────────────────
|
|
@@ -1008,7 +1034,7 @@ function displayNumberedList(
|
|
|
1008
1034
|
/** System/inline event — text may contain %Z color codes. */
|
|
1009
1035
|
function makeEventMessage(text: string): Message {
|
|
1010
1036
|
return {
|
|
1011
|
-
id:
|
|
1037
|
+
id: nextMsgId(),
|
|
1012
1038
|
timestamp: new Date(),
|
|
1013
1039
|
type: "event",
|
|
1014
1040
|
text,
|
|
@@ -1019,7 +1045,7 @@ function makeEventMessage(text: string): Message {
|
|
|
1019
1045
|
/** IRC event with theme format key — rendered via [formats.events] in MessageLine. */
|
|
1020
1046
|
function makeFormattedEvent(key: string, params: string[]): Message {
|
|
1021
1047
|
return {
|
|
1022
|
-
id:
|
|
1048
|
+
id: nextMsgId(),
|
|
1023
1049
|
timestamp: new Date(),
|
|
1024
1050
|
type: "event",
|
|
1025
1051
|
text: params.join(" "),
|
|
@@ -1029,3 +1055,51 @@ function makeFormattedEvent(key: string, params: string[]): Message {
|
|
|
1029
1055
|
}
|
|
1030
1056
|
}
|
|
1031
1057
|
|
|
1058
|
+
// ─── Autosendcmd ─────────────────────────────────────────
|
|
1059
|
+
|
|
1060
|
+
/** Join channels from config, supporting "channel key" syntax. */
|
|
1061
|
+
function autojoinChannels(client: Client, serverConfig: import("@/types/config").ServerConfig) {
|
|
1062
|
+
for (const entry of serverConfig.channels) {
|
|
1063
|
+
const spaceIdx = entry.indexOf(" ")
|
|
1064
|
+
if (spaceIdx === -1) {
|
|
1065
|
+
client.join(entry)
|
|
1066
|
+
} else {
|
|
1067
|
+
client.join(entry.slice(0, spaceIdx), entry.slice(spaceIdx + 1))
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
/**
|
|
1073
|
+
* Execute autosendcmd string (erssi-style: ";"-separated commands, WAIT <ms> for delays).
|
|
1074
|
+
* Substitutes $N with current nick. Calls onDone() when all commands (including WAITs) complete.
|
|
1075
|
+
*/
|
|
1076
|
+
function runAutosendcmd(cmd: string, connectionId: string, nick: string, onDone: () => void) {
|
|
1077
|
+
const parts = cmd.split(";").map((p) => p.trim()).filter(Boolean)
|
|
1078
|
+
let i = 0
|
|
1079
|
+
|
|
1080
|
+
function next() {
|
|
1081
|
+
while (i < parts.length) {
|
|
1082
|
+
const part = parts[i++]
|
|
1083
|
+
// Variable substitution ($N = nick)
|
|
1084
|
+
const expanded = part.replace(/\$\{?N\}?/g, nick)
|
|
1085
|
+
|
|
1086
|
+
// WAIT <ms> — delay before next command
|
|
1087
|
+
const waitMatch = expanded.match(/^WAIT\s+(\d+)$/i)
|
|
1088
|
+
if (waitMatch) {
|
|
1089
|
+
setTimeout(next, parseInt(waitMatch[1], 10))
|
|
1090
|
+
return
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
// Treat as command: prepend / if missing, parse and execute
|
|
1094
|
+
const line = expanded.startsWith("/") ? expanded : "/" + expanded
|
|
1095
|
+
const parsed = parseCommand(line)
|
|
1096
|
+
if (parsed) {
|
|
1097
|
+
executeCommand(parsed, connectionId)
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
onDone()
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
next()
|
|
1104
|
+
}
|
|
1105
|
+
|
package/src/core/irc/netsplit.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useStore } from "@/core/state/store"
|
|
2
2
|
import { makeBufferId } from "@/types"
|
|
3
3
|
import type { Message } from "@/types"
|
|
4
|
+
import { nextMsgId } from "@/core/utils/id"
|
|
4
5
|
|
|
5
6
|
// ─── Types ───────────────────────────────────────────────────
|
|
6
7
|
|
|
@@ -232,7 +233,7 @@ function tick(connId: string) {
|
|
|
232
233
|
|
|
233
234
|
function makeEventMessage(text: string): Message {
|
|
234
235
|
return {
|
|
235
|
-
id:
|
|
236
|
+
id: nextMsgId(),
|
|
236
237
|
timestamp: new Date(),
|
|
237
238
|
type: "event",
|
|
238
239
|
text,
|
package/src/core/scripts/api.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useStore } from "@/core/state/store"
|
|
2
2
|
import { getClient } from "@/core/irc"
|
|
3
3
|
import { makeBufferId } from "@/types"
|
|
4
|
+
import { nextMsgId } from "@/core/utils/id"
|
|
4
5
|
import { eventBus } from "./event-bus"
|
|
5
6
|
import { EventPriority } from "./types"
|
|
6
7
|
import type {
|
|
@@ -106,7 +107,7 @@ export function createScriptAPI(meta: ScriptMeta, scriptDefaults: Record<string,
|
|
|
106
107
|
const buf = s.activeBufferId
|
|
107
108
|
if (!buf) return
|
|
108
109
|
s.addMessage(buf, {
|
|
109
|
-
id:
|
|
110
|
+
id: nextMsgId(),
|
|
110
111
|
timestamp: new Date(),
|
|
111
112
|
type: "event",
|
|
112
113
|
text,
|
|
@@ -115,7 +116,7 @@ export function createScriptAPI(meta: ScriptMeta, scriptDefaults: Record<string,
|
|
|
115
116
|
},
|
|
116
117
|
addMessage(bufferId, partial) {
|
|
117
118
|
useStore.getState().addMessage(bufferId, {
|
|
118
|
-
id:
|
|
119
|
+
id: nextMsgId(),
|
|
119
120
|
timestamp: new Date(),
|
|
120
121
|
...partial,
|
|
121
122
|
})
|