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,1031 @@
|
|
|
1
|
+
import type { Client } from "kofany-irc-framework"
|
|
2
|
+
import { useStore } from "@/core/state/store"
|
|
3
|
+
import { makeBufferId, BufferType, ActivityLevel } from "@/types"
|
|
4
|
+
import type { Message } from "@/types"
|
|
5
|
+
import { formatDuration, formatDate, buildModeString, buildPrefixMap, buildModeOrder, getHighestPrefix, getNickMode } from "./formatting"
|
|
6
|
+
import { handleNetsplitQuit, handleNetsplitJoin, destroyNetsplitState } from "./netsplit"
|
|
7
|
+
import { shouldSuppressNickFlood, destroyAntifloodState } from "./antiflood"
|
|
8
|
+
import { shouldIgnore } from "./ignore"
|
|
9
|
+
import { eventBus } from "@/core/scripts/event-bus"
|
|
10
|
+
|
|
11
|
+
function isChannelTarget(target: string): boolean {
|
|
12
|
+
return target.startsWith("#") || target.startsWith("&") || target.startsWith("+") || target.startsWith("!")
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Get list modes (CHANMODES category A) from ISUPPORT, fallback to beIR. */
|
|
16
|
+
function getListModes(connectionId: string): Set<string> {
|
|
17
|
+
const conn = useStore.getState().connections.get(connectionId)
|
|
18
|
+
const chanmodes = conn?.isupport?.CHANMODES
|
|
19
|
+
if (chanmodes) return new Set(chanmodes.split(",")[0])
|
|
20
|
+
return new Set(["b", "e", "I", "R"])
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function bindEvents(client: Client, connectionId: string) {
|
|
24
|
+
const getStore = () => useStore.getState()
|
|
25
|
+
const statusId = makeBufferId(connectionId, "Status")
|
|
26
|
+
|
|
27
|
+
/** Safely add a message to the Status buffer (must exist). */
|
|
28
|
+
function statusMsg(text: string) {
|
|
29
|
+
getStore().addMessage(statusId, makeEventMessage(text))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ─── Socket-level events ─────────────────────────────────
|
|
33
|
+
client.on("socket connected", () => {
|
|
34
|
+
statusMsg("%Z9ece6aSocket connected, registering...%N")
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
client.on("socket error", (err) => {
|
|
38
|
+
console.error(`[${connectionId}] Socket error:`, err)
|
|
39
|
+
getStore().updateConnection(connectionId, { status: "error" })
|
|
40
|
+
statusMsg(`%Zf7768eSocket error: ${err?.message ?? err}%N`)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
client.on("socket close", (hadError) => {
|
|
44
|
+
if (hadError) {
|
|
45
|
+
statusMsg(`%Zf7768eSocket closed with error%N`)
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
// ─── Registration ─────────────────────────────────────────
|
|
50
|
+
client.on("registered", (event) => {
|
|
51
|
+
eventBus.emit("connected", { connectionId, nick: event.nick })
|
|
52
|
+
|
|
53
|
+
const s = getStore()
|
|
54
|
+
s.updateConnection(connectionId, { status: "connected", nick: event.nick })
|
|
55
|
+
statusMsg(`%Z9ece6aRegistered as %Zc0caf5${event.nick}%N`)
|
|
56
|
+
// Auto-join channels from config
|
|
57
|
+
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
|
+
}
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
client.on("join", (event) => {
|
|
69
|
+
if (!eventBus.emit("irc.join", {
|
|
70
|
+
connectionId, nick: event.nick, ident: event.ident,
|
|
71
|
+
hostname: event.hostname, channel: event.channel, account: event.account,
|
|
72
|
+
})) return
|
|
73
|
+
|
|
74
|
+
const s = getStore()
|
|
75
|
+
const bufferId = makeBufferId(connectionId, event.channel)
|
|
76
|
+
const conn = s.connections.get(connectionId)
|
|
77
|
+
|
|
78
|
+
if (event.nick === conn?.nick) {
|
|
79
|
+
s.addBuffer({
|
|
80
|
+
id: bufferId,
|
|
81
|
+
connectionId,
|
|
82
|
+
type: BufferType.Channel,
|
|
83
|
+
name: event.channel,
|
|
84
|
+
messages: [],
|
|
85
|
+
activity: ActivityLevel.None,
|
|
86
|
+
unreadCount: 0,
|
|
87
|
+
lastRead: new Date(),
|
|
88
|
+
users: new Map(),
|
|
89
|
+
listModes: new Map(),
|
|
90
|
+
})
|
|
91
|
+
// Switch to the newly joined channel
|
|
92
|
+
getStore().setActiveBuffer(bufferId)
|
|
93
|
+
// Request channel modes so we get RPL_CHANNELMODEIS (324)
|
|
94
|
+
client.raw(`MODE ${event.channel}`)
|
|
95
|
+
} else {
|
|
96
|
+
s.addNick(bufferId, { nick: event.nick, prefix: "", modes: "", away: false, account: event.account })
|
|
97
|
+
|
|
98
|
+
// If this join is from a netsplit healing, batch it instead of showing individually
|
|
99
|
+
if (handleNetsplitJoin(connectionId, event.nick, bufferId)) {
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (shouldIgnore(event.nick, event.ident, event.hostname, "JOINS", event.channel)) return
|
|
104
|
+
|
|
105
|
+
s.addMessage(bufferId, makeFormattedEvent("join", [
|
|
106
|
+
event.nick, event.ident || "", event.hostname || "", event.channel,
|
|
107
|
+
]))
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
client.on("part", (event) => {
|
|
112
|
+
if (!eventBus.emit("irc.part", {
|
|
113
|
+
connectionId, nick: event.nick, ident: event.ident,
|
|
114
|
+
hostname: event.hostname, channel: event.channel, message: event.message,
|
|
115
|
+
})) return
|
|
116
|
+
|
|
117
|
+
const s = getStore()
|
|
118
|
+
const bufferId = makeBufferId(connectionId, event.channel)
|
|
119
|
+
const conn = s.connections.get(connectionId)
|
|
120
|
+
|
|
121
|
+
if (event.nick === conn?.nick) {
|
|
122
|
+
s.removeBuffer(bufferId)
|
|
123
|
+
} else {
|
|
124
|
+
s.removeNick(bufferId, event.nick)
|
|
125
|
+
if (shouldIgnore(event.nick, event.ident, event.hostname, "PARTS", event.channel)) return
|
|
126
|
+
s.addMessage(bufferId, makeFormattedEvent("part", [
|
|
127
|
+
event.nick, event.ident || "", event.hostname || "", event.channel, event.message || "",
|
|
128
|
+
]))
|
|
129
|
+
}
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
client.on("quit", (event) => {
|
|
133
|
+
if (!eventBus.emit("irc.quit", {
|
|
134
|
+
connectionId, nick: event.nick, ident: event.ident,
|
|
135
|
+
hostname: event.hostname, message: event.message,
|
|
136
|
+
})) return
|
|
137
|
+
|
|
138
|
+
const s = getStore()
|
|
139
|
+
const affected = Array.from(s.buffers.entries())
|
|
140
|
+
.filter(([_, buf]) => buf.connectionId === connectionId && buf.users.has(event.nick))
|
|
141
|
+
.map(([id]) => id)
|
|
142
|
+
|
|
143
|
+
// Remove nick from all affected channels
|
|
144
|
+
for (const id of affected) {
|
|
145
|
+
getStore().removeNick(id, event.nick)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Check if this is a netsplit — if so, batch it instead of showing individual quits
|
|
149
|
+
if (handleNetsplitQuit(connectionId, event.nick, event.message || "", affected)) {
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (shouldIgnore(event.nick, event.ident, event.hostname, "QUITS")) return
|
|
154
|
+
|
|
155
|
+
for (const id of affected) {
|
|
156
|
+
getStore().addMessage(id, makeFormattedEvent("quit", [
|
|
157
|
+
event.nick, event.ident || "", event.hostname || "", event.message || "",
|
|
158
|
+
]))
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
client.on("kick", (event) => {
|
|
163
|
+
if (!eventBus.emit("irc.kick", {
|
|
164
|
+
connectionId, nick: event.nick, ident: event.ident, hostname: event.hostname,
|
|
165
|
+
channel: event.channel, kicked: event.kicked, message: event.message,
|
|
166
|
+
})) return
|
|
167
|
+
|
|
168
|
+
const s = getStore()
|
|
169
|
+
const bufferId = makeBufferId(connectionId, event.channel)
|
|
170
|
+
const conn = s.connections.get(connectionId)
|
|
171
|
+
|
|
172
|
+
if (event.kicked === conn?.nick) {
|
|
173
|
+
s.addMessage(bufferId, makeEventMessage(
|
|
174
|
+
`%Zf7768eYou were kicked from ${event.channel} by %Za9b1d6${event.nick}%Zf7768e (${event.message || ""})%N`
|
|
175
|
+
))
|
|
176
|
+
} else {
|
|
177
|
+
s.removeNick(bufferId, event.kicked)
|
|
178
|
+
if (shouldIgnore(event.nick, event.ident, event.hostname, "KICKS", event.channel)) return
|
|
179
|
+
s.addMessage(bufferId, makeEventMessage(
|
|
180
|
+
`%Ze0af68${event.kicked}%Z565f89 was kicked by %Za9b1d6${event.nick}%Z565f89 (${event.message || ""})%N`
|
|
181
|
+
))
|
|
182
|
+
}
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
client.on("privmsg", (event) => {
|
|
186
|
+
const isChannel = isChannelTarget(event.target)
|
|
187
|
+
if (!eventBus.emit("irc.privmsg", {
|
|
188
|
+
connectionId, nick: event.nick, ident: event.ident, hostname: event.hostname,
|
|
189
|
+
target: event.target, message: event.message, tags: event.tags, time: event.time, isChannel,
|
|
190
|
+
})) return
|
|
191
|
+
|
|
192
|
+
const s = getStore()
|
|
193
|
+
const bufferName = isChannel ? event.target : event.nick
|
|
194
|
+
const bufferId = makeBufferId(connectionId, bufferName)
|
|
195
|
+
|
|
196
|
+
// Create query buffer if it doesn't exist
|
|
197
|
+
if (!isChannel && !s.buffers.has(bufferId)) {
|
|
198
|
+
const host = event.ident && event.hostname ? `${event.ident}@${event.hostname}` : undefined
|
|
199
|
+
s.addBuffer({
|
|
200
|
+
id: bufferId,
|
|
201
|
+
connectionId,
|
|
202
|
+
type: BufferType.Query,
|
|
203
|
+
name: event.nick,
|
|
204
|
+
messages: [],
|
|
205
|
+
activity: ActivityLevel.None,
|
|
206
|
+
unreadCount: 0,
|
|
207
|
+
lastRead: new Date(),
|
|
208
|
+
users: new Map(),
|
|
209
|
+
topic: host,
|
|
210
|
+
listModes: new Map(),
|
|
211
|
+
})
|
|
212
|
+
} else if (!isChannel) {
|
|
213
|
+
// Update hostname if we got new info
|
|
214
|
+
const host = event.ident && event.hostname ? `${event.ident}@${event.hostname}` : undefined
|
|
215
|
+
if (host) {
|
|
216
|
+
const buf = s.buffers.get(bufferId)
|
|
217
|
+
if (buf && buf.type === BufferType.Query && buf.topic !== host) {
|
|
218
|
+
s.updateBufferTopic(bufferId, host)
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const conn = s.connections.get(connectionId)
|
|
224
|
+
const isOwnMsg = event.nick === conn?.nick
|
|
225
|
+
const isMention = !isOwnMsg && conn?.nick
|
|
226
|
+
? event.message.toLowerCase().includes(conn.nick.toLowerCase())
|
|
227
|
+
: false
|
|
228
|
+
|
|
229
|
+
s.addMessage(bufferId, {
|
|
230
|
+
id: crypto.randomUUID(),
|
|
231
|
+
timestamp: new Date(event.time || Date.now()),
|
|
232
|
+
type: "message",
|
|
233
|
+
nick: event.nick,
|
|
234
|
+
nickMode: getNickMode(s.buffers, bufferId, event.nick),
|
|
235
|
+
text: event.message,
|
|
236
|
+
highlight: isMention,
|
|
237
|
+
tags: event.tags,
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
if (s.activeBufferId !== bufferId && !isOwnMsg) {
|
|
241
|
+
const level = !isChannel ? ActivityLevel.Mention
|
|
242
|
+
: isMention ? ActivityLevel.Mention
|
|
243
|
+
: ActivityLevel.Activity
|
|
244
|
+
s.updateBufferActivity(bufferId, level)
|
|
245
|
+
}
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
client.on("action", (event) => {
|
|
249
|
+
const isChannel = isChannelTarget(event.target)
|
|
250
|
+
if (!eventBus.emit("irc.action", {
|
|
251
|
+
connectionId, nick: event.nick, ident: event.ident, hostname: event.hostname,
|
|
252
|
+
target: event.target, message: event.message, tags: event.tags, time: event.time, isChannel,
|
|
253
|
+
})) return
|
|
254
|
+
|
|
255
|
+
const s = getStore()
|
|
256
|
+
const bufferName = isChannel ? event.target : event.nick
|
|
257
|
+
const bufferId = makeBufferId(connectionId, bufferName)
|
|
258
|
+
|
|
259
|
+
s.addMessage(bufferId, {
|
|
260
|
+
id: crypto.randomUUID(),
|
|
261
|
+
timestamp: new Date(event.time || Date.now()),
|
|
262
|
+
type: "action",
|
|
263
|
+
nick: event.nick,
|
|
264
|
+
text: event.message,
|
|
265
|
+
highlight: false,
|
|
266
|
+
tags: event.tags,
|
|
267
|
+
})
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
client.on("notice", (event) => {
|
|
271
|
+
if (!eventBus.emit("irc.notice", {
|
|
272
|
+
connectionId, nick: event.nick, target: event.target,
|
|
273
|
+
message: event.message, from_server: event.from_server,
|
|
274
|
+
})) return
|
|
275
|
+
|
|
276
|
+
const s = getStore()
|
|
277
|
+
// Server notices go to server buffer
|
|
278
|
+
const bufferId = event.from_server
|
|
279
|
+
? makeBufferId(connectionId, "Status")
|
|
280
|
+
: makeBufferId(connectionId, event.target && isChannelTarget(event.target) ? event.target : event.nick || "Status")
|
|
281
|
+
|
|
282
|
+
if (!s.buffers.has(bufferId)) {
|
|
283
|
+
// Fallback to server status buffer
|
|
284
|
+
const statusId = makeBufferId(connectionId, "Status")
|
|
285
|
+
s.addMessage(statusId, {
|
|
286
|
+
id: crypto.randomUUID(),
|
|
287
|
+
timestamp: new Date(event.time || Date.now()),
|
|
288
|
+
type: "notice",
|
|
289
|
+
nick: event.nick,
|
|
290
|
+
text: event.message,
|
|
291
|
+
highlight: false,
|
|
292
|
+
})
|
|
293
|
+
return
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
s.addMessage(bufferId, {
|
|
297
|
+
id: crypto.randomUUID(),
|
|
298
|
+
timestamp: new Date(event.time || Date.now()),
|
|
299
|
+
type: "notice",
|
|
300
|
+
nick: event.nick,
|
|
301
|
+
text: event.message,
|
|
302
|
+
highlight: false,
|
|
303
|
+
})
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
client.on("nick", (event) => {
|
|
307
|
+
if (!eventBus.emit("irc.nick", {
|
|
308
|
+
connectionId, nick: event.nick, new_nick: event.new_nick,
|
|
309
|
+
ident: event.ident, hostname: event.hostname,
|
|
310
|
+
})) return
|
|
311
|
+
|
|
312
|
+
const s = getStore()
|
|
313
|
+
const conn = s.connections.get(connectionId)
|
|
314
|
+
|
|
315
|
+
// If it's us, update connection nick
|
|
316
|
+
if (event.nick === conn?.nick) {
|
|
317
|
+
getStore().updateConnection(connectionId, { nick: event.new_nick })
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Collect affected buffer IDs first, then mutate with fresh state
|
|
321
|
+
const affected = Array.from(s.buffers.entries())
|
|
322
|
+
.filter(([_, buf]) => buf.connectionId === connectionId && buf.users.has(event.nick))
|
|
323
|
+
.map(([id]) => id)
|
|
324
|
+
|
|
325
|
+
const nickIgnored = shouldIgnore(event.nick, event.ident, event.hostname, "NICKS")
|
|
326
|
+
for (const id of affected) {
|
|
327
|
+
getStore().updateNick(id, event.nick, event.new_nick)
|
|
328
|
+
if (nickIgnored) continue
|
|
329
|
+
if (shouldSuppressNickFlood(connectionId, id)) continue
|
|
330
|
+
getStore().addMessage(id, makeFormattedEvent("nick_change", [
|
|
331
|
+
event.nick, event.new_nick,
|
|
332
|
+
]))
|
|
333
|
+
}
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
client.on("topic", (event) => {
|
|
337
|
+
if (!eventBus.emit("irc.topic", {
|
|
338
|
+
connectionId, nick: event.nick, channel: event.channel, topic: event.topic,
|
|
339
|
+
})) return
|
|
340
|
+
|
|
341
|
+
const s = getStore()
|
|
342
|
+
const bufferId = makeBufferId(connectionId, event.channel)
|
|
343
|
+
s.updateBufferTopic(bufferId, event.topic, event.nick)
|
|
344
|
+
if (event.nick) {
|
|
345
|
+
getStore().addMessage(bufferId, makeFormattedEvent("topic", [
|
|
346
|
+
event.nick, event.topic,
|
|
347
|
+
]))
|
|
348
|
+
}
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
client.on("topicsetby", (event) => {
|
|
352
|
+
const bufferId = makeBufferId(connectionId, event.channel)
|
|
353
|
+
const when = event.when ? formatDate(new Date(event.when * 1000)) : ""
|
|
354
|
+
getStore().addMessage(bufferId, makeEventMessage(
|
|
355
|
+
`%Z565f89Topic set by %Za9b1d6${event.nick}%Z565f89${when ? " on " + when : ""}%N`
|
|
356
|
+
))
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
client.on("userlist", (event) => {
|
|
360
|
+
const bufferId = makeBufferId(connectionId, event.channel)
|
|
361
|
+
if (!getStore().buffers.has(bufferId)) return
|
|
362
|
+
|
|
363
|
+
const conn = getStore().connections.get(connectionId)
|
|
364
|
+
const prefixMap = buildPrefixMap(conn?.isupport?.PREFIX)
|
|
365
|
+
const modeOrder = buildModeOrder(conn?.isupport?.PREFIX)
|
|
366
|
+
|
|
367
|
+
for (const user of event.users) {
|
|
368
|
+
// irc-framework gives modes as array of chars (["o","v"]) or prefix symbols (["@","+"])
|
|
369
|
+
// Normalize to mode chars and store all of them
|
|
370
|
+
const rawModes = (user.modes ?? [])
|
|
371
|
+
.map((m) => {
|
|
372
|
+
// If it's already a mode char in the order list, keep it
|
|
373
|
+
if (modeOrder.includes(m)) return m
|
|
374
|
+
// Otherwise it's a prefix symbol — reverse-lookup
|
|
375
|
+
for (const [modeChar, sym] of Object.entries(prefixMap)) {
|
|
376
|
+
if (sym === m && modeOrder.includes(modeChar)) return modeChar
|
|
377
|
+
}
|
|
378
|
+
return ""
|
|
379
|
+
})
|
|
380
|
+
.filter(Boolean)
|
|
381
|
+
.join("")
|
|
382
|
+
const prefix = getHighestPrefix(rawModes, modeOrder, prefixMap)
|
|
383
|
+
getStore().addNick(bufferId, {
|
|
384
|
+
nick: user.nick,
|
|
385
|
+
prefix,
|
|
386
|
+
modes: rawModes,
|
|
387
|
+
away: !!user.away,
|
|
388
|
+
account: user.account,
|
|
389
|
+
})
|
|
390
|
+
}
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
client.on("mode", (event) => {
|
|
394
|
+
if (!eventBus.emit("irc.mode", {
|
|
395
|
+
connectionId, nick: event.nick, target: event.target,
|
|
396
|
+
modes: Array.isArray(event.modes) ? event.modes : [],
|
|
397
|
+
})) return
|
|
398
|
+
|
|
399
|
+
const s = getStore()
|
|
400
|
+
const bufferId = makeBufferId(connectionId, event.target)
|
|
401
|
+
if (!s.buffers.has(bufferId)) return
|
|
402
|
+
|
|
403
|
+
// Build displayable mode string
|
|
404
|
+
const modeStr = buildModeString(event)
|
|
405
|
+
getStore().addMessage(bufferId, makeFormattedEvent("mode", [
|
|
406
|
+
event.nick || "server", modeStr, event.target,
|
|
407
|
+
]))
|
|
408
|
+
|
|
409
|
+
// Update nick prefixes for user prefix modes (+o, +v, etc.)
|
|
410
|
+
if (!Array.isArray(event.modes)) return
|
|
411
|
+
const conn = getStore().connections.get(connectionId)
|
|
412
|
+
const prefixMap = buildPrefixMap(conn?.isupport?.PREFIX)
|
|
413
|
+
const modeOrder = buildModeOrder(conn?.isupport?.PREFIX)
|
|
414
|
+
|
|
415
|
+
for (const mc of event.modes) {
|
|
416
|
+
if (!mc.param) continue
|
|
417
|
+
const isAdding = mc.mode.startsWith("+")
|
|
418
|
+
const modeChar = mc.mode.replace(/[+-]/, "")
|
|
419
|
+
if (!modeOrder.includes(modeChar)) continue // not a nick prefix mode
|
|
420
|
+
|
|
421
|
+
const buf = getStore().buffers.get(bufferId)
|
|
422
|
+
const entry = buf?.users.get(mc.param)
|
|
423
|
+
if (!entry) continue
|
|
424
|
+
|
|
425
|
+
// Add or remove this specific mode char from the user's modes string
|
|
426
|
+
let modes = entry.modes ?? ""
|
|
427
|
+
if (isAdding && !modes.includes(modeChar)) {
|
|
428
|
+
modes += modeChar
|
|
429
|
+
} else if (!isAdding) {
|
|
430
|
+
modes = modes.replace(modeChar, "")
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
getStore().addNick(bufferId, {
|
|
434
|
+
...entry,
|
|
435
|
+
modes,
|
|
436
|
+
prefix: getHighestPrefix(modes, modeOrder, prefixMap),
|
|
437
|
+
})
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Update channel modes (non-nick-prefix, non-list modes)
|
|
441
|
+
const listModes = getListModes(connectionId)
|
|
442
|
+
const buf = getStore().buffers.get(bufferId)
|
|
443
|
+
if (buf) {
|
|
444
|
+
let chanModes = buf.modes ?? ""
|
|
445
|
+
const params: Record<string, string> = { ...buf.modeParams }
|
|
446
|
+
for (const mc of event.modes) {
|
|
447
|
+
const isAdding = mc.mode.startsWith("+")
|
|
448
|
+
const modeChar = mc.mode.replace(/[+-]/, "")
|
|
449
|
+
if (modeOrder.includes(modeChar)) continue // nick prefix mode
|
|
450
|
+
if (listModes.has(modeChar)) {
|
|
451
|
+
// Track list mode changes in store
|
|
452
|
+
if (isAdding && mc.param) {
|
|
453
|
+
getStore().addListEntry(bufferId, modeChar, {
|
|
454
|
+
mask: mc.param,
|
|
455
|
+
setBy: event.nick || "server",
|
|
456
|
+
setAt: Date.now() / 1000,
|
|
457
|
+
})
|
|
458
|
+
} else if (!isAdding && mc.param) {
|
|
459
|
+
getStore().removeListEntry(bufferId, modeChar, mc.param)
|
|
460
|
+
}
|
|
461
|
+
continue // don't add to channel modes string
|
|
462
|
+
}
|
|
463
|
+
if (isAdding && !chanModes.includes(modeChar)) {
|
|
464
|
+
chanModes += modeChar
|
|
465
|
+
} else if (!isAdding) {
|
|
466
|
+
chanModes = chanModes.replace(modeChar, "")
|
|
467
|
+
delete params[modeChar]
|
|
468
|
+
}
|
|
469
|
+
if (isAdding && mc.param) {
|
|
470
|
+
params[modeChar] = mc.param
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
getStore().updateBufferModes(bufferId, chanModes, params)
|
|
474
|
+
}
|
|
475
|
+
})
|
|
476
|
+
|
|
477
|
+
// Lag measurement via CTCP PING or PONG
|
|
478
|
+
let lastPingSent = 0
|
|
479
|
+
const lagPingInterval = setInterval(() => {
|
|
480
|
+
if (getStore().connections.get(connectionId)?.status === "connected") {
|
|
481
|
+
lastPingSent = Date.now()
|
|
482
|
+
client.raw("PING " + lastPingSent)
|
|
483
|
+
}
|
|
484
|
+
}, 30000)
|
|
485
|
+
|
|
486
|
+
client.on("pong", () => {
|
|
487
|
+
if (lastPingSent > 0) {
|
|
488
|
+
const lag = Date.now() - lastPingSent
|
|
489
|
+
getStore().updateConnection(connectionId, { lag })
|
|
490
|
+
}
|
|
491
|
+
})
|
|
492
|
+
|
|
493
|
+
client.on("close", () => {
|
|
494
|
+
eventBus.emit("disconnected", { connectionId })
|
|
495
|
+
|
|
496
|
+
clearInterval(lagPingInterval)
|
|
497
|
+
destroyNetsplitState(connectionId)
|
|
498
|
+
destroyAntifloodState(connectionId)
|
|
499
|
+
getStore().updateConnection(connectionId, { status: "disconnected" })
|
|
500
|
+
statusMsg("%Zf7768eDisconnected from server%N")
|
|
501
|
+
})
|
|
502
|
+
|
|
503
|
+
client.on("reconnecting", (event) => {
|
|
504
|
+
getStore().updateConnection(connectionId, { status: "connecting" })
|
|
505
|
+
const attempt = event?.attempt ?? "?"
|
|
506
|
+
const max = event?.max_retries ?? "?"
|
|
507
|
+
statusMsg(`%Ze0af68Reconnecting (attempt ${attempt}/${max})...%N`)
|
|
508
|
+
})
|
|
509
|
+
|
|
510
|
+
client.on("error", (event) => {
|
|
511
|
+
console.error(`[${connectionId}] IRC error:`, event)
|
|
512
|
+
statusMsg(`%Zf7768eError: ${event.message || event.error || JSON.stringify(event)}%N`)
|
|
513
|
+
})
|
|
514
|
+
|
|
515
|
+
client.on("irc error", (event) => {
|
|
516
|
+
const s = getStore()
|
|
517
|
+
|
|
518
|
+
// Route to channel buffer if available
|
|
519
|
+
let targetBuffer = statusId
|
|
520
|
+
if (event.channel) {
|
|
521
|
+
const chanBufferId = makeBufferId(connectionId, event.channel)
|
|
522
|
+
if (s.buffers.has(chanBufferId)) {
|
|
523
|
+
targetBuffer = chanBufferId
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Build message with context
|
|
528
|
+
const prefix = event.nick ? `${event.nick}: `
|
|
529
|
+
: event.channel ? `${event.channel}: `
|
|
530
|
+
: ""
|
|
531
|
+
const reason = event.reason
|
|
532
|
+
|| event.message
|
|
533
|
+
|| (event.error ? event.error.replace(/_/g, " ") : "Unknown error")
|
|
534
|
+
|
|
535
|
+
s.addMessage(targetBuffer, makeEventMessage(`%Zf7768e${prefix}${reason}%N`))
|
|
536
|
+
})
|
|
537
|
+
|
|
538
|
+
// Nick in use — irc-framework does NOT auto-retry, we must send alternate nick
|
|
539
|
+
let nickRetries = 0
|
|
540
|
+
client.on("nick in use", (event) => {
|
|
541
|
+
nickRetries++
|
|
542
|
+
if (nickRetries > 5) {
|
|
543
|
+
statusMsg(`%Zf7768eCould not find available nick after 5 attempts%N`)
|
|
544
|
+
return
|
|
545
|
+
}
|
|
546
|
+
const newNick = event.nick + "_"
|
|
547
|
+
statusMsg(`%Ze0af68Nick ${event.nick} is already in use, trying ${newNick}...%N`)
|
|
548
|
+
client.changeNick(newNick)
|
|
549
|
+
getStore().updateConnection(connectionId, { nick: newNick })
|
|
550
|
+
})
|
|
551
|
+
|
|
552
|
+
// Invalid nick
|
|
553
|
+
client.on("nick invalid", (event) => {
|
|
554
|
+
statusMsg(`%Zf7768eNick ${event.nick} is invalid: ${event.reason}%N`)
|
|
555
|
+
})
|
|
556
|
+
|
|
557
|
+
// SASL failure
|
|
558
|
+
client.on("sasl failed", (event) => {
|
|
559
|
+
statusMsg(`%Zf7768eSASL authentication failed: ${event.reason}${event.message ? " — " + event.message : ""}%N`)
|
|
560
|
+
})
|
|
561
|
+
|
|
562
|
+
// Store ISUPPORT/server options
|
|
563
|
+
client.on("server options", (event) => {
|
|
564
|
+
const s = getStore()
|
|
565
|
+
s.updateConnection(connectionId, { isupport: event.options || {} })
|
|
566
|
+
})
|
|
567
|
+
|
|
568
|
+
// RPL_UMODEIS — user's own modes (emitted on connect and after MODE <nick>)
|
|
569
|
+
client.on("user info", (event) => {
|
|
570
|
+
const modes = (event.raw_modes || "").replace(/^\+/, "")
|
|
571
|
+
getStore().updateConnection(connectionId, { userModes: modes })
|
|
572
|
+
})
|
|
573
|
+
|
|
574
|
+
// ─── MOTD ──────────────────────────────────────────────────
|
|
575
|
+
client.on("motd", (event) => {
|
|
576
|
+
if (event.error) {
|
|
577
|
+
statusMsg(`%Z565f89${event.error}%N`)
|
|
578
|
+
return
|
|
579
|
+
}
|
|
580
|
+
if (!event.motd) return
|
|
581
|
+
for (const line of event.motd.split("\n")) {
|
|
582
|
+
if (line.trim()) statusMsg(`%Z565f89${line}%N`)
|
|
583
|
+
}
|
|
584
|
+
})
|
|
585
|
+
|
|
586
|
+
// ─── Away / Back ───────────────────────────────────────────
|
|
587
|
+
client.on("away", (event) => {
|
|
588
|
+
const s = getStore()
|
|
589
|
+
if (event.self) {
|
|
590
|
+
statusMsg(`%Z565f89You are now marked as away${event.message ? ": " + event.message : ""}%N`)
|
|
591
|
+
return
|
|
592
|
+
}
|
|
593
|
+
if (!event.nick) return
|
|
594
|
+
|
|
595
|
+
// Update nick away status in all shared channels
|
|
596
|
+
for (const [bufId, buf] of s.buffers) {
|
|
597
|
+
if (buf.connectionId === connectionId && buf.users.has(event.nick)) {
|
|
598
|
+
const entry = buf.users.get(event.nick)!
|
|
599
|
+
getStore().addNick(bufId, { ...entry, away: true })
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// Show in query buffer if we have one open (RPL_AWAY response to messaging)
|
|
604
|
+
const queryId = makeBufferId(connectionId, event.nick)
|
|
605
|
+
if (s.buffers.has(queryId)) {
|
|
606
|
+
s.addMessage(queryId, makeEventMessage(
|
|
607
|
+
`%Z565f89${event.nick} is away${event.message ? ": " + event.message : ""}%N`
|
|
608
|
+
))
|
|
609
|
+
}
|
|
610
|
+
})
|
|
611
|
+
|
|
612
|
+
client.on("back", (event) => {
|
|
613
|
+
const s = getStore()
|
|
614
|
+
if (event.self) {
|
|
615
|
+
statusMsg(`%Z565f89You are no longer marked as away%N`)
|
|
616
|
+
return
|
|
617
|
+
}
|
|
618
|
+
if (!event.nick) return
|
|
619
|
+
|
|
620
|
+
// Update nick away status in all shared channels
|
|
621
|
+
for (const [bufId, buf] of s.buffers) {
|
|
622
|
+
if (buf.connectionId === connectionId && buf.users.has(event.nick)) {
|
|
623
|
+
const entry = buf.users.get(event.nick)!
|
|
624
|
+
getStore().addNick(bufId, { ...entry, away: false })
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
})
|
|
628
|
+
|
|
629
|
+
// ─── Channel redirect ─────────────────────────────────────
|
|
630
|
+
client.on("channel_redirect", (event) => {
|
|
631
|
+
statusMsg(`%Ze0af68${event.from} is redirecting to ${event.to}%N`)
|
|
632
|
+
})
|
|
633
|
+
|
|
634
|
+
// ─── Invite ────────────────────────────────────────────────
|
|
635
|
+
client.on("invite", (event) => {
|
|
636
|
+
if (!eventBus.emit("irc.invite", {
|
|
637
|
+
connectionId, nick: event.nick, channel: event.channel,
|
|
638
|
+
})) return
|
|
639
|
+
|
|
640
|
+
const s = getStore()
|
|
641
|
+
const target = s.activeBufferId ?? statusId
|
|
642
|
+
s.addMessage(target, makeEventMessage(
|
|
643
|
+
`%Zbb9af7${event.nick} invites you to ${event.channel}%N`
|
|
644
|
+
))
|
|
645
|
+
})
|
|
646
|
+
|
|
647
|
+
client.on("invited", (event) => {
|
|
648
|
+
const s = getStore()
|
|
649
|
+
const bufferId = makeBufferId(connectionId, event.channel)
|
|
650
|
+
const target = s.buffers.has(bufferId) ? bufferId : statusId
|
|
651
|
+
s.addMessage(target, makeEventMessage(
|
|
652
|
+
`%Z9ece6aInviting ${event.nick} to ${event.channel}%N`
|
|
653
|
+
))
|
|
654
|
+
})
|
|
655
|
+
|
|
656
|
+
// ─── Ban list ──────────────────────────────────────────────
|
|
657
|
+
client.on("banlist", (event) => {
|
|
658
|
+
const s = getStore()
|
|
659
|
+
const bufferId = makeBufferId(connectionId, event.channel)
|
|
660
|
+
const target = s.buffers.has(bufferId) ? bufferId : statusId
|
|
661
|
+
|
|
662
|
+
// Convert to ListEntry[] and store
|
|
663
|
+
const entries = event.bans.map((ban: any) => ({
|
|
664
|
+
mask: ban.banned,
|
|
665
|
+
setBy: ban.banned_by || "",
|
|
666
|
+
setAt: ban.banned_at || 0,
|
|
667
|
+
}))
|
|
668
|
+
getStore().setListEntries(bufferId, "b", entries)
|
|
669
|
+
|
|
670
|
+
displayNumberedList(target, "Ban list", event.channel, entries)
|
|
671
|
+
})
|
|
672
|
+
|
|
673
|
+
// ─── Exception list (irc-framework "exceptlist" event) ──────
|
|
674
|
+
client.on("exceptlist", (event: any) => {
|
|
675
|
+
const s = getStore()
|
|
676
|
+
const bufferId = makeBufferId(connectionId, event.channel)
|
|
677
|
+
const target = s.buffers.has(bufferId) ? bufferId : statusId
|
|
678
|
+
|
|
679
|
+
const entries = (event.excepts ?? []).map((e: any) => ({
|
|
680
|
+
mask: e.except || "",
|
|
681
|
+
setBy: e.except_by || "",
|
|
682
|
+
setAt: e.except_at ? parseInt(e.except_at, 10) : 0,
|
|
683
|
+
}))
|
|
684
|
+
getStore().setListEntries(bufferId, "e", entries)
|
|
685
|
+
displayNumberedList(target, "Exception list", event.channel, entries)
|
|
686
|
+
})
|
|
687
|
+
|
|
688
|
+
// ─── Invite list (irc-framework "inviteList" event) ────────
|
|
689
|
+
client.on("inviteList", (event: any) => {
|
|
690
|
+
const s = getStore()
|
|
691
|
+
const bufferId = makeBufferId(connectionId, event.channel)
|
|
692
|
+
const target = s.buffers.has(bufferId) ? bufferId : statusId
|
|
693
|
+
|
|
694
|
+
const entries = (event.invites ?? []).map((e: any) => ({
|
|
695
|
+
mask: e.invited || "",
|
|
696
|
+
setBy: e.invited_by || "",
|
|
697
|
+
setAt: e.invited_at ? parseInt(e.invited_at, 10) : 0,
|
|
698
|
+
}))
|
|
699
|
+
getStore().setListEntries(bufferId, "I", entries)
|
|
700
|
+
displayNumberedList(target, "Invite exception list", event.channel, entries)
|
|
701
|
+
})
|
|
702
|
+
|
|
703
|
+
// ─── Login / Account ───────────────────────────────────────
|
|
704
|
+
client.on("loggedin", (event) => {
|
|
705
|
+
statusMsg(`%Z9ece6aLogged in as %Zc0caf5${event.account}%N`)
|
|
706
|
+
})
|
|
707
|
+
|
|
708
|
+
client.on("loggedout", () => {
|
|
709
|
+
statusMsg(`%Ze0af68Logged out from account%N`)
|
|
710
|
+
})
|
|
711
|
+
|
|
712
|
+
// ACCOUNT-NOTIFY — a user's account changed (requires account-notify cap)
|
|
713
|
+
client.on("account", (event) => {
|
|
714
|
+
const s = getStore()
|
|
715
|
+
for (const [bufId, buf] of s.buffers) {
|
|
716
|
+
if (buf.connectionId === connectionId && buf.users.has(event.nick)) {
|
|
717
|
+
const entry = buf.users.get(event.nick)!
|
|
718
|
+
getStore().addNick(bufId, {
|
|
719
|
+
...entry,
|
|
720
|
+
account: event.account === false ? undefined : event.account,
|
|
721
|
+
})
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
})
|
|
725
|
+
|
|
726
|
+
// ─── Displayed host ────────────────────────────────────────
|
|
727
|
+
client.on("displayed host", (event) => {
|
|
728
|
+
statusMsg(`%Z565f89Your displayed host is now %Za9b1d6${event.hostname}%N`)
|
|
729
|
+
})
|
|
730
|
+
|
|
731
|
+
// ─── Channel info (324 modes, 329 creation time, 328 URL) ─
|
|
732
|
+
client.on("channel info", (event) => {
|
|
733
|
+
const bufferId = makeBufferId(connectionId, event.channel)
|
|
734
|
+
|
|
735
|
+
// 324 — RPL_CHANNELMODEIS
|
|
736
|
+
if (event.raw_modes) {
|
|
737
|
+
const listModeSet = getListModes(connectionId)
|
|
738
|
+
// Filter out list mode chars from the displayed modes
|
|
739
|
+
const modeChars = event.raw_modes.replace(/^\+/, "")
|
|
740
|
+
.split("").filter((ch) => !listModeSet.has(ch)).join("")
|
|
741
|
+
const params: Record<string, string> = {}
|
|
742
|
+
if (event.modes) {
|
|
743
|
+
for (const mc of event.modes) {
|
|
744
|
+
const ch = mc.mode.replace(/[+-]/, "")
|
|
745
|
+
if (listModeSet.has(ch)) continue
|
|
746
|
+
if (mc.param) params[ch] = mc.param
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
getStore().updateBufferModes(bufferId, modeChars, params)
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// 329 — RPL_CREATIONTIME
|
|
753
|
+
if (event.created_at) {
|
|
754
|
+
const buf = getStore().buffers.get(bufferId)
|
|
755
|
+
if (buf) {
|
|
756
|
+
getStore().addMessage(bufferId, makeEventMessage(
|
|
757
|
+
`%Z565f89Channel created: ${formatDate(new Date(event.created_at * 1000))}%N`
|
|
758
|
+
))
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
})
|
|
762
|
+
|
|
763
|
+
// ─── Wallops ───────────────────────────────────────────────
|
|
764
|
+
client.on("wallops", (event) => {
|
|
765
|
+
if (!eventBus.emit("irc.wallops", {
|
|
766
|
+
connectionId, nick: event.nick, message: event.message, from_server: event.from_server,
|
|
767
|
+
})) return
|
|
768
|
+
|
|
769
|
+
const from = event.from_server ? "Server" : event.nick
|
|
770
|
+
statusMsg(`%Zbb9af7[Wallops/${from}] ${event.message}%N`)
|
|
771
|
+
})
|
|
772
|
+
|
|
773
|
+
// ─── CTCP ─────────────────────────────────────────────────
|
|
774
|
+
client.on("ctcp response", (event) => {
|
|
775
|
+
if (!eventBus.emit("irc.ctcp_response", {
|
|
776
|
+
connectionId, nick: event.nick, type: event.type, message: event.message,
|
|
777
|
+
})) return
|
|
778
|
+
|
|
779
|
+
const s = getStore()
|
|
780
|
+
const target = s.activeBufferId ?? statusId
|
|
781
|
+
s.addMessage(target, makeEventMessage(
|
|
782
|
+
`%Z565f89CTCP %Za9b1d6${event.type}%Z565f89 reply from %Zc0caf5${event.nick}%Z565f89: ${event.message}%N`
|
|
783
|
+
))
|
|
784
|
+
})
|
|
785
|
+
|
|
786
|
+
client.on("ctcp request", (event) => {
|
|
787
|
+
// VERSION is handled internally by irc-framework unless version: null
|
|
788
|
+
// Show other CTCP requests (ACTION is handled separately)
|
|
789
|
+
if (event.type === "ACTION" || event.type === "VERSION") return
|
|
790
|
+
if (!eventBus.emit("irc.ctcp_request", {
|
|
791
|
+
connectionId, nick: event.nick, type: event.type, message: event.message,
|
|
792
|
+
})) return
|
|
793
|
+
const s = getStore()
|
|
794
|
+
const target = s.activeBufferId ?? statusId
|
|
795
|
+
s.addMessage(target, makeEventMessage(
|
|
796
|
+
`%Z565f89CTCP %Za9b1d6${event.type}%Z565f89 from %Zc0caf5${event.nick}%Z565f89${event.message ? ": " + event.message : ""}%N`
|
|
797
|
+
))
|
|
798
|
+
})
|
|
799
|
+
|
|
800
|
+
// ─── Reop list (344/345) ─────────────────────────────────────
|
|
801
|
+
// 344 is mapped to RPL_WHOISCOUNTRY in irc-framework's numerics and gets
|
|
802
|
+
// consumed by the WHOIS handler — it never reaches "unknown command".
|
|
803
|
+
// We intercept it via raw middleware which fires before handler dispatch.
|
|
804
|
+
// 345 (end-of-list) is NOT mapped, so it arrives via "unknown command".
|
|
805
|
+
const reopCollector = new Map<string, { mask: string; setBy: string; setAt: number }[]>()
|
|
806
|
+
|
|
807
|
+
client.use(function reopMiddleware(_client: any, rawEvents: any, _parsedEvents: any) {
|
|
808
|
+
rawEvents.use(function reopHandler(command: string, message: any, _rawLine: string, __client: any, next: () => void) {
|
|
809
|
+
if (command !== "344") { next(); return }
|
|
810
|
+
const params = message.params ?? []
|
|
811
|
+
const channel = params[1]
|
|
812
|
+
// Disambiguate: RPL_REOPLIST has a channel (#...) in param[1],
|
|
813
|
+
// RPL_WHOISCOUNTRY has a nick. Only collect if it's a channel.
|
|
814
|
+
if (channel && isChannelTarget(channel)) {
|
|
815
|
+
if (!reopCollector.has(channel)) reopCollector.set(channel, [])
|
|
816
|
+
reopCollector.get(channel)!.push({
|
|
817
|
+
mask: params[2] || "",
|
|
818
|
+
setBy: params[3] || "",
|
|
819
|
+
setAt: params[4] ? parseInt(params[4], 10) : 0,
|
|
820
|
+
})
|
|
821
|
+
}
|
|
822
|
+
next()
|
|
823
|
+
})
|
|
824
|
+
})
|
|
825
|
+
|
|
826
|
+
// ─── Catch-all for unhandled numerics ──────────────────────
|
|
827
|
+
client.on("unknown command", (command) => {
|
|
828
|
+
// Only handle IRC numerics (3-digit codes)
|
|
829
|
+
if (!/^\d{3}$/.test(command.command)) return
|
|
830
|
+
|
|
831
|
+
const numeric = parseInt(command.command, 10)
|
|
832
|
+
const params = [...command.params]
|
|
833
|
+
|
|
834
|
+
// 345 RPL_ENDOFREOPLIST: <nick> <channel> :End of Channel Reop List
|
|
835
|
+
if (numeric === 345) {
|
|
836
|
+
const channel = params[1]
|
|
837
|
+
const entries = reopCollector.get(channel) ?? []
|
|
838
|
+
reopCollector.delete(channel)
|
|
839
|
+
const bufferId = makeBufferId(connectionId, channel)
|
|
840
|
+
const s = getStore()
|
|
841
|
+
const target = s.buffers.has(bufferId) ? bufferId : statusId
|
|
842
|
+
getStore().setListEntries(bufferId, "R", entries)
|
|
843
|
+
displayNumberedList(target, "Reop list", channel, entries)
|
|
844
|
+
return
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// First param is usually our nick — skip it
|
|
848
|
+
if (params.length > 1) params.shift()
|
|
849
|
+
|
|
850
|
+
const text = params.join(" ")
|
|
851
|
+
if (!text.trim()) return
|
|
852
|
+
|
|
853
|
+
const isError = numeric >= 400 && numeric < 600
|
|
854
|
+
const s = getStore()
|
|
855
|
+
|
|
856
|
+
if (isError) {
|
|
857
|
+
// Route channel errors to the channel buffer
|
|
858
|
+
let targetBuffer = statusId
|
|
859
|
+
for (const p of params) {
|
|
860
|
+
if (isChannelTarget(p)) {
|
|
861
|
+
const chanBufferId = makeBufferId(connectionId, p)
|
|
862
|
+
if (s.buffers.has(chanBufferId)) {
|
|
863
|
+
targetBuffer = chanBufferId
|
|
864
|
+
break
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
s.addMessage(targetBuffer, makeEventMessage(`%Zf7768e${text}%N`))
|
|
869
|
+
} else {
|
|
870
|
+
statusMsg(`%Z565f89${text}%N`)
|
|
871
|
+
}
|
|
872
|
+
})
|
|
873
|
+
|
|
874
|
+
// ─── Whois response ──────────────────────────────────────
|
|
875
|
+
client.on("whois", (event) => {
|
|
876
|
+
const s = getStore()
|
|
877
|
+
const targetBuffer = s.activeBufferId
|
|
878
|
+
if (!targetBuffer) return
|
|
879
|
+
|
|
880
|
+
if (event.error) {
|
|
881
|
+
s.addMessage(targetBuffer, makeEventMessage(
|
|
882
|
+
`%Zf7768e${event.nick || "?"}: No such nick/channel%N`
|
|
883
|
+
))
|
|
884
|
+
return
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
const lines: string[] = []
|
|
888
|
+
lines.push(`%Z7aa2f7───── WHOIS ${event.nick} ──────────────────────────%N`)
|
|
889
|
+
|
|
890
|
+
if (event.ident && event.hostname) {
|
|
891
|
+
lines.push(`%Zc0caf5${event.nick}%Z565f89 (${event.ident}@${event.hostname})%N`)
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
if (event.real_name) {
|
|
895
|
+
lines.push(` %Za9b1d6${event.real_name}%N`)
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
if (event.channels) {
|
|
899
|
+
lines.push(`%Z565f89 channels: %Za9b1d6${event.channels}%N`)
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
if (event.server) {
|
|
903
|
+
const info = event.server_info ? ` (${event.server_info})` : ""
|
|
904
|
+
lines.push(`%Z565f89 server: %Za9b1d6${event.server}${info}%N`)
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
if (event.account) {
|
|
908
|
+
lines.push(`%Z565f89 account: %Z9ece6a${event.account}%N`)
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
if (event.idle != null) {
|
|
912
|
+
let line = `%Z565f89 idle: %Za9b1d6${formatDuration(event.idle)}`
|
|
913
|
+
if (event.logon) {
|
|
914
|
+
line += `%Z565f89, signon: %Za9b1d6${formatDate(new Date(event.logon * 1000))}`
|
|
915
|
+
}
|
|
916
|
+
lines.push(line + `%N`)
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
if (event.away) {
|
|
920
|
+
lines.push(`%Z565f89 away: %Ze0af68${event.away}%N`)
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
if (event.operator) {
|
|
924
|
+
lines.push(` %Zbb9af7${event.operator}%N`)
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
if (event.secure || event.actually_secure) {
|
|
928
|
+
lines.push(` %Z9ece6ais using a secure connection%N`)
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
if (event.bot) {
|
|
932
|
+
lines.push(` %Z7dcfffis a bot%N`)
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// RPL_WHOISSPECIAL — can be array or string
|
|
936
|
+
if (event.special) {
|
|
937
|
+
const specials = Array.isArray(event.special) ? event.special : [event.special]
|
|
938
|
+
for (const line of specials) {
|
|
939
|
+
lines.push(` %Zbb9af7${line}%N`)
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
lines.push(`%Z7aa2f7─────────────────────────────────────────────%N`)
|
|
944
|
+
|
|
945
|
+
for (const line of lines) {
|
|
946
|
+
getStore().addMessage(targetBuffer, makeEventMessage(line))
|
|
947
|
+
}
|
|
948
|
+
})
|
|
949
|
+
|
|
950
|
+
// ─── Whowas response ──────────────────────────────────────
|
|
951
|
+
client.on("whowas", (event) => {
|
|
952
|
+
const s = getStore()
|
|
953
|
+
const targetBuffer = s.activeBufferId ?? statusId
|
|
954
|
+
|
|
955
|
+
if (event.error) {
|
|
956
|
+
s.addMessage(targetBuffer, makeEventMessage(
|
|
957
|
+
`%Zf7768e${event.nick}: No such nick in history%N`
|
|
958
|
+
))
|
|
959
|
+
return
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
const lines: string[] = []
|
|
963
|
+
lines.push(`%Z7aa2f7───── WHOWAS ${event.nick} ──────────────────────────%N`)
|
|
964
|
+
|
|
965
|
+
if (event.ident && event.hostname) {
|
|
966
|
+
lines.push(`%Zc0caf5${event.nick}%Z565f89 was (${event.ident}@${event.hostname})%N`)
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
if (event.real_name) {
|
|
970
|
+
lines.push(` %Za9b1d6${event.real_name}%N`)
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
lines.push(`%Z7aa2f7─────────────────────────────────────────────%N`)
|
|
974
|
+
|
|
975
|
+
for (const line of lines) {
|
|
976
|
+
getStore().addMessage(targetBuffer, makeEventMessage(line))
|
|
977
|
+
}
|
|
978
|
+
})
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
/** Display a numbered list of ListEntry items. */
|
|
982
|
+
function displayNumberedList(
|
|
983
|
+
target: string,
|
|
984
|
+
label: string,
|
|
985
|
+
channel: string,
|
|
986
|
+
entries: { mask: string; setBy: string; setAt: number }[],
|
|
987
|
+
) {
|
|
988
|
+
const s = useStore.getState()
|
|
989
|
+
if (entries.length === 0) {
|
|
990
|
+
s.addMessage(target, makeEventMessage(
|
|
991
|
+
`%Z565f89${channel}: ${label} is empty%N`
|
|
992
|
+
))
|
|
993
|
+
return
|
|
994
|
+
}
|
|
995
|
+
s.addMessage(target, makeEventMessage(
|
|
996
|
+
`%Z7aa2f7───── ${label} for ${channel} ─────%N`
|
|
997
|
+
))
|
|
998
|
+
for (let i = 0; i < entries.length; i++) {
|
|
999
|
+
const e = entries[i]
|
|
1000
|
+
const by = e.setBy ? ` set by ${e.setBy}` : ""
|
|
1001
|
+
const at = e.setAt ? ` [${formatDate(new Date(e.setAt * 1000))}]` : ""
|
|
1002
|
+
s.addMessage(target, makeEventMessage(
|
|
1003
|
+
`%Ze0af68${(i + 1).toString().padStart(2)}.%N %Za9b1d6${e.mask}%Z565f89${by}${at}%N`
|
|
1004
|
+
))
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
/** System/inline event — text may contain %Z color codes. */
|
|
1009
|
+
function makeEventMessage(text: string): Message {
|
|
1010
|
+
return {
|
|
1011
|
+
id: crypto.randomUUID(),
|
|
1012
|
+
timestamp: new Date(),
|
|
1013
|
+
type: "event",
|
|
1014
|
+
text,
|
|
1015
|
+
highlight: false,
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
/** IRC event with theme format key — rendered via [formats.events] in MessageLine. */
|
|
1020
|
+
function makeFormattedEvent(key: string, params: string[]): Message {
|
|
1021
|
+
return {
|
|
1022
|
+
id: crypto.randomUUID(),
|
|
1023
|
+
timestamp: new Date(),
|
|
1024
|
+
type: "event",
|
|
1025
|
+
text: params.join(" "),
|
|
1026
|
+
eventKey: key,
|
|
1027
|
+
eventParams: params,
|
|
1028
|
+
highlight: false,
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
|