kokoirc 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/README.md +227 -0
  2. package/docs/commands/alias.md +42 -0
  3. package/docs/commands/ban.md +26 -0
  4. package/docs/commands/close.md +25 -0
  5. package/docs/commands/connect.md +26 -0
  6. package/docs/commands/deop.md +24 -0
  7. package/docs/commands/devoice.md +24 -0
  8. package/docs/commands/disconnect.md +26 -0
  9. package/docs/commands/help.md +28 -0
  10. package/docs/commands/ignore.md +47 -0
  11. package/docs/commands/items.md +95 -0
  12. package/docs/commands/join.md +25 -0
  13. package/docs/commands/kb.md +26 -0
  14. package/docs/commands/kick.md +25 -0
  15. package/docs/commands/log.md +82 -0
  16. package/docs/commands/me.md +24 -0
  17. package/docs/commands/mode.md +29 -0
  18. package/docs/commands/msg.md +26 -0
  19. package/docs/commands/nick.md +24 -0
  20. package/docs/commands/notice.md +24 -0
  21. package/docs/commands/op.md +24 -0
  22. package/docs/commands/part.md +25 -0
  23. package/docs/commands/quit.md +24 -0
  24. package/docs/commands/reload.md +19 -0
  25. package/docs/commands/script.md +126 -0
  26. package/docs/commands/server.md +61 -0
  27. package/docs/commands/set.md +37 -0
  28. package/docs/commands/topic.md +24 -0
  29. package/docs/commands/unalias.md +22 -0
  30. package/docs/commands/unban.md +25 -0
  31. package/docs/commands/unignore.md +25 -0
  32. package/docs/commands/voice.md +25 -0
  33. package/docs/commands/whois.md +24 -0
  34. package/docs/commands/wii.md +23 -0
  35. package/package.json +38 -0
  36. package/src/app/App.tsx +205 -0
  37. package/src/core/commands/docs.ts +183 -0
  38. package/src/core/commands/execution.ts +114 -0
  39. package/src/core/commands/help-formatter.ts +185 -0
  40. package/src/core/commands/helpers.ts +168 -0
  41. package/src/core/commands/index.ts +7 -0
  42. package/src/core/commands/parser.ts +33 -0
  43. package/src/core/commands/registry.ts +1394 -0
  44. package/src/core/commands/types.ts +19 -0
  45. package/src/core/config/defaults.ts +66 -0
  46. package/src/core/config/loader.ts +209 -0
  47. package/src/core/constants.ts +20 -0
  48. package/src/core/init.ts +32 -0
  49. package/src/core/irc/antiflood.ts +244 -0
  50. package/src/core/irc/client.ts +145 -0
  51. package/src/core/irc/events.ts +1031 -0
  52. package/src/core/irc/formatting.ts +132 -0
  53. package/src/core/irc/ignore.ts +84 -0
  54. package/src/core/irc/index.ts +2 -0
  55. package/src/core/irc/netsplit.ts +292 -0
  56. package/src/core/scripts/api.ts +240 -0
  57. package/src/core/scripts/event-bus.ts +82 -0
  58. package/src/core/scripts/index.ts +26 -0
  59. package/src/core/scripts/manager.ts +154 -0
  60. package/src/core/scripts/types.ts +256 -0
  61. package/src/core/state/selectors.ts +39 -0
  62. package/src/core/state/sorting.ts +30 -0
  63. package/src/core/state/store.ts +242 -0
  64. package/src/core/storage/crypto.ts +78 -0
  65. package/src/core/storage/db.ts +107 -0
  66. package/src/core/storage/index.ts +80 -0
  67. package/src/core/storage/query.ts +204 -0
  68. package/src/core/storage/types.ts +37 -0
  69. package/src/core/storage/writer.ts +130 -0
  70. package/src/core/theme/index.ts +3 -0
  71. package/src/core/theme/loader.ts +45 -0
  72. package/src/core/theme/parser.ts +518 -0
  73. package/src/core/theme/renderer.tsx +25 -0
  74. package/src/index.tsx +17 -0
  75. package/src/types/config.ts +126 -0
  76. package/src/types/index.ts +107 -0
  77. package/src/types/irc-framework.d.ts +569 -0
  78. package/src/types/theme.ts +37 -0
  79. package/src/ui/ErrorBoundary.tsx +42 -0
  80. package/src/ui/chat/ChatView.tsx +39 -0
  81. package/src/ui/chat/MessageLine.tsx +92 -0
  82. package/src/ui/hooks/useStatusbarColors.ts +23 -0
  83. package/src/ui/input/CommandInput.tsx +273 -0
  84. package/src/ui/layout/AppLayout.tsx +126 -0
  85. package/src/ui/layout/TopicBar.tsx +46 -0
  86. package/src/ui/sidebar/BufferList.tsx +55 -0
  87. package/src/ui/sidebar/NickList.tsx +96 -0
  88. package/src/ui/splash/SplashScreen.tsx +100 -0
  89. package/src/ui/statusbar/StatusLine.tsx +205 -0
  90. package/themes/.gitkeep +0 -0
  91. package/themes/default.theme +57 -0
  92. package/tsconfig.json +19 -0
@@ -0,0 +1,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")
@@ -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
+ }