kokoirc 0.2.1 → 0.2.3

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/README.md CHANGED
@@ -36,8 +36,8 @@ A modern terminal IRC client built with [OpenTUI](https://github.com/anomalyco/o
36
36
  ## Install
37
37
 
38
38
  ```bash
39
- git clone https://github.com/kofany/OpenIRC.git
40
- cd OpenIRC
39
+ git clone https://github.com/kofany/kokoIRC.git
40
+ cd kokoIRC
41
41
  bun install
42
42
  ```
43
43
 
@@ -74,7 +74,8 @@ address = "irc.libera.chat"
74
74
  port = 6697
75
75
  tls = true
76
76
  autoconnect = true
77
- channels = ["#kokoirc"]
77
+ channels = ["#kokoirc", "#secret mykey"] # "channel key" syntax for keyed channels
78
+ autosendcmd = "MSG NickServ identify pass; WAIT 2000; MODE $N +i"
78
79
  # sasl_user = "mynick"
79
80
  # sasl_pass = "hunter2"
80
81
 
@@ -91,16 +92,18 @@ j = "/join"
91
92
 
92
93
  ## Commands
93
94
 
94
- 33 built-in commands. Type `/help` for the full list, `/help <command>` for details.
95
+ 38 built-in commands. Type `/help` for the full list, `/help <command>` for details.
95
96
 
96
97
  | Category | Commands |
97
98
  |----------|----------|
98
99
  | Connection | `/connect`, `/disconnect`, `/quit`, `/server` |
99
100
  | Channel | `/join`, `/part`, `/close`, `/topic`, `/list` |
100
- | Messaging | `/msg`, `/me`, `/notice`, `/action`, `/slap` |
101
- | Moderation | `/kick`, `/ban`, `/unban`, `/kb`, `/mode` |
101
+ | Messaging | `/msg`, `/me`, `/notice`, `/action`, `/slap`, `/wallops` |
102
+ | Moderation | `/kick`, `/ban`, `/unban`, `/kb`, `/kill`, `/mode` |
102
103
  | Nick/Ops | `/nick`, `/op`, `/deop`, `/voice`, `/devoice` |
103
104
  | User | `/whois`, `/wii`, `/ignore`, `/unignore` |
105
+ | Info | `/stats` |
106
+ | Server | `/quote` (`/raw`), `/oper` |
104
107
  | Config | `/set`, `/alias`, `/unalias`, `/reload` |
105
108
  | Logging | `/log status`, `/log search <query>` |
106
109
  | Scripts | `/script load`, `/script unload`, `/script list` |
@@ -0,0 +1,24 @@
1
+ ---
2
+ category: Moderation
3
+ description: Disconnect a user from the network
4
+ ---
5
+
6
+ # /kill
7
+
8
+ ## Syntax
9
+
10
+ /kill <nick> [reason]
11
+
12
+ ## Description
13
+
14
+ Forcefully disconnect a user from the IRC network. This is an operator-only
15
+ command. If no reason is given, the target's nick is used as the reason.
16
+
17
+ ## Examples
18
+
19
+ /kill spammer Spamming is not allowed
20
+ /kill baduser
21
+
22
+ ## See Also
23
+
24
+ /oper, /kick, /ban
@@ -0,0 +1,24 @@
1
+ ---
2
+ category: Server
3
+ description: Authenticate as an IRC operator
4
+ ---
5
+
6
+ # /oper
7
+
8
+ ## Syntax
9
+
10
+ /oper <name> <password>
11
+
12
+ ## Description
13
+
14
+ Authenticate as an IRC operator. Requires valid operator credentials
15
+ configured on the server. Once authenticated, you gain access to
16
+ operator commands like /kill and /wallops.
17
+
18
+ ## Examples
19
+
20
+ /oper admin secretpass
21
+
22
+ ## See Also
23
+
24
+ /kill, /wallops
@@ -0,0 +1,29 @@
1
+ ---
2
+ category: Server
3
+ description: Send a raw IRC command
4
+ ---
5
+
6
+ # /quote
7
+
8
+ ## Syntax
9
+
10
+ /quote <raw command>
11
+
12
+ ## Description
13
+
14
+ Send a raw IRC command directly to the server. This is useful for
15
+ commands not yet implemented, testing, or advanced protocol interaction.
16
+
17
+ ## Aliases
18
+
19
+ /raw
20
+
21
+ ## Examples
22
+
23
+ /quote VERSION
24
+ /raw LUSERS
25
+ /quote PRIVMSG #channel :hello
26
+
27
+ ## See Also
28
+
29
+ /stats
@@ -39,6 +39,12 @@ Add a new server to the configuration.
39
39
  - `-label=<name>` — Display name
40
40
  - `-password=<pass>` — Server password (saved to .env)
41
41
  - `-sasl=<user>:<pass>` — SASL auth (saved to .env)
42
+ - `-autosendcmd=<cmds>` — Commands to run on connect, before autojoin (must be last flag)
43
+
44
+ Autosendcmd uses erssi-style syntax: commands separated by `;`, with `WAIT <ms>`
45
+ for delays. `$N` is replaced with your nick.
46
+
47
+ /server add libera irc.libera.chat:6697 -tls -autosendcmd=MSG NickServ identify pass; WAIT 2000; MODE $N +i
42
48
 
43
49
  ### remove
44
50
 
@@ -0,0 +1,31 @@
1
+ ---
2
+ category: Info
3
+ description: Request server statistics
4
+ ---
5
+
6
+ # /stats
7
+
8
+ ## Syntax
9
+
10
+ /stats [type] [server]
11
+
12
+ ## Description
13
+
14
+ Request statistics from the IRC server. Common stat types include:
15
+
16
+ - `u` — server uptime
17
+ - `m` — command usage counts
18
+ - `o` — configured operators
19
+ - `l` — connection information
20
+
21
+ If no type is given, the server may return a summary or help text.
22
+
23
+ ## Examples
24
+
25
+ /stats
26
+ /stats u
27
+ /stats o irc.libera.chat
28
+
29
+ ## See Also
30
+
31
+ /quote
@@ -0,0 +1,24 @@
1
+ ---
2
+ category: Messaging
3
+ description: Send a message to all IRC operators
4
+ ---
5
+
6
+ # /wallops
7
+
8
+ ## Syntax
9
+
10
+ /wallops <message>
11
+
12
+ ## Description
13
+
14
+ Send a message to all connected IRC operators. This is an operator-only
15
+ command typically used for server-wide announcements among staff.
16
+
17
+ ## Examples
18
+
19
+ /wallops Server maintenance in 10 minutes
20
+ /wallops New oper guidelines posted on the wiki
21
+
22
+ ## See Also
23
+
24
+ /oper, /quote
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kokoirc",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Terminal IRC client built with OpenTUI and React",
5
5
  "module": "src/index.tsx",
6
6
  "type": "module",
@@ -411,7 +411,7 @@ export const commands: Record<string, CommandDef> = {
411
411
  const id = args[1]?.toLowerCase()
412
412
  const addrArg = args[2]
413
413
  if (!id || !addrArg) {
414
- addLocalEvent(`%Zf7768eUsage: /server add <id> <address>[:<port>] [-tls] [-noauto] [-bind=<ip>] [-label=<name>] [-password=<pass>] [-sasl=<user>:<pass>]%N`)
414
+ addLocalEvent(`%Zf7768eUsage: /server add <id> <address>[:<port>] [-tls] [-noauto] [-bind=<ip>] [-label=<name>] [-password=<pass>] [-sasl=<user>:<pass>] [-autosendcmd=<cmds>]%N`)
415
415
  return
416
416
  }
417
417
 
@@ -435,6 +435,7 @@ export const commands: Record<string, CommandDef> = {
435
435
  let sasl_pass: string | undefined
436
436
  let label = id
437
437
  let tls_verify = true
438
+ let autosendcmd: string | undefined
438
439
 
439
440
  for (let i = 3; i < args.length; i++) {
440
441
  const a = args[i]
@@ -450,6 +451,11 @@ export const commands: Record<string, CommandDef> = {
450
451
  sasl_user = saslParts[0]
451
452
  sasl_pass = saslParts.slice(1).join(":")
452
453
  }
454
+ else if (a.startsWith("-autosendcmd=")) {
455
+ // Consumes rest of args since the value contains spaces
456
+ autosendcmd = [a.slice(13), ...args.slice(i + 1)].join(" ").replace(/^"|"$/g, "")
457
+ break
458
+ }
453
459
  }
454
460
 
455
461
  const serverConfig: ServerConfig = {
@@ -463,6 +469,7 @@ export const commands: Record<string, CommandDef> = {
463
469
  nick,
464
470
  bind_ip,
465
471
  sasl_user,
472
+ autosendcmd,
466
473
  }
467
474
 
468
475
  const s = useStore.getState()
@@ -1371,6 +1378,70 @@ export const commands: Record<string, CommandDef> = {
1371
1378
  description: "Disconnect from a server",
1372
1379
  usage: "/disconnect [server-id] [message]",
1373
1380
  },
1381
+
1382
+ quote: {
1383
+ handler(args, connId) {
1384
+ const client = getClient(connId)
1385
+ if (!client || args.length === 0) {
1386
+ addLocalEvent(`%Zf7768eUsage: /quote <raw command>%N`)
1387
+ return
1388
+ }
1389
+ client.raw(args.join(" "))
1390
+ },
1391
+ aliases: ["raw"],
1392
+ description: "Send a raw IRC command to the server",
1393
+ usage: "/quote <raw command>",
1394
+ },
1395
+
1396
+ stats: {
1397
+ handler(args, connId) {
1398
+ const client = getClient(connId)
1399
+ if (!client) return
1400
+ client.raw("STATS" + (args.length ? " " + args.join(" ") : ""))
1401
+ },
1402
+ description: "Request server statistics",
1403
+ usage: "/stats [type] [server]",
1404
+ },
1405
+
1406
+ oper: {
1407
+ handler(args, connId) {
1408
+ const client = getClient(connId)
1409
+ if (!client || args.length < 2) {
1410
+ addLocalEvent(`%Zf7768eUsage: /oper <name> <password>%N`)
1411
+ return
1412
+ }
1413
+ client.raw(`OPER ${args[0]} ${args[1]}`)
1414
+ },
1415
+ description: "Authenticate as an IRC operator",
1416
+ usage: "/oper <name> <password>",
1417
+ },
1418
+
1419
+ kill: {
1420
+ handler(args, connId) {
1421
+ const client = getClient(connId)
1422
+ if (!client || args.length === 0) {
1423
+ addLocalEvent(`%Zf7768eUsage: /kill <nick> [reason]%N`)
1424
+ return
1425
+ }
1426
+ const reason = args.slice(1).join(" ") || args[0]
1427
+ client.raw(`KILL ${args[0]} :${reason}`)
1428
+ },
1429
+ description: "Disconnect a user from the network (oper only)",
1430
+ usage: "/kill <nick> [reason]",
1431
+ },
1432
+
1433
+ wallops: {
1434
+ handler(args, connId) {
1435
+ const client = getClient(connId)
1436
+ if (!client || args.length === 0) {
1437
+ addLocalEvent(`%Zf7768eUsage: /wallops <message>%N`)
1438
+ return
1439
+ }
1440
+ client.raw(`WALLOPS :${args.join(" ")}`)
1441
+ },
1442
+ description: "Send a message to all opers (oper only)",
1443
+ usage: "/wallops <message>",
1444
+ },
1374
1445
  }
1375
1446
 
1376
1447
  // ─── Built-in Alias Resolution ────────────────────────────────
@@ -80,6 +80,22 @@ export async function loadConfig(configPath: string): Promise<AppConfig> {
80
80
  const text = await file.text()
81
81
  const parsed = parseTOML(text)
82
82
  const config = mergeWithDefaults(parsed)
83
+
84
+ // Load ~/.kokoirc/.env into process.env (Bun only auto-loads cwd/.env)
85
+ const envFile = Bun.file(ENV_PATH)
86
+ if (await envFile.exists()) {
87
+ const envText = await envFile.text()
88
+ for (const line of envText.split("\n")) {
89
+ const trimmed = line.trim()
90
+ if (!trimmed || trimmed.startsWith("#")) continue
91
+ const eqIdx = trimmed.indexOf("=")
92
+ if (eqIdx === -1) continue
93
+ const key = trimmed.slice(0, eqIdx).trim()
94
+ const value = trimmed.slice(eqIdx + 1).trim()
95
+ if (key) process.env[key] = value
96
+ }
97
+ }
98
+
83
99
  config.servers = loadCredentials(config.servers, process.env)
84
100
  return config
85
101
  }
@@ -104,6 +120,7 @@ function cleanServerForTOML(server: ServerConfig): Record<string, any> {
104
120
  if (server.auto_reconnect === false) obj.auto_reconnect = false
105
121
  if (server.reconnect_delay && server.reconnect_delay !== 30) obj.reconnect_delay = server.reconnect_delay
106
122
  if (server.reconnect_max_retries != null) obj.reconnect_max_retries = server.reconnect_max_retries
123
+ if (server.autosendcmd) obj.autosendcmd = server.autosendcmd
107
124
  // SASL and password stored in config only if NOT in .env — sasl_user kept, pass stripped
108
125
  if (server.sasl_user) obj.sasl_user = server.sasl_user
109
126
  // password and sasl_pass NOT saved to TOML — they go to .env
@@ -7,6 +7,8 @@ import { handleNetsplitQuit, handleNetsplitJoin, destroyNetsplitState } from "./
7
7
  import { shouldSuppressNickFlood, destroyAntifloodState } from "./antiflood"
8
8
  import { shouldIgnore } from "./ignore"
9
9
  import { eventBus } from "@/core/scripts/event-bus"
10
+ import { parseCommand } from "@/core/commands/parser"
11
+ import { executeCommand } from "@/core/commands/execution"
10
12
 
11
13
  function isChannelTarget(target: string): boolean {
12
14
  return target.startsWith("#") || target.startsWith("&") || target.startsWith("+") || target.startsWith("!")
@@ -53,15 +55,18 @@ export function bindEvents(client: Client, connectionId: string) {
53
55
  const s = getStore()
54
56
  s.updateConnection(connectionId, { status: "connected", nick: event.nick })
55
57
  statusMsg(`%Z9ece6aRegistered as %Zc0caf5${event.nick}%N`)
56
- // Auto-join channels from config
57
58
  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
- }
59
+ const serverConfig = config
60
+ ? Object.entries(config.servers).find(([id]) => id === connectionId)?.[1]
61
+ : undefined
62
+
63
+ // Execute autosendcmd before autojoin (erssi-style: ";"-separated, WAIT <ms> for delays)
64
+ if (serverConfig?.autosendcmd) {
65
+ runAutosendcmd(serverConfig.autosendcmd, connectionId, event.nick, () => {
66
+ autojoinChannels(client, serverConfig)
67
+ })
68
+ } else {
69
+ if (serverConfig) autojoinChannels(client, serverConfig)
65
70
  }
66
71
  })
67
72
 
@@ -1029,3 +1034,51 @@ function makeFormattedEvent(key: string, params: string[]): Message {
1029
1034
  }
1030
1035
  }
1031
1036
 
1037
+ // ─── Autosendcmd ─────────────────────────────────────────
1038
+
1039
+ /** Join channels from config, supporting "channel key" syntax. */
1040
+ function autojoinChannels(client: Client, serverConfig: import("@/types/config").ServerConfig) {
1041
+ for (const entry of serverConfig.channels) {
1042
+ const spaceIdx = entry.indexOf(" ")
1043
+ if (spaceIdx === -1) {
1044
+ client.join(entry)
1045
+ } else {
1046
+ client.join(entry.slice(0, spaceIdx), entry.slice(spaceIdx + 1))
1047
+ }
1048
+ }
1049
+ }
1050
+
1051
+ /**
1052
+ * Execute autosendcmd string (erssi-style: ";"-separated commands, WAIT <ms> for delays).
1053
+ * Substitutes $N with current nick. Calls onDone() when all commands (including WAITs) complete.
1054
+ */
1055
+ function runAutosendcmd(cmd: string, connectionId: string, nick: string, onDone: () => void) {
1056
+ const parts = cmd.split(";").map((p) => p.trim()).filter(Boolean)
1057
+ let i = 0
1058
+
1059
+ function next() {
1060
+ while (i < parts.length) {
1061
+ const part = parts[i++]
1062
+ // Variable substitution ($N = nick)
1063
+ const expanded = part.replace(/\$\{?N\}?/g, nick)
1064
+
1065
+ // WAIT <ms> — delay before next command
1066
+ const waitMatch = expanded.match(/^WAIT\s+(\d+)$/i)
1067
+ if (waitMatch) {
1068
+ setTimeout(next, parseInt(waitMatch[1], 10))
1069
+ return
1070
+ }
1071
+
1072
+ // Treat as command: prepend / if missing, parse and execute
1073
+ const line = expanded.startsWith("/") ? expanded : "/" + expanded
1074
+ const parsed = parseCommand(line)
1075
+ if (parsed) {
1076
+ executeCommand(parsed, connectionId)
1077
+ }
1078
+ }
1079
+ onDone()
1080
+ }
1081
+
1082
+ next()
1083
+ }
1084
+
@@ -123,4 +123,5 @@ export interface ServerConfig {
123
123
  auto_reconnect?: boolean // auto reconnect on disconnect (default: true)
124
124
  reconnect_delay?: number // seconds between reconnect attempts (default: 30)
125
125
  reconnect_max_retries?: number // max reconnect attempts (default: 10)
126
+ autosendcmd?: string // commands to run on connect, before autojoin (;-separated, WAIT <ms> for delays)
126
127
  }