serverless-ircd 0.4.0 → 0.6.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/.github/workflows/ci.yml +34 -2
- package/.github/workflows/deploy-aws.yml +1 -2
- package/.github/workflows/deploy-cf-tcp.yml +1 -5
- package/.github/workflows/deploy-cf.yml +1 -2
- package/CHANGELOG.md +435 -0
- package/README.md +206 -27
- package/apps/aws-stack/README.md +37 -3
- package/apps/aws-stack/package.json +1 -1
- package/apps/aws-stack/src/aws-stack.ts +106 -13
- package/apps/aws-stack/tests/coverage-command.test.ts +155 -0
- package/apps/aws-stack/tests/stack.test.ts +49 -3
- package/apps/aws-stack/tests/synth-no-bundle.test.ts +129 -0
- package/apps/aws-stack/tests/vitest.setup.ts +18 -0
- package/apps/aws-stack/vitest.config.ts +3 -0
- package/apps/cf-tcp-container/package.json +1 -1
- package/apps/cf-tcp-container/src/config-loader.ts +6 -7
- package/apps/cf-tcp-container/src/container-server.ts +3 -0
- package/apps/cf-tcp-container/tests/config-loader.test.ts +10 -3
- package/apps/cf-tcp-container/wrangler.toml +1 -1
- package/apps/cf-worker/package.json +2 -1
- package/apps/cf-worker/vitest.config.ts +1 -1
- package/apps/cf-worker/wrangler.toml +5 -3
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +6 -6
- package/apps/local-cli/src/main.ts +20 -0
- package/apps/local-cli/src/server.ts +138 -28
- package/apps/local-cli/tests/e2e.test.ts +113 -29
- package/apps/local-cli/tests/tcp.test.ts +71 -19
- package/apps/local-cli/tests/ws-subprotocol.test.ts +262 -0
- package/biome.json +1 -0
- package/docs/ADR-001-pure-reducers-and-effect-system.md +74 -0
- package/docs/ADR-002-location-of-authority.md +82 -0
- package/docs/ADR-003-durable-object-sharding.md +110 -0
- package/docs/ADR-004-dynamodb-schema.md +96 -0
- package/docs/ADR-005-wss-only-transport-v1.md +83 -0
- package/docs/ADR-006-sasl-mechanism-scope.md +96 -0
- package/docs/ADR-007-deterministic-ports.md +82 -0
- package/docs/ADR-008-monorepo-tooling.md +60 -0
- package/docs/ADR-009-dual-transport-wss-and-irc-tls.md +138 -0
- package/docs/ADR-Index.md +15 -0
- package/docs/AWS-Adapter-Architecture.md +496 -0
- package/docs/AWS-Deployment.md +1275 -0
- package/docs/AWS-TCP-Deployment.md +410 -0
- package/docs/Cloudflare-Deployment-Guide.md +719 -0
- package/docs/Cloudflare-TCP-Deployment.md +510 -0
- package/docs/Home.md +15 -0
- package/docs/Observability.md +87 -0
- package/docs/PlanExtensions.md +812 -0
- package/docs/PlanIRCv3Websocket.md +503 -0
- package/docs/PlanWebClient.md +451 -0
- package/docs/Release-Process.md +443 -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 +66 -5
- package/packages/aws-adapter/src/handlers/index.ts +41 -2
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +18 -0
- package/packages/aws-adapter/src/index.ts +2 -0
- package/packages/aws-adapter/src/serialize.ts +40 -2
- package/packages/aws-adapter/src/stats.ts +80 -0
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +49 -8
- package/packages/aws-adapter/tests/account-store.test.ts +4 -7
- package/packages/aws-adapter/tests/aws-harness.ts +5 -2
- package/packages/aws-adapter/tests/aws-integration.test.ts +8 -4
- package/packages/aws-adapter/tests/aws-runtime.test.ts +140 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +11 -2
- package/packages/aws-adapter/tests/connect.test.ts +100 -4
- package/packages/aws-adapter/tests/disconnect-fanout.test.ts +3 -4
- package/packages/aws-adapter/tests/global-setup.ts +55 -30
- package/packages/aws-adapter/tests/gone-exception.test.ts +4 -9
- package/packages/aws-adapter/tests/handlers.test.ts +238 -4
- package/packages/aws-adapter/tests/message-store.test.ts +6 -5
- package/packages/aws-adapter/tests/nlb-stream.test.ts +62 -8
- package/packages/aws-adapter/tests/stats.test.ts +317 -0
- package/packages/cf-adapter/package.json +6 -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 +185 -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/serialize.ts +25 -4
- 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 +307 -2
- package/packages/cf-adapter/tests/channel-do.test.ts +119 -2
- package/packages/cf-adapter/tests/config-loader.test.ts +49 -0
- 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-pure.test.ts +91 -0
- 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/d1-account-store.test.ts +48 -0
- package/packages/cf-adapter/tests/registry-do.test.ts +108 -4
- package/packages/cf-adapter/tests/serialize.test.ts +308 -0
- 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/vitest.config.ts +1 -1
- 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 +38 -4
- package/packages/irc-core/src/caps/index.ts +1 -0
- package/packages/irc-core/src/caps/sts.ts +84 -0
- package/packages/irc-core/src/commands/account-notify.ts +53 -0
- package/packages/irc-core/src/commands/away.ts +9 -3
- package/packages/irc-core/src/commands/cap.ts +23 -5
- package/packages/irc-core/src/commands/chathistory.ts +40 -11
- package/packages/irc-core/src/commands/index.ts +42 -0
- package/packages/irc-core/src/commands/invite.ts +2 -4
- package/packages/irc-core/src/commands/isupport.ts +59 -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 +12 -9
- package/packages/irc-core/src/commands/monitor.ts +327 -0
- package/packages/irc-core/src/commands/multiline.ts +256 -0
- 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/pre-away.ts +112 -0
- package/packages/irc-core/src/commands/privmsg.ts +19 -12
- package/packages/irc-core/src/commands/read-marker.ts +108 -0
- package/packages/irc-core/src/commands/registration.ts +8 -0
- package/packages/irc-core/src/commands/rehash.ts +119 -0
- package/packages/irc-core/src/commands/sasl.ts +24 -1
- 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/tagmsg.ts +71 -1
- 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/commands/whois.ts +5 -0
- package/packages/irc-core/src/config.ts +72 -10
- package/packages/irc-core/src/effects.ts +41 -1
- package/packages/irc-core/src/index.ts +2 -0
- package/packages/irc-core/src/ports.ts +568 -0
- package/packages/irc-core/src/protocol/index.ts +14 -0
- package/packages/irc-core/src/protocol/labeled-response.ts +133 -0
- package/packages/irc-core/src/protocol/numerics.ts +57 -11
- package/packages/irc-core/src/protocol/outbound.ts +36 -4
- package/packages/irc-core/src/protocol/standard-replies.ts +277 -0
- package/packages/irc-core/src/state/connection.ts +32 -1
- package/packages/irc-core/src/types.ts +120 -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/stryker.commands.conf.json +1 -2
- package/packages/irc-core/tests/admission.test.ts +18 -0
- package/packages/irc-core/tests/away-store.test.ts +73 -0
- package/packages/irc-core/tests/caps/capabilities.test.ts +119 -2
- package/packages/irc-core/tests/caps/sts.test.ts +123 -0
- package/packages/irc-core/tests/commands/account-notify.test.ts +171 -0
- package/packages/irc-core/tests/commands/away.test.ts +85 -2
- package/packages/irc-core/tests/commands/cap.test.ts +165 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +148 -1
- package/packages/irc-core/tests/commands/echo-message.test.ts +9 -3
- package/packages/irc-core/tests/commands/isupport.test.ts +80 -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 +129 -0
- package/packages/irc-core/tests/commands/monitor.test.ts +411 -0
- package/packages/irc-core/tests/commands/multiline.test.ts +428 -0
- package/packages/irc-core/tests/commands/pre-away.test.ts +241 -0
- package/packages/irc-core/tests/commands/privmsg.test.ts +154 -3
- package/packages/irc-core/tests/commands/read-marker.test.ts +211 -0
- package/packages/irc-core/tests/commands/registration.test.ts +64 -0
- package/packages/irc-core/tests/commands/rehash.test.ts +171 -0
- package/packages/irc-core/tests/commands/sasl.test.ts +116 -2
- 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/tagmsg.test.ts +357 -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/commands/whois.test.ts +34 -0
- package/packages/irc-core/tests/config.test.ts +103 -13
- 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/labeled-response.test.ts +181 -0
- package/packages/irc-core/tests/message-tags.test.ts +114 -0
- package/packages/irc-core/tests/monitor-store.test.ts +200 -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/read-marker-store.test.ts +108 -0
- package/packages/irc-core/tests/standard-replies.test.ts +412 -0
- package/packages/irc-core/tests/stats-store.test.ts +222 -0
- package/packages/irc-core/tests/types.test.ts +27 -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 +699 -19
- package/packages/irc-server/src/dispatch.ts +109 -16
- 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 +2523 -42
- package/packages/irc-server/tests/dispatch.test.ts +300 -2
- 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/index.ts +6 -0
- package/packages/irc-test-support/src/scenarios.ts +9 -1
- package/packages/irc-test-support/src/test-config.ts +54 -0
- package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +1 -1
- package/packages/irc-test-support/tests/test-config.test.ts +51 -0
- package/pnpm-workspace.yaml +1 -0
- package/tools/ci-hardening/package.json +1 -1
- package/tools/ci-hardening/tests/ddb-local.test.ts +82 -0
- package/tools/load-test/package.json +33 -0
- package/tools/load-test/src/client.ts +351 -0
- package/tools/load-test/src/config.ts +313 -0
- package/tools/load-test/src/harness.ts +116 -0
- package/tools/load-test/src/main.ts +120 -0
- package/tools/load-test/src/metrics.ts +168 -0
- package/tools/load-test/src/report.ts +106 -0
- package/tools/load-test/tests/client.test.ts +212 -0
- package/tools/load-test/tests/config.test.ts +152 -0
- package/tools/load-test/tests/framing.test.ts +37 -0
- package/tools/load-test/tests/harness.test.ts +165 -0
- package/tools/load-test/tests/metrics.test.ts +174 -0
- package/tools/load-test/tests/report.test.ts +161 -0
- package/tools/load-test/tests/smoke.test.ts +67 -0
- package/tools/load-test/tsconfig.build.json +12 -0
- package/tools/load-test/tsconfig.test.json +10 -0
- package/tools/load-test/vitest.config.ts +29 -0
- 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
|
@@ -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
|
}
|
|
@@ -532,7 +528,7 @@ const USER_MODE_LETTERS: Readonly<Record<string, keyof ConnectionState['userMode
|
|
|
532
528
|
};
|
|
533
529
|
|
|
534
530
|
/** User-mode letters in canonical 221 output order. */
|
|
535
|
-
const USER_MODE_LETTER_ORDER = ['i', 'w', 's', 'o'] as const;
|
|
531
|
+
const USER_MODE_LETTER_ORDER = ['i', 'w', 's', 'o', 'S'] as const;
|
|
536
532
|
|
|
537
533
|
/**
|
|
538
534
|
* Handles `MODE <nick> [<modestring>]`.
|
|
@@ -644,6 +640,13 @@ export const userModeReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
|
|
|
644
640
|
function buildUserModeString(state: ConnectionState): string {
|
|
645
641
|
let modeStr = '+';
|
|
646
642
|
for (const letter of USER_MODE_LETTER_ORDER) {
|
|
643
|
+
// `S` (TLS connected) is read-only: it is not in USER_MODE_LETTERS
|
|
644
|
+
// (the write-side map the parser consults) so `MODE nick +S` rejects
|
|
645
|
+
// with 501. Surface it on the read side directly from its field.
|
|
646
|
+
if (letter === 'S') {
|
|
647
|
+
if (state.userModes.tls) modeStr += 'S';
|
|
648
|
+
continue;
|
|
649
|
+
}
|
|
647
650
|
const field = USER_MODE_LETTERS[letter];
|
|
648
651
|
if (field !== undefined && state.userModes[field]) modeStr += letter;
|
|
649
652
|
}
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure reducer for the IRCv3 `MONITOR` command.
|
|
3
|
+
*
|
|
4
|
+
* IRCv3 MONITOR spec <https://ircv3.net/specs/extensions/monitor-3.2.html>:
|
|
5
|
+
*
|
|
6
|
+
* - `MONITOR + <nick>{,<nick>}` — add targets to the watchlist.
|
|
7
|
+
* - `MONITOR - <nick>{,<nick>}` — remove targets from the watchlist.
|
|
8
|
+
* - `MONITOR C` — clear the watchlist.
|
|
9
|
+
* - `MONITOR L` — list the watchlist (`732` per line +
|
|
10
|
+
* `733` terminator).
|
|
11
|
+
* - `MONITOR S` — status: `730 RPL_MONONLINE` for the
|
|
12
|
+
* online subset, `731 RPL_MONOFFLINE`
|
|
13
|
+
* for the rest.
|
|
14
|
+
*
|
|
15
|
+
* Numerics:
|
|
16
|
+
* - `730 RPL_MONONLINE` `:<server> 730 <nick> :<online>{,<online>}`
|
|
17
|
+
* - `731 RPL_MONOFFLINE` `:<server> 731 <nick> :<offline>{,<offline>}`
|
|
18
|
+
* - `732 RPL_MONLIST` `:<server> 732 <nick> :<entry>`
|
|
19
|
+
* - `733 RPL_ENDOFMONLIST` `:<server> 733 <nick> :End of MONITOR list`
|
|
20
|
+
* - `734 ERR_MONLISTFULL` `:<server> 734 <nick> <limit> :<rejected>{,<rejected>}`
|
|
21
|
+
*
|
|
22
|
+
* The watchlist lives on {@link ConnectionState.monitorList} (folded nicks,
|
|
23
|
+
* case-insensitive membership). The reducer resolves each requested nick
|
|
24
|
+
* against the supplied `online` map (folded-keyed → display spelling); the
|
|
25
|
+
* actor builds that map from the nick registry before invoking the reducer,
|
|
26
|
+
* mirroring the {@link isonReducer} pattern.
|
|
27
|
+
*
|
|
28
|
+
* Asynchronous `730`/`731` pushes on online/offline transitions are emitted
|
|
29
|
+
* by the actor layer (it watches `ReserveNick` / `ReleaseNick` effects
|
|
30
|
+
* against the watchlists); the pure reducer only handles the request/reply
|
|
31
|
+
* subcommands above.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { Effect } from '../effects.js';
|
|
35
|
+
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
36
|
+
import { Numerics } from '../protocol/numerics.js';
|
|
37
|
+
import type { ConnectionState } from '../state/connection.js';
|
|
38
|
+
import type { Ctx, ReducerResult } from '../types.js';
|
|
39
|
+
|
|
40
|
+
/** Wire name of the IRCv3 MONITOR capability. */
|
|
41
|
+
export const MONITOR_CAP = 'monitor';
|
|
42
|
+
|
|
43
|
+
/** Default monitor cap (max watchlist size) advertised via `MONITOR=<n>`. */
|
|
44
|
+
export const DEFAULT_MONITOR_LIMIT = 30;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Handles the IRCv3 `MONITOR` subcommands.
|
|
48
|
+
*
|
|
49
|
+
* Signature is bespoke (not {@link Reducer}) because the actor must resolve
|
|
50
|
+
* the requested nicks asynchronously before formatting — mirrors
|
|
51
|
+
* {@link isonReducer} / {@link whoisReducer}.
|
|
52
|
+
*
|
|
53
|
+
* @param online Folded-keyed → display-spelling map of currently
|
|
54
|
+
* online nicks (built by the actor from the nick
|
|
55
|
+
* registry).
|
|
56
|
+
* @param maxMonitor Server-wide cap on the per-connection watchlist size
|
|
57
|
+
* (advertised via `MONITOR=<n>` in `005`).
|
|
58
|
+
* @param msg Parsed `MONITOR <sub> [<args>]` message.
|
|
59
|
+
* @param ctx Per-invocation context.
|
|
60
|
+
*/
|
|
61
|
+
export function monitorReducer(
|
|
62
|
+
online: ReadonlyMap<string, string>,
|
|
63
|
+
maxMonitor: number,
|
|
64
|
+
msg: { params: readonly string[] },
|
|
65
|
+
ctx: Ctx,
|
|
66
|
+
): ReducerResult<ConnectionState> {
|
|
67
|
+
ctx.connection.lastSeen = ctx.clock.now();
|
|
68
|
+
|
|
69
|
+
const state = ctx.connection;
|
|
70
|
+
const nick = state.nick ?? '*';
|
|
71
|
+
const serverName = ctx.serverName;
|
|
72
|
+
|
|
73
|
+
const sub = (msg.params[0] ?? '').toUpperCase();
|
|
74
|
+
|
|
75
|
+
// No subcommand at all → 461 NEEDMOREPARAMS (the parser hands us an
|
|
76
|
+
// empty string when the sender wrote bare `MONITOR`). A non-empty but
|
|
77
|
+
// unknown subcommand (e.g. `MONITOR Z`) is also 461 with a different
|
|
78
|
+
// trailing string per the IRCv3 MONITOR dispatch table.
|
|
79
|
+
if (sub.length === 0) {
|
|
80
|
+
return {
|
|
81
|
+
state,
|
|
82
|
+
effects: [
|
|
83
|
+
Effect.send(ctx.connId, [
|
|
84
|
+
numericLine(
|
|
85
|
+
serverName,
|
|
86
|
+
Numerics.ERR_NEEDMOREPARAMS,
|
|
87
|
+
nick,
|
|
88
|
+
'MONITOR',
|
|
89
|
+
'Not enough parameters',
|
|
90
|
+
),
|
|
91
|
+
]),
|
|
92
|
+
],
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
switch (sub) {
|
|
97
|
+
case '+':
|
|
98
|
+
return handleAdd(state, online, maxMonitor, msg.params[1] ?? '', nick, serverName);
|
|
99
|
+
case '-':
|
|
100
|
+
return handleRemove(state, msg.params[1] ?? '');
|
|
101
|
+
case 'C':
|
|
102
|
+
return handleClear(state);
|
|
103
|
+
case 'L':
|
|
104
|
+
return handleList(state, nick, serverName);
|
|
105
|
+
case 'S':
|
|
106
|
+
return handleStatus(state, online, nick, serverName);
|
|
107
|
+
default:
|
|
108
|
+
return {
|
|
109
|
+
state,
|
|
110
|
+
effects: [
|
|
111
|
+
Effect.send(ctx.connId, [
|
|
112
|
+
numericLine(
|
|
113
|
+
serverName,
|
|
114
|
+
Numerics.ERR_NEEDMOREPARAMS,
|
|
115
|
+
nick,
|
|
116
|
+
'MONITOR',
|
|
117
|
+
'Unknown command',
|
|
118
|
+
),
|
|
119
|
+
]),
|
|
120
|
+
],
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// Subcommand handlers
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
function handleAdd(
|
|
130
|
+
state: ConnectionState,
|
|
131
|
+
online: ReadonlyMap<string, string>,
|
|
132
|
+
maxMonitor: number,
|
|
133
|
+
targetList: string,
|
|
134
|
+
nick: string,
|
|
135
|
+
serverName: string,
|
|
136
|
+
): ReducerResult<ConnectionState> {
|
|
137
|
+
if (targetList.length === 0) {
|
|
138
|
+
return {
|
|
139
|
+
state,
|
|
140
|
+
effects: [
|
|
141
|
+
Effect.send(state.id, [
|
|
142
|
+
numericLine(
|
|
143
|
+
serverName,
|
|
144
|
+
Numerics.ERR_NEEDMOREPARAMS,
|
|
145
|
+
nick,
|
|
146
|
+
'MONITOR',
|
|
147
|
+
'Not enough parameters',
|
|
148
|
+
),
|
|
149
|
+
]),
|
|
150
|
+
],
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const watchlist = ensureWatchlist(state);
|
|
155
|
+
|
|
156
|
+
const added: { folded: string; display: string }[] = [];
|
|
157
|
+
const rejected: string[] = [];
|
|
158
|
+
for (const requested of splitNicks(targetList)) {
|
|
159
|
+
const folded = requested.toLowerCase();
|
|
160
|
+
if (watchlist.has(folded)) continue; // already monitored; no output
|
|
161
|
+
if (watchlist.size >= maxMonitor) {
|
|
162
|
+
rejected.push(requested);
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
watchlist.add(folded);
|
|
166
|
+
// For the display spelling prefer the online map (canonical) and fall
|
|
167
|
+
// back to the requester spelling when the nick is offline.
|
|
168
|
+
const display = online.get(folded) ?? requested;
|
|
169
|
+
added.push({ folded, display });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const effects: EffectType[] = [];
|
|
173
|
+
|
|
174
|
+
const onlineDisplays = added.filter((a) => online.has(a.folded)).map((a) => a.display);
|
|
175
|
+
const offlineDisplays = added.filter((a) => !online.has(a.folded)).map((a) => a.display);
|
|
176
|
+
|
|
177
|
+
if (onlineDisplays.length > 0) {
|
|
178
|
+
effects.push(
|
|
179
|
+
Effect.send(state.id, [
|
|
180
|
+
monLine(Numerics.RPL_MONONLINE, serverName, nick, onlineDisplays.join(',')),
|
|
181
|
+
]),
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
if (offlineDisplays.length > 0) {
|
|
185
|
+
effects.push(
|
|
186
|
+
Effect.send(state.id, [
|
|
187
|
+
monLine(Numerics.RPL_MONOFFLINE, serverName, nick, offlineDisplays.join(',')),
|
|
188
|
+
]),
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
if (rejected.length > 0) {
|
|
192
|
+
effects.push(
|
|
193
|
+
Effect.send(state.id, [monListFullLine(serverName, nick, maxMonitor, rejected.join(','))]),
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return { state, effects };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function handleRemove(state: ConnectionState, targetList: string): ReducerResult<ConnectionState> {
|
|
201
|
+
if (state.monitorList === undefined) return { state, effects: [] };
|
|
202
|
+
if (targetList.length === 0) return { state, effects: [] };
|
|
203
|
+
for (const requested of splitNicks(targetList)) {
|
|
204
|
+
state.monitorList.delete(requested.toLowerCase());
|
|
205
|
+
}
|
|
206
|
+
return { state, effects: [] };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function handleClear(state: ConnectionState): ReducerResult<ConnectionState> {
|
|
210
|
+
// Allocate the set on first clear so callers observe a stable post-state
|
|
211
|
+
// (size 0) regardless of whether the watchlist previously existed.
|
|
212
|
+
if (state.monitorList === undefined) {
|
|
213
|
+
state.monitorList = new Set<string>();
|
|
214
|
+
} else {
|
|
215
|
+
state.monitorList.clear();
|
|
216
|
+
}
|
|
217
|
+
return { state, effects: [] };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function handleList(
|
|
221
|
+
state: ConnectionState,
|
|
222
|
+
nick: string,
|
|
223
|
+
serverName: string,
|
|
224
|
+
): ReducerResult<ConnectionState> {
|
|
225
|
+
const effects: EffectType[] = [];
|
|
226
|
+
if (state.monitorList !== undefined) {
|
|
227
|
+
for (const entry of state.monitorList) {
|
|
228
|
+
effects.push(Effect.send(state.id, [monLine(Numerics.RPL_MONLIST, serverName, nick, entry)]));
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
effects.push(
|
|
232
|
+
Effect.send(state.id, [
|
|
233
|
+
monLine(Numerics.RPL_ENDOFMONLIST, serverName, nick, 'End of MONITOR list'),
|
|
234
|
+
]),
|
|
235
|
+
);
|
|
236
|
+
return { state, effects };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function handleStatus(
|
|
240
|
+
state: ConnectionState,
|
|
241
|
+
online: ReadonlyMap<string, string>,
|
|
242
|
+
nick: string,
|
|
243
|
+
serverName: string,
|
|
244
|
+
): ReducerResult<ConnectionState> {
|
|
245
|
+
if (state.monitorList === undefined || state.monitorList.size === 0) {
|
|
246
|
+
return { state, effects: [] };
|
|
247
|
+
}
|
|
248
|
+
const onlineDisplays: string[] = [];
|
|
249
|
+
const offlineDisplays: string[] = [];
|
|
250
|
+
for (const folded of state.monitorList) {
|
|
251
|
+
const display = online.get(folded);
|
|
252
|
+
if (display !== undefined) {
|
|
253
|
+
onlineDisplays.push(display);
|
|
254
|
+
} else {
|
|
255
|
+
offlineDisplays.push(folded);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
const effects: EffectType[] = [];
|
|
259
|
+
if (onlineDisplays.length > 0) {
|
|
260
|
+
effects.push(
|
|
261
|
+
Effect.send(state.id, [
|
|
262
|
+
monLine(Numerics.RPL_MONONLINE, serverName, nick, onlineDisplays.join(',')),
|
|
263
|
+
]),
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
if (offlineDisplays.length > 0) {
|
|
267
|
+
effects.push(
|
|
268
|
+
Effect.send(state.id, [
|
|
269
|
+
monLine(Numerics.RPL_MONOFFLINE, serverName, nick, offlineDisplays.join(',')),
|
|
270
|
+
]),
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
return { state, effects };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ---------------------------------------------------------------------------
|
|
277
|
+
// Helpers
|
|
278
|
+
// ---------------------------------------------------------------------------
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Returns the connection's watchlist, lazily allocating it on first use.
|
|
282
|
+
* Existing persisted state migrates without a schema bump (the field stays
|
|
283
|
+
* optional; reducers that pre-date MONITOR never touch it).
|
|
284
|
+
*/
|
|
285
|
+
function ensureWatchlist(state: ConnectionState): Set<string> {
|
|
286
|
+
if (state.monitorList === undefined) {
|
|
287
|
+
state.monitorList = new Set<string>();
|
|
288
|
+
}
|
|
289
|
+
return state.monitorList;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Splits a comma-separated nick list, dropping empty entries. */
|
|
293
|
+
function splitNicks(csv: string): string[] {
|
|
294
|
+
return csv
|
|
295
|
+
.split(',')
|
|
296
|
+
.map((s) => s.trim())
|
|
297
|
+
.filter((s) => s.length > 0);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** `:<server> <code> <nick> :<trailing>` — used by 730/731/732/733. */
|
|
301
|
+
function monLine(code: number, serverName: string, nick: string, trailing: string): RawLine {
|
|
302
|
+
const codeStr = code.toString().padStart(3, '0');
|
|
303
|
+
return { text: `:${serverName} ${codeStr} ${nick} :${trailing}` };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** `:<server> 734 <nick> <limit> :<rejected>` (ERR_MONLISTFULL). */
|
|
307
|
+
function monListFullLine(
|
|
308
|
+
serverName: string,
|
|
309
|
+
nick: string,
|
|
310
|
+
limit: number,
|
|
311
|
+
rejected: string,
|
|
312
|
+
): RawLine {
|
|
313
|
+
const codeStr = Numerics.ERR_MONLISTFULL.toString().padStart(3, '0');
|
|
314
|
+
return { text: `:${serverName} ${codeStr} ${nick} ${limit} :${rejected}` };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** `:<server> <code> <nick> <cmd> :<trailing>` — used by 461-style errors. */
|
|
318
|
+
function numericLine(
|
|
319
|
+
serverName: string,
|
|
320
|
+
code: number,
|
|
321
|
+
nick: string,
|
|
322
|
+
cmd: string,
|
|
323
|
+
trailing: string,
|
|
324
|
+
): RawLine {
|
|
325
|
+
const codeStr = code.toString().padStart(3, '0');
|
|
326
|
+
return { text: `:${serverName} ${codeStr} ${nick} ${cmd} :${trailing}` };
|
|
327
|
+
}
|