serverless-ircd 0.5.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 +260 -0
- package/README.md +132 -22
- package/apps/aws-stack/README.md +33 -0
- package/apps/aws-stack/package.json +1 -1
- package/apps/aws-stack/src/aws-stack.ts +74 -9
- package/apps/aws-stack/tests/coverage-command.test.ts +155 -0
- package/apps/aws-stack/tests/stack.test.ts +2 -2
- 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 +9 -7
- package/apps/local-cli/tests/e2e.test.ts +112 -28
- package/apps/local-cli/tests/tcp.test.ts +71 -19
- package/apps/local-cli/tests/ws-subprotocol.test.ts +12 -7
- 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/handlers/default.ts +3 -0
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +9 -0
- package/packages/aws-adapter/src/serialize.ts +29 -1
- 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 +79 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +11 -2
- package/packages/aws-adapter/tests/connect.test.ts +9 -9
- 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 +90 -4
- package/packages/aws-adapter/tests/message-store.test.ts +6 -5
- package/packages/aws-adapter/tests/nlb-stream.test.ts +60 -8
- package/packages/cf-adapter/package.json +2 -1
- package/packages/cf-adapter/src/connection-do.ts +3 -0
- package/packages/cf-adapter/src/serialize.ts +25 -4
- package/packages/cf-adapter/tests/cf-runtime.test.ts +269 -0
- package/packages/cf-adapter/tests/channel-do.test.ts +117 -0
- package/packages/cf-adapter/tests/config-loader.test.ts +49 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +91 -0
- package/packages/cf-adapter/tests/d1-account-store.test.ts +48 -0
- package/packages/cf-adapter/tests/registry-do.test.ts +104 -0
- package/packages/cf-adapter/tests/serialize.test.ts +308 -0
- package/packages/cf-adapter/vitest.config.ts +1 -1
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/caps/capabilities.ts +37 -3
- 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 +34 -0
- package/packages/irc-core/src/commands/isupport.ts +53 -0
- package/packages/irc-core/src/commands/mode.ts +8 -1
- 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/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/sasl.ts +24 -1
- package/packages/irc-core/src/commands/tagmsg.ts +71 -1
- package/packages/irc-core/src/commands/whois.ts +5 -0
- package/packages/irc-core/src/config.ts +65 -10
- package/packages/irc-core/src/effects.ts +14 -0
- package/packages/irc-core/src/ports.ts +389 -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 +15 -0
- package/packages/irc-core/src/protocol/outbound.ts +16 -1
- 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 +112 -0
- package/packages/irc-core/stryker.commands.conf.json +1 -2
- 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/mode.test.ts +72 -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/sasl.test.ts +116 -2
- package/packages/irc-core/tests/commands/tagmsg.test.ts +357 -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/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/read-marker-store.test.ts +108 -0
- package/packages/irc-core/tests/standard-replies.test.ts +412 -0
- package/packages/irc-core/tests/types.test.ts +27 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +450 -3
- package/packages/irc-server/src/dispatch.ts +108 -16
- package/packages/irc-server/tests/actor.test.ts +1432 -39
- package/packages/irc-server/tests/dispatch.test.ts +263 -2
- 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/test-config.ts +54 -0
- package/packages/irc-test-support/tests/test-config.test.ts +51 -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/tcp-ws-forwarder/package.json +1 -1
|
@@ -27,6 +27,7 @@ import type { ChanName } from '../state/channel.js';
|
|
|
27
27
|
import { hostmaskOf } from '../state/connection.js';
|
|
28
28
|
import type { ConnectionState } from '../state/connection.js';
|
|
29
29
|
import type { Reducer } from '../types.js';
|
|
30
|
+
import { clearPersistedAway, persistAway } from './pre-away.js';
|
|
30
31
|
|
|
31
32
|
/**
|
|
32
33
|
* Handles `AWAY [:<reason>]`.
|
|
@@ -45,7 +46,10 @@ export const awayReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
|
|
|
45
46
|
const nick = state.nick ?? '*';
|
|
46
47
|
|
|
47
48
|
if (isSettingAway) {
|
|
48
|
-
|
|
49
|
+
// IRCv3 draft/pre-away: persist the reason keyed by account when an
|
|
50
|
+
// AwayStore is bound and the connection is identified. The in-memory
|
|
51
|
+
// away is also set so the current session tracks the new reason.
|
|
52
|
+
persistAway(ctx, reason as string);
|
|
49
53
|
effects.push(
|
|
50
54
|
Effect.send(ctx.connId, [
|
|
51
55
|
{
|
|
@@ -54,8 +58,10 @@ export const awayReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
|
|
|
54
58
|
]),
|
|
55
59
|
);
|
|
56
60
|
} else {
|
|
57
|
-
//
|
|
58
|
-
|
|
61
|
+
// IRCv3 draft/pre-away: clear the persisted reason on unset so a
|
|
62
|
+
// reconnect does not re-mark the user away. The in-memory away is
|
|
63
|
+
// also cleared so the session's view stays consistent.
|
|
64
|
+
clearPersistedAway(ctx);
|
|
59
65
|
effects.push(
|
|
60
66
|
Effect.send(ctx.connId, [
|
|
61
67
|
{
|
|
@@ -33,13 +33,17 @@
|
|
|
33
33
|
*/
|
|
34
34
|
|
|
35
35
|
import {
|
|
36
|
+
DEFAULT_MULTILINE_MAX_BYTES,
|
|
37
|
+
MULTILINE_CAP_NAME,
|
|
36
38
|
SASL_CAP_NAME,
|
|
37
39
|
SUPPORTED_CAPABILITIES,
|
|
38
40
|
capToLsString,
|
|
39
41
|
isSupportedCap,
|
|
42
|
+
multilineCapValue,
|
|
40
43
|
saslCapValue,
|
|
41
44
|
splitCapsForWire,
|
|
42
45
|
} from '../caps/capabilities.js';
|
|
46
|
+
import { STS_CAP_NAME, stsCapValue } from '../caps/sts.js';
|
|
43
47
|
import { Effect } from '../effects.js';
|
|
44
48
|
import type { RawLine } from '../effects.js';
|
|
45
49
|
import { Numerics } from '../protocol/numerics.js';
|
|
@@ -91,11 +95,21 @@ function handleLs(state: ConnectionState, ctx: Ctx): ReducerResult<ConnectionSta
|
|
|
91
95
|
|
|
92
96
|
const target = state.nick ?? '*';
|
|
93
97
|
const hasMtls = ctx.mtlsIdentity !== undefined;
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
98
|
+
const multilineMax = ctx.serverConfig.multilineMaxBytes ?? DEFAULT_MULTILINE_MAX_BYTES;
|
|
99
|
+
const tokens = SUPPORTED_CAPABILITIES.map((cap) => {
|
|
100
|
+
if (cap.name === SASL_CAP_NAME) {
|
|
101
|
+
return capToLsString({ ...cap, value: saslCapValue(hasMtls) });
|
|
102
|
+
}
|
|
103
|
+
if (cap.name === MULTILINE_CAP_NAME) {
|
|
104
|
+
return capToLsString({ ...cap, value: multilineCapValue(multilineMax) });
|
|
105
|
+
}
|
|
106
|
+
return capToLsString(cap);
|
|
107
|
+
});
|
|
108
|
+
// STS is conditionally advertised: appended only when the deployment
|
|
109
|
+
// supplies a policy, and the value is per-transport (plaintext sees the
|
|
110
|
+
// full `duration,port[,preload]`; TLS sees only `duration`).
|
|
111
|
+
const stsValue = stsCapValue(ctx.serverConfig.sts, ctx.connection.secure);
|
|
112
|
+
if (stsValue !== undefined) tokens.push(`${STS_CAP_NAME}=${stsValue}`);
|
|
99
113
|
const maxLen = maxCapsContentLen(ctx, target);
|
|
100
114
|
const chunks = splitCapsForWire(tokens, maxLen);
|
|
101
115
|
|
|
@@ -164,8 +178,12 @@ function handleReq(
|
|
|
164
178
|
const requested = rawCaps.split(' ').filter((c) => c.length > 0);
|
|
165
179
|
const target = state.nick ?? '*';
|
|
166
180
|
|
|
181
|
+
// STS is conditionally supported: ACKed only when the deployment supplies
|
|
182
|
+
// a policy. Other caps consult the static table.
|
|
183
|
+
const stsConfigured = ctx.serverConfig.sts !== undefined;
|
|
167
184
|
const allSupported = requested.every((tok) => {
|
|
168
185
|
const name = tok.startsWith('-') ? tok.slice(1) : tok;
|
|
186
|
+
if (name === STS_CAP_NAME) return stsConfigured;
|
|
169
187
|
return isSupportedCap(name);
|
|
170
188
|
});
|
|
171
189
|
|
|
@@ -141,7 +141,17 @@ function handleChannelSub(
|
|
|
141
141
|
// LATEST / BEFORE / AFTER / AROUND
|
|
142
142
|
const pivot = msg.params[2];
|
|
143
143
|
const limitRaw = msg.params[3];
|
|
144
|
-
|
|
144
|
+
|
|
145
|
+
// IRCv3 draft/read-marker integration: BEFORE without an explicit pivot
|
|
146
|
+
// falls back to the connection's per-channel last-read marker so a client
|
|
147
|
+
// can resume "everything before my last read position" without re-supplying
|
|
148
|
+
// the msgid. AFTER/AROUND still require an explicit pivot.
|
|
149
|
+
const markerPivot =
|
|
150
|
+
sub === 'BEFORE' && pivot === undefined
|
|
151
|
+
? ctx.connection.lastReadMarkers?.get(chanLower)
|
|
152
|
+
: undefined;
|
|
153
|
+
|
|
154
|
+
if (sub !== 'LATEST' && pivot === undefined && markerPivot === undefined) {
|
|
145
155
|
return { effects: [Effect.send(ctx.connId, [needMoreParamsLine(ctx)])] };
|
|
146
156
|
}
|
|
147
157
|
const limit = parseLimit(limitRaw);
|
|
@@ -163,12 +173,13 @@ function handleChannelSub(
|
|
|
163
173
|
return { effects: buildChathistoryBatch(ctx, channel.name, result) };
|
|
164
174
|
}
|
|
165
175
|
|
|
166
|
-
// BEFORE / AFTER / AROUND — pivot is required.
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
176
|
+
// BEFORE / AFTER / AROUND — pivot is required. Resolve from the explicit
|
|
177
|
+
// msgid when supplied, otherwise from the marker fallback computed above.
|
|
178
|
+
// The guard near the top already guaranteed at least one is defined.
|
|
179
|
+
const pivotMsgid = (pivot ?? markerPivot) as string;
|
|
180
|
+
// An explicit pivot must reference a retained message; a marker fallback
|
|
181
|
+
// may point at an evicted message (→ empty result, not INVALID_PARAMS).
|
|
182
|
+
if (pivot !== undefined && !storeHas(ctx, chanLower, pivotMsgid)) {
|
|
172
183
|
return { effects: [Effect.send(ctx.connId, [invalidParamsLine(ctx)])] };
|
|
173
184
|
}
|
|
174
185
|
const result = storeQuery(ctx, {
|
|
@@ -228,16 +239,34 @@ export function buildChathistoryBatch(
|
|
|
228
239
|
/**
|
|
229
240
|
* Reconstructs the wire line for one stored message, decorated with the
|
|
230
241
|
* original server-time and msgid tags. PRIVMSG/NOTICE carry the trailing
|
|
231
|
-
* text; TAGMSG has no trailing parameter.
|
|
242
|
+
* text; TAGMSG has no trailing parameter. Optional `clientTags` recorded
|
|
243
|
+
* with the message (e.g. `+draft/multiline`) are appended to the tag
|
|
244
|
+
* section so chathistory clients can recognise the message kind.
|
|
232
245
|
*/
|
|
233
246
|
export function formatReplayLine(m: StoredMessage, displayChan: string): RawLine {
|
|
234
247
|
const stamp = formatServerTime(m.time);
|
|
235
248
|
const prefix = storedHostmask(m);
|
|
236
|
-
const
|
|
249
|
+
const tags = buildReplayTags(stamp, m.msgid, m.clientTags);
|
|
237
250
|
if (m.command === 'TAGMSG') {
|
|
238
|
-
return { text: `${
|
|
251
|
+
return { text: `${tags} :${prefix} TAGMSG ${displayChan}` };
|
|
252
|
+
}
|
|
253
|
+
return { text: `${tags} :${prefix} ${m.command} ${displayChan} :${m.text}` };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Builds the `@time=…;msgid=…[;+client-tag…]` tag section for a replay line.
|
|
258
|
+
* Client tags (when present) are appended after the server tags, matching the
|
|
259
|
+
* IRCv3 message-tags wire order.
|
|
260
|
+
*/
|
|
261
|
+
function buildReplayTags(
|
|
262
|
+
stamp: string,
|
|
263
|
+
msgid: string,
|
|
264
|
+
clientTags: readonly string[] | undefined,
|
|
265
|
+
): string {
|
|
266
|
+
if (clientTags === undefined || clientTags.length === 0) {
|
|
267
|
+
return `@time=${stamp};msgid=${msgid}`;
|
|
239
268
|
}
|
|
240
|
-
return {
|
|
269
|
+
return `@time=${stamp};msgid=${msgid};${clientTags.join(';')}`;
|
|
241
270
|
}
|
|
242
271
|
|
|
243
272
|
/** Builds the `nick!user@host` source for a stored message. */
|
|
@@ -46,6 +46,8 @@ export { generateIsupport } from './isupport.js';
|
|
|
46
46
|
export { capReducer } from './cap.js';
|
|
47
47
|
export { emitChghost } from './chghost.js';
|
|
48
48
|
export type { ChgHostInput } from './chghost.js';
|
|
49
|
+
export { emitAccountNotify } from './account-notify.js';
|
|
50
|
+
export type { AccountNotifyInput } from './account-notify.js';
|
|
49
51
|
export { awayReducer } from './away.js';
|
|
50
52
|
export { setnameReducer } from './setname.js';
|
|
51
53
|
export { authenticateReducer } from './sasl.js';
|
|
@@ -56,6 +58,18 @@ export {
|
|
|
56
58
|
CHATHISTORY_CAP,
|
|
57
59
|
} from './chathistory.js';
|
|
58
60
|
export { tagmsgChannelReducer, tagmsgUserReducer } from './tagmsg.js';
|
|
61
|
+
export {
|
|
62
|
+
DEFAULT_MULTILINE_MAX_BYTES,
|
|
63
|
+
MULTILINE_CAP,
|
|
64
|
+
type FinalizeMultilineResult,
|
|
65
|
+
type MultilineEntry,
|
|
66
|
+
type MultilineFanout,
|
|
67
|
+
buildMultilineFanout,
|
|
68
|
+
finalizeMultilineBatch,
|
|
69
|
+
joinMultiline,
|
|
70
|
+
multilineByteLength,
|
|
71
|
+
recordMultilineMessage,
|
|
72
|
+
} from './multiline.js';
|
|
59
73
|
export {
|
|
60
74
|
versionReducer,
|
|
61
75
|
timeReducer,
|
|
@@ -64,3 +78,23 @@ export {
|
|
|
64
78
|
} from './server-info.js';
|
|
65
79
|
export { userhostReducer, formatUserhostReply } from './userhost.js';
|
|
66
80
|
export { isonReducer } from './ison.js';
|
|
81
|
+
export {
|
|
82
|
+
monitorReducer,
|
|
83
|
+
MONITOR_CAP,
|
|
84
|
+
DEFAULT_MONITOR_LIMIT,
|
|
85
|
+
} from './monitor.js';
|
|
86
|
+
export {
|
|
87
|
+
READ_MARKER_CAP,
|
|
88
|
+
READ_MARKER_TAG,
|
|
89
|
+
isReadMarkerTagmsg,
|
|
90
|
+
persistReadMarker,
|
|
91
|
+
readMarkerMsgid,
|
|
92
|
+
seedReadMarkers,
|
|
93
|
+
} from './read-marker.js';
|
|
94
|
+
export {
|
|
95
|
+
PRE_AWAY_CAP,
|
|
96
|
+
clearPersistedAway,
|
|
97
|
+
nowAwayLine,
|
|
98
|
+
persistAway,
|
|
99
|
+
replayPersistedAway,
|
|
100
|
+
} from './pre-away.js';
|
|
@@ -15,8 +15,10 @@
|
|
|
15
15
|
* per-deployment (PLAN §10 capacity planning).
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
+
import { DEFAULT_MULTILINE_MAX_BYTES } from '../caps/capabilities.js';
|
|
18
19
|
import type { RawLine } from '../effects.js';
|
|
19
20
|
import type { ServerConfig } from '../types.js';
|
|
21
|
+
import { DEFAULT_MONITOR_LIMIT } from './monitor.js';
|
|
20
22
|
|
|
21
23
|
/** Max bytes per IRC wire line, including trailing `\r\n`. */
|
|
22
24
|
const LINE_MAX_BYTES = 510;
|
|
@@ -36,6 +38,32 @@ const CHANMODES = {
|
|
|
36
38
|
/** Channel-user prefix sigils, in PREFIX order (highest rank first). */
|
|
37
39
|
const PREFIX = '(ov)@+';
|
|
38
40
|
|
|
41
|
+
/**
|
|
42
|
+
* STATUSMSG delivery prefixes — the channel-user sigils a sender may prefix
|
|
43
|
+
* a target with (`@#channel` / `+#channel`) so only that rank and above
|
|
44
|
+
* receive the message. Mirrors the PREFIX sigil tail.
|
|
45
|
+
*/
|
|
46
|
+
const STATUSMSG_PREFIXES = '@+';
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Default extended-ban prefix when {@link ServerConfig.extbanPrefix} is
|
|
50
|
+
* unset. `~` is the de-facto IRC standard (matches nearly every daemon).
|
|
51
|
+
*/
|
|
52
|
+
const DEFAULT_EXTBAN_PREFIX = '~';
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Extended-ban types this server understands independent of services.
|
|
56
|
+
* `q` = quiet ban (prevents matching targets from speaking).
|
|
57
|
+
*/
|
|
58
|
+
const EXTBAN_TYPES = 'q';
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Account-name extended-ban type, advertised as a dedicated `ACCOUNTEXTBAN=a`
|
|
62
|
+
* token only when services are enabled, since account bans require registered
|
|
63
|
+
* nicks to be meaningful.
|
|
64
|
+
*/
|
|
65
|
+
const ACCOUNTEXTBAN_TYPE = 'a';
|
|
66
|
+
|
|
39
67
|
/** Channel name sigils this server recognises. */
|
|
40
68
|
const CHANTYPES = '#';
|
|
41
69
|
|
|
@@ -56,6 +84,9 @@ const TRAILER = 'are supported by this server';
|
|
|
56
84
|
* can diff between servers. Tokens are space-separated, never empty.
|
|
57
85
|
*/
|
|
58
86
|
function buildTokens(cfg: ServerConfig): string[] {
|
|
87
|
+
const monitorLimit = cfg.monitorLimit ?? DEFAULT_MONITOR_LIMIT;
|
|
88
|
+
const extbanPrefix = cfg.extbanPrefix ?? DEFAULT_EXTBAN_PREFIX;
|
|
89
|
+
const multilineMax = cfg.multilineMaxBytes ?? DEFAULT_MULTILINE_MAX_BYTES;
|
|
59
90
|
return [
|
|
60
91
|
`CHANTYPES=${CHANTYPES}`,
|
|
61
92
|
`CHANMODES=${CHANMODES.list},${CHANMODES.paramSet},${CHANMODES.paramAlways},${CHANMODES.boolean}`,
|
|
@@ -73,6 +104,28 @@ function buildTokens(cfg: ServerConfig): string[] {
|
|
|
73
104
|
// per-connection opt-in). LIST is always non-destructive here so the
|
|
74
105
|
// token is unconditional.
|
|
75
106
|
'SAFELIST',
|
|
107
|
+
// IRCv3 MONITOR cap ceiling. The value matches the per-connection
|
|
108
|
+
// watchlist size enforced by the monitor reducer; deployments lower it
|
|
109
|
+
// via `serverConfig.monitorLimit`.
|
|
110
|
+
`MONITOR=${monitorLimit}`,
|
|
111
|
+
// STATUSMSG — prefixes a sender may target (`@#chan`/`+#chan`) for
|
|
112
|
+
// rank-scoped delivery. The sigil set mirrors PREFIX.
|
|
113
|
+
`STATUSMSG=${STATUSMSG_PREFIXES}`,
|
|
114
|
+
// EXTBAN — extended-ban prefix and the base type list (`q` = quiet).
|
|
115
|
+
// The prefix is config-derived. The account-name extban is advertised
|
|
116
|
+
// as a separate `ACCOUNTEXTBAN=a` token (below) when services ship.
|
|
117
|
+
`EXTBAN=${extbanPrefix},${EXTBAN_TYPES}`,
|
|
118
|
+
// draft/typing advertisement — the cap is always supported, so the
|
|
119
|
+
// token is unconditional. (Draft caps have no RFC ISUPPORT token; this
|
|
120
|
+
// is the local marker clients can key on.)
|
|
121
|
+
'TYPING',
|
|
122
|
+
// draft/multiline byte budget — mirrors the `draft/multiline=<n>` cap
|
|
123
|
+
// value so the ceiling is visible pre-cap-negotiation.
|
|
124
|
+
`MULTILINE=${multilineMax}`,
|
|
125
|
+
// ACCOUNTEXTBAN — account-name extended ban. Meaningful only once
|
|
126
|
+
// services register nicks, so suppressed until a deployment wires
|
|
127
|
+
// services up via `serverConfig.servicesEnabled`.
|
|
128
|
+
...(cfg.servicesEnabled ? [`ACCOUNTEXTBAN=${ACCOUNTEXTBAN_TYPE}`] : []),
|
|
76
129
|
];
|
|
77
130
|
}
|
|
78
131
|
|
|
@@ -528,7 +528,7 @@ const USER_MODE_LETTERS: Readonly<Record<string, keyof ConnectionState['userMode
|
|
|
528
528
|
};
|
|
529
529
|
|
|
530
530
|
/** User-mode letters in canonical 221 output order. */
|
|
531
|
-
const USER_MODE_LETTER_ORDER = ['i', 'w', 's', 'o'] as const;
|
|
531
|
+
const USER_MODE_LETTER_ORDER = ['i', 'w', 's', 'o', 'S'] as const;
|
|
532
532
|
|
|
533
533
|
/**
|
|
534
534
|
* Handles `MODE <nick> [<modestring>]`.
|
|
@@ -640,6 +640,13 @@ export const userModeReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
|
|
|
640
640
|
function buildUserModeString(state: ConnectionState): string {
|
|
641
641
|
let modeStr = '+';
|
|
642
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
|
+
}
|
|
643
650
|
const field = USER_MODE_LETTERS[letter];
|
|
644
651
|
if (field !== undefined && state.userModes[field]) modeStr += letter;
|
|
645
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
|
+
}
|