serverless-ircd 0.4.0 → 0.5.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/CHANGELOG.md +175 -0
- package/README.md +88 -19
- package/apps/aws-stack/README.md +4 -3
- package/apps/aws-stack/package.json +1 -1
- package/apps/aws-stack/src/aws-stack.ts +32 -4
- package/apps/aws-stack/tests/stack.test.ts +47 -1
- package/apps/cf-tcp-container/package.json +1 -1
- package/apps/cf-worker/package.json +1 -1
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/server.ts +129 -21
- package/apps/local-cli/tests/e2e.test.ts +1 -1
- package/apps/local-cli/tests/ws-subprotocol.test.ts +257 -0
- package/package.json +2 -2
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/aws-runtime.ts +69 -0
- package/packages/aws-adapter/src/handlers/connect.ts +36 -5
- package/packages/aws-adapter/src/handlers/default.ts +63 -5
- package/packages/aws-adapter/src/handlers/index.ts +41 -2
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +9 -0
- package/packages/aws-adapter/src/index.ts +2 -0
- package/packages/aws-adapter/src/serialize.ts +11 -1
- package/packages/aws-adapter/src/stats.ts +80 -0
- package/packages/aws-adapter/tests/aws-integration.test.ts +1 -1
- package/packages/aws-adapter/tests/aws-runtime.test.ts +61 -0
- package/packages/aws-adapter/tests/connect.test.ts +97 -1
- package/packages/aws-adapter/tests/handlers.test.ts +148 -0
- package/packages/aws-adapter/tests/nlb-stream.test.ts +2 -0
- package/packages/aws-adapter/tests/stats.test.ts +317 -0
- package/packages/cf-adapter/package.json +5 -1
- package/packages/cf-adapter/src/cf-runtime.ts +66 -1
- package/packages/cf-adapter/src/channel-do.ts +2 -2
- package/packages/cf-adapter/src/connection-do.ts +182 -54
- package/packages/cf-adapter/src/env.ts +25 -6
- package/packages/cf-adapter/src/index.ts +2 -0
- package/packages/cf-adapter/src/registry-do.ts +22 -3
- package/packages/cf-adapter/src/sharding.ts +1 -2
- package/packages/cf-adapter/src/stats.ts +65 -0
- package/packages/cf-adapter/tests/cf-harness.ts +1 -1
- package/packages/cf-adapter/tests/cf-integration.test.ts +4 -4
- package/packages/cf-adapter/tests/cf-runtime.test.ts +38 -2
- package/packages/cf-adapter/tests/channel-do.test.ts +2 -2
- package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +2 -2
- package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +2 -2
- package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +289 -0
- package/packages/cf-adapter/tests/connection-do-ws-subprotocol.test.ts +184 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +27 -2
- package/packages/cf-adapter/tests/registry-do.test.ts +4 -4
- package/packages/cf-adapter/tests/sharding.test.ts +1 -1
- package/packages/cf-adapter/tests/stats.test.ts +120 -0
- package/packages/cf-adapter/tests/worker/main.ts +7 -7
- package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +2 -2
- package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +8 -2
- package/packages/cf-adapter/wrangler.test.toml +7 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +39 -0
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +259 -0
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/admission.ts +16 -15
- package/packages/irc-core/src/caps/capabilities.ts +1 -1
- package/packages/irc-core/src/commands/index.ts +8 -0
- package/packages/irc-core/src/commands/invite.ts +2 -4
- package/packages/irc-core/src/commands/isupport.ts +6 -2
- package/packages/irc-core/src/commands/kick.ts +2 -4
- package/packages/irc-core/src/commands/kill.ts +127 -0
- package/packages/irc-core/src/commands/list.ts +1 -1
- package/packages/irc-core/src/commands/lusers.ts +204 -0
- package/packages/irc-core/src/commands/mode.ts +4 -8
- package/packages/irc-core/src/commands/names.ts +3 -5
- package/packages/irc-core/src/commands/part.ts +2 -4
- package/packages/irc-core/src/commands/rehash.ts +119 -0
- package/packages/irc-core/src/commands/setname.ts +109 -0
- package/packages/irc-core/src/commands/stats.ts +152 -0
- package/packages/irc-core/src/commands/topic.ts +2 -4
- package/packages/irc-core/src/commands/trace.ts +137 -0
- package/packages/irc-core/src/commands/wallops.ts +118 -0
- package/packages/irc-core/src/config.ts +7 -0
- package/packages/irc-core/src/effects.ts +27 -1
- package/packages/irc-core/src/index.ts +2 -0
- package/packages/irc-core/src/ports.ts +179 -0
- package/packages/irc-core/src/protocol/numerics.ts +42 -11
- package/packages/irc-core/src/protocol/outbound.ts +20 -3
- package/packages/irc-core/src/types.ts +8 -1
- package/packages/irc-core/src/ws-framing.ts +132 -0
- package/packages/irc-core/src/ws-subprotocol.ts +66 -0
- package/packages/irc-core/tests/admission.test.ts +18 -0
- package/packages/irc-core/tests/commands/kill.test.ts +243 -0
- package/packages/irc-core/tests/commands/lusers.test.ts +368 -0
- package/packages/irc-core/tests/commands/mode.test.ts +57 -0
- package/packages/irc-core/tests/commands/rehash.test.ts +171 -0
- package/packages/irc-core/tests/commands/setname.test.ts +225 -0
- package/packages/irc-core/tests/commands/stats.test.ts +294 -0
- package/packages/irc-core/tests/commands/trace.test.ts +282 -0
- package/packages/irc-core/tests/commands/wallops.test.ts +231 -0
- package/packages/irc-core/tests/dropped-s2s-and-obsolete-verbs.test.ts +90 -0
- package/packages/irc-core/tests/effects.test.ts +14 -0
- package/packages/irc-core/tests/numerics.test.ts +90 -0
- package/packages/irc-core/tests/outbound.test.ts +51 -0
- package/packages/irc-core/tests/ports.test.ts +22 -0
- package/packages/irc-core/tests/raw-modules.d.ts +11 -0
- package/packages/irc-core/tests/stats-store.test.ts +222 -0
- package/packages/irc-core/tests/ws-framing.test.ts +213 -0
- package/packages/irc-core/tests/ws-subprotocol.test.ts +111 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +249 -16
- package/packages/irc-server/src/dispatch.ts +1 -0
- package/packages/irc-server/src/routing.ts +3 -0
- package/packages/irc-server/src/runtime.ts +31 -0
- package/packages/irc-server/src/transport.ts +10 -7
- package/packages/irc-server/tests/actor.test.ts +1089 -1
- package/packages/irc-server/tests/dispatch.test.ts +37 -0
- package/packages/irc-server/tests/raw-modules.d.ts +11 -0
- package/packages/irc-server/tests/routing.test.ts +1 -0
- package/packages/irc-server/tests/runtime.test.ts +7 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/scenarios.ts +9 -1
- package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +1 -1
- package/pnpm-workspace.yaml +1 -0
- package/tools/ci-hardening/package.json +1 -1
- package/tools/package.json +6 -1
- package/tools/seed-cf-accounts.ts +4 -1
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/src/forwarder.ts +57 -9
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +34 -1
- package/tools/tcp-ws-forwarder/tests/framing.test.ts +65 -1
|
@@ -123,18 +123,18 @@ export class AdmissionStats {
|
|
|
123
123
|
if (rec === undefined) return;
|
|
124
124
|
this.records.delete(recordId);
|
|
125
125
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
126
|
+
// recordAdmission always sets both counters, so they are present for
|
|
127
|
+
// any id found in `records`. The decrement-and-delete-if-zero keeps
|
|
128
|
+
// the maps compact (no zero entries hanging around).
|
|
129
|
+
const ipCount = this.perIpCount.get(rec.ip) as number;
|
|
130
|
+
const ipNext = ipCount - 1;
|
|
131
|
+
if (ipNext <= 0) this.perIpCount.delete(rec.ip);
|
|
132
|
+
else this.perIpCount.set(rec.ip, ipNext);
|
|
133
|
+
|
|
134
|
+
const userCount = this.perUserCount.get(rec.user) as number;
|
|
135
|
+
const userNext = userCount - 1;
|
|
136
|
+
if (userNext <= 0) this.perUserCount.delete(rec.user);
|
|
137
|
+
else this.perUserCount.set(rec.user, userNext);
|
|
138
138
|
}
|
|
139
139
|
|
|
140
140
|
/**
|
|
@@ -147,9 +147,10 @@ export class AdmissionStats {
|
|
|
147
147
|
if (times === undefined || times.length === 0) return 0;
|
|
148
148
|
const cutoff = this.clock.now() - windowMs;
|
|
149
149
|
let writeIdx = 0;
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
150
|
+
// `times` is dense (only ever appended to via push, never spliced), so
|
|
151
|
+
// iterating values via for-of avoids the noUncheckedIndexedAccess trap
|
|
152
|
+
// while still allowing in-place compaction of stale entries.
|
|
153
|
+
for (const t of times) {
|
|
153
154
|
if (t >= cutoff) {
|
|
154
155
|
times[writeIdx++] = t;
|
|
155
156
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* The CAP *negotiation reducer* ships separately; this module is the
|
|
5
5
|
* pure-data table that advertises what caps this server supports. PLAN §3
|
|
6
|
-
* lists the v1 cap set; later
|
|
6
|
+
* lists the v1 cap set; later phases implement each cap's behavior.
|
|
7
7
|
*
|
|
8
8
|
* The table is exposed as a frozen, sorted list so the `CAP LS` response
|
|
9
9
|
* (and any future `CAP NEW` push) serializes deterministically.
|
|
@@ -19,6 +19,8 @@ export { handleJoinZero, isValidChannelName, joinReducer } from './join.js';
|
|
|
19
19
|
export { partReducer } from './part.js';
|
|
20
20
|
export { motdReducer } from './motd.js';
|
|
21
21
|
export { operReducer, matchOperCred } from './oper.js';
|
|
22
|
+
export { rehashReducer, rehashLine, buildRehashTrailing } from './rehash.js';
|
|
23
|
+
export type { RehashResult } from './rehash.js';
|
|
22
24
|
export {
|
|
23
25
|
isChannelTarget,
|
|
24
26
|
noticeChannelReducer,
|
|
@@ -27,6 +29,8 @@ export {
|
|
|
27
29
|
privmsgUserReducer,
|
|
28
30
|
} from './privmsg.js';
|
|
29
31
|
export { kickReducer } from './kick.js';
|
|
32
|
+
export { killReducer, formatKillReason } from './kill.js';
|
|
33
|
+
export { wallopsReducer, wallopsLine } from './wallops.js';
|
|
30
34
|
export { topicReducer } from './topic.js';
|
|
31
35
|
export { namesReducer } from './names.js';
|
|
32
36
|
export { listReducer } from './list.js';
|
|
@@ -35,11 +39,15 @@ export { inviteReducer } from './invite.js';
|
|
|
35
39
|
export { whoReducer } from './who.js';
|
|
36
40
|
export { whoisReducer } from './whois.js';
|
|
37
41
|
export { whowasReducer } from './whowas.js';
|
|
42
|
+
export { traceReducer } from './trace.js';
|
|
43
|
+
export { lusersReducer } from './lusers.js';
|
|
44
|
+
export { statsReducer } from './stats.js';
|
|
38
45
|
export { generateIsupport } from './isupport.js';
|
|
39
46
|
export { capReducer } from './cap.js';
|
|
40
47
|
export { emitChghost } from './chghost.js';
|
|
41
48
|
export type { ChgHostInput } from './chghost.js';
|
|
42
49
|
export { awayReducer } from './away.js';
|
|
50
|
+
export { setnameReducer } from './setname.js';
|
|
43
51
|
export { authenticateReducer } from './sasl.js';
|
|
44
52
|
export {
|
|
45
53
|
chathistoryReducer,
|
|
@@ -42,12 +42,10 @@ function callerHostmask(conn: ConnectionState): string {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
/** Formats a numeric error/reply line addressed to the connection's nick. */
|
|
45
|
-
function numericErr(ctx: Ctx, code: number, trailing: string, middle
|
|
45
|
+
function numericErr(ctx: Ctx, code: number, trailing: string, middle: string): RawLine {
|
|
46
46
|
const nick = ctx.connection.nick ?? '*';
|
|
47
47
|
const codeStr = code.toString().padStart(3, '0');
|
|
48
|
-
const parts = [`:${ctx.serverName}`, codeStr, nick];
|
|
49
|
-
if (middle !== undefined) parts.push(middle);
|
|
50
|
-
parts.push(`:${trailing}`);
|
|
48
|
+
const parts = [`:${ctx.serverName}`, codeStr, nick, middle, `:${trailing}`];
|
|
51
49
|
return { text: parts.join(' ') };
|
|
52
50
|
}
|
|
53
51
|
|
|
@@ -82,6 +82,10 @@ function buildTokens(cfg: ServerConfig): string[] {
|
|
|
82
82
|
* batch. The caller appends the trailer separately.
|
|
83
83
|
*
|
|
84
84
|
* Each batch returns just the joined token string (no `005`, no trailer).
|
|
85
|
+
*
|
|
86
|
+
* Contract: `tokens` is non-empty. The only caller (`generateIsupport`)
|
|
87
|
+
* satisfies this — `buildTokens` always emits at least the network/casing
|
|
88
|
+
* tokens — so no defensive empty-input handling is needed here.
|
|
85
89
|
*/
|
|
86
90
|
function packTokens(tokens: ReadonlyArray<string>, baseLen: number): string[] {
|
|
87
91
|
const batches: string[] = [];
|
|
@@ -96,7 +100,7 @@ function packTokens(tokens: ReadonlyArray<string>, baseLen: number): string[] {
|
|
|
96
100
|
current = candidate;
|
|
97
101
|
}
|
|
98
102
|
}
|
|
99
|
-
|
|
103
|
+
batches.push(current);
|
|
100
104
|
return batches;
|
|
101
105
|
}
|
|
102
106
|
|
|
@@ -106,7 +110,7 @@ function packTokens(tokens: ReadonlyArray<string>, baseLen: number): string[] {
|
|
|
106
110
|
* @param cfg Server configuration (size limits, network name).
|
|
107
111
|
* @param _connCaps IRCv3 capabilities negotiated by the connection
|
|
108
112
|
* (reserved for `BOT=` and similar v3 tokens; currently
|
|
109
|
-
* unused — kept in the signature
|
|
113
|
+
* unused — kept in the signature for forward compatibility).
|
|
110
114
|
* @param nick Optional nick for the reply target; defaults to `*`.
|
|
111
115
|
* @returns One or more {@link RawLine}s, each within the 510-byte ceiling.
|
|
112
116
|
* Only the final line carries the `:are supported by this server`
|
|
@@ -35,12 +35,10 @@ function isValidChannelName(name: string, maxLen: number): boolean {
|
|
|
35
35
|
* nick (or `*` when unregistered). Optional `middle` is emitted before the
|
|
36
36
|
* trailing `:`-prefixed text.
|
|
37
37
|
*/
|
|
38
|
-
function numericErr(ctx: Ctx, code: number, trailing: string, middle
|
|
38
|
+
function numericErr(ctx: Ctx, code: number, trailing: string, middle: string): RawLine {
|
|
39
39
|
const nick = ctx.connection.nick ?? '*';
|
|
40
40
|
const codeStr = code.toString().padStart(3, '0');
|
|
41
|
-
const parts = [`:${ctx.serverName}`, codeStr, nick];
|
|
42
|
-
if (middle !== undefined) parts.push(middle);
|
|
43
|
-
parts.push(`:${trailing}`);
|
|
41
|
+
const parts = [`:${ctx.serverName}`, codeStr, nick, middle, `:${trailing}`];
|
|
44
42
|
return { text: parts.join(' ') };
|
|
45
43
|
}
|
|
46
44
|
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure reducer for the IRC `KILL` command (oper-gated force-disconnect).
|
|
3
|
+
*
|
|
4
|
+
* PLAN §2.1 location-of-authority: KILL runs against the *target*'s
|
|
5
|
+
* connection. The actor layer resolves `<nick>` to a live
|
|
6
|
+
* {@link ConnectionState} via the nick registry and passes it here. The
|
|
7
|
+
* reducer performs the oper gate, validates parameters, and emits a single
|
|
8
|
+
* {@link DisconnectEffect} for the target. The runtime's `disconnect`
|
|
9
|
+
* method is responsible for closing the target's transport and fanning the
|
|
10
|
+
* QUIT out to shared-channel peers — the reducer does not model that
|
|
11
|
+
* fanout itself.
|
|
12
|
+
*
|
|
13
|
+
* Wire format (RFC 2812 §4.6.1):
|
|
14
|
+
* - `KILL <nickname> <comment>`
|
|
15
|
+
* - The target receives an `ERROR` / `QUIT` carrying a reason formatted
|
|
16
|
+
* as `Killed (<oper> (<comment>))`.
|
|
17
|
+
* - `481 ERR_NOPRIVILEGES`: `:<server> 481 <nick> :Permission Denied - ...`
|
|
18
|
+
* - `461 ERR_NEEDMOREPARAMS`:
|
|
19
|
+
* `:<server> 461 <nick> KILL :Not enough parameters`
|
|
20
|
+
* - `401 ERR_NOSUCHNICK`: `:<server> 401 <nick> <target> :No such nick/channel`
|
|
21
|
+
*
|
|
22
|
+
* Behaviour:
|
|
23
|
+
* - Non-oper sender → `481` (checked before parameters so a non-oper
|
|
24
|
+
* cannot probe which nicks are online).
|
|
25
|
+
* - Missing `<nickname>` or `<comment>` → `461`.
|
|
26
|
+
* - Unknown nick → `401`.
|
|
27
|
+
* - Otherwise emit `Disconnect(targetConnId, "Killed (<oper> (<comment>))")`.
|
|
28
|
+
* - An oper may `KILL` themselves (RFC-permitted); no special-casing.
|
|
29
|
+
*
|
|
30
|
+
* Platform asymmetry: the emitted `Disconnect` is interpreted by the bound
|
|
31
|
+
* runtime. On CF the `disconnect` RPC closes the target DO's socket
|
|
32
|
+
* immediately; on AWS a `$default` invocation cannot close a remote APIGW
|
|
33
|
+
* WebSocket, so QUIT fanout to peers fires but the target's live socket
|
|
34
|
+
* only closes on its next `$disconnect`/sweeper cycle. The reducer is
|
|
35
|
+
* identical across adapters — only the runtime's `disconnect` semantics
|
|
36
|
+
* differ.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { Effect } from '../effects.js';
|
|
40
|
+
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
41
|
+
import { Numerics } from '../protocol/numerics.js';
|
|
42
|
+
import type { ConnectionState } from '../state/connection.js';
|
|
43
|
+
import type { Ctx } from '../types.js';
|
|
44
|
+
|
|
45
|
+
/** Fixed text emitted for `481 ERR_NOPRIVILEGES`. */
|
|
46
|
+
const NOPRIVILEGES_TRAILING_TEXT = "Permission Denied - You're not an IRC operator";
|
|
47
|
+
|
|
48
|
+
/** Fixed text emitted for `461 ERR_NEEDMOREPARAMS`. */
|
|
49
|
+
const NEEDMOREPARAMS_TRAILING_TEXT = 'Not enough parameters';
|
|
50
|
+
|
|
51
|
+
/** Fixed text emitted for `401 ERR_NOSUCHNICK`. */
|
|
52
|
+
const NOSUCHNICK_TRAILING_TEXT = 'No such nick/channel';
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Formats the canonical kill reason, `Killed (<oper> (<comment>))`, that
|
|
56
|
+
* the target receives on its closing transport. `<oper>` is the killing
|
|
57
|
+
* oper's display nick; `<comment>` is the oper-supplied reason verbatim.
|
|
58
|
+
*/
|
|
59
|
+
export function formatKillReason(operNick: string, comment: string): string {
|
|
60
|
+
return `Killed (${operNick} (${comment}))`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Handles `KILL <nick> <comment>`.
|
|
65
|
+
*
|
|
66
|
+
* `target` is the live {@link ConnectionState} the actor resolved for
|
|
67
|
+
* `<nick>`, or `undefined` when the nick is offline / unknown. The
|
|
68
|
+
* signature is bespoke (not {@link Reducer}) because the actor must
|
|
69
|
+
* resolve the target asynchronously — mirrors `whoisReducer`.
|
|
70
|
+
*/
|
|
71
|
+
export function killReducer(
|
|
72
|
+
target: ConnectionState | undefined,
|
|
73
|
+
msg: { command: string; params: readonly string[] },
|
|
74
|
+
ctx: Ctx,
|
|
75
|
+
): { effects: EffectType[] } {
|
|
76
|
+
ctx.connection.lastSeen = ctx.clock.now();
|
|
77
|
+
|
|
78
|
+
if (!ctx.connection.userModes.oper) {
|
|
79
|
+
return {
|
|
80
|
+
effects: [
|
|
81
|
+
Effect.send(ctx.connId, [
|
|
82
|
+
numericLine(ctx, Numerics.ERR_NOPRIVILEGES, NOPRIVILEGES_TRAILING_TEXT),
|
|
83
|
+
]),
|
|
84
|
+
],
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const targetNick = msg.params[0];
|
|
89
|
+
const comment = msg.params[1];
|
|
90
|
+
|
|
91
|
+
if (targetNick === undefined || comment === undefined) {
|
|
92
|
+
return {
|
|
93
|
+
effects: [
|
|
94
|
+
Effect.send(ctx.connId, [
|
|
95
|
+
numericLine(ctx, Numerics.ERR_NEEDMOREPARAMS, NEEDMOREPARAMS_TRAILING_TEXT, 'KILL'),
|
|
96
|
+
]),
|
|
97
|
+
],
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (target === undefined) {
|
|
102
|
+
return {
|
|
103
|
+
effects: [
|
|
104
|
+
Effect.send(ctx.connId, [
|
|
105
|
+
numericLine(ctx, Numerics.ERR_NOSUCHNICK, NOSUCHNICK_TRAILING_TEXT, targetNick),
|
|
106
|
+
]),
|
|
107
|
+
],
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const operNick = ctx.connection.nick ?? '*';
|
|
112
|
+
return { effects: [Effect.disconnect(target.id, formatKillReason(operNick, comment))] };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Builds a `:<server> <code> <nick> [<middle>] :<trailing>` line. The nick
|
|
117
|
+
* falls back to `*` when the connection has not yet registered one,
|
|
118
|
+
* matching the rest of the codebase's numeric formatting.
|
|
119
|
+
*/
|
|
120
|
+
function numericLine(ctx: Ctx, code: number, trailing: string, middle?: string): RawLine {
|
|
121
|
+
const nick = ctx.connection.nick ?? '*';
|
|
122
|
+
const codeStr = code.toString().padStart(3, '0');
|
|
123
|
+
const parts = [`:${ctx.serverName}`, codeStr, nick];
|
|
124
|
+
if (middle !== undefined) parts.push(middle);
|
|
125
|
+
parts.push(`:${trailing}`);
|
|
126
|
+
return { text: parts.join(' ') };
|
|
127
|
+
}
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* - `322 RPL_LIST` — `:<server> 322 <nick> <chan> <#users> :<topic>`
|
|
12
12
|
* - `323 RPL_LISTEND` — `:<server> 323 <nick> :End of /LIST`
|
|
13
13
|
*
|
|
14
|
-
* Visibility rules
|
|
14
|
+
* Visibility rules:
|
|
15
15
|
* - Secret (`+s`) channels are hidden from non-members.
|
|
16
16
|
* - Membership is checked against `chan.members` using `ctx.connId`.
|
|
17
17
|
*
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure reducer for the IRC `LUSERS` command (RFC 2812 §4.6.2).
|
|
3
|
+
*
|
|
4
|
+
* PLAN §2.1 location-of-authority: LUSERS is a read-only diagnostic that
|
|
5
|
+
* reports network-wide counts. The reducer takes an already-aggregated
|
|
6
|
+
* {@link ServerStatsSnapshot} (the actor layer awaits a {@link ServerStats}
|
|
7
|
+
* backend before calling here — same pattern as `listChannels` →
|
|
8
|
+
* `listReducer`). The reducer never mutates anything but the requester's
|
|
9
|
+
* `lastSeen`.
|
|
10
|
+
*
|
|
11
|
+
* Wire format (RFC 2812 §5.1):
|
|
12
|
+
* - `LUSERS [<mask> [<server>]]`
|
|
13
|
+
* - `251 RPL_LUSERCLIENT`:
|
|
14
|
+
* `:<server> 251 <nick> :There are <users> users and <invisible>
|
|
15
|
+
* invisible on <servers> servers`
|
|
16
|
+
* - `252 RPL_LUSEROP`:
|
|
17
|
+
* `:<server> 252 <nick> <count> :operator(s) online` (only when non-zero)
|
|
18
|
+
* - `253 RPL_LUSERUNKNOWN`:
|
|
19
|
+
* `:<server> 253 <nick> <count> :unknown connection(s)` (only when
|
|
20
|
+
* non-zero)
|
|
21
|
+
* - `254 RPL_LUSERCHANNELS`:
|
|
22
|
+
* `:<server> 254 <nick> <count> :channel(s) formed` (only when non-zero)
|
|
23
|
+
* - `255 RPL_LUSERME`:
|
|
24
|
+
* `:<server> 255 <nick> :I have <localConns> clients and <servers>
|
|
25
|
+
* servers`
|
|
26
|
+
* - `265 RPL_LOCALUSERS`:
|
|
27
|
+
* `:<server> 265 <nick> <cur> <max> :Current local users <cur>, max
|
|
28
|
+
* <max>`
|
|
29
|
+
* - `266 RPL_GLOBALUSERS`:
|
|
30
|
+
* `:<server> 266 <nick> <cur> <max> :Current global users <cur>, max
|
|
31
|
+
* <max>`
|
|
32
|
+
* - `402 ERR_NOSUCHSERVER`:
|
|
33
|
+
* `:<server> 402 <nick> <server> :No such server`
|
|
34
|
+
*
|
|
35
|
+
* Non-zero omission: 252/253/254 are suppressed when their count is zero
|
|
36
|
+
* (per RFC 2812). 251, 255, 265, and 266 are always emitted — modern
|
|
37
|
+
* clients rely on 265/266 for the user-list count and charybdis emits
|
|
38
|
+
* them unconditionally.
|
|
39
|
+
*
|
|
40
|
+
* Single-server deployment: `servers` is always 1 (PLAN §1 — S2S linking
|
|
41
|
+
* is a non-goal). `<mask>` is accepted for RFC compliance but not applied
|
|
42
|
+
* (the serverless deployment has one server, so a wildcard match is
|
|
43
|
+
* trivially true).
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import { Effect } from '../effects.js';
|
|
47
|
+
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
48
|
+
import type { ServerStatsSnapshot } from '../ports.js';
|
|
49
|
+
import { Numerics } from '../protocol/numerics.js';
|
|
50
|
+
import type { Ctx } from '../types.js';
|
|
51
|
+
|
|
52
|
+
/** Fixed text emitted for `402 ERR_NOSUCHSERVER`. */
|
|
53
|
+
const NOSUCHSERVER_TRAILING_TEXT = 'No such server';
|
|
54
|
+
|
|
55
|
+
/** Human-readable trailing templates per numeric. */
|
|
56
|
+
const LUSERCLIENT_TEXT = (users: number, invisible: number, servers: number): string =>
|
|
57
|
+
`There are ${users} users and ${invisible} invisible on ${servers} servers`;
|
|
58
|
+
const LUSEROP_TEXT = 'operator(s) online';
|
|
59
|
+
const LUSERUNKNOWN_TEXT = 'unknown connection(s)';
|
|
60
|
+
const LUSERCHANNELS_TEXT = 'channel(s) formed';
|
|
61
|
+
const LUSERME_TEXT = (localConns: number, servers: number): string =>
|
|
62
|
+
`I have ${localConns} clients and ${servers} servers`;
|
|
63
|
+
const LOCALUSERS_TEXT = (cur: number, max: number): string =>
|
|
64
|
+
`Current local users ${cur}, max ${max}`;
|
|
65
|
+
const GLOBALUSERS_TEXT = (cur: number, max: number): string =>
|
|
66
|
+
`Current global users ${cur}, max ${max}`;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Builds a `:<server> <code> <nick> :<trailing>` line. The middle field is
|
|
70
|
+
* empty for the summary numerics; the count-formatting helpers below carry
|
|
71
|
+
* a `<count>` middle for 252/253/254.
|
|
72
|
+
*/
|
|
73
|
+
function trailingLine(ctx: Ctx, code: number, trailing: string): RawLine {
|
|
74
|
+
const nick = ctx.connection.nick ?? '*';
|
|
75
|
+
const codeStr = code.toString().padStart(3, '0');
|
|
76
|
+
return {
|
|
77
|
+
text: `:${ctx.serverName} ${codeStr} ${nick} :${trailing}`,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Builds a `:<server> <code> <nick> <count> :<trailing>` line for the
|
|
83
|
+
* count-carrying numerics (252/253/254).
|
|
84
|
+
*/
|
|
85
|
+
function countLine(ctx: Ctx, code: number, count: number, trailing: string): RawLine {
|
|
86
|
+
const nick = ctx.connection.nick ?? '*';
|
|
87
|
+
const codeStr = code.toString().padStart(3, '0');
|
|
88
|
+
return {
|
|
89
|
+
text: `:${ctx.serverName} ${codeStr} ${nick} ${count} :${trailing}`,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Builds a `:<server> <code> <nick> <cur> <max> :<trailing>` line for
|
|
95
|
+
* 265/266.
|
|
96
|
+
*/
|
|
97
|
+
function usageLine(ctx: Ctx, code: number, cur: number, max: number, trailing: string): RawLine {
|
|
98
|
+
const nick = ctx.connection.nick ?? '*';
|
|
99
|
+
const codeStr = code.toString().padStart(3, '0');
|
|
100
|
+
return {
|
|
101
|
+
text: `:${ctx.serverName} ${codeStr} ${nick} ${cur} ${max} :${trailing}`,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Handles `LUSERS [<mask> [<server>]]`.
|
|
107
|
+
*
|
|
108
|
+
* @param snapshot The aggregated network statistics (counts).
|
|
109
|
+
* @param _msg The parsed `LUSERS` message. The optional `<mask>`
|
|
110
|
+
* is accepted for RFC compliance but not applied — single-server
|
|
111
|
+
* deployments collapse any wildcard match to the local server.
|
|
112
|
+
* @param ctx The per-invocation context.
|
|
113
|
+
* @param serverTarget When set, the actor determined `<server>` names a
|
|
114
|
+
* non-local server; the reducer emits `402 ERR_NOSUCHSERVER` against this
|
|
115
|
+
* name and nothing else.
|
|
116
|
+
*/
|
|
117
|
+
export function lusersReducer(
|
|
118
|
+
snapshot: ServerStatsSnapshot,
|
|
119
|
+
_msg: { command: string; params: readonly string[] },
|
|
120
|
+
ctx: Ctx,
|
|
121
|
+
serverTarget?: string,
|
|
122
|
+
): { effects: EffectType[] } {
|
|
123
|
+
ctx.connection.lastSeen = ctx.clock.now();
|
|
124
|
+
|
|
125
|
+
const effects: EffectType[] = [];
|
|
126
|
+
|
|
127
|
+
if (serverTarget !== undefined) {
|
|
128
|
+
const nick = ctx.connection.nick ?? '*';
|
|
129
|
+
const code = Numerics.ERR_NOSUCHSERVER.toString().padStart(3, '0');
|
|
130
|
+
effects.push(
|
|
131
|
+
Effect.send(ctx.connId, [
|
|
132
|
+
{
|
|
133
|
+
text: `:${ctx.serverName} ${code} ${nick} ${serverTarget} :${NOSUCHSERVER_TRAILING_TEXT}`,
|
|
134
|
+
},
|
|
135
|
+
]),
|
|
136
|
+
);
|
|
137
|
+
return { effects };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// 251 always.
|
|
141
|
+
effects.push(
|
|
142
|
+
Effect.send(ctx.connId, [
|
|
143
|
+
trailingLine(
|
|
144
|
+
ctx,
|
|
145
|
+
Numerics.RPL_LUSERCLIENT,
|
|
146
|
+
LUSERCLIENT_TEXT(snapshot.users, snapshot.invisible, snapshot.servers),
|
|
147
|
+
),
|
|
148
|
+
]),
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
// 252/253/254 only when non-zero (RFC: omit zero-count summary lines).
|
|
152
|
+
if (snapshot.opers > 0) {
|
|
153
|
+
effects.push(
|
|
154
|
+
Effect.send(ctx.connId, [countLine(ctx, Numerics.RPL_LUSEROP, snapshot.opers, LUSEROP_TEXT)]),
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
if (snapshot.unknownConnections > 0) {
|
|
158
|
+
effects.push(
|
|
159
|
+
Effect.send(ctx.connId, [
|
|
160
|
+
countLine(ctx, Numerics.RPL_LUSERUNKNOWN, snapshot.unknownConnections, LUSERUNKNOWN_TEXT),
|
|
161
|
+
]),
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
if (snapshot.channels > 0) {
|
|
165
|
+
effects.push(
|
|
166
|
+
Effect.send(ctx.connId, [
|
|
167
|
+
countLine(ctx, Numerics.RPL_LUSERCHANNELS, snapshot.channels, LUSERCHANNELS_TEXT),
|
|
168
|
+
]),
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// 255 always.
|
|
173
|
+
effects.push(
|
|
174
|
+
Effect.send(ctx.connId, [
|
|
175
|
+
trailingLine(ctx, Numerics.RPL_LUSERME, LUSERME_TEXT(snapshot.localConns, snapshot.servers)),
|
|
176
|
+
]),
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
// 265/266 always (modern clients depend on them).
|
|
180
|
+
effects.push(
|
|
181
|
+
Effect.send(ctx.connId, [
|
|
182
|
+
usageLine(
|
|
183
|
+
ctx,
|
|
184
|
+
Numerics.RPL_LOCALUSERS,
|
|
185
|
+
snapshot.localConns,
|
|
186
|
+
snapshot.maxLocalConns,
|
|
187
|
+
LOCALUSERS_TEXT(snapshot.localConns, snapshot.maxLocalConns),
|
|
188
|
+
),
|
|
189
|
+
]),
|
|
190
|
+
);
|
|
191
|
+
effects.push(
|
|
192
|
+
Effect.send(ctx.connId, [
|
|
193
|
+
usageLine(
|
|
194
|
+
ctx,
|
|
195
|
+
Numerics.RPL_GLOBALUSERS,
|
|
196
|
+
snapshot.globalConns,
|
|
197
|
+
snapshot.maxGlobalConns,
|
|
198
|
+
GLOBALUSERS_TEXT(snapshot.globalConns, snapshot.maxGlobalConns),
|
|
199
|
+
),
|
|
200
|
+
]),
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
return { effects };
|
|
204
|
+
}
|
|
@@ -191,14 +191,10 @@ export const channelModeReducer: Reducer<ChannelState> = (state, msg, ctx) => {
|
|
|
191
191
|
// authority BEFORE the broadcast so distributed runtimes (CF
|
|
192
192
|
// ChannelDO) have persisted the change before any peer's next
|
|
193
193
|
// prefetch observes it. In the in-memory runtime the actor's
|
|
194
|
-
// `state` IS authoritative so this is redundant.
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
(delta.memberships !== undefined && delta.memberships.length > 0)
|
|
199
|
-
) {
|
|
200
|
-
effects.push(Effect.applyChannelDelta(state.name, delta));
|
|
201
|
-
}
|
|
194
|
+
// `state` IS authoritative so this is redundant. Every non-null
|
|
195
|
+
// `applyChannelOp` result is paired with at least one delta push,
|
|
196
|
+
// so `delta` is guaranteed non-empty here.
|
|
197
|
+
effects.push(Effect.applyChannelDelta(state.name, delta));
|
|
202
198
|
const hostmask = callerHostmask(ctx.connection);
|
|
203
199
|
effects.push(Effect.broadcast(state.name, [buildModeBroadcast(hostmask, state.name, applied)]));
|
|
204
200
|
}
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* `@` (private `+p`). Private wins over secret when both are set, matching
|
|
15
15
|
* the JOIN reducer's NAMES reply and RFC 2812 §3.2.4.
|
|
16
16
|
*
|
|
17
|
-
* Visibility rules (PLAN §3
|
|
17
|
+
* Visibility rules (PLAN §3):
|
|
18
18
|
* - `+s` or `+p` set: requester must be a member to receive the NAMES list.
|
|
19
19
|
* Otherwise `442 ERR_NOTONCHANNEL`.
|
|
20
20
|
* - Otherwise: any connection may list.
|
|
@@ -44,12 +44,10 @@ function isValidChannelName(name: string, maxLen: number): boolean {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
/** Formats a numeric error/reply line addressed to the connection's nick. */
|
|
47
|
-
function numericErr(ctx: Ctx, code: number, trailing: string, middle
|
|
47
|
+
function numericErr(ctx: Ctx, code: number, trailing: string, middle: string): RawLine {
|
|
48
48
|
const nick = ctx.connection.nick ?? '*';
|
|
49
49
|
const codeStr = code.toString().padStart(3, '0');
|
|
50
|
-
const parts = [`:${ctx.serverName}`, codeStr, nick];
|
|
51
|
-
if (middle !== undefined) parts.push(middle);
|
|
52
|
-
parts.push(`:${trailing}`);
|
|
50
|
+
const parts = [`:${ctx.serverName}`, codeStr, nick, middle, `:${trailing}`];
|
|
53
51
|
return { text: parts.join(' ') };
|
|
54
52
|
}
|
|
55
53
|
|
|
@@ -33,12 +33,10 @@ function isValidChannelName(name: string, maxLen: number): boolean {
|
|
|
33
33
|
* nick (or `*` when unregistered). Optional `middle` is emitted before the
|
|
34
34
|
* trailing `:`-prefixed text.
|
|
35
35
|
*/
|
|
36
|
-
function numericErr(ctx: Ctx, code: number, trailing: string, middle
|
|
36
|
+
function numericErr(ctx: Ctx, code: number, trailing: string, middle: string): RawLine {
|
|
37
37
|
const nick = ctx.connection.nick ?? '*';
|
|
38
38
|
const codeStr = code.toString().padStart(3, '0');
|
|
39
|
-
const parts = [`:${ctx.serverName}`, codeStr, nick];
|
|
40
|
-
if (middle !== undefined) parts.push(middle);
|
|
41
|
-
parts.push(`:${trailing}`);
|
|
39
|
+
const parts = [`:${ctx.serverName}`, codeStr, nick, middle, `:${trailing}`];
|
|
42
40
|
return { text: parts.join(' ') };
|
|
43
41
|
}
|
|
44
42
|
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure reducer for the IRC `REHASH` command (oper-gated config reload).
|
|
3
|
+
*
|
|
4
|
+
* PLAN §2.1 location-of-authority: REHASH runs in the Connection entity. The
|
|
5
|
+
* reducer is pure — it only enforces the oper gate and emits the
|
|
6
|
+
* `382 RPL_REHASHING` reply. The actual config re-fetch is a request/response
|
|
7
|
+
* side effect (re-invoke the adapter's bound config loader and swap the live
|
|
8
|
+
* {@link ServerConfig} reference on the actor), so — exactly like
|
|
9
|
+
* `lookupNick` / `getConnection` — it is NOT modelled as a fire-and-forget
|
|
10
|
+
* `Effect`; the actor layer performs it and surfaces success/failure back to
|
|
11
|
+
* the oper via a second `382` line on failure.
|
|
12
|
+
*
|
|
13
|
+
* Wire format:
|
|
14
|
+
* - `REHASH` (no params).
|
|
15
|
+
* - `382 RPL_REHASHING`:
|
|
16
|
+
* `:<server> 382 <nick> :Rehashing from <source>`
|
|
17
|
+
* - `481 ERR_NOPRIVILEGES`:
|
|
18
|
+
* `:<server> 481 <nick> :Permission Denied - You're not an IRC operator`
|
|
19
|
+
*
|
|
20
|
+
* Behaviour:
|
|
21
|
+
* - Non-oper → `481 ERR_NOPRIVILEGES`; no reload is signalled.
|
|
22
|
+
* - Oper → emits `382` noting the bound config source and signals
|
|
23
|
+
* `shouldReload: true`. The actor re-invokes its loader and swaps the
|
|
24
|
+
* live config; on failure the previous config stays in effect and a
|
|
25
|
+
* graceful error-suffixed `382` follows (handled by the actor).
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { Effect } from '../effects.js';
|
|
29
|
+
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
30
|
+
import type { IrcMessage } from '../protocol/messages.js';
|
|
31
|
+
import { Numerics } from '../protocol/numerics.js';
|
|
32
|
+
import type { ConnectionState } from '../state/connection.js';
|
|
33
|
+
import type { Ctx } from '../types.js';
|
|
34
|
+
|
|
35
|
+
/** Fixed text emitted for `481 ERR_NOPRIVILEGES` (matches `mode.ts`). */
|
|
36
|
+
const NOPRIVILEGES_TRAILING_TEXT = "Permission Denied - You're not an IRC operator";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Result of {@link rehashReducer}. Adds a `shouldReload` intent flag on top of
|
|
40
|
+
* the standard reducer shape so the actor layer knows whether to perform the
|
|
41
|
+
* async config re-fetch (oper gate passed) without re-deriving the gate.
|
|
42
|
+
*/
|
|
43
|
+
export interface RehashResult {
|
|
44
|
+
state: ConnectionState;
|
|
45
|
+
effects: EffectType[];
|
|
46
|
+
shouldReload: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Composes the trailing text for the `382 RPL_REHASHING` line. The success
|
|
51
|
+
* form notes the bound config source; the failure form (used by the actor on
|
|
52
|
+
* reload rejection) appends the error so the oper sees what went wrong
|
|
53
|
+
* without being disconnected.
|
|
54
|
+
*/
|
|
55
|
+
export function buildRehashTrailing(configSource: string, error?: string): string {
|
|
56
|
+
const base = `Rehashing from ${configSource}`;
|
|
57
|
+
return error === undefined ? base : `${base} failed: ${error}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Renders the full `:<server> 382 <nick> :<trailing>` wire line. Shared by
|
|
62
|
+
* the reducer (optimistic success line) and the actor (error-suffixed line
|
|
63
|
+
* on reload failure) so the two paths cannot drift in formatting.
|
|
64
|
+
*/
|
|
65
|
+
export function rehashLine(
|
|
66
|
+
serverName: string,
|
|
67
|
+
nick: string,
|
|
68
|
+
configSource: string,
|
|
69
|
+
error?: string,
|
|
70
|
+
): RawLine {
|
|
71
|
+
const code = Numerics.RPL_REHASHING.toString().padStart(3, '0');
|
|
72
|
+
return { text: `:${serverName} ${code} ${nick} :${buildRehashTrailing(configSource, error)}` };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Handles `REHASH`.
|
|
77
|
+
*
|
|
78
|
+
* Enforces the oper gate, stamps `lastSeen`, and — for an oper — emits the
|
|
79
|
+
* `382 RPL_REHASHING` reply and signals the actor to perform the reload via
|
|
80
|
+
* `shouldReload`. The reducer never performs IO.
|
|
81
|
+
*/
|
|
82
|
+
export function rehashReducer(
|
|
83
|
+
state: ConnectionState,
|
|
84
|
+
_msg: IrcMessage,
|
|
85
|
+
ctx: Ctx,
|
|
86
|
+
configSource: string,
|
|
87
|
+
): RehashResult {
|
|
88
|
+
state.lastSeen = ctx.clock.now();
|
|
89
|
+
|
|
90
|
+
if (!state.userModes.oper) {
|
|
91
|
+
return {
|
|
92
|
+
state,
|
|
93
|
+
effects: [
|
|
94
|
+
Effect.send(ctx.connId, [
|
|
95
|
+
numericLine(ctx, Numerics.ERR_NOPRIVILEGES, NOPRIVILEGES_TRAILING_TEXT),
|
|
96
|
+
]),
|
|
97
|
+
],
|
|
98
|
+
shouldReload: false,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const nick = ctx.connection.nick ?? '*';
|
|
103
|
+
return {
|
|
104
|
+
state,
|
|
105
|
+
effects: [Effect.send(ctx.connId, [rehashLine(ctx.serverName, nick, configSource)])],
|
|
106
|
+
shouldReload: true,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Builds a `:<server> <code> <nick> :<trailing>` line. The nick falls back to
|
|
112
|
+
* `*` for an unregistered connection, matching the rest of the codebase's
|
|
113
|
+
* numeric formatting.
|
|
114
|
+
*/
|
|
115
|
+
function numericLine(ctx: Ctx, code: number, trailing: string): RawLine {
|
|
116
|
+
const nick = ctx.connection.nick ?? '*';
|
|
117
|
+
const codeStr = code.toString().padStart(3, '0');
|
|
118
|
+
return { text: [`:${ctx.serverName}`, codeStr, nick, `:${trailing}`].join(' ') };
|
|
119
|
+
}
|