serverless-ircd 0.1.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 +47 -0
- package/.github/workflows/deploy-cf.yml +97 -0
- package/LICENSE +29 -0
- package/README.md +323 -0
- package/apps/cf-worker/package.json +37 -0
- package/apps/cf-worker/scripts/smoke.mjs +175 -0
- package/apps/cf-worker/src/worker.ts +59 -0
- package/apps/cf-worker/tests/smoke.test.ts +150 -0
- package/apps/cf-worker/tsconfig.build.json +17 -0
- package/apps/cf-worker/tsconfig.test.json +18 -0
- package/apps/cf-worker/vitest.config.ts +31 -0
- package/apps/cf-worker/wrangler.test.toml +33 -0
- package/apps/cf-worker/wrangler.toml +98 -0
- package/apps/local-cli/package.json +35 -0
- package/apps/local-cli/src/line-scanner.ts +60 -0
- package/apps/local-cli/src/main.ts +145 -0
- package/apps/local-cli/src/motd-file.ts +45 -0
- package/apps/local-cli/src/server.ts +409 -0
- package/apps/local-cli/tests/e2e.test.ts +346 -0
- package/apps/local-cli/tests/id-factory.test.ts +25 -0
- package/apps/local-cli/tests/line-scanner.test.ts +81 -0
- package/apps/local-cli/tests/motd-file.test.ts +71 -0
- package/apps/local-cli/tests/tcp.test.ts +358 -0
- package/apps/local-cli/tsconfig.build.json +17 -0
- package/apps/local-cli/tsconfig.test.json +15 -0
- package/apps/local-cli/vitest.config.ts +24 -0
- package/biome.json +52 -0
- package/package.json +35 -0
- package/packages/cf-adapter/package.json +38 -0
- package/packages/cf-adapter/src/cf-runtime.ts +308 -0
- package/packages/cf-adapter/src/channel-do.ts +374 -0
- package/packages/cf-adapter/src/connection-do.ts +542 -0
- package/packages/cf-adapter/src/env.ts +67 -0
- package/packages/cf-adapter/src/index.ts +38 -0
- package/packages/cf-adapter/src/registry-do.ts +130 -0
- package/packages/cf-adapter/src/serialize.ts +142 -0
- package/packages/cf-adapter/src/sharding.ts +101 -0
- package/packages/cf-adapter/tests/cf-harness.ts +264 -0
- package/packages/cf-adapter/tests/cf-integration.test.ts +73 -0
- package/packages/cf-adapter/tests/cf-runtime.test.ts +527 -0
- package/packages/cf-adapter/tests/channel-do.test.ts +480 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +329 -0
- package/packages/cf-adapter/tests/integration/harness.test.ts +229 -0
- package/packages/cf-adapter/tests/integration/harness.ts +102 -0
- package/packages/cf-adapter/tests/integration/in-memory-harness.ts +242 -0
- package/packages/cf-adapter/tests/integration/index.ts +17 -0
- package/packages/cf-adapter/tests/integration/scenarios.test.ts +20 -0
- package/packages/cf-adapter/tests/integration/scenarios.ts +495 -0
- package/packages/cf-adapter/tests/registry-do.test.ts +335 -0
- package/packages/cf-adapter/tests/sharding.test.ts +100 -0
- package/packages/cf-adapter/tests/worker/main.ts +33 -0
- package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +50 -0
- package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +57 -0
- package/packages/cf-adapter/tsconfig.build.json +16 -0
- package/packages/cf-adapter/tsconfig.test.json +18 -0
- package/packages/cf-adapter/vitest.config.ts +32 -0
- package/packages/cf-adapter/wrangler.test.toml +50 -0
- package/packages/in-memory-runtime/package.json +29 -0
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +245 -0
- package/packages/in-memory-runtime/src/index.ts +9 -0
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +502 -0
- package/packages/in-memory-runtime/tsconfig.build.json +16 -0
- package/packages/in-memory-runtime/tsconfig.test.json +14 -0
- package/packages/in-memory-runtime/vitest.config.ts +21 -0
- package/packages/irc-core/package.json +29 -0
- package/packages/irc-core/src/caps/capabilities.ts +96 -0
- package/packages/irc-core/src/caps/index.ts +1 -0
- package/packages/irc-core/src/commands/away.ts +82 -0
- package/packages/irc-core/src/commands/cap.ts +257 -0
- package/packages/irc-core/src/commands/chghost.ts +30 -0
- package/packages/irc-core/src/commands/index.ts +40 -0
- package/packages/irc-core/src/commands/invite.ts +156 -0
- package/packages/irc-core/src/commands/isupport.ts +133 -0
- package/packages/irc-core/src/commands/join.ts +309 -0
- package/packages/irc-core/src/commands/kick.ts +162 -0
- package/packages/irc-core/src/commands/list.ts +126 -0
- package/packages/irc-core/src/commands/mode.ts +655 -0
- package/packages/irc-core/src/commands/motd.ts +146 -0
- package/packages/irc-core/src/commands/names.ts +164 -0
- package/packages/irc-core/src/commands/part.ts +118 -0
- package/packages/irc-core/src/commands/ping.ts +47 -0
- package/packages/irc-core/src/commands/privmsg.ts +256 -0
- package/packages/irc-core/src/commands/quit.ts +70 -0
- package/packages/irc-core/src/commands/registration.ts +251 -0
- package/packages/irc-core/src/commands/topic.ts +171 -0
- package/packages/irc-core/src/commands/who.ts +169 -0
- package/packages/irc-core/src/commands/whois.ts +165 -0
- package/packages/irc-core/src/effects.ts +184 -0
- package/packages/irc-core/src/index.ts +20 -0
- package/packages/irc-core/src/ports.ts +153 -0
- package/packages/irc-core/src/protocol/batch.ts +85 -0
- package/packages/irc-core/src/protocol/index.ts +18 -0
- package/packages/irc-core/src/protocol/messages.ts +36 -0
- package/packages/irc-core/src/protocol/numerics.ts +155 -0
- package/packages/irc-core/src/protocol/outbound.ts +145 -0
- package/packages/irc-core/src/protocol/parser.ts +175 -0
- package/packages/irc-core/src/protocol/serializer.ts +71 -0
- package/packages/irc-core/src/state/channel.ts +293 -0
- package/packages/irc-core/src/state/connection.ts +153 -0
- package/packages/irc-core/src/state/index.ts +25 -0
- package/packages/irc-core/src/state/registry.ts +22 -0
- package/packages/irc-core/src/types.ts +83 -0
- package/packages/irc-core/tests/batch.test.ts +79 -0
- package/packages/irc-core/tests/caps/capabilities.test.ts +148 -0
- package/packages/irc-core/tests/commands/away.test.ts +233 -0
- package/packages/irc-core/tests/commands/batch.test.ts +178 -0
- package/packages/irc-core/tests/commands/cap.test.ts +499 -0
- package/packages/irc-core/tests/commands/chghost.test.ts +186 -0
- package/packages/irc-core/tests/commands/echo-message.test.ts +212 -0
- package/packages/irc-core/tests/commands/extended-join.test.ts +147 -0
- package/packages/irc-core/tests/commands/invite-notify.test.ts +160 -0
- package/packages/irc-core/tests/commands/invite.test.ts +321 -0
- package/packages/irc-core/tests/commands/isupport.test.ts +209 -0
- package/packages/irc-core/tests/commands/join.test.ts +687 -0
- package/packages/irc-core/tests/commands/kick.test.ts +316 -0
- package/packages/irc-core/tests/commands/list.test.ts +452 -0
- package/packages/irc-core/tests/commands/mode.test.ts +1048 -0
- package/packages/irc-core/tests/commands/motd.test.ts +322 -0
- package/packages/irc-core/tests/commands/names.test.ts +342 -0
- package/packages/irc-core/tests/commands/part.test.ts +265 -0
- package/packages/irc-core/tests/commands/ping.test.ts +144 -0
- package/packages/irc-core/tests/commands/privmsg.test.ts +665 -0
- package/packages/irc-core/tests/commands/quit.test.ts +220 -0
- package/packages/irc-core/tests/commands/registration.test.ts +599 -0
- package/packages/irc-core/tests/commands/topic.test.ts +337 -0
- package/packages/irc-core/tests/commands/who.test.ts +441 -0
- package/packages/irc-core/tests/commands/whois.test.ts +422 -0
- package/packages/irc-core/tests/effects.test.ts +147 -0
- package/packages/irc-core/tests/message-tags.test.ts +182 -0
- package/packages/irc-core/tests/numerics.test.ts +98 -0
- package/packages/irc-core/tests/outbound.test.ts +232 -0
- package/packages/irc-core/tests/parser.test.ts +162 -0
- package/packages/irc-core/tests/ports.test.ts +101 -0
- package/packages/irc-core/tests/serializer.test.ts +164 -0
- package/packages/irc-core/tests/state/channel.test.ts +351 -0
- package/packages/irc-core/tests/state/connection.test.ts +122 -0
- package/packages/irc-core/tests/state/registry.test.ts +20 -0
- package/packages/irc-core/tests/types.test.ts +127 -0
- package/packages/irc-core/tsconfig.build.json +12 -0
- package/packages/irc-core/tsconfig.test.json +10 -0
- package/packages/irc-core/vitest.config.ts +21 -0
- package/packages/irc-server/package.json +28 -0
- package/packages/irc-server/src/actor.ts +449 -0
- package/packages/irc-server/src/dispatch.ts +120 -0
- package/packages/irc-server/src/index.ts +11 -0
- package/packages/irc-server/src/runtime.ts +61 -0
- package/packages/irc-server/tests/actor.test.ts +816 -0
- package/packages/irc-server/tests/dispatch.test.ts +512 -0
- package/packages/irc-server/tests/runtime.test.ts +52 -0
- package/packages/irc-server/tsconfig.build.json +13 -0
- package/packages/irc-server/tsconfig.test.json +11 -0
- package/packages/irc-server/vitest.config.ts +21 -0
- package/pnpm-workspace.yaml +4 -0
- package/tools/tcp-ws-forwarder/package.json +32 -0
- package/tools/tcp-ws-forwarder/src/forwarder.ts +244 -0
- package/tools/tcp-ws-forwarder/src/line-scanner.ts +55 -0
- package/tools/tcp-ws-forwarder/src/main.ts +114 -0
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +618 -0
- package/tools/tcp-ws-forwarder/tests/framing.test.ts +51 -0
- package/tools/tcp-ws-forwarder/tests/line-scanner.test.ts +75 -0
- package/tools/tcp-ws-forwarder/tsconfig.build.json +12 -0
- package/tools/tcp-ws-forwarder/tsconfig.test.json +10 -0
- package/tools/tcp-ws-forwarder/vitest.config.ts +27 -0
- package/tsconfig.base.json +25 -0
- package/turbo.json +29 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure reducers for the IRC `PRIVMSG` and `NOTICE` commands.
|
|
3
|
+
*
|
|
4
|
+
* PLAN §2.1 location-of-authority splits these by target type:
|
|
5
|
+
*
|
|
6
|
+
* - **Channel target** (`PRIVMSG #foo :text`): the channel is authoritative
|
|
7
|
+
* for `+n`/`+m`/`+b`, so the reducer's `state` is the channel's
|
|
8
|
+
* {@link ChannelState}. Sender identity is `ctx.connection`. Emits a
|
|
9
|
+
* single `Broadcast` effect (excluding the sender; `echo-message` echo
|
|
10
|
+
* handled elsewhere).
|
|
11
|
+
* - **User target** (`PRIVMSG bob :text`): the connection is authoritative
|
|
12
|
+
* for the sender's identity, so the reducer's `state` is the sender's
|
|
13
|
+
* {@link ConnectionState}. Emits a `SendToNick` effect that the dispatch
|
|
14
|
+
* layer resolves via `lookupNick`; if the recipient is offline the
|
|
15
|
+
* optional `notFoundLines` (PRIVMSG only) carries the `401` back to the
|
|
16
|
+
* sender.
|
|
17
|
+
*
|
|
18
|
+
* Multi-target commands (`PRIVMSG #a,#b` / `PRIVMSG a,b`) are split by the
|
|
19
|
+
* actor layer, which also enforces `maxTargetsPerCommand`;
|
|
20
|
+
* each reducer handles exactly one target per invocation.
|
|
21
|
+
*
|
|
22
|
+
* RFC-critical: **`NOTICE` MUST NEVER produce a numeric error reply.** All
|
|
23
|
+
* rejection paths short-circuit with an empty effect list for NOTICE.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { Effect } from '../effects.js';
|
|
27
|
+
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
28
|
+
import { Numerics } from '../protocol/numerics.js';
|
|
29
|
+
import type { ChannelState } from '../state/channel.js';
|
|
30
|
+
import { hostmaskOf } from '../state/connection.js';
|
|
31
|
+
import type { ConnectionState } from '../state/connection.js';
|
|
32
|
+
import type { Ctx, Reducer } from '../types.js';
|
|
33
|
+
|
|
34
|
+
/** Channel-name grammar; mirrors {@link isValidChannelName} in `join.ts`. */
|
|
35
|
+
const CHANNEL_NAME_RE = /^[#&][^\s,:]+$/u;
|
|
36
|
+
|
|
37
|
+
/** Returns true iff `name` looks like a channel target (vs. a nick target). */
|
|
38
|
+
function isChannelTarget(name: string): boolean {
|
|
39
|
+
return CHANNEL_NAME_RE.test(name);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Returns true iff `hostmask` matches the IRC ban mask `mask`. Identical to
|
|
44
|
+
* the implementation in `join.ts`; duplicated to keep the channel-message
|
|
45
|
+
* path self-contained (ban-matching consolidation is planned).
|
|
46
|
+
*/
|
|
47
|
+
function matchesBanMask(mask: string, hostmask: string): boolean {
|
|
48
|
+
let pattern = '^';
|
|
49
|
+
for (const ch of mask) {
|
|
50
|
+
if (ch === '*') {
|
|
51
|
+
pattern += '.*';
|
|
52
|
+
} else if (ch === '?') {
|
|
53
|
+
pattern += '.';
|
|
54
|
+
} else if (/[A-Za-z0-9]/.test(ch)) {
|
|
55
|
+
pattern += ch;
|
|
56
|
+
} else {
|
|
57
|
+
pattern += `\\${ch}`;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
pattern += '$';
|
|
61
|
+
return new RegExp(pattern, 'i').test(hostmask);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Formats a numeric error line addressed to the connection's current nick
|
|
66
|
+
* (or `*` when unregistered).
|
|
67
|
+
*/
|
|
68
|
+
function numericErr(ctx: Ctx, code: number, trailing: string, middle?: string): RawLine {
|
|
69
|
+
const nick = ctx.connection.nick ?? '*';
|
|
70
|
+
const codeStr = code.toString().padStart(3, '0');
|
|
71
|
+
const parts = [`:${ctx.serverName}`, codeStr, nick];
|
|
72
|
+
if (middle !== undefined) parts.push(middle);
|
|
73
|
+
parts.push(`:${trailing}`);
|
|
74
|
+
return { text: parts.join(' ') };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Resolves the sender's hostmask, falling back to nick then `?`. */
|
|
78
|
+
function senderHostmask(conn: ConnectionState): string {
|
|
79
|
+
return hostmaskOf(conn) ?? conn.nick ?? '?';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Returns true iff `entry` may speak on a moderated channel: ops and voiced
|
|
84
|
+
* members pass; everyone else is blocked.
|
|
85
|
+
*/
|
|
86
|
+
function maySpeakOnModerated(entry: { op: boolean; voice: boolean } | undefined): boolean {
|
|
87
|
+
if (entry === undefined) return false;
|
|
88
|
+
return entry.op || entry.voice;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Command flavor: PRIVMSG surfaces errors; NOTICE is silent. */
|
|
92
|
+
type MessageCommand = 'PRIVMSG' | 'NOTICE';
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Builds a channel-target reducer for one of PRIVMSG / NOTICE. The shared
|
|
96
|
+
* body validates params, checks `+n` / `+m` / `+b`, and emits `Broadcast`.
|
|
97
|
+
*/
|
|
98
|
+
function buildChannelReducer(command: MessageCommand): Reducer<ChannelState> {
|
|
99
|
+
return (state, msg, ctx) => {
|
|
100
|
+
ctx.connection.lastSeen = ctx.clock.now();
|
|
101
|
+
|
|
102
|
+
const effects: EffectType[] = [];
|
|
103
|
+
const silent = command === 'NOTICE';
|
|
104
|
+
|
|
105
|
+
const target = msg.params[0];
|
|
106
|
+
const text = msg.params[1];
|
|
107
|
+
|
|
108
|
+
if (target === undefined) {
|
|
109
|
+
if (!silent) {
|
|
110
|
+
effects.push(
|
|
111
|
+
Effect.send(ctx.connId, [
|
|
112
|
+
numericErr(ctx, Numerics.ERR_NORECIPIENT, `No recipient given (${command})`),
|
|
113
|
+
]),
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
return { state, effects };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (text === undefined || text.length === 0) {
|
|
120
|
+
if (!silent) {
|
|
121
|
+
effects.push(
|
|
122
|
+
Effect.send(ctx.connId, [numericErr(ctx, Numerics.ERR_NOTEXTTOSEND, 'No text to send')]),
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
return { state, effects };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const senderEntry = state.members.get(ctx.connId);
|
|
129
|
+
const hostmask = senderHostmask(ctx.connection);
|
|
130
|
+
|
|
131
|
+
// +n (no external messages): sender must be a member.
|
|
132
|
+
if (state.modes.noExternal && senderEntry === undefined) {
|
|
133
|
+
if (!silent) {
|
|
134
|
+
effects.push(
|
|
135
|
+
Effect.send(ctx.connId, [
|
|
136
|
+
numericErr(ctx, Numerics.ERR_CANNOTSENDTOCHAN, 'Cannot send to channel', state.name),
|
|
137
|
+
]),
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
return { state, effects };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// +m (moderated): sender must be op or voiced.
|
|
144
|
+
if (state.modes.moderated && !maySpeakOnModerated(senderEntry)) {
|
|
145
|
+
if (!silent) {
|
|
146
|
+
effects.push(
|
|
147
|
+
Effect.send(ctx.connId, [
|
|
148
|
+
numericErr(ctx, Numerics.ERR_CANNOTSENDTOCHAN, 'Cannot send to channel', state.name),
|
|
149
|
+
]),
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
return { state, effects };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// +b (ban): sender's hostmask must not match any ban mask.
|
|
156
|
+
if (state.banMasks.size > 0) {
|
|
157
|
+
for (const mask of state.banMasks) {
|
|
158
|
+
if (matchesBanMask(mask, hostmask)) {
|
|
159
|
+
if (!silent) {
|
|
160
|
+
effects.push(
|
|
161
|
+
Effect.send(ctx.connId, [
|
|
162
|
+
numericErr(
|
|
163
|
+
ctx,
|
|
164
|
+
Numerics.ERR_CANNOTSENDTOCHAN,
|
|
165
|
+
'Cannot send to channel',
|
|
166
|
+
state.name,
|
|
167
|
+
),
|
|
168
|
+
]),
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
return { state, effects };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// All checks passed: broadcast to the channel, excluding the sender.
|
|
177
|
+
const line: RawLine = { text: `:${hostmask} ${command} ${state.name} :${text}` };
|
|
178
|
+
effects.push(Effect.broadcast(state.name, [line], ctx.connId));
|
|
179
|
+
|
|
180
|
+
// IRCv3 echo-message: echo the same line back to the sender, after
|
|
181
|
+
// the broadcast effect so clients observe their own message last.
|
|
182
|
+
if (ctx.connection.caps.has('echo-message')) {
|
|
183
|
+
effects.push(Effect.send(ctx.connId, [line]));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return { state, effects };
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Builds a user-target reducer for one of PRIVMSG / NOTICE. The shared body
|
|
192
|
+
* validates params and emits a `SendToNick` effect. PRIVMSG attaches a
|
|
193
|
+
* `401` fallback for offline recipients; NOTICE omits the fallback entirely.
|
|
194
|
+
*/
|
|
195
|
+
function buildUserReducer(command: MessageCommand): Reducer<ConnectionState> {
|
|
196
|
+
return (state, msg, ctx) => {
|
|
197
|
+
state.lastSeen = ctx.clock.now();
|
|
198
|
+
|
|
199
|
+
const effects: EffectType[] = [];
|
|
200
|
+
const silent = command === 'NOTICE';
|
|
201
|
+
|
|
202
|
+
const target = msg.params[0];
|
|
203
|
+
const text = msg.params[1];
|
|
204
|
+
|
|
205
|
+
if (target === undefined) {
|
|
206
|
+
if (!silent) {
|
|
207
|
+
effects.push(
|
|
208
|
+
Effect.send(ctx.connId, [
|
|
209
|
+
numericErr(ctx, Numerics.ERR_NORECIPIENT, `No recipient given (${command})`),
|
|
210
|
+
]),
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
return { state, effects };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (text === undefined || text.length === 0) {
|
|
217
|
+
if (!silent) {
|
|
218
|
+
effects.push(
|
|
219
|
+
Effect.send(ctx.connId, [numericErr(ctx, Numerics.ERR_NOTEXTTOSEND, 'No text to send')]),
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
return { state, effects };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const hostmask = senderHostmask(state);
|
|
226
|
+
const line: RawLine = { text: `:${hostmask} ${command} ${target} :${text}` };
|
|
227
|
+
const notFoundLines = silent
|
|
228
|
+
? undefined
|
|
229
|
+
: [numericErr(ctx, Numerics.ERR_NOSUCHNICK, 'No such nick/channel', target)];
|
|
230
|
+
|
|
231
|
+
effects.push(Effect.sendToNick(target, ctx.connId, [line], notFoundLines));
|
|
232
|
+
|
|
233
|
+
// IRCv3 echo-message: echo the same line back to the sender, after
|
|
234
|
+
// the SendToNick effect so clients observe their own message last.
|
|
235
|
+
if (ctx.connection.caps.has('echo-message')) {
|
|
236
|
+
effects.push(Effect.send(ctx.connId, [line]));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return { state, effects };
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Reducer for `PRIVMSG <channel> :<text>`. Authority: ChannelState. */
|
|
244
|
+
export const privmsgChannelReducer: Reducer<ChannelState> = buildChannelReducer('PRIVMSG');
|
|
245
|
+
|
|
246
|
+
/** Reducer for `NOTICE <channel> :<text>`. Authority: ChannelState. */
|
|
247
|
+
export const noticeChannelReducer: Reducer<ChannelState> = buildChannelReducer('NOTICE');
|
|
248
|
+
|
|
249
|
+
/** Reducer for `PRIVMSG <nick> :<text>`. Authority: sender's ConnectionState. */
|
|
250
|
+
export const privmsgUserReducer: Reducer<ConnectionState> = buildUserReducer('PRIVMSG');
|
|
251
|
+
|
|
252
|
+
/** Reducer for `NOTICE <nick> :<text>`. Authority: sender's ConnectionState. */
|
|
253
|
+
export const noticeUserReducer: Reducer<ConnectionState> = buildUserReducer('NOTICE');
|
|
254
|
+
|
|
255
|
+
/** Exported for the actor layer so it knows which reducer a target string routes to. */
|
|
256
|
+
export { isChannelTarget };
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure reducer for the IRC `QUIT` command.
|
|
3
|
+
*
|
|
4
|
+
* Per PLAN §2.1 this runs in the Connection entity. It does not own the
|
|
5
|
+
* channel rosters or the nick registry, so every mutation is expressed as
|
|
6
|
+
* an effect the runtime interprets:
|
|
7
|
+
*
|
|
8
|
+
* - `Broadcast(QUIT)` to each joined channel (excluding the quitter,
|
|
9
|
+
* who is being disconnected anyway).
|
|
10
|
+
* - `ApplyChannelDelta` removing the connection from each channel roster.
|
|
11
|
+
* - `ReleaseNick` for the reserved nick (only if registered).
|
|
12
|
+
* - `Disconnect` to close the transport.
|
|
13
|
+
*
|
|
14
|
+
* The reducer is intentionally idempotent for never-registered connections:
|
|
15
|
+
* with no nick and no joined channels there is nothing to broadcast or
|
|
16
|
+
* release, so the only effect is `Disconnect`.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { Effect } from '../effects.js';
|
|
20
|
+
import type { RawLine } from '../effects.js';
|
|
21
|
+
import type { ChanName } from '../state/channel.js';
|
|
22
|
+
import { hostmaskOf } from '../state/connection.js';
|
|
23
|
+
import type { ConnectionState } from '../state/connection.js';
|
|
24
|
+
import type { Reducer } from '../types.js';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Handles `QUIT [:reason]`.
|
|
28
|
+
*
|
|
29
|
+
* Reason resolution: a non-empty `params[0]` is used verbatim; anything
|
|
30
|
+
* else (no params, or an explicit empty trailing) falls back to
|
|
31
|
+
* `ctx.serverConfig.quitMessage`.
|
|
32
|
+
*/
|
|
33
|
+
export const quitReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
|
|
34
|
+
state.lastSeen = ctx.clock.now();
|
|
35
|
+
|
|
36
|
+
const effects = [];
|
|
37
|
+
|
|
38
|
+
const nick = state.nick;
|
|
39
|
+
if (nick !== undefined) {
|
|
40
|
+
const hostmask = hostmaskOf(state);
|
|
41
|
+
const reason = pickReason(msg.params, ctx.serverConfig.quitMessage);
|
|
42
|
+
const line: RawLine = { text: `:${hostmask} QUIT :${reason}` };
|
|
43
|
+
|
|
44
|
+
for (const chan of state.joinedChannels) {
|
|
45
|
+
effects.push(Effect.broadcast(chan as ChanName, [line], ctx.connId));
|
|
46
|
+
}
|
|
47
|
+
for (const chan of state.joinedChannels) {
|
|
48
|
+
effects.push(
|
|
49
|
+
Effect.applyChannelDelta(chan as ChanName, {
|
|
50
|
+
memberships: [{ type: 'remove', conn: ctx.connId }],
|
|
51
|
+
}),
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
effects.push(Effect.releaseNick(nick));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
effects.push(Effect.disconnect(ctx.connId));
|
|
58
|
+
|
|
59
|
+
return { state, effects };
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Returns the provided reason if it is a non-empty string, otherwise the
|
|
64
|
+
* configured default. Treats `QUIT` and `QUIT :` identically (per modern
|
|
65
|
+
* client behavior).
|
|
66
|
+
*/
|
|
67
|
+
function pickReason(params: string[], fallback: string): string {
|
|
68
|
+
const supplied = params[0];
|
|
69
|
+
return supplied && supplied.length > 0 ? supplied : fallback;
|
|
70
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure reducers for the IRC registration handshake: `NICK`, `USER`, `PASS`.
|
|
3
|
+
*
|
|
4
|
+
* PLAN §2.1 location-of-authority: registration runs in the Connection
|
|
5
|
+
* entity. These reducers handle the pre-`CAP` handshake (CAP negotiation
|
|
6
|
+
* itself arrives in the CAP layer and calls back into {@link emitWelcomeIfReady}
|
|
7
|
+
* on `CAP END`).
|
|
8
|
+
*
|
|
9
|
+
* Nick collision (`433 ERR_NICKNAMEINUSE`) is detected by the Registry
|
|
10
|
+
* entity when it interprets the `ReserveNick` / `ChangeNick` effect; the
|
|
11
|
+
* reducer cannot observe that purely and so emits the reservation
|
|
12
|
+
* optimistically. The actor layer surfaces the collision
|
|
13
|
+
* numeric on `{ ok: false }`.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { Effect } from '../effects.js';
|
|
17
|
+
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
18
|
+
import { Numerics } from '../protocol/numerics.js';
|
|
19
|
+
import { hostmaskOf } from '../state/connection.js';
|
|
20
|
+
import type { ConnectionState } from '../state/connection.js';
|
|
21
|
+
import type { Ctx, Reducer } from '../types.js';
|
|
22
|
+
import { generateIsupport } from './isupport.js';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Server version reported in `002` / `004`. Placeholder until a config-driven
|
|
26
|
+
* version lands.
|
|
27
|
+
*/
|
|
28
|
+
const SERVER_VERSION = '0.1.0';
|
|
29
|
+
|
|
30
|
+
/** "Created" text for `003 RPL_CREATED`. Placeholder. */
|
|
31
|
+
const CREATED_TEXT = 'Phase 1';
|
|
32
|
+
|
|
33
|
+
/** User mode letters for `004 RPL_MYINFO` (invisible, oper, wallops, server-notices). */
|
|
34
|
+
const USER_MODES_004 = 'iosw';
|
|
35
|
+
|
|
36
|
+
/** Channel mode letters for `004 RPL_MYINFO` (matches PLAN §3 channel mode set). */
|
|
37
|
+
const CHANNEL_MODES_004 = 'biklmnstp';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* RFC 1459/2812 nickname grammar. First char must be a letter; subsequent
|
|
41
|
+
* chars may be letters, digits, underscore, hyphen, or the "special" set
|
|
42
|
+
* `[]\`^{|}`.
|
|
43
|
+
*/
|
|
44
|
+
const NICK_GRAMMAR_RE = /^[A-Za-z][\w\-\[\]\\`^{|}]*$/u;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Returns true iff `nick` matches the IRC nick grammar and is within the
|
|
48
|
+
* configured length cap.
|
|
49
|
+
*/
|
|
50
|
+
export function isValidNick(nick: string, maxLen: number): boolean {
|
|
51
|
+
if (nick.length === 0 || nick.length > maxLen) return false;
|
|
52
|
+
return NICK_GRAMMAR_RE.test(nick);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Builds the welcome numeric block emitted once registration completes:
|
|
57
|
+
* `001`–`005` (with `005` as a placeholder) followed by
|
|
58
|
+
* the `375`/`372`/`376` MOTD placeholders (real MOTD content via the
|
|
59
|
+
* `MotdProvider` port).
|
|
60
|
+
*
|
|
61
|
+
* Caller guarantees `state.nick` and `state.user` are set.
|
|
62
|
+
*/
|
|
63
|
+
export function buildWelcomeLines(state: ConnectionState, ctx: Ctx): RawLine[] {
|
|
64
|
+
const prefix = `:${ctx.serverName}`;
|
|
65
|
+
const nick = state.nick;
|
|
66
|
+
if (nick === undefined) return [];
|
|
67
|
+
// hostmaskOf returns string|undefined, but after the guard above nick is
|
|
68
|
+
// always defined, so hostmaskOf always returns at least `nick`. The cast
|
|
69
|
+
// avoids a dead `??` branch that v8 coverage cannot satisfy.
|
|
70
|
+
const hostmask = hostmaskOf(state) as string;
|
|
71
|
+
|
|
72
|
+
return [
|
|
73
|
+
{ text: `${prefix} 001 ${nick} :Welcome to the ${ctx.networkName} IRC Network, ${hostmask}` },
|
|
74
|
+
{
|
|
75
|
+
text: `${prefix} 002 ${nick} :Your host is ${ctx.serverName}, running version ${SERVER_VERSION}`,
|
|
76
|
+
},
|
|
77
|
+
{ text: `${prefix} 003 ${nick} :This server was created in ${CREATED_TEXT}` },
|
|
78
|
+
{
|
|
79
|
+
text: `${prefix} 004 ${nick} ${ctx.serverName} ${SERVER_VERSION} ${USER_MODES_004} ${CHANNEL_MODES_004}`,
|
|
80
|
+
},
|
|
81
|
+
// 005 RPL_ISUPPORT — real tokens generated from serverConfig.
|
|
82
|
+
...generateIsupport(ctx.serverConfig, state.caps, nick),
|
|
83
|
+
// MOTD placeholders — filled via MotdProvider elsewhere.
|
|
84
|
+
{ text: `${prefix} 375 ${nick} :- ${ctx.serverName} Message of the day -` },
|
|
85
|
+
{ text: `${prefix} 372 ${nick} :- MOTD placeholder` },
|
|
86
|
+
{ text: `${prefix} 376 ${nick} :End of MOTD command` },
|
|
87
|
+
];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Attempts to finalize registration: if both `nick` and `user` are present
|
|
92
|
+
* and the connection is not yet registered, transitions `state.registration`
|
|
93
|
+
* to `'registered'` and returns a `Send` effect carrying the welcome block.
|
|
94
|
+
* Otherwise returns `null` and leaves state untouched.
|
|
95
|
+
*
|
|
96
|
+
* Reused by the CAP `END` handler.
|
|
97
|
+
*/
|
|
98
|
+
export function emitWelcomeIfReady(state: ConnectionState, ctx: Ctx): EffectType | null {
|
|
99
|
+
if (state.registration === 'registered') return null;
|
|
100
|
+
if (state.nick === undefined || state.user === undefined) return null;
|
|
101
|
+
if (state.capNegotiating) return null;
|
|
102
|
+
state.registration = 'registered';
|
|
103
|
+
return Effect.send(ctx.connId, buildWelcomeLines(state, ctx));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Formats a single numeric error line addressed to the connection's current
|
|
108
|
+
* nick (or `*` when unregistered). Optional `middle` param is emitted before
|
|
109
|
+
* the trailing `:`-prefixed text — used for `432 <badnick>` and
|
|
110
|
+
* `461 <command>`.
|
|
111
|
+
*/
|
|
112
|
+
function numericErr(ctx: Ctx, code: number, trailing: string, middle?: string): RawLine {
|
|
113
|
+
const nick = ctx.connection.nick ?? '*';
|
|
114
|
+
const codeStr = code.toString().padStart(3, '0');
|
|
115
|
+
const parts = [`:${ctx.serverName}`, codeStr, nick];
|
|
116
|
+
if (middle !== undefined) parts.push(middle);
|
|
117
|
+
parts.push(`:${trailing}`);
|
|
118
|
+
return { text: parts.join(' ') };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Reducer for `NICK <nickname>`.
|
|
123
|
+
*
|
|
124
|
+
* Pre-registration: validates grammar, stashes the nick on the connection,
|
|
125
|
+
* emits `ReserveNick`, and attempts to complete registration.
|
|
126
|
+
*
|
|
127
|
+
* Post-registration: validates grammar, emits `ChangeNick` plus the
|
|
128
|
+
* `:oldhostmask NICK newnick` echo back to the client.
|
|
129
|
+
*/
|
|
130
|
+
export const nickReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
|
|
131
|
+
const effects: EffectType[] = [];
|
|
132
|
+
const nick = msg.params[0];
|
|
133
|
+
|
|
134
|
+
if (nick === undefined) {
|
|
135
|
+
effects.push(
|
|
136
|
+
Effect.send(ctx.connId, [numericErr(ctx, Numerics.ERR_NONICKNAMEGIVEN, 'No nickname given')]),
|
|
137
|
+
);
|
|
138
|
+
return { state, effects };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (!isValidNick(nick, ctx.serverConfig.nickLen)) {
|
|
142
|
+
effects.push(
|
|
143
|
+
Effect.send(ctx.connId, [
|
|
144
|
+
numericErr(ctx, Numerics.ERR_ERRONEUSNICKNAME, 'Erroneous nickname', nick),
|
|
145
|
+
]),
|
|
146
|
+
);
|
|
147
|
+
return { state, effects };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (state.registration === 'registered') {
|
|
151
|
+
const oldNick = state.nick;
|
|
152
|
+
if (oldNick === undefined) {
|
|
153
|
+
return { state, effects };
|
|
154
|
+
}
|
|
155
|
+
// hostmaskOf is guaranteed non-undefined here because oldNick is defined.
|
|
156
|
+
const source = hostmaskOf(state) as string;
|
|
157
|
+
state.nick = nick;
|
|
158
|
+
effects.push(Effect.changeNick(ctx.connId, oldNick, nick));
|
|
159
|
+
effects.push(Effect.send(ctx.connId, [{ text: `:${source} NICK ${nick}` }]));
|
|
160
|
+
return { state, effects };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
state.nick = nick;
|
|
164
|
+
if (state.registration === 'pre-registration') {
|
|
165
|
+
state.registration = 'registering';
|
|
166
|
+
}
|
|
167
|
+
effects.push(Effect.reserveNick(nick, ctx.connId));
|
|
168
|
+
|
|
169
|
+
const welcome = emitWelcomeIfReady(state, ctx);
|
|
170
|
+
if (welcome !== null) effects.push(welcome);
|
|
171
|
+
|
|
172
|
+
return { state, effects };
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Reducer for `USER <user> <mode> <unused> :<realname>` (RFC 2812 form).
|
|
177
|
+
*
|
|
178
|
+
* Stores username (params[0]) and realname (params[3]); the historical
|
|
179
|
+
* mode/hostname/servername slots (params[1], params[2]) are ignored. If
|
|
180
|
+
* `NICK` has already been received, completes registration and emits the
|
|
181
|
+
* welcome block.
|
|
182
|
+
*/
|
|
183
|
+
export const userReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
|
|
184
|
+
const effects: EffectType[] = [];
|
|
185
|
+
|
|
186
|
+
if (state.registration === 'registered') {
|
|
187
|
+
effects.push(
|
|
188
|
+
Effect.send(ctx.connId, [
|
|
189
|
+
numericErr(ctx, Numerics.ERR_ALREADYREGISTRED, 'You may not reregister'),
|
|
190
|
+
]),
|
|
191
|
+
);
|
|
192
|
+
return { state, effects };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const user = msg.params[0];
|
|
196
|
+
const realname = msg.params[3];
|
|
197
|
+
if (user === undefined || realname === undefined) {
|
|
198
|
+
effects.push(
|
|
199
|
+
Effect.send(ctx.connId, [
|
|
200
|
+
numericErr(ctx, Numerics.ERR_NEEDMOREPARAMS, 'Not enough parameters', 'USER'),
|
|
201
|
+
]),
|
|
202
|
+
);
|
|
203
|
+
return { state, effects };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
state.user = user;
|
|
207
|
+
state.realname = realname;
|
|
208
|
+
if (state.registration === 'pre-registration') {
|
|
209
|
+
state.registration = 'registering';
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const welcome = emitWelcomeIfReady(state, ctx);
|
|
213
|
+
if (welcome !== null) effects.push(welcome);
|
|
214
|
+
|
|
215
|
+
return { state, effects };
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Reducer for `PASS <password>`.
|
|
220
|
+
*
|
|
221
|
+
* Only stashes the attempt as `state.passAttempt`. Actual server-password
|
|
222
|
+
* enforcement lives in the actor layer / security hardening
|
|
223
|
+
* since it requires comparing against the deployment's configured
|
|
224
|
+
* password, which the pure reducer does not see.
|
|
225
|
+
*/
|
|
226
|
+
export const passReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
|
|
227
|
+
const effects: EffectType[] = [];
|
|
228
|
+
|
|
229
|
+
if (state.registration === 'registered') {
|
|
230
|
+
effects.push(
|
|
231
|
+
Effect.send(ctx.connId, [
|
|
232
|
+
numericErr(ctx, Numerics.ERR_ALREADYREGISTRED, 'You may not reregister'),
|
|
233
|
+
]),
|
|
234
|
+
);
|
|
235
|
+
return { state, effects };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const password = msg.params[0];
|
|
239
|
+
if (password === undefined) {
|
|
240
|
+
effects.push(
|
|
241
|
+
Effect.send(ctx.connId, [
|
|
242
|
+
numericErr(ctx, Numerics.ERR_NEEDMOREPARAMS, 'Not enough parameters', 'PASS'),
|
|
243
|
+
]),
|
|
244
|
+
);
|
|
245
|
+
return { state, effects };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
state.passAttempt = password;
|
|
249
|
+
|
|
250
|
+
return { state, effects };
|
|
251
|
+
};
|