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,655 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure reducers for the IRC `MODE` command.
|
|
3
|
+
*
|
|
4
|
+
* PLAN §2.1 location-of-authority splits MODE across two authorities:
|
|
5
|
+
*
|
|
6
|
+
* - **Channel MODE** (`MODE #chan …`): the channel is authoritative for
|
|
7
|
+
* modes, keys, ban masks, and prefix flags on its roster, so the
|
|
8
|
+
* reducer's `state` argument is the channel's {@link ChannelState}.
|
|
9
|
+
* Writes require op (482 otherwise); reads require membership for
|
|
10
|
+
* +s/+p channels (442 otherwise).
|
|
11
|
+
* - **User MODE** (`MODE <nick> …`): the connection is authoritative
|
|
12
|
+
* for its own user modes, so the reducer's `state` argument is the
|
|
13
|
+
* caller's {@link ConnectionState}. A user may only change their own
|
|
14
|
+
* modes (502 otherwise). The oper flag (`+o`) is granted via the
|
|
15
|
+
* future OPER command, not via MODE — `+o` here is rejected with 481.
|
|
16
|
+
*
|
|
17
|
+
* Mode classes (per modern IRC de-facto convention):
|
|
18
|
+
* - Type A (list mode, always takes arg): `b` (ban mask).
|
|
19
|
+
* - Type B (prefix mode, always takes nick arg): `o`, `v`.
|
|
20
|
+
* - Type C (param-on-set, no-param-on-clear): `k`, `l`.
|
|
21
|
+
* - Type D (boolean, no arg): `i`, `t`, `n`, `m`, `s`, `p`.
|
|
22
|
+
*
|
|
23
|
+
* Wire format:
|
|
24
|
+
* - Read modes: `:<server> 324 <nick> <chan> <modestring> [args]`
|
|
25
|
+
* - Query bans: `:<server> 367 <nick> <chan> <mask>` × N, then
|
|
26
|
+
* `:<server> 368 <nick> <chan> :End of Channel Ban List`.
|
|
27
|
+
* - Apply: `:<source> MODE <chan> <modestring> [args]` broadcast to the
|
|
28
|
+
* channel.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { Effect } from '../effects.js';
|
|
32
|
+
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
33
|
+
import { Numerics } from '../protocol/numerics.js';
|
|
34
|
+
import type { BooleanChannelMode, ChannelState } from '../state/channel.js';
|
|
35
|
+
import type {
|
|
36
|
+
BanMaskChange,
|
|
37
|
+
ChannelDelta,
|
|
38
|
+
ChannelModeName,
|
|
39
|
+
MembershipDelta,
|
|
40
|
+
ModeChange,
|
|
41
|
+
} from '../state/channel.js';
|
|
42
|
+
import { hostmaskOf } from '../state/connection.js';
|
|
43
|
+
import type { ConnectionState } from '../state/connection.js';
|
|
44
|
+
import type { Ctx, Reducer } from '../types.js';
|
|
45
|
+
|
|
46
|
+
/** Channel-name grammar; mirrors {@link isValidChannelName} in `join.ts`. */
|
|
47
|
+
const CHANNEL_NAME_RE = /^[#&][^\s,:]+$/u;
|
|
48
|
+
|
|
49
|
+
function isValidChannelName(name: string, maxLen: number): boolean {
|
|
50
|
+
if (name.length === 0 || name.length > maxLen) return false;
|
|
51
|
+
return CHANNEL_NAME_RE.test(name);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Returns the caller's hostmask, falling back to nick then `?`. */
|
|
55
|
+
function callerHostmask(conn: ConnectionState): string {
|
|
56
|
+
return hostmaskOf(conn) ?? conn.nick ?? '?';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Formats a numeric error/reply line addressed to the connection's nick. */
|
|
60
|
+
function numericErr(ctx: Ctx, code: number, trailing: string, middle?: string): RawLine {
|
|
61
|
+
const nick = ctx.connection.nick ?? '*';
|
|
62
|
+
const codeStr = code.toString().padStart(3, '0');
|
|
63
|
+
const parts = [`:${ctx.serverName}`, codeStr, nick];
|
|
64
|
+
if (middle !== undefined) parts.push(middle);
|
|
65
|
+
parts.push(`:${trailing}`);
|
|
66
|
+
return { text: parts.join(' ') };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Lowercases a nick using ASCII case folding (matches kick.ts). */
|
|
70
|
+
function lowerNick(nick: string): string {
|
|
71
|
+
return nick.toLowerCase();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Boolean channel mode letters → mode-field name. */
|
|
75
|
+
const BOOLEAN_LETTERS: Readonly<Record<string, BooleanChannelMode>> = {
|
|
76
|
+
i: 'inviteOnly',
|
|
77
|
+
t: 'topicLock',
|
|
78
|
+
n: 'noExternal',
|
|
79
|
+
m: 'moderated',
|
|
80
|
+
s: 'secret',
|
|
81
|
+
p: 'private',
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** Boolean channel mode letters in canonical 324 output order. */
|
|
85
|
+
const BOOLEAN_LETTER_ORDER = ['i', 't', 'n', 'm', 's', 'p'] as const;
|
|
86
|
+
|
|
87
|
+
/** Prefix channel mode letters (always take a nick arg). */
|
|
88
|
+
const PREFIX_LETTERS = new Set<string>(['o', 'v']);
|
|
89
|
+
|
|
90
|
+
/** List channel mode letters (always take a mask arg). */
|
|
91
|
+
const LIST_LETTERS = new Set<string>(['b']);
|
|
92
|
+
|
|
93
|
+
/** One parsed op from a modestring. */
|
|
94
|
+
interface ModeOp {
|
|
95
|
+
sign: '+' | '-';
|
|
96
|
+
letter: string;
|
|
97
|
+
/** Consumed argument from the params tail, when applicable. */
|
|
98
|
+
arg?: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Result of applying a sequence of ops; used to rebuild the broadcast. */
|
|
102
|
+
interface AppliedMode {
|
|
103
|
+
sign: '+' | '-';
|
|
104
|
+
letter: string;
|
|
105
|
+
/** Argument echoed back in the broadcast (e.g. the nick for +o). */
|
|
106
|
+
arg?: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ============================================================================
|
|
110
|
+
// Channel MODE
|
|
111
|
+
// ============================================================================
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Handles `MODE <chan> [<modestring> [args…]]`.
|
|
115
|
+
*
|
|
116
|
+
* Validation order: missing-param → invalid-name → membership check for
|
|
117
|
+
* +s/+p reads → write authorization (member + op) → parse + apply per op.
|
|
118
|
+
*/
|
|
119
|
+
export const channelModeReducer: Reducer<ChannelState> = (state, msg, ctx) => {
|
|
120
|
+
ctx.connection.lastSeen = ctx.clock.now();
|
|
121
|
+
|
|
122
|
+
const effects: EffectType[] = [];
|
|
123
|
+
|
|
124
|
+
const rawName = msg.params[0];
|
|
125
|
+
if (rawName === undefined) {
|
|
126
|
+
effects.push(
|
|
127
|
+
Effect.send(ctx.connId, [
|
|
128
|
+
numericErr(ctx, Numerics.ERR_NEEDMOREPARAMS, 'Not enough parameters', 'MODE'),
|
|
129
|
+
]),
|
|
130
|
+
);
|
|
131
|
+
return { state, effects };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (!isValidChannelName(rawName, ctx.serverConfig.channelLen)) {
|
|
135
|
+
effects.push(
|
|
136
|
+
Effect.send(ctx.connId, [
|
|
137
|
+
numericErr(ctx, Numerics.ERR_NOSUCHCHANNEL, 'No such channel', rawName),
|
|
138
|
+
]),
|
|
139
|
+
);
|
|
140
|
+
return { state, effects };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Read form: no modestring supplied.
|
|
144
|
+
const modestring = msg.params[1];
|
|
145
|
+
if (modestring === undefined) {
|
|
146
|
+
return readChannelModes(state, ctx, effects);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Write form. Non-members cannot change modes.
|
|
150
|
+
const callerEntry = state.members.get(ctx.connId);
|
|
151
|
+
const isMember = callerEntry !== undefined;
|
|
152
|
+
if ((state.modes.secret || state.modes.private) && !isMember) {
|
|
153
|
+
effects.push(
|
|
154
|
+
Effect.send(ctx.connId, [
|
|
155
|
+
numericErr(ctx, Numerics.ERR_NOTONCHANNEL, "You're not on that channel", state.name),
|
|
156
|
+
]),
|
|
157
|
+
);
|
|
158
|
+
return { state, effects };
|
|
159
|
+
}
|
|
160
|
+
if (!isMember) {
|
|
161
|
+
effects.push(
|
|
162
|
+
Effect.send(ctx.connId, [
|
|
163
|
+
numericErr(ctx, Numerics.ERR_NOTONCHANNEL, "You're not on that channel", state.name),
|
|
164
|
+
]),
|
|
165
|
+
);
|
|
166
|
+
return { state, effects };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Ban-list query forms: `+b` with no arg, or `b` as a bare modestring.
|
|
170
|
+
if (msg.params.length === 2 && (modestring === 'b' || modestring === '+b')) {
|
|
171
|
+
return listBans(state, ctx, effects);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Anything else with a sign in front requires op.
|
|
175
|
+
if (!callerEntry.op) {
|
|
176
|
+
effects.push(
|
|
177
|
+
Effect.send(ctx.connId, [
|
|
178
|
+
numericErr(ctx, Numerics.ERR_CHANOPRIVSNEEDED, "You're not channel operator", state.name),
|
|
179
|
+
]),
|
|
180
|
+
);
|
|
181
|
+
return { state, effects };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const ops = parseModestring(modestring, msg.params.slice(2));
|
|
185
|
+
const applied: AppliedMode[] = [];
|
|
186
|
+
const delta: ChannelDelta = {};
|
|
187
|
+
|
|
188
|
+
for (const op of ops) {
|
|
189
|
+
const result = applyChannelOp(state, ctx, op, effects, delta);
|
|
190
|
+
if (result !== null) applied.push(result);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (applied.length > 0) {
|
|
194
|
+
// Mirror the mode/roster/ban changes to the runtime's channel
|
|
195
|
+
// authority BEFORE the broadcast so distributed runtimes (CF
|
|
196
|
+
// ChannelDO) have persisted the change before any peer's next
|
|
197
|
+
// prefetch observes it. In the in-memory runtime the actor's
|
|
198
|
+
// `state` IS authoritative so this is redundant.
|
|
199
|
+
if (
|
|
200
|
+
(delta.modeChanges !== undefined && delta.modeChanges.length > 0) ||
|
|
201
|
+
(delta.banMaskChanges !== undefined && delta.banMaskChanges.length > 0) ||
|
|
202
|
+
(delta.memberships !== undefined && delta.memberships.length > 0)
|
|
203
|
+
) {
|
|
204
|
+
effects.push(Effect.applyChannelDelta(state.name, delta));
|
|
205
|
+
}
|
|
206
|
+
const hostmask = callerHostmask(ctx.connection);
|
|
207
|
+
effects.push(Effect.broadcast(state.name, [buildModeBroadcast(hostmask, state.name, applied)]));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return { state, effects };
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Parses a modestring into a sequence of ops with consumed args.
|
|
215
|
+
*
|
|
216
|
+
* - Signs `+`/`-` toggle the active sign; an unrecognized leading char is
|
|
217
|
+
* treated as `+` (defensive — but the parser also emits 472 for unknown
|
|
218
|
+
* letters so the visible behavior is still correct).
|
|
219
|
+
* - Type B (`o`/`v`) and Type A (`b`) consume one arg on both signs.
|
|
220
|
+
* - Type C (`k`/`l`) consume one arg on `+` only.
|
|
221
|
+
* - Type D (boolean) consumes no arg.
|
|
222
|
+
* - Unknown letters are still emitted as ops so applyChannelOp can produce
|
|
223
|
+
* the 472 numeric; they consume no arg.
|
|
224
|
+
*/
|
|
225
|
+
function parseModestring(modestring: string, args: string[]): ModeOp[] {
|
|
226
|
+
const ops: ModeOp[] = [];
|
|
227
|
+
let sign: '+' | '-' = '+';
|
|
228
|
+
let argIdx = 0;
|
|
229
|
+
|
|
230
|
+
for (const ch of modestring) {
|
|
231
|
+
if (ch === '+') {
|
|
232
|
+
sign = '+';
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (ch === '-') {
|
|
236
|
+
sign = '-';
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
let arg: string | undefined;
|
|
241
|
+
if (consumesArgs(ch, sign)) {
|
|
242
|
+
arg = args[argIdx++];
|
|
243
|
+
}
|
|
244
|
+
ops.push({ sign, letter: ch, ...(arg !== undefined ? { arg } : {}) });
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return ops;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Helper kept separate for clarity; mirrors the consuming-arg rules above. */
|
|
251
|
+
function consumesArgs(letter: string, sign: '+' | '-'): boolean {
|
|
252
|
+
if (PREFIX_LETTERS.has(letter)) return true;
|
|
253
|
+
if (LIST_LETTERS.has(letter)) return true;
|
|
254
|
+
if (sign === '+' && (letter === 'k' || letter === 'l')) return true;
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Applies a single parsed op to the channel state. Returns an
|
|
260
|
+
* {@link AppliedMode} to include in the broadcast, or `null` if the op was
|
|
261
|
+
* rejected (in which case the appropriate numeric was pushed to `effects`).
|
|
262
|
+
*
|
|
263
|
+
* Special case: `+b` with no arg is a ban-list query. The caller has already
|
|
264
|
+
* handled bare `b` / `+b` modestrings before reaching here, but defensive
|
|
265
|
+
* handling emits the list inline if a query sneaks through inside a combined
|
|
266
|
+
* modestring. In that case the op produces no AppliedMode and no broadcast.
|
|
267
|
+
*/
|
|
268
|
+
function applyChannelOp(
|
|
269
|
+
state: ChannelState,
|
|
270
|
+
ctx: Ctx,
|
|
271
|
+
op: ModeOp,
|
|
272
|
+
effects: EffectType[],
|
|
273
|
+
delta: ChannelDelta,
|
|
274
|
+
): AppliedMode | null {
|
|
275
|
+
const { sign, letter } = op;
|
|
276
|
+
|
|
277
|
+
if (BOOLEAN_LETTERS[letter] !== undefined) {
|
|
278
|
+
const field = BOOLEAN_LETTERS[letter] as BooleanChannelMode;
|
|
279
|
+
state.modes[field] = sign === '+';
|
|
280
|
+
pushModeChange(delta, field, sign === '+');
|
|
281
|
+
return { sign, letter };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (letter === 'k') {
|
|
285
|
+
if (sign === '+') {
|
|
286
|
+
if (op.arg === undefined || op.arg.length === 0) {
|
|
287
|
+
effects.push(
|
|
288
|
+
Effect.send(ctx.connId, [
|
|
289
|
+
numericErr(ctx, Numerics.ERR_NEEDMOREPARAMS, 'Not enough parameters', 'k'),
|
|
290
|
+
]),
|
|
291
|
+
);
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
state.modes.key = op.arg;
|
|
295
|
+
pushModeChange(delta, 'key', true, op.arg);
|
|
296
|
+
return { sign, letter, arg: op.arg };
|
|
297
|
+
}
|
|
298
|
+
// Clearing k: ignore any supplied arg (modern behavior).
|
|
299
|
+
// biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes forbids `= undefined`.
|
|
300
|
+
delete state.modes.key;
|
|
301
|
+
pushModeChange(delta, 'key', false);
|
|
302
|
+
return { sign, letter };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (letter === 'l') {
|
|
306
|
+
if (sign === '+') {
|
|
307
|
+
if (op.arg === undefined || op.arg.length === 0 || !/^[0-9]+$/u.test(op.arg)) {
|
|
308
|
+
effects.push(
|
|
309
|
+
Effect.send(ctx.connId, [
|
|
310
|
+
numericErr(ctx, Numerics.ERR_NEEDMOREPARAMS, 'Not enough parameters', 'l'),
|
|
311
|
+
]),
|
|
312
|
+
);
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
const n = Number.parseInt(op.arg, 10);
|
|
316
|
+
state.modes.limit = n;
|
|
317
|
+
pushModeChange(delta, 'limit', true, n);
|
|
318
|
+
return { sign, letter, arg: String(n) };
|
|
319
|
+
}
|
|
320
|
+
// biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes forbids `= undefined`.
|
|
321
|
+
delete state.modes.limit;
|
|
322
|
+
pushModeChange(delta, 'limit', false);
|
|
323
|
+
return { sign, letter };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (PREFIX_LETTERS.has(letter)) {
|
|
327
|
+
if (op.arg === undefined || op.arg.length === 0) {
|
|
328
|
+
effects.push(
|
|
329
|
+
Effect.send(ctx.connId, [
|
|
330
|
+
numericErr(ctx, Numerics.ERR_NEEDMOREPARAMS, 'Not enough parameters', letter),
|
|
331
|
+
]),
|
|
332
|
+
);
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
const target = findMemberByNick(state, op.arg);
|
|
336
|
+
if (target === null) {
|
|
337
|
+
effects.push(
|
|
338
|
+
Effect.send(ctx.connId, [
|
|
339
|
+
numericErr(
|
|
340
|
+
ctx,
|
|
341
|
+
Numerics.ERR_USERNOTINCHANNEL,
|
|
342
|
+
'They are not on that channel',
|
|
343
|
+
`${op.arg} ${state.name}`,
|
|
344
|
+
),
|
|
345
|
+
]),
|
|
346
|
+
);
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
if (letter === 'o') target.op = sign === '+';
|
|
350
|
+
else target.voice = sign === '+';
|
|
351
|
+
pushMembershipChange(delta, target.conn, target.nick, target.op, target.voice);
|
|
352
|
+
return { sign, letter, arg: target.nick };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (LIST_LETTERS.has(letter)) {
|
|
356
|
+
// `b`: with no arg → query list (handled before we reach here for the
|
|
357
|
+
// bare modestring). Inside a combined modestring, treat no-arg +b as a
|
|
358
|
+
// query and emit the ban list inline.
|
|
359
|
+
if (op.arg === undefined || op.arg.length === 0) {
|
|
360
|
+
emitBanList(state, ctx, effects);
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
if (sign === '+') {
|
|
364
|
+
state.banMasks.add(op.arg);
|
|
365
|
+
pushBanMaskChange(delta, 'add', op.arg);
|
|
366
|
+
} else {
|
|
367
|
+
state.banMasks.delete(op.arg);
|
|
368
|
+
pushBanMaskChange(delta, 'remove', op.arg);
|
|
369
|
+
}
|
|
370
|
+
return { sign, letter, arg: op.arg };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Unknown mode char.
|
|
374
|
+
effects.push(
|
|
375
|
+
Effect.send(ctx.connId, [
|
|
376
|
+
numericErr(ctx, Numerics.ERR_UNKNOWNMODE, 'is unknown mode char to me', letter),
|
|
377
|
+
]),
|
|
378
|
+
);
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Appends a boolean/parameterized mode toggle to `delta.modeChanges`. */
|
|
383
|
+
function pushModeChange(
|
|
384
|
+
delta: ChannelDelta,
|
|
385
|
+
mode: ChannelModeName,
|
|
386
|
+
set: boolean,
|
|
387
|
+
arg?: string | number,
|
|
388
|
+
): void {
|
|
389
|
+
if (delta.modeChanges === undefined) delta.modeChanges = [];
|
|
390
|
+
const change: ModeChange = { mode, set };
|
|
391
|
+
if (arg !== undefined) change.arg = arg;
|
|
392
|
+
delta.modeChanges.push(change);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** Appends a ban-mask add/remove to `delta.banMaskChanges`. */
|
|
396
|
+
function pushBanMaskChange(delta: ChannelDelta, type: 'add' | 'remove', mask: string): void {
|
|
397
|
+
if (delta.banMaskChanges === undefined) delta.banMaskChanges = [];
|
|
398
|
+
const change: BanMaskChange = { type, mask };
|
|
399
|
+
delta.banMaskChanges.push(change);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** Appends a roster entry update (prefix change) to `delta.memberships`. */
|
|
403
|
+
function pushMembershipChange(
|
|
404
|
+
delta: ChannelDelta,
|
|
405
|
+
conn: string,
|
|
406
|
+
nick: string,
|
|
407
|
+
op: boolean,
|
|
408
|
+
voice: boolean,
|
|
409
|
+
): void {
|
|
410
|
+
if (delta.memberships === undefined) delta.memberships = [];
|
|
411
|
+
const m: MembershipDelta = { type: 'add', conn, nick, op, voice };
|
|
412
|
+
delta.memberships.push(m);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** Finds a roster entry by nick (case-insensitive); returns a writable ref. */
|
|
416
|
+
function findMemberByNick(
|
|
417
|
+
state: ChannelState,
|
|
418
|
+
nick: string,
|
|
419
|
+
): { conn: string; nick: string; op: boolean; voice: boolean } | null {
|
|
420
|
+
const targetLower = lowerNick(nick);
|
|
421
|
+
for (const entry of state.members.values()) {
|
|
422
|
+
if (lowerNick(entry.nick) === targetLower) {
|
|
423
|
+
return entry;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
return null;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** Rebuilds the broadcast line for a sequence of applied ops. */
|
|
430
|
+
function buildModeBroadcast(hostmask: string, chanName: string, applied: AppliedMode[]): RawLine {
|
|
431
|
+
let modeStr = '';
|
|
432
|
+
const args: string[] = [];
|
|
433
|
+
let curSign: '+' | '-' | '' = '';
|
|
434
|
+
for (const op of applied) {
|
|
435
|
+
if (op.sign !== curSign) {
|
|
436
|
+
modeStr += op.sign;
|
|
437
|
+
curSign = op.sign;
|
|
438
|
+
}
|
|
439
|
+
modeStr += op.letter;
|
|
440
|
+
if (op.arg !== undefined) args.push(op.arg);
|
|
441
|
+
}
|
|
442
|
+
const lead = `:${hostmask} MODE ${chanName} ${modeStr}`;
|
|
443
|
+
if (args.length === 0) return { text: lead };
|
|
444
|
+
return { text: `${lead} ${args.join(' ')}` };
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// ============================================================================
|
|
448
|
+
// Channel MODE — read / query helpers
|
|
449
|
+
// ============================================================================
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Emits `324 RPL_CHANNELMODEIS` for the channel's current modes.
|
|
453
|
+
*
|
|
454
|
+
* Boolean modes are listed in {@link BOOLEAN_LETTER_ORDER}; parameterized
|
|
455
|
+
* modes follow in `k` then `l` order with their args. Prefix modes (`o`/`v`)
|
|
456
|
+
* are intentionally omitted from `324`; clients read them from NAMES.
|
|
457
|
+
*/
|
|
458
|
+
function readChannelModes(
|
|
459
|
+
state: ChannelState,
|
|
460
|
+
ctx: Ctx,
|
|
461
|
+
effects: EffectType[],
|
|
462
|
+
): { state: ChannelState; effects: EffectType[] } {
|
|
463
|
+
// Visibility: +s/+p channels hide their modes from non-members.
|
|
464
|
+
const isMember = state.members.has(ctx.connId);
|
|
465
|
+
if ((state.modes.secret || state.modes.private) && !isMember) {
|
|
466
|
+
effects.push(
|
|
467
|
+
Effect.send(ctx.connId, [
|
|
468
|
+
numericErr(ctx, Numerics.ERR_NOTONCHANNEL, "You're not on that channel", state.name),
|
|
469
|
+
]),
|
|
470
|
+
);
|
|
471
|
+
return { state, effects };
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const modeStr = buildReadModeString(state);
|
|
475
|
+
const nick = ctx.connection.nick ?? '*';
|
|
476
|
+
effects.push(
|
|
477
|
+
Effect.send(ctx.connId, [{ text: `:${ctx.serverName} 324 ${nick} ${state.name} ${modeStr}` }]),
|
|
478
|
+
);
|
|
479
|
+
return { state, effects };
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** Builds the modestring + args for a 324 reply. */
|
|
483
|
+
function buildReadModeString(state: ChannelState): string {
|
|
484
|
+
let modeStr = '+';
|
|
485
|
+
const args: string[] = [];
|
|
486
|
+
for (const letter of BOOLEAN_LETTER_ORDER) {
|
|
487
|
+
const field = BOOLEAN_LETTERS[letter];
|
|
488
|
+
if (field !== undefined && state.modes[field]) modeStr += letter;
|
|
489
|
+
}
|
|
490
|
+
if (state.modes.key !== undefined) {
|
|
491
|
+
modeStr += 'k';
|
|
492
|
+
args.push(state.modes.key);
|
|
493
|
+
}
|
|
494
|
+
if (state.modes.limit !== undefined) {
|
|
495
|
+
modeStr += 'l';
|
|
496
|
+
args.push(String(state.modes.limit));
|
|
497
|
+
}
|
|
498
|
+
if (args.length === 0) return modeStr;
|
|
499
|
+
return `${modeStr} ${args.join(' ')}`;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** Emits `367` per ban mask then `368`. */
|
|
503
|
+
function listBans(
|
|
504
|
+
state: ChannelState,
|
|
505
|
+
ctx: Ctx,
|
|
506
|
+
effects: EffectType[],
|
|
507
|
+
): { state: ChannelState; effects: EffectType[] } {
|
|
508
|
+
emitBanList(state, ctx, effects);
|
|
509
|
+
return { state, effects };
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function emitBanList(state: ChannelState, ctx: Ctx, effects: EffectType[]): void {
|
|
513
|
+
const nick = ctx.connection.nick ?? '*';
|
|
514
|
+
for (const mask of state.banMasks) {
|
|
515
|
+
effects.push(
|
|
516
|
+
Effect.send(ctx.connId, [{ text: `:${ctx.serverName} 367 ${nick} ${state.name} ${mask}` }]),
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
effects.push(
|
|
520
|
+
Effect.send(ctx.connId, [
|
|
521
|
+
{ text: `:${ctx.serverName} 368 ${nick} ${state.name} :End of Channel Ban List` },
|
|
522
|
+
]),
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// ============================================================================
|
|
527
|
+
// User MODE
|
|
528
|
+
// ============================================================================
|
|
529
|
+
|
|
530
|
+
/** User-mode letters → field on {@link UserModes}. */
|
|
531
|
+
const USER_MODE_LETTERS: Readonly<Record<string, keyof ConnectionState['userModes']>> = {
|
|
532
|
+
i: 'invisible',
|
|
533
|
+
w: 'wallops',
|
|
534
|
+
s: 'serverNotices',
|
|
535
|
+
o: 'oper',
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
/** User-mode letters in canonical 221 output order. */
|
|
539
|
+
const USER_MODE_LETTER_ORDER = ['i', 'w', 's', 'o'] as const;
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Handles `MODE <nick> [<modestring>]`.
|
|
543
|
+
*
|
|
544
|
+
* Read form (`MODE <nick>` with no modestring): emits `221 RPL_UMODEIS`.
|
|
545
|
+
* Write form: mutates `state.userModes` and emits a `MODE` echo to the
|
|
546
|
+
* connection. `+o` is rejected with `481 ERR_NOPRIVILEGES` because oper is
|
|
547
|
+
* granted via the OPER command, not via user MODE.
|
|
548
|
+
*
|
|
549
|
+
* RFC 501/502: clients may only change their own user modes. A target nick
|
|
550
|
+
* that does not match the connection's own nick yields `502`.
|
|
551
|
+
*/
|
|
552
|
+
export const userModeReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
|
|
553
|
+
state.lastSeen = ctx.clock.now();
|
|
554
|
+
|
|
555
|
+
const effects: EffectType[] = [];
|
|
556
|
+
|
|
557
|
+
const target = msg.params[0];
|
|
558
|
+
if (target === undefined) {
|
|
559
|
+
effects.push(
|
|
560
|
+
Effect.send(ctx.connId, [
|
|
561
|
+
numericErr(ctx, Numerics.ERR_NEEDMOREPARAMS, 'Not enough parameters', 'MODE'),
|
|
562
|
+
]),
|
|
563
|
+
);
|
|
564
|
+
return { state, effects };
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Target must be the caller's own nick (case-insensitive).
|
|
568
|
+
const myNick = state.nick;
|
|
569
|
+
if (myNick === undefined || lowerNick(target) !== lowerNick(myNick)) {
|
|
570
|
+
effects.push(
|
|
571
|
+
Effect.send(ctx.connId, [
|
|
572
|
+
numericErr(ctx, Numerics.ERR_USERSDONTMATCH, 'Cannot change mode for other users'),
|
|
573
|
+
]),
|
|
574
|
+
);
|
|
575
|
+
return { state, effects };
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// Read form.
|
|
579
|
+
const modestring = msg.params[1];
|
|
580
|
+
if (modestring === undefined) {
|
|
581
|
+
effects.push(
|
|
582
|
+
Effect.send(ctx.connId, [
|
|
583
|
+
{ text: `:${ctx.serverName} 221 ${myNick} ${buildUserModeString(state)}` },
|
|
584
|
+
]),
|
|
585
|
+
);
|
|
586
|
+
return { state, effects };
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// Write form.
|
|
590
|
+
const applied: AppliedMode[] = [];
|
|
591
|
+
let sign: '+' | '-' = '+';
|
|
592
|
+
for (const ch of modestring) {
|
|
593
|
+
if (ch === '+') {
|
|
594
|
+
sign = '+';
|
|
595
|
+
continue;
|
|
596
|
+
}
|
|
597
|
+
if (ch === '-') {
|
|
598
|
+
sign = '-';
|
|
599
|
+
continue;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const field = USER_MODE_LETTERS[ch];
|
|
603
|
+
if (field === undefined) {
|
|
604
|
+
effects.push(
|
|
605
|
+
Effect.send(ctx.connId, [
|
|
606
|
+
numericErr(ctx, Numerics.ERR_UMODEUNKNOWNFLAG, 'Unknown MODE flag'),
|
|
607
|
+
]),
|
|
608
|
+
);
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// +o via user MODE is reserved for OPER; reject.
|
|
613
|
+
if (ch === 'o' && sign === '+') {
|
|
614
|
+
effects.push(
|
|
615
|
+
Effect.send(ctx.connId, [
|
|
616
|
+
numericErr(
|
|
617
|
+
ctx,
|
|
618
|
+
Numerics.ERR_NOPRIVILEGES,
|
|
619
|
+
"Permission Denied - You're not an IRC operator",
|
|
620
|
+
),
|
|
621
|
+
]),
|
|
622
|
+
);
|
|
623
|
+
continue;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
state.userModes[field] = sign === '+';
|
|
627
|
+
applied.push({ sign, letter: ch });
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
if (applied.length > 0) {
|
|
631
|
+
const hostmask = callerHostmask(state);
|
|
632
|
+
let modeStr = '';
|
|
633
|
+
let curSign: '+' | '-' | '' = '';
|
|
634
|
+
for (const op of applied) {
|
|
635
|
+
if (op.sign !== curSign) {
|
|
636
|
+
modeStr += op.sign;
|
|
637
|
+
curSign = op.sign;
|
|
638
|
+
}
|
|
639
|
+
modeStr += op.letter;
|
|
640
|
+
}
|
|
641
|
+
effects.push(Effect.send(ctx.connId, [{ text: `:${hostmask} MODE ${myNick} ${modeStr}` }]));
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
return { state, effects };
|
|
645
|
+
};
|
|
646
|
+
|
|
647
|
+
/** Builds the modestring for a 221 reply (always `+`-prefixed). */
|
|
648
|
+
function buildUserModeString(state: ConnectionState): string {
|
|
649
|
+
let modeStr = '+';
|
|
650
|
+
for (const letter of USER_MODE_LETTER_ORDER) {
|
|
651
|
+
const field = USER_MODE_LETTERS[letter];
|
|
652
|
+
if (field !== undefined && state.userModes[field]) modeStr += letter;
|
|
653
|
+
}
|
|
654
|
+
return modeStr;
|
|
655
|
+
}
|