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.
- package/README.md +227 -0
- package/docs/commands/alias.md +42 -0
- package/docs/commands/ban.md +26 -0
- package/docs/commands/close.md +25 -0
- package/docs/commands/connect.md +26 -0
- package/docs/commands/deop.md +24 -0
- package/docs/commands/devoice.md +24 -0
- package/docs/commands/disconnect.md +26 -0
- package/docs/commands/help.md +28 -0
- package/docs/commands/ignore.md +47 -0
- package/docs/commands/items.md +95 -0
- package/docs/commands/join.md +25 -0
- package/docs/commands/kb.md +26 -0
- package/docs/commands/kick.md +25 -0
- package/docs/commands/log.md +82 -0
- package/docs/commands/me.md +24 -0
- package/docs/commands/mode.md +29 -0
- package/docs/commands/msg.md +26 -0
- package/docs/commands/nick.md +24 -0
- package/docs/commands/notice.md +24 -0
- package/docs/commands/op.md +24 -0
- package/docs/commands/part.md +25 -0
- package/docs/commands/quit.md +24 -0
- package/docs/commands/reload.md +19 -0
- package/docs/commands/script.md +126 -0
- package/docs/commands/server.md +61 -0
- package/docs/commands/set.md +37 -0
- package/docs/commands/topic.md +24 -0
- package/docs/commands/unalias.md +22 -0
- package/docs/commands/unban.md +25 -0
- package/docs/commands/unignore.md +25 -0
- package/docs/commands/voice.md +25 -0
- package/docs/commands/whois.md +24 -0
- package/docs/commands/wii.md +23 -0
- package/package.json +38 -0
- package/src/app/App.tsx +205 -0
- package/src/core/commands/docs.ts +183 -0
- package/src/core/commands/execution.ts +114 -0
- package/src/core/commands/help-formatter.ts +185 -0
- package/src/core/commands/helpers.ts +168 -0
- package/src/core/commands/index.ts +7 -0
- package/src/core/commands/parser.ts +33 -0
- package/src/core/commands/registry.ts +1394 -0
- package/src/core/commands/types.ts +19 -0
- package/src/core/config/defaults.ts +66 -0
- package/src/core/config/loader.ts +209 -0
- package/src/core/constants.ts +20 -0
- package/src/core/init.ts +32 -0
- package/src/core/irc/antiflood.ts +244 -0
- package/src/core/irc/client.ts +145 -0
- package/src/core/irc/events.ts +1031 -0
- package/src/core/irc/formatting.ts +132 -0
- package/src/core/irc/ignore.ts +84 -0
- package/src/core/irc/index.ts +2 -0
- package/src/core/irc/netsplit.ts +292 -0
- package/src/core/scripts/api.ts +240 -0
- package/src/core/scripts/event-bus.ts +82 -0
- package/src/core/scripts/index.ts +26 -0
- package/src/core/scripts/manager.ts +154 -0
- package/src/core/scripts/types.ts +256 -0
- package/src/core/state/selectors.ts +39 -0
- package/src/core/state/sorting.ts +30 -0
- package/src/core/state/store.ts +242 -0
- package/src/core/storage/crypto.ts +78 -0
- package/src/core/storage/db.ts +107 -0
- package/src/core/storage/index.ts +80 -0
- package/src/core/storage/query.ts +204 -0
- package/src/core/storage/types.ts +37 -0
- package/src/core/storage/writer.ts +130 -0
- package/src/core/theme/index.ts +3 -0
- package/src/core/theme/loader.ts +45 -0
- package/src/core/theme/parser.ts +518 -0
- package/src/core/theme/renderer.tsx +25 -0
- package/src/index.tsx +17 -0
- package/src/types/config.ts +126 -0
- package/src/types/index.ts +107 -0
- package/src/types/irc-framework.d.ts +569 -0
- package/src/types/theme.ts +37 -0
- package/src/ui/ErrorBoundary.tsx +42 -0
- package/src/ui/chat/ChatView.tsx +39 -0
- package/src/ui/chat/MessageLine.tsx +92 -0
- package/src/ui/hooks/useStatusbarColors.ts +23 -0
- package/src/ui/input/CommandInput.tsx +273 -0
- package/src/ui/layout/AppLayout.tsx +126 -0
- package/src/ui/layout/TopicBar.tsx +46 -0
- package/src/ui/sidebar/BufferList.tsx +55 -0
- package/src/ui/sidebar/NickList.tsx +96 -0
- package/src/ui/splash/SplashScreen.tsx +100 -0
- package/src/ui/statusbar/StatusLine.tsx +205 -0
- package/themes/.gitkeep +0 -0
- package/themes/default.theme +57 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { AppConfig } from "@/types/config"
|
|
2
|
+
|
|
3
|
+
export type Handler = (args: string[], connectionId: string) => void
|
|
4
|
+
|
|
5
|
+
export interface CommandDef {
|
|
6
|
+
handler: Handler
|
|
7
|
+
description: string
|
|
8
|
+
usage: string
|
|
9
|
+
aliases?: string[]
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ResolvedConfig {
|
|
13
|
+
value: any
|
|
14
|
+
field: string
|
|
15
|
+
isCredential: boolean
|
|
16
|
+
serverId?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const CREDENTIAL_FIELDS = new Set(["password", "sasl_pass"])
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { AppConfig } from "@/types/config"
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_CONFIG: AppConfig = {
|
|
4
|
+
general: {
|
|
5
|
+
nick: "kokoIRC",
|
|
6
|
+
username: "kokoirc",
|
|
7
|
+
realname: "kokoIRC Client",
|
|
8
|
+
theme: "default",
|
|
9
|
+
timestamp_format: "%H:%M:%S",
|
|
10
|
+
flood_protection: true,
|
|
11
|
+
ctcp_version: "kokoIRC",
|
|
12
|
+
},
|
|
13
|
+
display: {
|
|
14
|
+
nick_column_width: 8,
|
|
15
|
+
nick_max_length: 8,
|
|
16
|
+
nick_alignment: "right",
|
|
17
|
+
nick_truncation: true,
|
|
18
|
+
show_timestamps: true,
|
|
19
|
+
scrollback_lines: 2000,
|
|
20
|
+
},
|
|
21
|
+
sidepanel: {
|
|
22
|
+
left: { width: 20, visible: true },
|
|
23
|
+
right: { width: 18, visible: true },
|
|
24
|
+
},
|
|
25
|
+
statusbar: {
|
|
26
|
+
enabled: true,
|
|
27
|
+
items: ["time", "nick_info", "channel_info", "lag", "active_windows"],
|
|
28
|
+
separator: " | ",
|
|
29
|
+
item_formats: {},
|
|
30
|
+
|
|
31
|
+
// "" means "use theme color" — resolved at render time
|
|
32
|
+
background: "",
|
|
33
|
+
text_color: "",
|
|
34
|
+
accent_color: "",
|
|
35
|
+
muted_color: "",
|
|
36
|
+
dim_color: "",
|
|
37
|
+
|
|
38
|
+
prompt: "[$server\u2771 ",
|
|
39
|
+
prompt_color: "",
|
|
40
|
+
input_color: "",
|
|
41
|
+
cursor_color: "",
|
|
42
|
+
},
|
|
43
|
+
servers: {
|
|
44
|
+
ircnet: {
|
|
45
|
+
label: "IRCnet",
|
|
46
|
+
address: "hostsailor.ircnet.nl",
|
|
47
|
+
port: 6697,
|
|
48
|
+
tls: true,
|
|
49
|
+
tls_verify: true,
|
|
50
|
+
autoconnect: false,
|
|
51
|
+
channels: ["#ircnet", "#polska"],
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
aliases: {},
|
|
55
|
+
ignores: [],
|
|
56
|
+
scripts: {
|
|
57
|
+
autoload: [],
|
|
58
|
+
debug: false,
|
|
59
|
+
},
|
|
60
|
+
logging: {
|
|
61
|
+
enabled: true,
|
|
62
|
+
encrypt: false,
|
|
63
|
+
retention_days: 0,
|
|
64
|
+
exclude_types: [],
|
|
65
|
+
},
|
|
66
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { parse as parseTOML, stringify as stringifyTOML } from "smol-toml"
|
|
2
|
+
import { DEFAULT_CONFIG } from "./defaults"
|
|
3
|
+
import { ENV_PATH } from "@/core/constants"
|
|
4
|
+
import type { AppConfig, ServerConfig, IgnoreEntry, ScriptsConfig } from "@/types/config"
|
|
5
|
+
|
|
6
|
+
/** Create a deep-ish clone of config, safe for in-place mutation. */
|
|
7
|
+
export function cloneConfig(config: AppConfig): AppConfig {
|
|
8
|
+
return {
|
|
9
|
+
general: { ...config.general },
|
|
10
|
+
display: { ...config.display },
|
|
11
|
+
sidepanel: {
|
|
12
|
+
left: { ...config.sidepanel.left },
|
|
13
|
+
right: { ...config.sidepanel.right },
|
|
14
|
+
},
|
|
15
|
+
statusbar: { ...config.statusbar, items: [...config.statusbar.items], item_formats: { ...config.statusbar.item_formats } },
|
|
16
|
+
servers: Object.fromEntries(
|
|
17
|
+
Object.entries(config.servers).map(([id, srv]) => [id, { ...srv, channels: [...srv.channels] }])
|
|
18
|
+
),
|
|
19
|
+
aliases: { ...config.aliases },
|
|
20
|
+
ignores: config.ignores.map((e) => ({
|
|
21
|
+
...e,
|
|
22
|
+
levels: [...e.levels],
|
|
23
|
+
channels: e.channels ? [...e.channels] : undefined,
|
|
24
|
+
})),
|
|
25
|
+
scripts: {
|
|
26
|
+
...config.scripts,
|
|
27
|
+
autoload: [...config.scripts.autoload],
|
|
28
|
+
},
|
|
29
|
+
logging: {
|
|
30
|
+
...config.logging,
|
|
31
|
+
exclude_types: [...config.logging.exclude_types],
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function mergeWithDefaults(partial: Record<string, any>): AppConfig {
|
|
37
|
+
return {
|
|
38
|
+
general: { ...DEFAULT_CONFIG.general, ...partial.general },
|
|
39
|
+
display: { ...DEFAULT_CONFIG.display, ...partial.display },
|
|
40
|
+
sidepanel: {
|
|
41
|
+
left: { ...DEFAULT_CONFIG.sidepanel.left, ...partial.sidepanel?.left },
|
|
42
|
+
right: { ...DEFAULT_CONFIG.sidepanel.right, ...partial.sidepanel?.right },
|
|
43
|
+
},
|
|
44
|
+
statusbar: { ...DEFAULT_CONFIG.statusbar, ...partial.statusbar },
|
|
45
|
+
servers: partial.servers ?? {},
|
|
46
|
+
aliases: partial.aliases ?? {},
|
|
47
|
+
ignores: (partial.ignores as IgnoreEntry[] | undefined) ?? [],
|
|
48
|
+
scripts: {
|
|
49
|
+
autoload: [],
|
|
50
|
+
debug: false,
|
|
51
|
+
...DEFAULT_CONFIG.scripts,
|
|
52
|
+
...partial.scripts,
|
|
53
|
+
},
|
|
54
|
+
logging: { ...DEFAULT_CONFIG.logging, ...partial.logging },
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function loadCredentials(
|
|
59
|
+
servers: Record<string, ServerConfig>,
|
|
60
|
+
env: Record<string, string | undefined>,
|
|
61
|
+
): Record<string, ServerConfig> {
|
|
62
|
+
const result: Record<string, ServerConfig> = {}
|
|
63
|
+
for (const [id, server] of Object.entries(servers)) {
|
|
64
|
+
const prefix = id.toUpperCase()
|
|
65
|
+
result[id] = {
|
|
66
|
+
...server,
|
|
67
|
+
sasl_user: env[`${prefix}_SASL_USER`] ?? server.sasl_user,
|
|
68
|
+
sasl_pass: env[`${prefix}_SASL_PASS`] ?? server.sasl_pass,
|
|
69
|
+
password: env[`${prefix}_PASSWORD`] ?? server.password,
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return result
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function loadConfig(configPath: string): Promise<AppConfig> {
|
|
76
|
+
const file = Bun.file(configPath)
|
|
77
|
+
if (!(await file.exists())) {
|
|
78
|
+
return { ...DEFAULT_CONFIG }
|
|
79
|
+
}
|
|
80
|
+
const text = await file.text()
|
|
81
|
+
const parsed = parseTOML(text)
|
|
82
|
+
const config = mergeWithDefaults(parsed)
|
|
83
|
+
config.servers = loadCredentials(config.servers, process.env)
|
|
84
|
+
return config
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Strip undefined/empty optional fields before serializing to TOML */
|
|
88
|
+
function cleanServerForTOML(server: ServerConfig): Record<string, any> {
|
|
89
|
+
const obj: Record<string, any> = {
|
|
90
|
+
label: server.label,
|
|
91
|
+
address: server.address,
|
|
92
|
+
port: server.port,
|
|
93
|
+
tls: server.tls,
|
|
94
|
+
tls_verify: server.tls_verify,
|
|
95
|
+
autoconnect: server.autoconnect,
|
|
96
|
+
channels: server.channels,
|
|
97
|
+
}
|
|
98
|
+
// Only include non-empty optional fields
|
|
99
|
+
if (server.nick) obj.nick = server.nick
|
|
100
|
+
if (server.username) obj.username = server.username
|
|
101
|
+
if (server.realname) obj.realname = server.realname
|
|
102
|
+
if (server.bind_ip) obj.bind_ip = server.bind_ip
|
|
103
|
+
if (server.encoding && server.encoding !== "utf8") obj.encoding = server.encoding
|
|
104
|
+
if (server.auto_reconnect === false) obj.auto_reconnect = false
|
|
105
|
+
if (server.reconnect_delay && server.reconnect_delay !== 30) obj.reconnect_delay = server.reconnect_delay
|
|
106
|
+
if (server.reconnect_max_retries != null) obj.reconnect_max_retries = server.reconnect_max_retries
|
|
107
|
+
// SASL and password stored in config only if NOT in .env — sasl_user kept, pass stripped
|
|
108
|
+
if (server.sasl_user) obj.sasl_user = server.sasl_user
|
|
109
|
+
// password and sasl_pass NOT saved to TOML — they go to .env
|
|
110
|
+
return obj
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Save the full config to TOML file */
|
|
114
|
+
export async function saveConfig(configPath: string, config: AppConfig): Promise<void> {
|
|
115
|
+
// Build a clean object for serialization
|
|
116
|
+
const tomlObj: Record<string, any> = {
|
|
117
|
+
general: config.general,
|
|
118
|
+
display: config.display,
|
|
119
|
+
sidepanel: config.sidepanel,
|
|
120
|
+
statusbar: {} as Record<string, any>,
|
|
121
|
+
servers: {} as Record<string, any>,
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Statusbar: only non-empty values
|
|
125
|
+
const sb = config.statusbar
|
|
126
|
+
tomlObj.statusbar.enabled = sb.enabled
|
|
127
|
+
tomlObj.statusbar.items = sb.items
|
|
128
|
+
if (sb.separator) tomlObj.statusbar.separator = sb.separator
|
|
129
|
+
if (sb.background) tomlObj.statusbar.background = sb.background
|
|
130
|
+
if (sb.text_color) tomlObj.statusbar.text_color = sb.text_color
|
|
131
|
+
if (sb.accent_color) tomlObj.statusbar.accent_color = sb.accent_color
|
|
132
|
+
if (sb.muted_color) tomlObj.statusbar.muted_color = sb.muted_color
|
|
133
|
+
if (sb.dim_color) tomlObj.statusbar.dim_color = sb.dim_color
|
|
134
|
+
if (sb.prompt) tomlObj.statusbar.prompt = sb.prompt
|
|
135
|
+
if (sb.prompt_color) tomlObj.statusbar.prompt_color = sb.prompt_color
|
|
136
|
+
if (sb.input_color) tomlObj.statusbar.input_color = sb.input_color
|
|
137
|
+
if (sb.cursor_color) tomlObj.statusbar.cursor_color = sb.cursor_color
|
|
138
|
+
if (sb.item_formats && Object.keys(sb.item_formats).length > 0) {
|
|
139
|
+
tomlObj.statusbar.item_formats = sb.item_formats
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (Object.keys(config.aliases).length > 0) {
|
|
143
|
+
tomlObj.aliases = config.aliases
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
for (const [id, server] of Object.entries(config.servers)) {
|
|
147
|
+
tomlObj.servers[id] = cleanServerForTOML(server)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (config.ignores.length > 0) {
|
|
151
|
+
tomlObj.ignores = config.ignores.map((e) => {
|
|
152
|
+
const obj: Record<string, any> = { mask: e.mask, levels: e.levels }
|
|
153
|
+
if (e.channels?.length) obj.channels = e.channels
|
|
154
|
+
return obj
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Scripts config — only write if non-default
|
|
159
|
+
if (config.scripts) {
|
|
160
|
+
const sc: Record<string, any> = {}
|
|
161
|
+
if (config.scripts.autoload.length > 0) sc.autoload = config.scripts.autoload
|
|
162
|
+
if (config.scripts.debug) sc.debug = true
|
|
163
|
+
// Per-script configs: [scripts.my-script]
|
|
164
|
+
for (const [key, val] of Object.entries(config.scripts)) {
|
|
165
|
+
if (key === "autoload" || key === "debug") continue
|
|
166
|
+
if (typeof val === "object" && val !== null) sc[key] = val
|
|
167
|
+
}
|
|
168
|
+
if (Object.keys(sc).length > 0) tomlObj.scripts = sc
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Logging config — only write non-default values
|
|
172
|
+
if (config.logging) {
|
|
173
|
+
const lg: Record<string, any> = {}
|
|
174
|
+
if (!config.logging.enabled) lg.enabled = false // only write if disabled (default is true)
|
|
175
|
+
if (config.logging.encrypt) lg.encrypt = true
|
|
176
|
+
if (config.logging.retention_days > 0) lg.retention_days = config.logging.retention_days
|
|
177
|
+
if (config.logging.exclude_types.length > 0) lg.exclude_types = config.logging.exclude_types
|
|
178
|
+
if (Object.keys(lg).length > 0) tomlObj.logging = lg
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const toml = stringifyTOML(tomlObj)
|
|
182
|
+
await Bun.write(configPath, toml)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Append or update credentials in .env file */
|
|
186
|
+
export async function saveCredentialsToEnv(
|
|
187
|
+
serverId: string,
|
|
188
|
+
credentials: { sasl_user?: string; sasl_pass?: string; password?: string },
|
|
189
|
+
): Promise<void> {
|
|
190
|
+
const file = Bun.file(ENV_PATH)
|
|
191
|
+
let content = (await file.exists()) ? await file.text() : ""
|
|
192
|
+
|
|
193
|
+
const prefix = serverId.toUpperCase()
|
|
194
|
+
const updates: [string, string][] = []
|
|
195
|
+
if (credentials.sasl_user) updates.push([`${prefix}_SASL_USER`, credentials.sasl_user])
|
|
196
|
+
if (credentials.sasl_pass) updates.push([`${prefix}_SASL_PASS`, credentials.sasl_pass])
|
|
197
|
+
if (credentials.password) updates.push([`${prefix}_PASSWORD`, credentials.password])
|
|
198
|
+
|
|
199
|
+
for (const [key, value] of updates) {
|
|
200
|
+
const regex = new RegExp(`^${key}=.*$`, "m")
|
|
201
|
+
if (regex.test(content)) {
|
|
202
|
+
content = content.replace(regex, `${key}=${value}`)
|
|
203
|
+
} else {
|
|
204
|
+
content = content.trimEnd() + (content.length > 0 ? "\n" : "") + `${key}=${value}\n`
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
await Bun.write(ENV_PATH, content)
|
|
209
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { homedir } from "node:os"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
|
|
4
|
+
// Package directory — where bundled defaults and docs live
|
|
5
|
+
const PKG_DIR = join(import.meta.dir, "../..")
|
|
6
|
+
|
|
7
|
+
// User home directory — mutable config, themes, .env
|
|
8
|
+
export const HOME_DIR = join(homedir(), ".kokoirc")
|
|
9
|
+
|
|
10
|
+
export const CONFIG_PATH = join(HOME_DIR, "config.toml")
|
|
11
|
+
export const THEME_PATH = (name: string) => join(HOME_DIR, "themes", `${name}.theme`)
|
|
12
|
+
export const DOCS_DIR = join(PKG_DIR, "docs/commands")
|
|
13
|
+
export const ENV_PATH = join(HOME_DIR, ".env")
|
|
14
|
+
|
|
15
|
+
export const SCRIPTS_DIR = join(HOME_DIR, "scripts")
|
|
16
|
+
|
|
17
|
+
export const LOG_DB_PATH = join(HOME_DIR, "logs.db")
|
|
18
|
+
|
|
19
|
+
// Default assets bundled with the package
|
|
20
|
+
export const DEFAULT_THEMES_DIR = join(PKG_DIR, "themes")
|
package/src/core/init.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { mkdir } from "node:fs/promises"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
import { HOME_DIR, CONFIG_PATH, DEFAULT_THEMES_DIR, SCRIPTS_DIR } from "./constants"
|
|
4
|
+
import { DEFAULT_CONFIG } from "./config/defaults"
|
|
5
|
+
import { saveConfig } from "./config/loader"
|
|
6
|
+
|
|
7
|
+
/** Create ~/.kokoirc/ and copy default themes + generate config on first run */
|
|
8
|
+
export async function initHomeDir(): Promise<void> {
|
|
9
|
+
const themesDir = join(HOME_DIR, "themes")
|
|
10
|
+
|
|
11
|
+
// Create directories
|
|
12
|
+
await mkdir(themesDir, { recursive: true })
|
|
13
|
+
await mkdir(SCRIPTS_DIR, { recursive: true })
|
|
14
|
+
|
|
15
|
+
// Generate default config if missing
|
|
16
|
+
const configFile = Bun.file(CONFIG_PATH)
|
|
17
|
+
if (!(await configFile.exists())) {
|
|
18
|
+
await saveConfig(CONFIG_PATH, DEFAULT_CONFIG)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Copy default themes if themes dir is empty
|
|
22
|
+
const glob = new Bun.Glob("*.theme")
|
|
23
|
+
let hasThemes = false
|
|
24
|
+
for await (const _ of glob.scan(themesDir)) { hasThemes = true; break }
|
|
25
|
+
|
|
26
|
+
if (!hasThemes) {
|
|
27
|
+
for await (const file of glob.scan(DEFAULT_THEMES_DIR)) {
|
|
28
|
+
const src = Bun.file(join(DEFAULT_THEMES_DIR, file))
|
|
29
|
+
await Bun.write(join(themesDir, file), src)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { useStore } from "@/core/state/store"
|
|
2
|
+
import { makeBufferId } from "@/types"
|
|
3
|
+
import type { Message } from "@/types"
|
|
4
|
+
import type { Client } from "kofany-irc-framework"
|
|
5
|
+
|
|
6
|
+
// ─── Constants (proven thresholds from erssi) ────────────────
|
|
7
|
+
|
|
8
|
+
const CTCP_THRESHOLD = 5
|
|
9
|
+
const CTCP_WINDOW = 5_000
|
|
10
|
+
const CTCP_BLOCK = 60_000
|
|
11
|
+
|
|
12
|
+
const TILDE_THRESHOLD = 5
|
|
13
|
+
const TILDE_WINDOW = 5_000
|
|
14
|
+
const TILDE_BLOCK = 60_000
|
|
15
|
+
|
|
16
|
+
const DUP_MIN_IN_WINDOW = 5 // need 5+ msgs in window before checking dups
|
|
17
|
+
const DUP_THRESHOLD = 3 // 3 identical out of those = flood
|
|
18
|
+
const DUP_WINDOW = 5_000
|
|
19
|
+
const DUP_BLOCK = 60_000
|
|
20
|
+
|
|
21
|
+
const NICK_THRESHOLD = 5
|
|
22
|
+
const NICK_WINDOW = 3_000
|
|
23
|
+
const NICK_BLOCK = 60_000
|
|
24
|
+
|
|
25
|
+
// ─── Per-connection state ────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
interface FloodState {
|
|
28
|
+
ctcpTimes: number[]
|
|
29
|
+
ctcpBlockedUntil: number
|
|
30
|
+
tildeTimes: number[]
|
|
31
|
+
tildeBlockedUntil: number
|
|
32
|
+
msgWindow: { text: string; time: number }[]
|
|
33
|
+
blockedTexts: Map<string, number> // text → blockedUntil
|
|
34
|
+
nickTimes: Map<string, number[]> // bufferId → timestamps
|
|
35
|
+
nickBlockedUntil: Map<string, number> // bufferId → blockedUntil
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const states = new Map<string, FloodState>()
|
|
39
|
+
|
|
40
|
+
function getState(connId: string): FloodState {
|
|
41
|
+
let s = states.get(connId)
|
|
42
|
+
if (!s) {
|
|
43
|
+
s = {
|
|
44
|
+
ctcpTimes: [],
|
|
45
|
+
ctcpBlockedUntil: 0,
|
|
46
|
+
tildeTimes: [],
|
|
47
|
+
tildeBlockedUntil: 0,
|
|
48
|
+
msgWindow: [],
|
|
49
|
+
blockedTexts: new Map(),
|
|
50
|
+
nickTimes: new Map(),
|
|
51
|
+
nickBlockedUntil: new Map(),
|
|
52
|
+
}
|
|
53
|
+
states.set(connId, s)
|
|
54
|
+
}
|
|
55
|
+
return s
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function destroyAntifloodState(connId: string) {
|
|
59
|
+
states.delete(connId)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ─── Helpers ─────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
function isFloodProtectionEnabled(): boolean {
|
|
65
|
+
return useStore.getState().config?.general?.flood_protection ?? true
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function statusNotify(connId: string, text: string) {
|
|
69
|
+
const store = useStore.getState()
|
|
70
|
+
const statusId = makeBufferId(connId, "Status")
|
|
71
|
+
if (store.buffers.has(statusId)) {
|
|
72
|
+
store.addMessage(statusId, makeEventMessage(
|
|
73
|
+
`%Zf7768eFlood protection:%N %Ze0af68${text}%N`
|
|
74
|
+
))
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function makeEventMessage(text: string): Message {
|
|
79
|
+
return {
|
|
80
|
+
id: crypto.randomUUID(),
|
|
81
|
+
timestamp: new Date(),
|
|
82
|
+
type: "event",
|
|
83
|
+
text,
|
|
84
|
+
highlight: false,
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Prune timestamps older than `window` ms, return count remaining. */
|
|
89
|
+
function pruneWindow(times: number[], now: number, window: number): number {
|
|
90
|
+
const cutoff = now - window
|
|
91
|
+
let i = 0
|
|
92
|
+
while (i < times.length && times[i] < cutoff) i++
|
|
93
|
+
if (i > 0) times.splice(0, i)
|
|
94
|
+
return times.length
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ─── Parsed middleware (CTCP + message floods) ───────────────
|
|
98
|
+
|
|
99
|
+
export function createAntiFloodMiddleware(connId: string) {
|
|
100
|
+
return function middlewareInstaller(client: Client, rawEvents: any, parsedEvents: any) {
|
|
101
|
+
parsedEvents.use(function antiFloodHandler(command: string, event: any, _client: Client, next: () => void) {
|
|
102
|
+
if (!isFloodProtectionEnabled()) {
|
|
103
|
+
next()
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const state = getState(connId)
|
|
108
|
+
const now = Date.now()
|
|
109
|
+
|
|
110
|
+
// ── CTCP requests ──
|
|
111
|
+
if (command === "ctcp request") {
|
|
112
|
+
if (state.ctcpBlockedUntil > now) {
|
|
113
|
+
// Still blocked — extend silently
|
|
114
|
+
state.ctcpBlockedUntil = now + CTCP_BLOCK
|
|
115
|
+
return // don't call next() — suppress event + auto-response
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
state.ctcpTimes.push(now)
|
|
119
|
+
const count = pruneWindow(state.ctcpTimes, now, CTCP_WINDOW)
|
|
120
|
+
|
|
121
|
+
if (count >= CTCP_THRESHOLD) {
|
|
122
|
+
state.ctcpBlockedUntil = now + CTCP_BLOCK
|
|
123
|
+
state.ctcpTimes.length = 0
|
|
124
|
+
statusNotify(connId, "CTCP flood detected \u2014 blocking CTCP for 60s")
|
|
125
|
+
return // suppress
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
next()
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ── Messages: privmsg, notice, action ──
|
|
133
|
+
if (command === "privmsg" || command === "notice" || command === "action") {
|
|
134
|
+
const ident: string = event.ident || ""
|
|
135
|
+
const message: string = event.message || ""
|
|
136
|
+
const target: string = event.target || ""
|
|
137
|
+
const isChannel = target.startsWith("#") || target.startsWith("&") ||
|
|
138
|
+
target.startsWith("+") || target.startsWith("!")
|
|
139
|
+
|
|
140
|
+
// ~ident flood check
|
|
141
|
+
if (ident.startsWith("~")) {
|
|
142
|
+
if (state.tildeBlockedUntil > now) {
|
|
143
|
+
state.tildeBlockedUntil = now + TILDE_BLOCK
|
|
144
|
+
return // suppress
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
state.tildeTimes.push(now)
|
|
148
|
+
const count = pruneWindow(state.tildeTimes, now, TILDE_WINDOW)
|
|
149
|
+
|
|
150
|
+
if (count >= TILDE_THRESHOLD) {
|
|
151
|
+
state.tildeBlockedUntil = now + TILDE_BLOCK
|
|
152
|
+
state.tildeTimes.length = 0
|
|
153
|
+
statusNotify(connId, "~ident flood detected \u2014 blocking tilde messages for 60s")
|
|
154
|
+
return // suppress
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Duplicate text flood (channel messages only)
|
|
159
|
+
if (isChannel && message) {
|
|
160
|
+
// Check if this exact text is already blocked
|
|
161
|
+
const blockedUntil = state.blockedTexts.get(message)
|
|
162
|
+
if (blockedUntil && blockedUntil > now) {
|
|
163
|
+
state.blockedTexts.set(message, now + DUP_BLOCK)
|
|
164
|
+
return // suppress
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Add to sliding message window
|
|
168
|
+
state.msgWindow.push({ text: message, time: now })
|
|
169
|
+
// Prune old entries
|
|
170
|
+
const cutoff = now - DUP_WINDOW
|
|
171
|
+
while (state.msgWindow.length > 0 && state.msgWindow[0].time < cutoff) {
|
|
172
|
+
state.msgWindow.shift()
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Only analyze when enough messages in window
|
|
176
|
+
if (state.msgWindow.length >= DUP_MIN_IN_WINDOW) {
|
|
177
|
+
// Count occurrences of this message in window
|
|
178
|
+
let dupes = 0
|
|
179
|
+
for (const entry of state.msgWindow) {
|
|
180
|
+
if (entry.text === message) dupes++
|
|
181
|
+
}
|
|
182
|
+
if (dupes >= DUP_THRESHOLD) {
|
|
183
|
+
state.blockedTexts.set(message, now + DUP_BLOCK)
|
|
184
|
+
statusNotify(connId, "Duplicate flood detected \u2014 blocking pattern for 60s")
|
|
185
|
+
return // suppress
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Clean expired blocked texts periodically
|
|
190
|
+
if (state.blockedTexts.size > 50) {
|
|
191
|
+
for (const [text, until] of state.blockedTexts) {
|
|
192
|
+
if (until <= now) state.blockedTexts.delete(text)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
next()
|
|
198
|
+
return
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Everything else passes through
|
|
202
|
+
next()
|
|
203
|
+
})
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ─── Nick flood guard (called from events.ts) ────────────────
|
|
208
|
+
|
|
209
|
+
export function shouldSuppressNickFlood(connId: string, bufferId: string): boolean {
|
|
210
|
+
if (!isFloodProtectionEnabled()) return false
|
|
211
|
+
|
|
212
|
+
const state = getState(connId)
|
|
213
|
+
const now = Date.now()
|
|
214
|
+
|
|
215
|
+
// Check if currently blocked for this buffer
|
|
216
|
+
const blockedUntil = state.nickBlockedUntil.get(bufferId) ?? 0
|
|
217
|
+
if (blockedUntil > now) {
|
|
218
|
+
// Extend block silently
|
|
219
|
+
state.nickBlockedUntil.set(bufferId, now + NICK_BLOCK)
|
|
220
|
+
return true
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Track nick change timestamp
|
|
224
|
+
let times = state.nickTimes.get(bufferId)
|
|
225
|
+
if (!times) {
|
|
226
|
+
times = []
|
|
227
|
+
state.nickTimes.set(bufferId, times)
|
|
228
|
+
}
|
|
229
|
+
times.push(now)
|
|
230
|
+
pruneWindow(times, now, NICK_WINDOW)
|
|
231
|
+
|
|
232
|
+
if (times.length >= NICK_THRESHOLD) {
|
|
233
|
+
state.nickBlockedUntil.set(bufferId, now + NICK_BLOCK)
|
|
234
|
+
times.length = 0
|
|
235
|
+
|
|
236
|
+
// Extract channel name from bufferId for status message
|
|
237
|
+
const parts = bufferId.split("/")
|
|
238
|
+
const channel = parts.length > 1 ? parts[parts.length - 1] : bufferId
|
|
239
|
+
statusNotify(connId, `Nick flood in ${channel} \u2014 suppressing nick changes for 60s`)
|
|
240
|
+
return true
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return false
|
|
244
|
+
}
|