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
|
@@ -7,7 +7,7 @@ import { commands, findByAlias, getCanonicalName } from "./registry"
|
|
|
7
7
|
|
|
8
8
|
const CATEGORY_ORDER = [
|
|
9
9
|
"Connection", "Channel", "Messaging", "Moderation",
|
|
10
|
-
"Configuration", "Statusbar", "Info",
|
|
10
|
+
"Media", "Configuration", "Statusbar", "Info",
|
|
11
11
|
]
|
|
12
12
|
|
|
13
13
|
// ─── /help (no args) — categorized command list ─────────────
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useStore } from "@/core/state/store"
|
|
2
2
|
import { makeBufferId, BufferType } from "@/types"
|
|
3
3
|
import type { AppConfig } from "@/types/config"
|
|
4
|
+
import { nextMsgId } from "@/core/utils/id"
|
|
4
5
|
import { CREDENTIAL_FIELDS } from "./types"
|
|
5
6
|
import type { ResolvedConfig } from "./types"
|
|
6
7
|
|
|
@@ -10,7 +11,7 @@ export function addLocalEvent(text: string) {
|
|
|
10
11
|
const buf = s.activeBufferId
|
|
11
12
|
if (!buf) return
|
|
12
13
|
s.addMessage(buf, {
|
|
13
|
-
id:
|
|
14
|
+
id: nextMsgId(),
|
|
14
15
|
timestamp: new Date(),
|
|
15
16
|
type: "event",
|
|
16
17
|
text,
|
|
@@ -155,6 +156,7 @@ export function listAllSettings(config: AppConfig): void {
|
|
|
155
156
|
showSection("sidepanel.left", "sidepanel.left", config.sidepanel.left)
|
|
156
157
|
showSection("sidepanel.right", "sidepanel.right", config.sidepanel.right)
|
|
157
158
|
showSection("statusbar", "statusbar", config.statusbar)
|
|
159
|
+
showSection("image_preview", "image_preview", config.image_preview)
|
|
158
160
|
|
|
159
161
|
if (Object.keys(config.aliases).length > 0) {
|
|
160
162
|
showSection("aliases", "aliases", config.aliases)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { getClient, connectServer, disconnectServer, getAllClientIds } from "@/core/irc"
|
|
2
2
|
import { useStore } from "@/core/state/store"
|
|
3
|
+
import { nextMsgId } from "@/core/utils/id"
|
|
3
4
|
import { loadConfig, saveConfig, saveCredentialsToEnv, cloneConfig } from "@/core/config/loader"
|
|
4
5
|
import { loadTheme } from "@/core/theme/loader"
|
|
5
6
|
import { BufferType, makeBufferId, ActivityLevel } from "@/types"
|
|
@@ -192,7 +193,7 @@ export const commands: Record<string, CommandDef> = {
|
|
|
192
193
|
// Show the sent message in the target buffer
|
|
193
194
|
if (s.buffers.has(bufferId)) {
|
|
194
195
|
s.addMessage(bufferId, {
|
|
195
|
-
id:
|
|
196
|
+
id: nextMsgId(),
|
|
196
197
|
timestamp: new Date(),
|
|
197
198
|
type: "message",
|
|
198
199
|
nick: conn?.nick ?? "",
|
|
@@ -220,7 +221,7 @@ export const commands: Record<string, CommandDef> = {
|
|
|
220
221
|
client.action(buf.name, args[0])
|
|
221
222
|
const conn = s.connections.get(connId)
|
|
222
223
|
s.addMessage(buf.id, {
|
|
223
|
-
id:
|
|
224
|
+
id: nextMsgId(),
|
|
224
225
|
timestamp: new Date(),
|
|
225
226
|
type: "action",
|
|
226
227
|
nick: conn?.nick ?? "",
|
|
@@ -248,7 +249,7 @@ export const commands: Record<string, CommandDef> = {
|
|
|
248
249
|
|
|
249
250
|
quit: {
|
|
250
251
|
handler(args) {
|
|
251
|
-
const reason = args.join(" ") || "
|
|
252
|
+
const reason = args.join(" ") || "kokoIRC — https://github.com/kofany/kokoIRC"
|
|
252
253
|
const ids = getAllClientIds()
|
|
253
254
|
for (const id of ids) {
|
|
254
255
|
disconnectServer(id, reason)
|
|
@@ -267,13 +268,92 @@ export const commands: Record<string, CommandDef> = {
|
|
|
267
268
|
const s = useStore.getState()
|
|
268
269
|
const buf = s.activeBufferId ? s.buffers.get(s.activeBufferId) : null
|
|
269
270
|
if (args.length >= 2) {
|
|
271
|
+
// /topic #channel new topic text
|
|
270
272
|
client.setTopic(args[0], args[1])
|
|
273
|
+
} else if (args.length === 1 && args[0].startsWith("#")) {
|
|
274
|
+
// /topic #channel — request topic from server
|
|
275
|
+
client.raw(`TOPIC ${args[0]}`)
|
|
271
276
|
} else if (buf && args[0]) {
|
|
277
|
+
// /topic new topic text — set on current channel
|
|
272
278
|
client.setTopic(buf.name, args[0])
|
|
279
|
+
} else if (buf) {
|
|
280
|
+
// /topic — display current topic locally
|
|
281
|
+
if (buf.topic) {
|
|
282
|
+
const setBy = buf.topicSetBy ? ` %Z565f89(set by ${buf.topicSetBy})%N` : ""
|
|
283
|
+
addLocalEvent(`%Z7aa2f7Topic for ${buf.name}:%N ${buf.topic}${setBy}`)
|
|
284
|
+
} else {
|
|
285
|
+
addLocalEvent(`%Z565f89No topic set for ${buf.name}%N`)
|
|
286
|
+
}
|
|
273
287
|
}
|
|
274
288
|
},
|
|
275
289
|
description: "Set or view channel topic",
|
|
276
|
-
usage: "/topic [channel]
|
|
290
|
+
usage: "/topic [channel] [text]",
|
|
291
|
+
},
|
|
292
|
+
|
|
293
|
+
names: {
|
|
294
|
+
handler(args, connId) {
|
|
295
|
+
const client = getClient(connId)
|
|
296
|
+
if (!client) return
|
|
297
|
+
const s = useStore.getState()
|
|
298
|
+
const channel = args[0] || getActiveChannel()
|
|
299
|
+
if (!channel) {
|
|
300
|
+
addLocalEvent(`%Zf7768eUsage: /names [channel]%N`)
|
|
301
|
+
return
|
|
302
|
+
}
|
|
303
|
+
// Display current nick list from buffer state
|
|
304
|
+
const bufferId = s.activeBufferId?.split("/")[0]
|
|
305
|
+
? makeBufferId(s.activeBufferId.split("/")[0], channel)
|
|
306
|
+
: null
|
|
307
|
+
const buf = bufferId ? s.buffers.get(bufferId) : null
|
|
308
|
+
if (buf && buf.users.size > 0) {
|
|
309
|
+
const nicks: string[] = []
|
|
310
|
+
for (const [, entry] of buf.users) {
|
|
311
|
+
nicks.push(`${entry.prefix}${entry.nick}`)
|
|
312
|
+
}
|
|
313
|
+
nicks.sort((a, b) => a.replace(/^[@+%~&!]/, "").localeCompare(b.replace(/^[@+%~&!]/, "")))
|
|
314
|
+
addLocalEvent(`%Z7aa2f7Users on ${channel}%N [${buf.users.size}]: ${nicks.join(" ")}`)
|
|
315
|
+
}
|
|
316
|
+
// Also request fresh NAMES from server (updates nick list panel)
|
|
317
|
+
client.raw(`NAMES ${channel}`)
|
|
318
|
+
},
|
|
319
|
+
description: "List users in a channel",
|
|
320
|
+
usage: "/names [channel]",
|
|
321
|
+
},
|
|
322
|
+
|
|
323
|
+
invite: {
|
|
324
|
+
handler(args, connId) {
|
|
325
|
+
const client = getClient(connId)
|
|
326
|
+
if (!client || !args[0]) {
|
|
327
|
+
addLocalEvent(`%Zf7768eUsage: /invite <nick> [channel]%N`)
|
|
328
|
+
return
|
|
329
|
+
}
|
|
330
|
+
const nick = args[0]
|
|
331
|
+
const channel = args[1] || getActiveChannel()
|
|
332
|
+
if (!channel) {
|
|
333
|
+
addLocalEvent(`%Zf7768eNo active channel — specify a channel: /invite ${nick} #channel%N`)
|
|
334
|
+
return
|
|
335
|
+
}
|
|
336
|
+
client.raw(`INVITE ${nick} ${channel}`)
|
|
337
|
+
},
|
|
338
|
+
description: "Invite a user to a channel",
|
|
339
|
+
usage: "/invite <nick> [channel]",
|
|
340
|
+
},
|
|
341
|
+
|
|
342
|
+
version: {
|
|
343
|
+
handler(args, connId) {
|
|
344
|
+
const client = getClient(connId)
|
|
345
|
+
if (!client) return
|
|
346
|
+
if (args[0]) {
|
|
347
|
+
// /version <nick> — send CTCP VERSION query
|
|
348
|
+
client.ctcpRequest(args[0], "VERSION")
|
|
349
|
+
} else {
|
|
350
|
+
// /version — query server version
|
|
351
|
+
client.raw("VERSION")
|
|
352
|
+
}
|
|
353
|
+
},
|
|
354
|
+
description: "Query server or user client version",
|
|
355
|
+
usage: "/version [nick]",
|
|
356
|
+
aliases: ["ver"],
|
|
277
357
|
},
|
|
278
358
|
|
|
279
359
|
notice: {
|
|
@@ -303,13 +383,34 @@ export const commands: Record<string, CommandDef> = {
|
|
|
303
383
|
} else if (buf.type === BufferType.Query) {
|
|
304
384
|
s.removeBuffer(buf.id)
|
|
305
385
|
} else if (buf.type === BufferType.Server) {
|
|
306
|
-
|
|
386
|
+
const conn = s.connections.get(buf.connectionId)
|
|
387
|
+
const isDefault = buf.connectionId === "_default"
|
|
388
|
+
const isDisconnected = !conn || conn.status === "disconnected" || conn.status === "error"
|
|
389
|
+
|
|
390
|
+
if (!isDefault && !isDisconnected) {
|
|
391
|
+
addLocalEvent(`%Ze0af68Cannot close server buffer while connected. /disconnect first%N`)
|
|
392
|
+
return
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Atomically remove all buffers + connection for this server
|
|
396
|
+
s.closeConnection(buf.connectionId)
|
|
307
397
|
}
|
|
308
398
|
},
|
|
309
399
|
description: "Close current buffer",
|
|
310
400
|
usage: "/close [reason]",
|
|
311
401
|
},
|
|
312
402
|
|
|
403
|
+
clear: {
|
|
404
|
+
handler() {
|
|
405
|
+
const s = useStore.getState()
|
|
406
|
+
if (!s.activeBufferId) return
|
|
407
|
+
s.clearMessages(s.activeBufferId)
|
|
408
|
+
addLocalEvent(`%Z6e738dBuffer cleared%N`)
|
|
409
|
+
},
|
|
410
|
+
description: "Clear current buffer's messages",
|
|
411
|
+
usage: "/clear",
|
|
412
|
+
},
|
|
413
|
+
|
|
313
414
|
whois: {
|
|
314
415
|
handler(args, connId) {
|
|
315
416
|
const client = getClient(connId)
|
|
@@ -411,7 +512,7 @@ export const commands: Record<string, CommandDef> = {
|
|
|
411
512
|
const id = args[1]?.toLowerCase()
|
|
412
513
|
const addrArg = args[2]
|
|
413
514
|
if (!id || !addrArg) {
|
|
414
|
-
addLocalEvent(`%Zf7768eUsage: /server add <id> <address>[:<port>] [-tls] [-noauto] [-bind=<ip>] [-label=<name>] [-password=<pass>] [-sasl=<user>:<pass>]%N`)
|
|
515
|
+
addLocalEvent(`%Zf7768eUsage: /server add <id> <address>[:<port>] [-tls] [-noauto] [-bind=<ip>] [-label=<name>] [-password=<pass>] [-sasl=<user>:<pass>] [-autosendcmd=<cmds>]%N`)
|
|
415
516
|
return
|
|
416
517
|
}
|
|
417
518
|
|
|
@@ -435,6 +536,7 @@ export const commands: Record<string, CommandDef> = {
|
|
|
435
536
|
let sasl_pass: string | undefined
|
|
436
537
|
let label = id
|
|
437
538
|
let tls_verify = true
|
|
539
|
+
let autosendcmd: string | undefined
|
|
438
540
|
|
|
439
541
|
for (let i = 3; i < args.length; i++) {
|
|
440
542
|
const a = args[i]
|
|
@@ -450,6 +552,11 @@ export const commands: Record<string, CommandDef> = {
|
|
|
450
552
|
sasl_user = saslParts[0]
|
|
451
553
|
sasl_pass = saslParts.slice(1).join(":")
|
|
452
554
|
}
|
|
555
|
+
else if (a.startsWith("-autosendcmd=")) {
|
|
556
|
+
// Consumes rest of args since the value contains spaces
|
|
557
|
+
autosendcmd = [a.slice(13), ...args.slice(i + 1)].join(" ").replace(/^"|"$/g, "")
|
|
558
|
+
break
|
|
559
|
+
}
|
|
453
560
|
}
|
|
454
561
|
|
|
455
562
|
const serverConfig: ServerConfig = {
|
|
@@ -463,6 +570,7 @@ export const commands: Record<string, CommandDef> = {
|
|
|
463
570
|
nick,
|
|
464
571
|
bind_ip,
|
|
465
572
|
sasl_user,
|
|
573
|
+
autosendcmd,
|
|
466
574
|
}
|
|
467
575
|
|
|
468
576
|
const s = useStore.getState()
|
|
@@ -1343,6 +1451,79 @@ export const commands: Record<string, CommandDef> = {
|
|
|
1343
1451
|
usage: "/log [status|search] [query]",
|
|
1344
1452
|
},
|
|
1345
1453
|
|
|
1454
|
+
preview: {
|
|
1455
|
+
handler(args) {
|
|
1456
|
+
if (!args[0]) {
|
|
1457
|
+
addLocalEvent(`%Zf7768eUsage: /preview <url>%N`)
|
|
1458
|
+
return
|
|
1459
|
+
}
|
|
1460
|
+
useStore.getState().showImagePreview(args[0])
|
|
1461
|
+
},
|
|
1462
|
+
description: "Preview an image URL in the terminal",
|
|
1463
|
+
usage: "/preview <url>",
|
|
1464
|
+
},
|
|
1465
|
+
|
|
1466
|
+
image: {
|
|
1467
|
+
async handler(args) {
|
|
1468
|
+
const sub = args[0]?.toLowerCase()
|
|
1469
|
+
const s = useStore.getState()
|
|
1470
|
+
const config = s.config?.image_preview
|
|
1471
|
+
|
|
1472
|
+
if (!sub) {
|
|
1473
|
+
addLocalEvent(`%Z7aa2f7───── Image Preview ─────────────────────%N`)
|
|
1474
|
+
addLocalEvent(` %Z565f89Status:%N ${config?.enabled ? "%Z9ece6aenabled%N" : "%Zf7768edisabled%N"}`)
|
|
1475
|
+
addLocalEvent(` %Z565f89Protocol:%N %Zc0caf5${config?.protocol ?? "auto"}%N`)
|
|
1476
|
+
addLocalEvent(` %Z565f89Cache limit:%N %Zc0caf5${config?.cache_max_mb ?? 100}MB%N`)
|
|
1477
|
+
addLocalEvent(`%Z7aa2f7─────────────────────────────────────────────%N`)
|
|
1478
|
+
addLocalEvent(` %Z565f89Commands: stats, clear%N`)
|
|
1479
|
+
addLocalEvent(` %Z565f89Toggle: /set image_preview.enabled true|false%N`)
|
|
1480
|
+
return
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
if (sub === "stats") {
|
|
1484
|
+
const { readdir, stat } = await import("node:fs/promises")
|
|
1485
|
+
const { IMAGE_CACHE_DIR } = await import("@/core/constants")
|
|
1486
|
+
try {
|
|
1487
|
+
const files = await readdir(IMAGE_CACHE_DIR)
|
|
1488
|
+
let totalSize = 0
|
|
1489
|
+
for (const file of files) {
|
|
1490
|
+
const { join } = await import("node:path")
|
|
1491
|
+
const s = await stat(join(IMAGE_CACHE_DIR, file))
|
|
1492
|
+
totalSize += s.size
|
|
1493
|
+
}
|
|
1494
|
+
const sizeMb = (totalSize / 1024 / 1024).toFixed(1)
|
|
1495
|
+
addLocalEvent(`%Z7aa2f7Image cache:%N %Zc0caf5${files.length}%N files, %Zc0caf5${sizeMb}%N MB`)
|
|
1496
|
+
} catch {
|
|
1497
|
+
addLocalEvent(`%Z565f89Cache empty or not initialized%N`)
|
|
1498
|
+
}
|
|
1499
|
+
return
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
if (sub === "clear") {
|
|
1503
|
+
const { readdir, unlink } = await import("node:fs/promises")
|
|
1504
|
+
const { IMAGE_CACHE_DIR } = await import("@/core/constants")
|
|
1505
|
+
const { join } = await import("node:path")
|
|
1506
|
+
try {
|
|
1507
|
+
const files = await readdir(IMAGE_CACHE_DIR)
|
|
1508
|
+
let count = 0
|
|
1509
|
+
for (const file of files) {
|
|
1510
|
+
await unlink(join(IMAGE_CACHE_DIR, file))
|
|
1511
|
+
count++
|
|
1512
|
+
}
|
|
1513
|
+
addLocalEvent(`%Z9ece6aCleared ${count} cached images%N`)
|
|
1514
|
+
} catch {
|
|
1515
|
+
addLocalEvent(`%Z565f89Cache already empty%N`)
|
|
1516
|
+
}
|
|
1517
|
+
return
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
addLocalEvent(`%Zf7768eUnknown subcommand: /image ${sub}. Use: stats, clear%N`)
|
|
1521
|
+
addLocalEvent(`%Z565f89Settings via /set image_preview.<field> — enabled, protocol, max_width, etc.%N`)
|
|
1522
|
+
},
|
|
1523
|
+
description: "Image cache management (settings via /set image_preview.*)",
|
|
1524
|
+
usage: "/image [stats|clear]",
|
|
1525
|
+
},
|
|
1526
|
+
|
|
1346
1527
|
disconnect: {
|
|
1347
1528
|
handler(args, connId) {
|
|
1348
1529
|
const target = args[0]?.toLowerCase()
|
|
@@ -1371,6 +1552,70 @@ export const commands: Record<string, CommandDef> = {
|
|
|
1371
1552
|
description: "Disconnect from a server",
|
|
1372
1553
|
usage: "/disconnect [server-id] [message]",
|
|
1373
1554
|
},
|
|
1555
|
+
|
|
1556
|
+
quote: {
|
|
1557
|
+
handler(args, connId) {
|
|
1558
|
+
const client = getClient(connId)
|
|
1559
|
+
if (!client || args.length === 0) {
|
|
1560
|
+
addLocalEvent(`%Zf7768eUsage: /quote <raw command>%N`)
|
|
1561
|
+
return
|
|
1562
|
+
}
|
|
1563
|
+
client.raw(args.join(" "))
|
|
1564
|
+
},
|
|
1565
|
+
aliases: ["raw"],
|
|
1566
|
+
description: "Send a raw IRC command to the server",
|
|
1567
|
+
usage: "/quote <raw command>",
|
|
1568
|
+
},
|
|
1569
|
+
|
|
1570
|
+
stats: {
|
|
1571
|
+
handler(args, connId) {
|
|
1572
|
+
const client = getClient(connId)
|
|
1573
|
+
if (!client) return
|
|
1574
|
+
client.raw("STATS" + (args.length ? " " + args.join(" ") : ""))
|
|
1575
|
+
},
|
|
1576
|
+
description: "Request server statistics",
|
|
1577
|
+
usage: "/stats [type] [server]",
|
|
1578
|
+
},
|
|
1579
|
+
|
|
1580
|
+
oper: {
|
|
1581
|
+
handler(args, connId) {
|
|
1582
|
+
const client = getClient(connId)
|
|
1583
|
+
if (!client || args.length < 2) {
|
|
1584
|
+
addLocalEvent(`%Zf7768eUsage: /oper <name> <password>%N`)
|
|
1585
|
+
return
|
|
1586
|
+
}
|
|
1587
|
+
client.raw(`OPER ${args[0]} ${args[1]}`)
|
|
1588
|
+
},
|
|
1589
|
+
description: "Authenticate as an IRC operator",
|
|
1590
|
+
usage: "/oper <name> <password>",
|
|
1591
|
+
},
|
|
1592
|
+
|
|
1593
|
+
kill: {
|
|
1594
|
+
handler(args, connId) {
|
|
1595
|
+
const client = getClient(connId)
|
|
1596
|
+
if (!client || args.length === 0) {
|
|
1597
|
+
addLocalEvent(`%Zf7768eUsage: /kill <nick> [reason]%N`)
|
|
1598
|
+
return
|
|
1599
|
+
}
|
|
1600
|
+
const reason = args.slice(1).join(" ") || args[0]
|
|
1601
|
+
client.raw(`KILL ${args[0]} :${reason}`)
|
|
1602
|
+
},
|
|
1603
|
+
description: "Disconnect a user from the network (oper only)",
|
|
1604
|
+
usage: "/kill <nick> [reason]",
|
|
1605
|
+
},
|
|
1606
|
+
|
|
1607
|
+
wallops: {
|
|
1608
|
+
handler(args, connId) {
|
|
1609
|
+
const client = getClient(connId)
|
|
1610
|
+
if (!client || args.length === 0) {
|
|
1611
|
+
addLocalEvent(`%Zf7768eUsage: /wallops <message>%N`)
|
|
1612
|
+
return
|
|
1613
|
+
}
|
|
1614
|
+
client.raw(`WALLOPS :${args.join(" ")}`)
|
|
1615
|
+
},
|
|
1616
|
+
description: "Send a message to all opers (oper only)",
|
|
1617
|
+
usage: "/wallops <message>",
|
|
1618
|
+
},
|
|
1374
1619
|
}
|
|
1375
1620
|
|
|
1376
1621
|
// ─── Built-in Alias Resolution ────────────────────────────────
|
|
@@ -40,6 +40,17 @@ export const DEFAULT_CONFIG: AppConfig = {
|
|
|
40
40
|
input_color: "",
|
|
41
41
|
cursor_color: "",
|
|
42
42
|
},
|
|
43
|
+
image_preview: {
|
|
44
|
+
enabled: true,
|
|
45
|
+
max_width: 0,
|
|
46
|
+
max_height: 0,
|
|
47
|
+
cache_max_mb: 100,
|
|
48
|
+
cache_max_days: 7,
|
|
49
|
+
fetch_timeout: 30,
|
|
50
|
+
max_file_size: 10485760,
|
|
51
|
+
protocol: "auto",
|
|
52
|
+
kitty_format: "rgba",
|
|
53
|
+
},
|
|
43
54
|
servers: {
|
|
44
55
|
ircnet: {
|
|
45
56
|
label: "IRCnet",
|
|
@@ -13,6 +13,7 @@ export function cloneConfig(config: AppConfig): AppConfig {
|
|
|
13
13
|
right: { ...config.sidepanel.right },
|
|
14
14
|
},
|
|
15
15
|
statusbar: { ...config.statusbar, items: [...config.statusbar.items], item_formats: { ...config.statusbar.item_formats } },
|
|
16
|
+
image_preview: { ...config.image_preview },
|
|
16
17
|
servers: Object.fromEntries(
|
|
17
18
|
Object.entries(config.servers).map(([id, srv]) => [id, { ...srv, channels: [...srv.channels] }])
|
|
18
19
|
),
|
|
@@ -42,6 +43,7 @@ export function mergeWithDefaults(partial: Record<string, any>): AppConfig {
|
|
|
42
43
|
right: { ...DEFAULT_CONFIG.sidepanel.right, ...partial.sidepanel?.right },
|
|
43
44
|
},
|
|
44
45
|
statusbar: { ...DEFAULT_CONFIG.statusbar, ...partial.statusbar },
|
|
46
|
+
image_preview: { ...DEFAULT_CONFIG.image_preview, ...partial.image_preview },
|
|
45
47
|
servers: partial.servers ?? {},
|
|
46
48
|
aliases: partial.aliases ?? {},
|
|
47
49
|
ignores: (partial.ignores as IgnoreEntry[] | undefined) ?? [],
|
|
@@ -120,6 +122,7 @@ function cleanServerForTOML(server: ServerConfig): Record<string, any> {
|
|
|
120
122
|
if (server.auto_reconnect === false) obj.auto_reconnect = false
|
|
121
123
|
if (server.reconnect_delay && server.reconnect_delay !== 30) obj.reconnect_delay = server.reconnect_delay
|
|
122
124
|
if (server.reconnect_max_retries != null) obj.reconnect_max_retries = server.reconnect_max_retries
|
|
125
|
+
if (server.autosendcmd) obj.autosendcmd = server.autosendcmd
|
|
123
126
|
// SASL and password stored in config only if NOT in .env — sasl_user kept, pass stripped
|
|
124
127
|
if (server.sasl_user) obj.sasl_user = server.sasl_user
|
|
125
128
|
// password and sasl_pass NOT saved to TOML — they go to .env
|
|
@@ -163,6 +166,8 @@ export async function saveConfig(configPath: string, config: AppConfig): Promise
|
|
|
163
166
|
tomlObj.servers[id] = cleanServerForTOML(server)
|
|
164
167
|
}
|
|
165
168
|
|
|
169
|
+
tomlObj.image_preview = config.image_preview
|
|
170
|
+
|
|
166
171
|
if (config.ignores.length > 0) {
|
|
167
172
|
tomlObj.ignores = config.ignores.map((e) => {
|
|
168
173
|
const obj: Record<string, any> = { mask: e.mask, levels: e.levels }
|
package/src/core/constants.ts
CHANGED
|
@@ -16,5 +16,8 @@ export const SCRIPTS_DIR = join(HOME_DIR, "scripts")
|
|
|
16
16
|
|
|
17
17
|
export const LOG_DB_PATH = join(HOME_DIR, "logs.db")
|
|
18
18
|
|
|
19
|
+
// Image cache
|
|
20
|
+
export const IMAGE_CACHE_DIR = join(HOME_DIR, "image_cache")
|
|
21
|
+
|
|
19
22
|
// Default assets bundled with the package
|
|
20
23
|
export const DEFAULT_THEMES_DIR = join(PKG_DIR, "themes")
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { createHash } from "crypto"
|
|
2
|
+
import { readdir, stat, unlink } from "node:fs/promises"
|
|
3
|
+
import { join, extname } from "node:path"
|
|
4
|
+
import { IMAGE_CACHE_DIR } from "@/core/constants"
|
|
5
|
+
|
|
6
|
+
// Magic bytes for common image formats
|
|
7
|
+
const MAGIC = {
|
|
8
|
+
jpeg: [0xff, 0xd8, 0xff],
|
|
9
|
+
png: [0x89, 0x50, 0x4e, 0x47],
|
|
10
|
+
gif: [0x47, 0x49, 0x46],
|
|
11
|
+
webp: [0x52, 0x49, 0x46, 0x46], // RIFF header (check WEBP at offset 8)
|
|
12
|
+
} as const
|
|
13
|
+
|
|
14
|
+
/** Get the cache file path for a URL */
|
|
15
|
+
export function getCachePath(url: string, ext?: string): string {
|
|
16
|
+
const hash = createHash("sha256").update(url).digest("hex")
|
|
17
|
+
const suffix = ext || extname(new URL(url).pathname) || ".img"
|
|
18
|
+
return join(IMAGE_CACHE_DIR, hash + suffix)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Check if a URL is already cached and valid. Returns path or null. */
|
|
22
|
+
export async function isCached(url: string): Promise<string | null> {
|
|
23
|
+
// Try common extensions since we may not know the original
|
|
24
|
+
const hash = createHash("sha256").update(url).digest("hex")
|
|
25
|
+
const glob = new Bun.Glob(hash + ".*")
|
|
26
|
+
|
|
27
|
+
for await (const file of glob.scan(IMAGE_CACHE_DIR)) {
|
|
28
|
+
const path = join(IMAGE_CACHE_DIR, file)
|
|
29
|
+
if (await validateImage(path)) return path
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Write image data to cache, return the path */
|
|
36
|
+
export async function writeCache(url: string, data: Buffer, ext?: string): Promise<string> {
|
|
37
|
+
const path = getCachePath(url, ext)
|
|
38
|
+
await Bun.write(path, data)
|
|
39
|
+
return path
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Validate an image file by checking magic bytes */
|
|
43
|
+
export async function validateImage(path: string): Promise<boolean> {
|
|
44
|
+
const file = Bun.file(path)
|
|
45
|
+
if (!(await file.exists())) return false
|
|
46
|
+
|
|
47
|
+
const header = new Uint8Array(await file.slice(0, 12).arrayBuffer())
|
|
48
|
+
if (header.length < 3) return false
|
|
49
|
+
|
|
50
|
+
// JPEG
|
|
51
|
+
if (header[0] === 0xff && header[1] === 0xd8 && header[2] === 0xff) return true
|
|
52
|
+
// PNG
|
|
53
|
+
if (header[0] === 0x89 && header[1] === 0x50 && header[2] === 0x4e && header[3] === 0x47) return true
|
|
54
|
+
// GIF
|
|
55
|
+
if (header[0] === 0x47 && header[1] === 0x49 && header[2] === 0x46) return true
|
|
56
|
+
// WEBP (RIFF....WEBP)
|
|
57
|
+
if (header[0] === 0x52 && header[1] === 0x49 && header[2] === 0x46 && header[3] === 0x46 &&
|
|
58
|
+
header[8] === 0x57 && header[9] === 0x45 && header[10] === 0x42 && header[11] === 0x50) return true
|
|
59
|
+
|
|
60
|
+
return false
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Clean up old cache files based on size and age limits */
|
|
64
|
+
export async function cleanupCache(maxMb: number, maxDays: number): Promise<void> {
|
|
65
|
+
const entries: Array<{ path: string; size: number; mtimeMs: number }> = []
|
|
66
|
+
const maxAgeMs = maxDays * 24 * 60 * 60 * 1000
|
|
67
|
+
const now = Date.now()
|
|
68
|
+
|
|
69
|
+
let files: string[]
|
|
70
|
+
try {
|
|
71
|
+
files = await readdir(IMAGE_CACHE_DIR)
|
|
72
|
+
} catch {
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
for (const file of files) {
|
|
77
|
+
const path = join(IMAGE_CACHE_DIR, file)
|
|
78
|
+
try {
|
|
79
|
+
const s = await stat(path)
|
|
80
|
+
if (!s.isFile()) continue
|
|
81
|
+
|
|
82
|
+
// Remove files older than maxDays
|
|
83
|
+
if (now - s.mtimeMs > maxAgeMs) {
|
|
84
|
+
await unlink(path)
|
|
85
|
+
continue
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
entries.push({ path, size: s.size, mtimeMs: s.mtimeMs })
|
|
89
|
+
} catch {
|
|
90
|
+
// Skip files we can't stat
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Sort oldest first, remove until under size limit
|
|
95
|
+
entries.sort((a, b) => a.mtimeMs - b.mtimeMs)
|
|
96
|
+
const maxBytes = maxMb * 1024 * 1024
|
|
97
|
+
let totalSize = entries.reduce((sum, e) => sum + e.size, 0)
|
|
98
|
+
|
|
99
|
+
for (const entry of entries) {
|
|
100
|
+
if (totalSize <= maxBytes) break
|
|
101
|
+
try {
|
|
102
|
+
await unlink(entry.path)
|
|
103
|
+
totalSize -= entry.size
|
|
104
|
+
} catch {
|
|
105
|
+
// Skip
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
export type ImageProtocol = "kitty" | "iterm2" | "sixel" | "symbols"
|
|
2
|
+
|
|
3
|
+
export function isInsideTmux(): boolean {
|
|
4
|
+
return !!process.env.TMUX
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/** Get the tmux pane's absolute position on the outer terminal screen */
|
|
8
|
+
export function getTmuxPaneOffset(): { top: number; left: number } {
|
|
9
|
+
try {
|
|
10
|
+
const result = Bun.spawnSync(["tmux", "display-message", "-p", "#{pane_top}:#{pane_left}"])
|
|
11
|
+
const output = new TextDecoder().decode(result.stdout).trim()
|
|
12
|
+
const [top, left] = output.split(":").map(Number)
|
|
13
|
+
if (!isNaN(top) && !isNaN(left)) return { top, left }
|
|
14
|
+
} catch {}
|
|
15
|
+
return { top: 0, left: 0 }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Match a terminal identifier string to a protocol.
|
|
19
|
+
* Works with both #{client_termtype} (e.g. "iTerm2 3.6.8", "ghostty 1.3.0")
|
|
20
|
+
* and #{client_termname} (e.g. "xterm-ghostty", "xterm-kitty").
|
|
21
|
+
* iterm FIRST, then kitty family, then sixel-capable terminals. */
|
|
22
|
+
function matchTermName(name: string): ImageProtocol | null {
|
|
23
|
+
const t = name.toLowerCase()
|
|
24
|
+
|
|
25
|
+
// iTerm2 — "iTerm2 3.6.8" from termtype, or "iterm2" from termname
|
|
26
|
+
if (t.includes("iterm")) return "iterm2"
|
|
27
|
+
|
|
28
|
+
// Kitty protocol family
|
|
29
|
+
if (t.includes("kitty")) return "kitty"
|
|
30
|
+
if (t.includes("ghostty")) return "kitty"
|
|
31
|
+
if (t.includes("wezterm")) return "kitty"
|
|
32
|
+
if (t.includes("rio")) return "kitty"
|
|
33
|
+
// Subterm: "subterm x.x.x" from termtype, LC_TERMINAL=Subterm without tmux
|
|
34
|
+
if (t.includes("subterm")) return "kitty"
|
|
35
|
+
|
|
36
|
+
// Sixel-capable terminals
|
|
37
|
+
if (t.includes("foot")) return "sixel"
|
|
38
|
+
if (t.includes("contour")) return "sixel"
|
|
39
|
+
if (t.includes("konsole")) return "sixel"
|
|
40
|
+
if (t.includes("mintty")) return "sixel"
|
|
41
|
+
if (t.includes("mlterm")) return "sixel"
|
|
42
|
+
if (t.includes("xterm")) return "sixel"
|
|
43
|
+
|
|
44
|
+
return null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Detect the best image display protocol for the current terminal.
|
|
48
|
+
* Returns [protocol, detectedName] — detectedName is the raw string that matched.
|
|
49
|
+
* In tmux: queries #{client_termtype} first (returns real terminal identity
|
|
50
|
+
* like "iTerm2 3.6.8", "ghostty 1.3.0"), then #{client_termname} as fallback.
|
|
51
|
+
* Outside tmux: checks env vars, then generic TERM. */
|
|
52
|
+
export function detectProtocol(configOverride?: string): [ImageProtocol, string] {
|
|
53
|
+
if (configOverride && configOverride !== "auto") {
|
|
54
|
+
return [configOverride as ImageProtocol, `config:${configOverride}`]
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const inTmux = isInsideTmux()
|
|
58
|
+
|
|
59
|
+
// ─── tmux: query the REAL outer terminal ───────────────────
|
|
60
|
+
if (inTmux) {
|
|
61
|
+
// #{client_termtype} returns the actual terminal identity
|
|
62
|
+
// (e.g. "iTerm2 3.6.8", "ghostty 1.3.0-main+...", "subterm 1.0")
|
|
63
|
+
// unlike #{client_termname} which returns generic "xterm-256color" for iTerm2
|
|
64
|
+
try {
|
|
65
|
+
const result = Bun.spawnSync(["tmux", "display-message", "-p", "#{client_termtype}"])
|
|
66
|
+
const termType = new TextDecoder().decode(result.stdout).trim()
|
|
67
|
+
if (termType) {
|
|
68
|
+
const match = matchTermName(termType)
|
|
69
|
+
if (match) return [match, termType]
|
|
70
|
+
}
|
|
71
|
+
} catch {}
|
|
72
|
+
|
|
73
|
+
// Fallback: #{client_termname} (works for ghostty "xterm-ghostty", kitty "xterm-kitty")
|
|
74
|
+
try {
|
|
75
|
+
const result = Bun.spawnSync(["tmux", "display-message", "-p", "#{client_termname}"])
|
|
76
|
+
const termName = new TextDecoder().decode(result.stdout).trim()
|
|
77
|
+
if (termName) {
|
|
78
|
+
const match = matchTermName(termName)
|
|
79
|
+
if (match) return [match, termName]
|
|
80
|
+
}
|
|
81
|
+
} catch {}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ─── env var detection (non-tmux, or tmux query returned unknown) ──
|
|
85
|
+
const termProgram = (process.env.TERM_PROGRAM ?? "").toLowerCase()
|
|
86
|
+
const lcTerminal = (process.env.LC_TERMINAL ?? "").toLowerCase()
|
|
87
|
+
|
|
88
|
+
if (termProgram === "iterm.app" || termProgram === "iterm2" || lcTerminal === "iterm2") {
|
|
89
|
+
return ["iterm2", `TERM_PROGRAM=${process.env.TERM_PROGRAM ?? lcTerminal}`]
|
|
90
|
+
}
|
|
91
|
+
if (lcTerminal === "subterm") return ["kitty", `LC_TERMINAL=${process.env.LC_TERMINAL}`]
|
|
92
|
+
if (termProgram === "wezterm") return ["kitty", `TERM_PROGRAM=${process.env.TERM_PROGRAM}`]
|
|
93
|
+
if (termProgram === "rio") return ["kitty", `TERM_PROGRAM=${process.env.TERM_PROGRAM}`]
|
|
94
|
+
if (termProgram === "mintty") return ["sixel", `TERM_PROGRAM=${process.env.TERM_PROGRAM}`]
|
|
95
|
+
if (process.env.KITTY_PID) return ["kitty", `KITTY_PID=${process.env.KITTY_PID}`]
|
|
96
|
+
if (process.env.GHOSTTY_RESOURCES_DIR) return ["kitty", "GHOSTTY_RESOURCES_DIR"]
|
|
97
|
+
if (process.env.WT_SESSION) return ["sixel", "WT_SESSION"]
|
|
98
|
+
|
|
99
|
+
// Generic TERM value — last resort
|
|
100
|
+
const term = (process.env.TERM ?? "").toLowerCase()
|
|
101
|
+
const termMatch = matchTermName(term)
|
|
102
|
+
if (termMatch) return [termMatch, `TERM=${process.env.TERM}`]
|
|
103
|
+
|
|
104
|
+
return ["symbols", "unknown"]
|
|
105
|
+
}
|