serverless-ircd 0.7.0 → 0.8.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 +3 -3
- package/.gitmodules +1 -1
- package/CHANGELOG.md +273 -29
- package/README.md +155 -55
- package/apps/aws-stack/package.json +1 -1
- package/apps/aws-stack/src/aws-stack.ts +186 -18
- package/apps/aws-stack/tests/stack.test.ts +400 -56
- package/apps/cf-tcp-container/package.json +1 -1
- package/apps/cf-worker/package.json +1 -1
- package/apps/cf-worker/src/worker.ts +4 -4
- package/apps/cf-worker/tests/fixtures/web-dist/webclient/index.html +18 -0
- package/apps/cf-worker/tests/smoke.test.ts +5 -5
- package/apps/cf-worker/wrangler.test.toml +6 -6
- package/apps/cf-worker/wrangler.toml +7 -5
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +8 -0
- package/apps/local-cli/src/main.ts +16 -0
- package/apps/local-cli/src/server.ts +1 -0
- package/apps/local-cli/tests/config-loader.test.ts +14 -0
- package/apps/web/landing/index.html +14 -16
- package/apps/web/package.json +2 -2
- package/apps/web/scripts/build.mjs +23 -16
- package/apps/web/src/build-env.ts +1 -1
- package/apps/web/src/config-schema.ts +6 -6
- package/apps/web/tests/build-smoke.test.ts +14 -14
- package/apps/web/tests/config-schema.test.ts +1 -1
- package/docs/AWS-Deployment.md +21 -8
- package/docs/Cloudflare-Deployment-Guide.md +22 -6
- package/docs/PlanExtensions.md +113 -3
- package/docs/Release-Process.md +23 -13
- package/docs/Services.md +546 -0
- package/docs/WebClientGuide.md +32 -31
- package/package.json +2 -2
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/aws-runtime.ts +5 -0
- package/packages/aws-adapter/src/cdk-table-defs.ts +6 -0
- package/packages/aws-adapter/src/config-loader.ts +11 -0
- package/packages/aws-adapter/src/connection-counter.ts +89 -0
- package/packages/aws-adapter/src/dynamo-services-store.ts +649 -0
- package/packages/aws-adapter/src/handlers/connect.ts +55 -51
- package/packages/aws-adapter/src/handlers/default.ts +36 -4
- package/packages/aws-adapter/src/handlers/index.ts +15 -0
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +15 -0
- package/packages/aws-adapter/src/handlers/sweeper.ts +5 -1
- package/packages/aws-adapter/src/index.ts +4 -0
- package/packages/aws-adapter/src/stats.ts +6 -1
- package/packages/aws-adapter/src/tables.ts +34 -4
- package/packages/aws-adapter/tests/aws-harness.ts +3 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +8 -0
- package/packages/aws-adapter/tests/connect.test.ts +158 -32
- package/packages/aws-adapter/tests/connection-counter.test.ts +127 -0
- package/packages/aws-adapter/tests/dynamo-services-store-dynamo.test.ts +183 -0
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +568 -0
- package/packages/aws-adapter/tests/handlers.test.ts +105 -3
- package/packages/aws-adapter/tests/tables.test.ts +6 -1
- package/packages/cf-adapter/package.json +1 -1
- package/packages/cf-adapter/src/config-loader.ts +11 -0
- package/packages/cf-adapter/src/connection-do.ts +112 -2
- package/packages/cf-adapter/src/d1-services-store.ts +703 -0
- package/packages/cf-adapter/src/env.ts +8 -0
- package/packages/cf-adapter/src/index.ts +5 -0
- package/packages/cf-adapter/tests/config-loader.test.ts +19 -0
- package/packages/cf-adapter/tests/connection-do-nickserv-d1.test.ts +128 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +150 -2
- package/packages/cf-adapter/tests/d1-services-store.test.ts +582 -0
- package/packages/cf-adapter/tests/serialize.test.ts +1 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/scripts/generate-build-info.mjs +26 -5
- package/packages/irc-core/src/commands/account-auth.ts +172 -0
- package/packages/irc-core/src/commands/chanserv.ts +882 -0
- package/packages/irc-core/src/commands/hostserv.ts +487 -0
- package/packages/irc-core/src/commands/index.ts +12 -0
- package/packages/irc-core/src/commands/join.ts +164 -8
- package/packages/irc-core/src/commands/markread.ts +202 -0
- package/packages/irc-core/src/commands/memoserv.ts +319 -0
- package/packages/irc-core/src/commands/mode.ts +96 -4
- package/packages/irc-core/src/commands/nickserv.ts +390 -0
- package/packages/irc-core/src/commands/oper.ts +18 -1
- package/packages/irc-core/src/commands/operserv.ts +346 -0
- package/packages/irc-core/src/commands/pre-away.ts +3 -1
- package/packages/irc-core/src/commands/privmsg.ts +42 -0
- package/packages/irc-core/src/commands/read-marker.ts +8 -8
- package/packages/irc-core/src/commands/registration.ts +61 -6
- package/packages/irc-core/src/commands/sasl.ts +18 -49
- package/packages/irc-core/src/commands/tagmsg.ts +41 -6
- package/packages/irc-core/src/commands/topic.ts +37 -0
- package/packages/irc-core/src/config.ts +36 -5
- package/packages/irc-core/src/effects.ts +56 -1
- package/packages/irc-core/src/ports.ts +1653 -84
- package/packages/irc-core/src/protocol/numerics.ts +8 -0
- package/packages/irc-core/src/state/channel.ts +21 -1
- package/packages/irc-core/src/state/connection.ts +25 -1
- package/packages/irc-core/src/types.ts +48 -12
- package/packages/irc-core/tests/commands/chanserv.test.ts +1668 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +6 -0
- package/packages/irc-core/tests/commands/hostserv.test.ts +935 -0
- package/packages/irc-core/tests/commands/join.test.ts +393 -1
- package/packages/irc-core/tests/commands/markread.test.ts +361 -0
- package/packages/irc-core/tests/commands/memoserv.test.ts +654 -0
- package/packages/irc-core/tests/commands/mode.test.ts +381 -2
- package/packages/irc-core/tests/commands/nickserv.test.ts +807 -0
- package/packages/irc-core/tests/commands/oper.test.ts +13 -0
- package/packages/irc-core/tests/commands/operserv.test.ts +656 -0
- package/packages/irc-core/tests/commands/privmsg.test.ts +147 -0
- package/packages/irc-core/tests/commands/read-marker.test.ts +28 -28
- package/packages/irc-core/tests/commands/registration.test.ts +788 -14
- package/packages/irc-core/tests/commands/sasl.test.ts +185 -12
- package/packages/irc-core/tests/commands/server-info.test.ts +9 -5
- package/packages/irc-core/tests/commands/tagmsg.test.ts +73 -33
- package/packages/irc-core/tests/commands/topic.test.ts +94 -2
- package/packages/irc-core/tests/commands/unified-account.test.ts +416 -0
- package/packages/irc-core/tests/config.test.ts +49 -5
- package/packages/irc-core/tests/effects.test.ts +19 -0
- package/packages/irc-core/tests/message-store.test.ts +63 -0
- package/packages/irc-core/tests/persistent-services-store.test.ts +582 -0
- package/packages/irc-core/tests/services-store.test.ts +1289 -0
- package/packages/irc-core/tests/state/channel.test.ts +3 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +71 -16
- package/packages/irc-server/src/dispatch.ts +94 -7
- package/packages/irc-server/src/routing.ts +19 -0
- package/packages/irc-server/tests/actor.test.ts +623 -12
- package/packages/irc-server/tests/dispatch.test.ts +270 -2
- package/packages/irc-server/tests/routing.test.ts +6 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +29 -3
- package/packages/irc-test-support/src/index.ts +1 -0
- package/packages/irc-test-support/tests/in-memory-harness.test.ts +32 -0
- package/tools/ci-hardening/package.json +1 -1
- package/tools/load-test/package.json +1 -1
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +2 -2
- package/apps/cf-worker/tests/fixtures/web-dist/app/index.html +0 -18
- package/packages/irc-core/tests/read-marker-store.test.ts +0 -108
|
@@ -0,0 +1,882 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure reducer for the ChanServ services pseudo-client.
|
|
3
|
+
*
|
|
4
|
+
* ChanServ is invoked via `PRIVMSG ChanServ :<command>`. The actor routes
|
|
5
|
+
* any `PRIVMSG` whose target matches {@link CHANSERV_NICK}
|
|
6
|
+
* (case-insensitive) to {@link chanservReducer} instead of the normal
|
|
7
|
+
* user-target reducer.
|
|
8
|
+
*
|
|
9
|
+
* Supported subcommands:
|
|
10
|
+
* - `REGISTER <#channel>` — registers the channel against the bound
|
|
11
|
+
* {@link ServicesStore} with the invoking connection's account as the
|
|
12
|
+
* founder. On success ChanServ sets the channel mode `+r` (registered).
|
|
13
|
+
* The connection must be identified (NickServ `+r` user mode).
|
|
14
|
+
* - `DROP <#channel>` — removes the channel registration (founder-only).
|
|
15
|
+
* Clears the persisted `+r`/`+R`/`+M` channel modes via an
|
|
16
|
+
* `ApplyChannelDelta`.
|
|
17
|
+
* - `INFO <#channel>` — queries the registration record.
|
|
18
|
+
* - `SET <subkey> <args…>` — updates the persisted channel record:
|
|
19
|
+
* - `SET FOUNDER <account>` — transfer founder (identified target
|
|
20
|
+
* that has a registered nick).
|
|
21
|
+
* - `SET MLOCK <modestring>` — lock modes so the MODE reducer
|
|
22
|
+
* reapplies them if an op tries to unset them.
|
|
23
|
+
* - `SET RESTRICTED ON|OFF` — toggle the channel's `+R` mode
|
|
24
|
+
* (block join/PRIVMSG from unidentified).
|
|
25
|
+
* - `SET KEEPTOPIC ON|OFF` — persist the channel topic across
|
|
26
|
+
* empty-recreate via {@link ServicesStore.setChannelTopic}.
|
|
27
|
+
* - `ACCESS <#channel> ADD|DEL|LIST [args]` — manages the per-account
|
|
28
|
+
* access list. `ADD <account> <level>` records a positive integer
|
|
29
|
+
* level; `DEL <account>` removes; `LIST` enumerates founder + every
|
|
30
|
+
* entry. The JOIN reducer consults the list to apply channel
|
|
31
|
+
* prefixes (`@`/`+`) automatically.
|
|
32
|
+
* - `SOP|AOP|HOP|VOP <#channel> ADD|DEL|LIST [account]` — shorthand
|
|
33
|
+
* for the matching numeric level from {@link SHORTHAND_LEVELS}
|
|
34
|
+
* (mirrors the Atheme convention).
|
|
35
|
+
* - `LEVELS <#channel> SET|LIST|RESET [args]` — redefines the numeric
|
|
36
|
+
* threshold the JOIN hook compares against for the `AUTO*` ops.
|
|
37
|
+
* `SET <op> <level>` overrides; `LIST` enumerates; `RESET` clears
|
|
38
|
+
* every override so the {@link DEFAULT_CHANNEL_LEVELS} take effect.
|
|
39
|
+
*
|
|
40
|
+
* The reducer is a pure `Reducer<ConnectionState>`: all side effects are
|
|
41
|
+
* emitted as {@link Effect}s. Channel-mode mutations are emitted as
|
|
42
|
+
* `ApplyChannelDelta` effects (the actor layer applies them to the
|
|
43
|
+
* authoritative {@link ChannelState}).
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import { caseFold } from '../case-fold.js';
|
|
47
|
+
import { Effect } from '../effects.js';
|
|
48
|
+
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
49
|
+
import type { ChannelLevelOp, ServicesStore } from '../ports.js';
|
|
50
|
+
import type { ChannelDelta } from '../state/channel.js';
|
|
51
|
+
import type { ConnectionState } from '../state/connection.js';
|
|
52
|
+
import type { Ctx, Reducer } from '../types.js';
|
|
53
|
+
|
|
54
|
+
/** Canonical ChanServ pseudo-client nick. */
|
|
55
|
+
export const CHANSERV_NICK = 'ChanServ';
|
|
56
|
+
|
|
57
|
+
/** Services hostmask fragment used by every services pseudo-client. */
|
|
58
|
+
const SERVICES_HOST = 'services';
|
|
59
|
+
|
|
60
|
+
/** Hostmask used as the source of ChanServ-emitted MODE prefix grants. */
|
|
61
|
+
export const CHANSERV_HOSTMASK = `${CHANSERV_NICK}!${CHANSERV_NICK}@${SERVICES_HOST}`;
|
|
62
|
+
|
|
63
|
+
/** Channel-name grammar; mirrors {@link isValidChannelName} in `join.ts`. */
|
|
64
|
+
const CHANNEL_NAME_RE = /^[#&][^\s,:]+$/u;
|
|
65
|
+
|
|
66
|
+
/** Returns true iff `name` looks like a channel target. */
|
|
67
|
+
function isValidChannelName(name: string, maxLen: number): boolean {
|
|
68
|
+
if (name.length === 0 || name.length > maxLen) return false;
|
|
69
|
+
return CHANNEL_NAME_RE.test(name);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** ON / OFF values accepted by SET RESTRICTED / SET KEEPTOPIC. */
|
|
73
|
+
const ON_OFF = new Set<string>(['ON', 'OFF']);
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Default numeric thresholds for the ChanServ `AUTO*` level checks. The
|
|
77
|
+
* JOIN hook consults these (or the founder-redefined overrides from
|
|
78
|
+
* {@link ServicesStore.getChannelLevel}) to decide whether to grant a
|
|
79
|
+
* channel prefix automatically.
|
|
80
|
+
*
|
|
81
|
+
* The values follow the Atheme convention: VOP (auto-voice) sorts below
|
|
82
|
+
* HOP (auto-halfop; this server has no `+h` prefix, but the level is
|
|
83
|
+
* preserved so founders can tier access) sorts below AOP (auto-op).
|
|
84
|
+
* Channel founders implicitly carry the maximum level (`Infinity`) and
|
|
85
|
+
* always receive `+o` on JOIN.
|
|
86
|
+
*/
|
|
87
|
+
export const DEFAULT_CHANNEL_LEVELS: Readonly<Record<ChannelLevelOp, number>> = {
|
|
88
|
+
AUTOVOICE: 3,
|
|
89
|
+
AUTOHALFOP: 4,
|
|
90
|
+
AUTOOP: 5,
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Numeric access levels applied by the `SOP` / `AOP` / `HOP` / `VOP`
|
|
95
|
+
* shorthand commands. The shorthand subcommands exist purely as
|
|
96
|
+
* founder-friendly aliases for the matching numeric level; their values
|
|
97
|
+
* mirror the Atheme defaults so `AOP nick ADD` produces the same access
|
|
98
|
+
* level an explicit `ACCESS nick ADD 5` would.
|
|
99
|
+
*
|
|
100
|
+
* `SOP` has no `AUTO*` threshold wired to it (this server has no `+a`
|
|
101
|
+
* protect prefix); it is nonetheless tracked so founders can tier
|
|
102
|
+
* senior ops above AOPs without a numeric incantation.
|
|
103
|
+
*/
|
|
104
|
+
export const SHORTHAND_LEVELS: Readonly<Record<'SOP' | 'AOP' | 'HOP' | 'VOP', number>> = {
|
|
105
|
+
VOP: DEFAULT_CHANNEL_LEVELS.AUTOVOICE,
|
|
106
|
+
HOP: DEFAULT_CHANNEL_LEVELS.AUTOHALFOP,
|
|
107
|
+
AOP: DEFAULT_CHANNEL_LEVELS.AUTOOP,
|
|
108
|
+
SOP: 10,
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/** Set of recognised shorthand verbs (uppercased). */
|
|
112
|
+
const SHORTHAND_VERBS = new Set<string>(['SOP', 'AOP', 'HOP', 'VOP']);
|
|
113
|
+
|
|
114
|
+
/** Ordered list of level op names, for LEVELS LIST and validation. */
|
|
115
|
+
const LEVEL_OPS: readonly ChannelLevelOp[] = ['AUTOOP', 'AUTOHALFOP', 'AUTOVOICE'];
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Reducer for `PRIVMSG ChanServ :<subcommand> <args…>`.
|
|
119
|
+
* Authority: the invoking connection's {@link ConnectionState}.
|
|
120
|
+
*/
|
|
121
|
+
export const chanservReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
|
|
122
|
+
state.lastSeen = ctx.clock.now();
|
|
123
|
+
|
|
124
|
+
const effects: EffectType[] = [];
|
|
125
|
+
|
|
126
|
+
if (ctx.services === undefined) {
|
|
127
|
+
effects.push(notice(state, 'Services are not available on this server.'));
|
|
128
|
+
return { state, effects };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const body = msg.params[1] ?? '';
|
|
132
|
+
const tokens = body.split(/\s+/u).filter((t) => t.length > 0);
|
|
133
|
+
if (tokens.length === 0) {
|
|
134
|
+
effects.push(helpNotice(state));
|
|
135
|
+
return { state, effects };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const sub = tokens[0]?.toUpperCase();
|
|
139
|
+
const args = tokens.slice(1);
|
|
140
|
+
|
|
141
|
+
switch (sub) {
|
|
142
|
+
case 'REGISTER':
|
|
143
|
+
return handleRegister(state, args, ctx, effects);
|
|
144
|
+
case 'DROP':
|
|
145
|
+
return handleDrop(state, args, ctx, effects);
|
|
146
|
+
case 'INFO':
|
|
147
|
+
return handleInfo(state, args, ctx, effects);
|
|
148
|
+
case 'SET':
|
|
149
|
+
return handleSet(state, args, ctx, effects);
|
|
150
|
+
case 'ACCESS':
|
|
151
|
+
return handleAccess(state, args, ctx, effects);
|
|
152
|
+
case 'LEVELS':
|
|
153
|
+
return handleLevels(state, args, ctx, effects);
|
|
154
|
+
default:
|
|
155
|
+
if (sub !== undefined && SHORTHAND_VERBS.has(sub)) {
|
|
156
|
+
return handleShorthand(state, sub, args, ctx, effects);
|
|
157
|
+
}
|
|
158
|
+
effects.push(unknownNotice(state));
|
|
159
|
+
return { state, effects };
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
// ============================================================================
|
|
164
|
+
// REGISTER
|
|
165
|
+
// ============================================================================
|
|
166
|
+
|
|
167
|
+
function handleRegister(
|
|
168
|
+
state: ConnectionState,
|
|
169
|
+
args: string[],
|
|
170
|
+
ctx: Ctx,
|
|
171
|
+
effects: EffectType[],
|
|
172
|
+
): { state: ConnectionState; effects: EffectType[] } {
|
|
173
|
+
const services = ctx.services as ServicesStore;
|
|
174
|
+
const channel = args[0];
|
|
175
|
+
|
|
176
|
+
if (channel === undefined || !isValidChannelName(channel, ctx.serverConfig.channelLen)) {
|
|
177
|
+
effects.push(notice(state, 'Syntax: REGISTER <#channel>'));
|
|
178
|
+
return { state, effects };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (state.account === undefined) {
|
|
182
|
+
effects.push(notice(state, 'You must identify before registering a channel.'));
|
|
183
|
+
return { state, effects };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const result = services.registerChannel(channel, state.account);
|
|
187
|
+
if (!result.ok) {
|
|
188
|
+
effects.push(notice(state, `Channel ${channel} is already registered.`));
|
|
189
|
+
return { state, effects };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Set channel mode +r via an ApplyChannelDelta effect. The actor layer
|
|
193
|
+
// applies this to the authoritative ChannelState.
|
|
194
|
+
effects.push(Effect.applyChannelDelta(channel, setRegisteredDelta()));
|
|
195
|
+
effects.push(notice(state, `Channel ${channel} is now registered.`));
|
|
196
|
+
return { state, effects };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ============================================================================
|
|
200
|
+
// DROP
|
|
201
|
+
// ============================================================================
|
|
202
|
+
|
|
203
|
+
function handleDrop(
|
|
204
|
+
state: ConnectionState,
|
|
205
|
+
args: string[],
|
|
206
|
+
ctx: Ctx,
|
|
207
|
+
effects: EffectType[],
|
|
208
|
+
): { state: ConnectionState; effects: EffectType[] } {
|
|
209
|
+
const services = ctx.services as ServicesStore;
|
|
210
|
+
const channel = args[0];
|
|
211
|
+
|
|
212
|
+
if (channel === undefined || !isValidChannelName(channel, ctx.serverConfig.channelLen)) {
|
|
213
|
+
effects.push(notice(state, 'Syntax: DROP <#channel>'));
|
|
214
|
+
return { state, effects };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (state.account === undefined) {
|
|
218
|
+
effects.push(notice(state, 'You must identify before dropping a channel.'));
|
|
219
|
+
return { state, effects };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const founder = services.getChannelFounder(channel);
|
|
223
|
+
if (founder === undefined) {
|
|
224
|
+
effects.push(notice(state, `Channel ${channel} is not registered.`));
|
|
225
|
+
return { state, effects };
|
|
226
|
+
}
|
|
227
|
+
if (caseFold('rfc1459', founder) !== caseFold('rfc1459', state.account)) {
|
|
228
|
+
effects.push(notice(state, `Only the founder of ${channel} may drop it.`));
|
|
229
|
+
return { state, effects };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
services.dropChannel(channel);
|
|
233
|
+
// Clear the services-derived channel modes (r/R/M) on drop so the channel
|
|
234
|
+
// goes back to unregistered behaviour.
|
|
235
|
+
effects.push(Effect.applyChannelDelta(channel, clearServicesModesDelta()));
|
|
236
|
+
effects.push(notice(state, `Channel ${channel} has been dropped.`));
|
|
237
|
+
return { state, effects };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ============================================================================
|
|
241
|
+
// INFO
|
|
242
|
+
// ============================================================================
|
|
243
|
+
|
|
244
|
+
function handleInfo(
|
|
245
|
+
state: ConnectionState,
|
|
246
|
+
args: string[],
|
|
247
|
+
ctx: Ctx,
|
|
248
|
+
effects: EffectType[],
|
|
249
|
+
): { state: ConnectionState; effects: EffectType[] } {
|
|
250
|
+
const services = ctx.services as ServicesStore;
|
|
251
|
+
const channel = args[0];
|
|
252
|
+
|
|
253
|
+
if (channel === undefined || !isValidChannelName(channel, ctx.serverConfig.channelLen)) {
|
|
254
|
+
effects.push(notice(state, 'Syntax: INFO <#channel>'));
|
|
255
|
+
return { state, effects };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const founder = services.getChannelFounder(channel);
|
|
259
|
+
if (founder === undefined) {
|
|
260
|
+
effects.push(notice(state, `Channel ${channel} is not registered.`));
|
|
261
|
+
return { state, effects };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
effects.push(notice(state, `Channel: ${channel}`));
|
|
265
|
+
effects.push(notice(state, `Founder: ${founder}`));
|
|
266
|
+
return { state, effects };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ============================================================================
|
|
270
|
+
// SET
|
|
271
|
+
// ============================================================================
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Handles `SET <subkey> <args…>`. Routes to the matching subcommand handler.
|
|
275
|
+
* All subcommands require the caller to be the channel founder.
|
|
276
|
+
*/
|
|
277
|
+
function handleSet(
|
|
278
|
+
state: ConnectionState,
|
|
279
|
+
args: string[],
|
|
280
|
+
ctx: Ctx,
|
|
281
|
+
effects: EffectType[],
|
|
282
|
+
): { state: ConnectionState; effects: EffectType[] } {
|
|
283
|
+
const services = ctx.services as ServicesStore;
|
|
284
|
+
const subkey = args[0]?.toUpperCase();
|
|
285
|
+
const channelArg = args[1];
|
|
286
|
+
const rest = args.slice(2);
|
|
287
|
+
|
|
288
|
+
// Validate channel name first; every SET subcommand requires one.
|
|
289
|
+
if (channelArg === undefined || !isValidChannelName(channelArg, ctx.serverConfig.channelLen)) {
|
|
290
|
+
effects.push(setSyntaxNotice(state, subkey));
|
|
291
|
+
return { state, effects };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (state.account === undefined) {
|
|
295
|
+
effects.push(notice(state, 'You must identify before changing channel settings.'));
|
|
296
|
+
return { state, effects };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const founder = services.getChannelFounder(channelArg);
|
|
300
|
+
if (founder === undefined) {
|
|
301
|
+
effects.push(notice(state, `Channel ${channelArg} is not registered.`));
|
|
302
|
+
return { state, effects };
|
|
303
|
+
}
|
|
304
|
+
if (caseFold('rfc1459', founder) !== caseFold('rfc1459', state.account)) {
|
|
305
|
+
effects.push(notice(state, `Only the founder of ${channelArg} may change its settings.`));
|
|
306
|
+
return { state, effects };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
switch (subkey) {
|
|
310
|
+
case 'FOUNDER':
|
|
311
|
+
return handleSetFounder(state, channelArg, rest, ctx, effects);
|
|
312
|
+
case 'MLOCK':
|
|
313
|
+
return handleSetMlock(state, channelArg, rest, ctx, effects);
|
|
314
|
+
case 'RESTRICTED':
|
|
315
|
+
return handleSetRestricted(state, channelArg, rest, ctx, effects);
|
|
316
|
+
case 'KEEPTOPIC':
|
|
317
|
+
return handleSetKeepTopic(state, channelArg, rest, ctx, effects);
|
|
318
|
+
default:
|
|
319
|
+
effects.push(setSyntaxNotice(state, undefined));
|
|
320
|
+
return { state, effects };
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function handleSetFounder(
|
|
325
|
+
state: ConnectionState,
|
|
326
|
+
channel: string,
|
|
327
|
+
args: string[],
|
|
328
|
+
ctx: Ctx,
|
|
329
|
+
effects: EffectType[],
|
|
330
|
+
): { state: ConnectionState; effects: EffectType[] } {
|
|
331
|
+
const services = ctx.services as ServicesStore;
|
|
332
|
+
const newFounder = args[0];
|
|
333
|
+
if (newFounder === undefined) {
|
|
334
|
+
effects.push(notice(state, 'Syntax: SET FOUNDER <#channel> <account>'));
|
|
335
|
+
return { state, effects };
|
|
336
|
+
}
|
|
337
|
+
// The new founder must have a registered nick (account exists).
|
|
338
|
+
if (!services.isRegisteredNick(newFounder)) {
|
|
339
|
+
effects.push(notice(state, `Nick ${newFounder} is not registered.`));
|
|
340
|
+
return { state, effects };
|
|
341
|
+
}
|
|
342
|
+
services.setChannelFounder(channel, newFounder);
|
|
343
|
+
effects.push(notice(state, `Founder for ${channel} is now set to ${newFounder}.`));
|
|
344
|
+
return { state, effects };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function handleSetMlock(
|
|
348
|
+
state: ConnectionState,
|
|
349
|
+
channel: string,
|
|
350
|
+
args: string[],
|
|
351
|
+
ctx: Ctx,
|
|
352
|
+
effects: EffectType[],
|
|
353
|
+
): { state: ConnectionState; effects: EffectType[] } {
|
|
354
|
+
const services = ctx.services as ServicesStore;
|
|
355
|
+
// MLOCK accepts a single modestring argument; an empty modestring (or the
|
|
356
|
+
// literal `*` per Atheme convention) clears the lock. The token arrives as
|
|
357
|
+
// args[0]; the splitter would have dropped a truly-empty token, so a `*`
|
|
358
|
+
// sentinel or absent arg means clear.
|
|
359
|
+
const modestring = args[0];
|
|
360
|
+
if (modestring === undefined || modestring === '*') {
|
|
361
|
+
services.setChannelMlock(channel, '');
|
|
362
|
+
effects.push(notice(state, `Mode lock for ${channel} has been cleared.`));
|
|
363
|
+
return { state, effects };
|
|
364
|
+
}
|
|
365
|
+
if (!isValidMlockString(modestring)) {
|
|
366
|
+
effects.push(notice(state, `Invalid mode lock: ${modestring}`));
|
|
367
|
+
return { state, effects };
|
|
368
|
+
}
|
|
369
|
+
services.setChannelMlock(channel, modestring);
|
|
370
|
+
effects.push(notice(state, `Mode lock for ${channel} is now set to ${modestring}.`));
|
|
371
|
+
return { state, effects };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function handleSetRestricted(
|
|
375
|
+
state: ConnectionState,
|
|
376
|
+
channel: string,
|
|
377
|
+
args: string[],
|
|
378
|
+
ctx: Ctx,
|
|
379
|
+
effects: EffectType[],
|
|
380
|
+
): { state: ConnectionState; effects: EffectType[] } {
|
|
381
|
+
const services = ctx.services as ServicesStore;
|
|
382
|
+
const value = args[0]?.toUpperCase();
|
|
383
|
+
if (value === undefined || !ON_OFF.has(value)) {
|
|
384
|
+
effects.push(notice(state, 'Syntax: SET RESTRICTED <#channel> ON|OFF'));
|
|
385
|
+
return { state, effects };
|
|
386
|
+
}
|
|
387
|
+
const on = value === 'ON';
|
|
388
|
+
services.setChannelRestricted(channel, on);
|
|
389
|
+
// SET RESTRICTED toggles the +R channel mode (ChanServ-driven).
|
|
390
|
+
effects.push(
|
|
391
|
+
Effect.applyChannelDelta(channel, { modeChanges: [{ mode: 'blockUnidentified', set: on }] }),
|
|
392
|
+
);
|
|
393
|
+
effects.push(notice(state, `Restricted (block unidentified) for ${channel} is now ${value}.`));
|
|
394
|
+
return { state, effects };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function handleSetKeepTopic(
|
|
398
|
+
state: ConnectionState,
|
|
399
|
+
channel: string,
|
|
400
|
+
args: string[],
|
|
401
|
+
ctx: Ctx,
|
|
402
|
+
effects: EffectType[],
|
|
403
|
+
): { state: ConnectionState; effects: EffectType[] } {
|
|
404
|
+
const services = ctx.services as ServicesStore;
|
|
405
|
+
const value = args[0]?.toUpperCase();
|
|
406
|
+
if (value === undefined || !ON_OFF.has(value)) {
|
|
407
|
+
effects.push(notice(state, 'Syntax: SET KEEPTOPIC <#channel> ON|OFF'));
|
|
408
|
+
return { state, effects };
|
|
409
|
+
}
|
|
410
|
+
const on = value === 'ON';
|
|
411
|
+
services.setChannelKeepTopic(channel, on);
|
|
412
|
+
effects.push(notice(state, `Keep-topic for ${channel} is now ${value}.`));
|
|
413
|
+
return { state, effects };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// ============================================================================
|
|
417
|
+
// ACCESS
|
|
418
|
+
// ============================================================================
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Validates the founder invariant for an ACCESS / LEVELS / shorthand
|
|
422
|
+
* subcommand. On success returns the bound {@link ServicesStore}; on
|
|
423
|
+
* failure pushes the matching notice into `effects` and returns `null`.
|
|
424
|
+
*
|
|
425
|
+
* Mirrors the founder-precondition block in {@link handleSet} so the
|
|
426
|
+
* access surface shares a single error-message shape with `SET`.
|
|
427
|
+
*/
|
|
428
|
+
function requireFounderForChannel(
|
|
429
|
+
state: ConnectionState,
|
|
430
|
+
channel: string,
|
|
431
|
+
ctx: Ctx,
|
|
432
|
+
effects: EffectType[],
|
|
433
|
+
): ServicesStore | null {
|
|
434
|
+
const services = ctx.services as ServicesStore;
|
|
435
|
+
if (state.account === undefined) {
|
|
436
|
+
effects.push(notice(state, 'You must identify before changing channel settings.'));
|
|
437
|
+
return null;
|
|
438
|
+
}
|
|
439
|
+
const founder = services.getChannelFounder(channel);
|
|
440
|
+
if (founder === undefined) {
|
|
441
|
+
effects.push(notice(state, `Channel ${channel} is not registered.`));
|
|
442
|
+
return null;
|
|
443
|
+
}
|
|
444
|
+
if (caseFold('rfc1459', founder) !== caseFold('rfc1459', state.account)) {
|
|
445
|
+
effects.push(notice(state, `Only the founder of ${channel} may change its settings.`));
|
|
446
|
+
return null;
|
|
447
|
+
}
|
|
448
|
+
return services;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Handles `ACCESS <#channel> ADD|DEL|LIST [args]`.
|
|
453
|
+
*
|
|
454
|
+
* - `ACCESS <chan> ADD <account> <level>` — records (or replaces) the
|
|
455
|
+
* numeric access level for `account`. `<level>` must be a positive
|
|
456
|
+
* integer.
|
|
457
|
+
* - `ACCESS <chan> DEL <account>` — removes the access entry.
|
|
458
|
+
* - `ACCESS <chan> LIST` — enumerates the founder and every access
|
|
459
|
+
* entry with its level.
|
|
460
|
+
*/
|
|
461
|
+
function handleAccess(
|
|
462
|
+
state: ConnectionState,
|
|
463
|
+
args: string[],
|
|
464
|
+
ctx: Ctx,
|
|
465
|
+
effects: EffectType[],
|
|
466
|
+
): { state: ConnectionState; effects: EffectType[] } {
|
|
467
|
+
const channel = args[0];
|
|
468
|
+
if (channel === undefined || !isValidChannelName(channel, ctx.serverConfig.channelLen)) {
|
|
469
|
+
effects.push(notice(state, 'Syntax: ACCESS <#channel> ADD|DEL|LIST [args].'));
|
|
470
|
+
return { state, effects };
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const action = args[1]?.toUpperCase();
|
|
474
|
+
const rest = args.slice(2);
|
|
475
|
+
const services = ctx.services as ServicesStore;
|
|
476
|
+
|
|
477
|
+
if (action === 'LIST') {
|
|
478
|
+
const founder = services.getChannelFounder(channel);
|
|
479
|
+
if (founder === undefined) {
|
|
480
|
+
effects.push(notice(state, `Channel ${channel} is not registered.`));
|
|
481
|
+
return { state, effects };
|
|
482
|
+
}
|
|
483
|
+
effects.push(notice(state, `Channel: ${channel}`));
|
|
484
|
+
effects.push(notice(state, `Founder: ${founder}`));
|
|
485
|
+
for (const { account, level } of services.listChannelAccess(channel)) {
|
|
486
|
+
effects.push(notice(state, `Nick ${account} (level ${level})`));
|
|
487
|
+
}
|
|
488
|
+
effects.push(notice(state, 'End of access list.'));
|
|
489
|
+
return { state, effects };
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const founderServices = requireFounderForChannel(state, channel, ctx, effects);
|
|
493
|
+
if (founderServices === null) return { state, effects };
|
|
494
|
+
|
|
495
|
+
switch (action) {
|
|
496
|
+
case 'ADD':
|
|
497
|
+
return handleAccessAdd(state, channel, rest, founderServices, effects);
|
|
498
|
+
case 'DEL':
|
|
499
|
+
return handleAccessDel(state, channel, rest, founderServices, effects);
|
|
500
|
+
default:
|
|
501
|
+
effects.push(notice(state, 'Syntax: ACCESS <#channel> ADD|DEL|LIST [args].'));
|
|
502
|
+
return { state, effects };
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function handleAccessAdd(
|
|
507
|
+
state: ConnectionState,
|
|
508
|
+
channel: string,
|
|
509
|
+
args: string[],
|
|
510
|
+
services: ServicesStore,
|
|
511
|
+
effects: EffectType[],
|
|
512
|
+
): { state: ConnectionState; effects: EffectType[] } {
|
|
513
|
+
const target = args[0];
|
|
514
|
+
const levelToken = args[1];
|
|
515
|
+
if (target === undefined || levelToken === undefined) {
|
|
516
|
+
effects.push(notice(state, 'Syntax: ACCESS <#channel> ADD <account> <level>.'));
|
|
517
|
+
return { state, effects };
|
|
518
|
+
}
|
|
519
|
+
const level = Number.parseInt(levelToken, 10);
|
|
520
|
+
if (!Number.isInteger(level) || level <= 0) {
|
|
521
|
+
effects.push(notice(state, 'Syntax: ACCESS <#channel> ADD <account> <level>.'));
|
|
522
|
+
return { state, effects };
|
|
523
|
+
}
|
|
524
|
+
if (!services.isRegisteredNick(target)) {
|
|
525
|
+
effects.push(notice(state, `Nick ${target} is not registered.`));
|
|
526
|
+
return { state, effects };
|
|
527
|
+
}
|
|
528
|
+
services.setChannelAccess(channel, target, level);
|
|
529
|
+
effects.push(
|
|
530
|
+
notice(
|
|
531
|
+
state,
|
|
532
|
+
`${capitalize(target)} added to the access list for ${channel} at level ${level}.`,
|
|
533
|
+
),
|
|
534
|
+
);
|
|
535
|
+
return { state, effects };
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function handleAccessDel(
|
|
539
|
+
state: ConnectionState,
|
|
540
|
+
channel: string,
|
|
541
|
+
args: string[],
|
|
542
|
+
services: ServicesStore,
|
|
543
|
+
effects: EffectType[],
|
|
544
|
+
): { state: ConnectionState; effects: EffectType[] } {
|
|
545
|
+
const target = args[0];
|
|
546
|
+
if (target === undefined) {
|
|
547
|
+
effects.push(notice(state, 'Syntax: ACCESS <#channel> DEL <account>.'));
|
|
548
|
+
return { state, effects };
|
|
549
|
+
}
|
|
550
|
+
services.setChannelAccess(channel, target, 0);
|
|
551
|
+
effects.push(notice(state, `${capitalize(target)} removed from the access list for ${channel}.`));
|
|
552
|
+
return { state, effects };
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// ============================================================================
|
|
556
|
+
// SOP / AOP / HOP / VOP shorthand
|
|
557
|
+
// ============================================================================
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Handles `SOP|AOP|HOP|VOP <#channel> ADD|DEL|LIST [account]` shorthand.
|
|
561
|
+
*
|
|
562
|
+
* The verb selects a fixed numeric level from {@link SHORTHAND_LEVELS}
|
|
563
|
+
* (mirroring the Atheme convention) and routes the request through the
|
|
564
|
+
* canonical {@link handleAccessAdd} / {@link handleAccessDel} helpers so
|
|
565
|
+
* the only divergence between `AOP #chan ADD nick` and the explicit
|
|
566
|
+
* `ACCESS #chan ADD nick 5` is the level source.
|
|
567
|
+
*/
|
|
568
|
+
function handleShorthand(
|
|
569
|
+
state: ConnectionState,
|
|
570
|
+
verb: string,
|
|
571
|
+
args: string[],
|
|
572
|
+
ctx: Ctx,
|
|
573
|
+
effects: EffectType[],
|
|
574
|
+
): { state: ConnectionState; effects: EffectType[] } {
|
|
575
|
+
const channel = args[0];
|
|
576
|
+
if (channel === undefined || !isValidChannelName(channel, ctx.serverConfig.channelLen)) {
|
|
577
|
+
effects.push(notice(state, `Syntax: ${verb} <#channel> ADD|DEL|LIST [account].`));
|
|
578
|
+
return { state, effects };
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const action = args[1]?.toUpperCase();
|
|
582
|
+
const rest = args.slice(2);
|
|
583
|
+
|
|
584
|
+
if (action === 'LIST') {
|
|
585
|
+
return handleAccess(state, [channel, 'LIST'], ctx, effects);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const services = requireFounderForChannel(state, channel, ctx, effects);
|
|
589
|
+
if (services === null) return { state, effects };
|
|
590
|
+
|
|
591
|
+
// `verb` is one of SOP/AOP/HOP/VOP at the dispatch site, so the lookup
|
|
592
|
+
// is total; the cast keeps TS happy without a runtime branch.
|
|
593
|
+
const verbKey = verb as keyof typeof SHORTHAND_LEVELS;
|
|
594
|
+
const level = SHORTHAND_LEVELS[verbKey];
|
|
595
|
+
|
|
596
|
+
switch (action) {
|
|
597
|
+
case 'ADD':
|
|
598
|
+
return handleShorthandAdd(state, verb, channel, rest, level, services, effects);
|
|
599
|
+
case 'DEL':
|
|
600
|
+
return handleShorthandDel(state, verb, channel, rest, services, effects);
|
|
601
|
+
default:
|
|
602
|
+
effects.push(notice(state, `Syntax: ${verb} <#channel> ADD|DEL|LIST [account].`));
|
|
603
|
+
return { state, effects };
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function handleShorthandAdd(
|
|
608
|
+
state: ConnectionState,
|
|
609
|
+
verb: string,
|
|
610
|
+
channel: string,
|
|
611
|
+
args: string[],
|
|
612
|
+
level: number,
|
|
613
|
+
services: ServicesStore,
|
|
614
|
+
effects: EffectType[],
|
|
615
|
+
): { state: ConnectionState; effects: EffectType[] } {
|
|
616
|
+
const target = args[0];
|
|
617
|
+
if (target === undefined) {
|
|
618
|
+
effects.push(notice(state, `Syntax: ${verb} <#channel> ADD|DEL|LIST [account].`));
|
|
619
|
+
return { state, effects };
|
|
620
|
+
}
|
|
621
|
+
if (!services.isRegisteredNick(target)) {
|
|
622
|
+
effects.push(notice(state, `Nick ${target} is not registered.`));
|
|
623
|
+
return { state, effects };
|
|
624
|
+
}
|
|
625
|
+
services.setChannelAccess(channel, target, level);
|
|
626
|
+
effects.push(
|
|
627
|
+
notice(
|
|
628
|
+
state,
|
|
629
|
+
`${capitalize(target)} added to the access list for ${channel} at level ${level}.`,
|
|
630
|
+
),
|
|
631
|
+
);
|
|
632
|
+
return { state, effects };
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function handleShorthandDel(
|
|
636
|
+
state: ConnectionState,
|
|
637
|
+
verb: string,
|
|
638
|
+
channel: string,
|
|
639
|
+
args: string[],
|
|
640
|
+
services: ServicesStore,
|
|
641
|
+
effects: EffectType[],
|
|
642
|
+
): { state: ConnectionState; effects: EffectType[] } {
|
|
643
|
+
const target = args[0];
|
|
644
|
+
if (target === undefined) {
|
|
645
|
+
effects.push(notice(state, `Syntax: ${verb} <#channel> ADD|DEL|LIST [account].`));
|
|
646
|
+
return { state, effects };
|
|
647
|
+
}
|
|
648
|
+
services.setChannelAccess(channel, target, 0);
|
|
649
|
+
effects.push(notice(state, `${capitalize(target)} removed from the access list for ${channel}.`));
|
|
650
|
+
return { state, effects };
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// ============================================================================
|
|
654
|
+
// LEVELS
|
|
655
|
+
// ============================================================================
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* Handles `LEVELS <#channel> SET|LIST|RESET [args]`.
|
|
659
|
+
*
|
|
660
|
+
* - `LEVELS <chan> SET <op> <level>` — redefines the numeric threshold
|
|
661
|
+
* the JOIN hook compares against for the `AUTO*` op. `<op>` must be
|
|
662
|
+
* one of {@link LEVEL_OPS}.
|
|
663
|
+
* - `LEVELS <chan> LIST` — enumerates every threshold, falling back
|
|
664
|
+
* to the {@link DEFAULT_CHANNEL_LEVELS} default for ops the founder
|
|
665
|
+
* has not overridden.
|
|
666
|
+
* - `LEVELS <chan> RESET` — drops every override.
|
|
667
|
+
*/
|
|
668
|
+
function handleLevels(
|
|
669
|
+
state: ConnectionState,
|
|
670
|
+
args: string[],
|
|
671
|
+
ctx: Ctx,
|
|
672
|
+
effects: EffectType[],
|
|
673
|
+
): { state: ConnectionState; effects: EffectType[] } {
|
|
674
|
+
const channel = args[0];
|
|
675
|
+
if (channel === undefined || !isValidChannelName(channel, ctx.serverConfig.channelLen)) {
|
|
676
|
+
effects.push(notice(state, 'Syntax: LEVELS <#channel> SET|LIST|RESET [args].'));
|
|
677
|
+
return { state, effects };
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
const action = args[1]?.toUpperCase();
|
|
681
|
+
const services = ctx.services as ServicesStore;
|
|
682
|
+
|
|
683
|
+
if (action === 'LIST') {
|
|
684
|
+
const founder = services.getChannelFounder(channel);
|
|
685
|
+
if (founder === undefined) {
|
|
686
|
+
effects.push(notice(state, `Channel ${channel} is not registered.`));
|
|
687
|
+
return { state, effects };
|
|
688
|
+
}
|
|
689
|
+
effects.push(notice(state, `Channel: ${channel}`));
|
|
690
|
+
const overrides = new Map<ChannelLevelOp, number>();
|
|
691
|
+
for (const { op, level } of services.listChannelLevels(channel)) overrides.set(op, level);
|
|
692
|
+
for (const op of LEVEL_OPS) {
|
|
693
|
+
const level = overrides.get(op) ?? DEFAULT_CHANNEL_LEVELS[op];
|
|
694
|
+
effects.push(notice(state, `${op} ${level}`));
|
|
695
|
+
}
|
|
696
|
+
effects.push(notice(state, 'End of level list.'));
|
|
697
|
+
return { state, effects };
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
const founderServices = requireFounderForChannel(state, channel, ctx, effects);
|
|
701
|
+
if (founderServices === null) return { state, effects };
|
|
702
|
+
|
|
703
|
+
if (action === 'RESET') {
|
|
704
|
+
founderServices.resetChannelLevels(channel);
|
|
705
|
+
effects.push(notice(state, `Levels for ${channel} reset to defaults.`));
|
|
706
|
+
return { state, effects };
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
if (action === 'SET') {
|
|
710
|
+
const opName = args[2]?.toUpperCase();
|
|
711
|
+
const levelToken = args[3];
|
|
712
|
+
if (opName === undefined || levelToken === undefined) {
|
|
713
|
+
effects.push(notice(state, 'Syntax: LEVELS <#channel> SET <op> <level>.'));
|
|
714
|
+
return { state, effects };
|
|
715
|
+
}
|
|
716
|
+
if (!isKnownLevelOp(opName)) {
|
|
717
|
+
effects.push(notice(state, `Unknown level ${opName}. Available: ${LEVEL_OPS.join(', ')}.`));
|
|
718
|
+
return { state, effects };
|
|
719
|
+
}
|
|
720
|
+
const level = Number.parseInt(levelToken, 10);
|
|
721
|
+
if (!Number.isInteger(level)) {
|
|
722
|
+
effects.push(notice(state, 'Syntax: LEVELS <#channel> SET <op> <level>.'));
|
|
723
|
+
return { state, effects };
|
|
724
|
+
}
|
|
725
|
+
founderServices.setChannelLevel(channel, opName, level);
|
|
726
|
+
effects.push(notice(state, `Level ${opName} for ${channel} is now ${level}.`));
|
|
727
|
+
return { state, effects };
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
effects.push(notice(state, 'Syntax: LEVELS <#channel> SET|LIST|RESET [args].'));
|
|
731
|
+
return { state, effects };
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/** Returns true iff `op` is a recognised level-threshold name. */
|
|
735
|
+
function isKnownLevelOp(op: string): op is ChannelLevelOp {
|
|
736
|
+
return LEVEL_OPS.includes(op as ChannelLevelOp);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* Capitalises the first character of `value` (display helper). Callers
|
|
741
|
+
* pre-validate that `value` is non-empty (the token splitter drops empty
|
|
742
|
+
* args), so the empty-string degenerate case returns the empty string
|
|
743
|
+
* via `charAt(0)` returning `''` — no explicit length check needed.
|
|
744
|
+
*/
|
|
745
|
+
function capitalize(value: string): string {
|
|
746
|
+
return value.charAt(0).toUpperCase() + value.slice(1);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// ============================================================================
|
|
750
|
+
// Notice helpers
|
|
751
|
+
// ============================================================================
|
|
752
|
+
|
|
753
|
+
/** Builds a `:ChanServ!ChanServ@services NOTICE <nick> :<text>` line. */
|
|
754
|
+
function notice(state: ConnectionState, text: string): EffectType {
|
|
755
|
+
return Effect.send(state.id, [servicesNotice(CHANSERV_NICK, state, text)]);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/** Builds a help `:ChanServ!ChanServ@services NOTICE <nick> :…` line. */
|
|
759
|
+
function helpNotice(state: ConnectionState): EffectType {
|
|
760
|
+
return notice(
|
|
761
|
+
state,
|
|
762
|
+
'Available commands: REGISTER, DROP, INFO, SET, ACCESS, LEVELS, SOP, AOP, HOP, VOP',
|
|
763
|
+
);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function unknownNotice(state: ConnectionState): EffectType {
|
|
767
|
+
return notice(
|
|
768
|
+
state,
|
|
769
|
+
'Unknown command. Available: REGISTER, DROP, INFO, SET, ACCESS, LEVELS, SOP, AOP, HOP, VOP',
|
|
770
|
+
);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/** Builds a `SET <subkey>` syntax NOTICE; `subkey === undefined` lists every subcommand. */
|
|
774
|
+
function setSyntaxNotice(state: ConnectionState, subkey: string | undefined): EffectType {
|
|
775
|
+
if (subkey === 'FOUNDER') return notice(state, 'Syntax: SET FOUNDER <#channel> <account>');
|
|
776
|
+
if (subkey === 'MLOCK') return notice(state, 'Syntax: SET MLOCK <#channel> <modestring>');
|
|
777
|
+
if (subkey === 'RESTRICTED') return notice(state, 'Syntax: SET RESTRICTED <#channel> ON|OFF');
|
|
778
|
+
if (subkey === 'KEEPTOPIC') return notice(state, 'Syntax: SET KEEPTOPIC <#channel> ON|OFF');
|
|
779
|
+
return notice(state, 'Available SET subcommands: FOUNDER, MLOCK, RESTRICTED, KEEPTOPIC');
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* Formats a services NOTICE line. The source hostmask is
|
|
784
|
+
* `<svc>!<svc>@services` regardless of the deployment's serverName so the
|
|
785
|
+
* pseudo-clients are stable across deployments and easy to identify in
|
|
786
|
+
* client UIs.
|
|
787
|
+
*/
|
|
788
|
+
function servicesNotice(svc: string, state: ConnectionState, text: string): RawLine {
|
|
789
|
+
const nick = state.nick ?? '*';
|
|
790
|
+
return { text: `:${svc}!${svc}@${SERVICES_HOST} NOTICE ${nick} :${text}` };
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// ============================================================================
|
|
794
|
+
// Mode-delta helpers
|
|
795
|
+
// ============================================================================
|
|
796
|
+
|
|
797
|
+
/** Builds a delta that sets the `+r` (registered) channel mode. */
|
|
798
|
+
export function setRegisteredDelta(): ChannelDelta {
|
|
799
|
+
return { modeChanges: [{ mode: 'registered', set: true }] };
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/** Builds a delta that clears every services-derived channel mode (r/R/M). */
|
|
803
|
+
export function clearServicesModesDelta(): ChannelDelta {
|
|
804
|
+
return {
|
|
805
|
+
modeChanges: [
|
|
806
|
+
{ mode: 'registered', set: false },
|
|
807
|
+
{ mode: 'blockUnidentified', set: false },
|
|
808
|
+
{ mode: 'moderatedIdent', set: false },
|
|
809
|
+
],
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// ============================================================================
|
|
814
|
+
// MLOCK validation
|
|
815
|
+
// ============================================================================
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* Set of channel-mode letters that may appear in an MLOCK string. Booleans
|
|
819
|
+
* (i/t/n/m/s/p/r/R/M), parameterized-on-set (k/l), and prefix (o/v) plus the
|
|
820
|
+
* list mode (b) are all permitted;ChanServ reapplies only the boolean ones
|
|
821
|
+
* (parameterized modes are sticky via ChanServ-set state, not auto-set on
|
|
822
|
+
* every op MODE).
|
|
823
|
+
*/
|
|
824
|
+
const MLOCK_ALLOWED_LETTERS = new Set<string>([
|
|
825
|
+
'i',
|
|
826
|
+
't',
|
|
827
|
+
'n',
|
|
828
|
+
'm',
|
|
829
|
+
's',
|
|
830
|
+
'p',
|
|
831
|
+
'r',
|
|
832
|
+
'R',
|
|
833
|
+
'M',
|
|
834
|
+
'k',
|
|
835
|
+
'l',
|
|
836
|
+
'o',
|
|
837
|
+
'v',
|
|
838
|
+
'b',
|
|
839
|
+
]);
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* Returns true iff `modestring` is a syntactically-valid MLOCK argument: an
|
|
843
|
+
* optional leading sign, then one or more allowed mode letters, optionally
|
|
844
|
+
* followed by a space and arguments for parameterized modes (passed through
|
|
845
|
+
* verbatim; ChanServ does not currently reapply them).
|
|
846
|
+
*/
|
|
847
|
+
function isValidMlockString(modestring: string): boolean {
|
|
848
|
+
// Allow a bare sign-prefixed modestring (the common case). A leading `+`
|
|
849
|
+
// is implicit when omitted; the canonical form stores the explicit sign.
|
|
850
|
+
let s = modestring;
|
|
851
|
+
if (s.length > 0 && (s[0] === '+' || s[0] === '-')) {
|
|
852
|
+
s = s.slice(1);
|
|
853
|
+
}
|
|
854
|
+
if (s.length === 0) return false;
|
|
855
|
+
for (const ch of s) {
|
|
856
|
+
if (!MLOCK_ALLOWED_LETTERS.has(ch)) return false;
|
|
857
|
+
}
|
|
858
|
+
return true;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/**
|
|
862
|
+
* Parses the locked-mode letters out of a stored MLOCK modestring. Returns
|
|
863
|
+
* the set of boolean-mode letters that are positively locked (i.e. signed
|
|
864
|
+
* `+` or unsigned). Used by the MODE reducer to reapply modes an op tried to
|
|
865
|
+
* unset.
|
|
866
|
+
*/
|
|
867
|
+
export function positiveMlockLetters(mlock: string): Set<string> {
|
|
868
|
+
const out = new Set<string>();
|
|
869
|
+
let positive = true;
|
|
870
|
+
for (const ch of mlock) {
|
|
871
|
+
if (ch === '+') {
|
|
872
|
+
positive = true;
|
|
873
|
+
continue;
|
|
874
|
+
}
|
|
875
|
+
if (ch === '-') {
|
|
876
|
+
positive = false;
|
|
877
|
+
continue;
|
|
878
|
+
}
|
|
879
|
+
if (positive) out.add(ch);
|
|
880
|
+
}
|
|
881
|
+
return out;
|
|
882
|
+
}
|