serverless-ircd 0.7.0 → 0.9.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 +7 -3
- package/.gitmodules +1 -1
- package/CHANGELOG.md +518 -29
- package/README.md +221 -161
- 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-tcp-container/src/container-server.ts +21 -1
- package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
- package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
- 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 +95 -31
- package/apps/local-cli/tests/config-loader.test.ts +14 -0
- package/apps/local-cli/tests/config-resolution.test.ts +65 -0
- package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
- package/apps/local-cli/tests/rehash.test.ts +147 -0
- package/apps/local-cli/tests/server-helpers.test.ts +63 -0
- package/apps/local-cli/tests/tcp.test.ts +89 -0
- package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
- package/apps/web/landing/index.html +237 -16
- package/apps/web/package.json +3 -2
- package/apps/web/scripts/build.mjs +46 -16
- package/apps/web/src/build-env.ts +1 -1
- package/apps/web/src/config-schema.ts +6 -6
- package/apps/web/src/render-docs.ts +292 -0
- package/apps/web/tests/build-smoke.test.ts +43 -14
- package/apps/web/tests/config-schema.test.ts +1 -1
- package/apps/web/tests/landing-content.test.ts +103 -0
- package/apps/web/tests/render-docs.test.ts +198 -0
- package/docs/AWS-Adapter-Architecture.md +3 -2
- 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 +578 -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 +20 -1
- 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 +25 -2
- 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/aws-runtime.test.ts +23 -1
- 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 +144 -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/global-setup.ts +28 -1
- package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
- package/packages/aws-adapter/tests/handlers.test.ts +105 -3
- package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
- package/packages/aws-adapter/tests/sweeper.test.ts +20 -0
- 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 +130 -8
- 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-pure.test.ts +130 -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 +200 -0
- package/packages/irc-core/src/commands/chanserv.ts +1166 -0
- package/packages/irc-core/src/commands/hostserv.ts +522 -0
- package/packages/irc-core/src/commands/index.ts +13 -0
- package/packages/irc-core/src/commands/join.ts +171 -9
- 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 +402 -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 +78 -12
- package/packages/irc-core/src/commands/sasl.ts +18 -49
- package/packages/irc-core/src/commands/service-aliases.ts +52 -0
- package/packages/irc-core/src/commands/tagmsg.ts +41 -6
- package/packages/irc-core/src/commands/topic.ts +60 -10
- 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 +38 -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 +2335 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +6 -0
- package/packages/irc-core/tests/commands/hostserv.test.ts +1006 -0
- package/packages/irc-core/tests/commands/join.test.ts +572 -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 +990 -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 +1015 -20
- package/packages/irc-core/tests/commands/sasl.test.ts +229 -12
- package/packages/irc-core/tests/commands/server-info.test.ts +9 -5
- package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
- 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 +121 -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 +988 -15
- 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 +37 -8
- package/packages/irc-test-support/src/index.ts +1 -0
- package/packages/irc-test-support/src/scenarios.ts +21 -6
- package/packages/irc-test-support/tests/in-memory-harness.test.ts +51 -0
- package/packages/irc-test-support/vitest.config.ts +6 -1
- package/tools/ci-hardening/package.json +1 -1
- package/tools/load-test/package.json +1 -1
- package/tools/load-test/src/client.ts +13 -13
- package/tools/load-test/tests/client.test.ts +258 -2
- package/tools/load-test/tests/config.test.ts +39 -0
- package/tools/load-test/tests/harness.test.ts +21 -0
- package/tools/load-test/tests/metrics.test.ts +7 -0
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
- package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +53 -2
- package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
- 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,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure reducer for the MemoServ services pseudo-client.
|
|
3
|
+
*
|
|
4
|
+
* MemoServ is invoked via `PRIVMSG MemoServ :<command>`. The actor routes
|
|
5
|
+
* any `PRIVMSG` whose target matches {@link MEMOSERV_NICK}
|
|
6
|
+
* (case-insensitive) to {@link memoservReducer} instead of the normal
|
|
7
|
+
* user-target reducer.
|
|
8
|
+
*
|
|
9
|
+
* Supported subcommands:
|
|
10
|
+
* - `SEND <nick> <message>` — records a memo against the recipient's
|
|
11
|
+
* registered account. The caller must be identified (NickServ `+r`).
|
|
12
|
+
* The memo is queued and delivered as a MemoServ NOTICE on the
|
|
13
|
+
* recipient's next NickServ IDENTIFY (handled by the NickServ
|
|
14
|
+
* reducer's `applyIdentify` path).
|
|
15
|
+
* - `LIST` — enumerates the caller's unread memos (id, sender, body).
|
|
16
|
+
* - `READ <n>` — delivers memo #n to the caller as a NOTICE and marks
|
|
17
|
+
* it read.
|
|
18
|
+
* - `DEL <n>` — removes memo #n from the caller's inbox.
|
|
19
|
+
*
|
|
20
|
+
* The reducer is a pure `Reducer<ConnectionState>`: all side effects are
|
|
21
|
+
* emitted as {@link Effect}s. The services store is synchronous (mirrors
|
|
22
|
+
* the {@link AccountStore} / {@link MessageStore} pattern).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { caseFold } from '../case-fold.js';
|
|
26
|
+
import { Effect } from '../effects.js';
|
|
27
|
+
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
28
|
+
import type { MemoEntry, ServicesStore } from '../ports.js';
|
|
29
|
+
import type { ConnectionState } from '../state/connection.js';
|
|
30
|
+
import type { Ctx, Reducer } from '../types.js';
|
|
31
|
+
|
|
32
|
+
/** Canonical MemoServ pseudo-client nick. */
|
|
33
|
+
export const MEMOSERV_NICK = 'MemoServ';
|
|
34
|
+
|
|
35
|
+
/** Services hostmask fragment used by every services pseudo-client. */
|
|
36
|
+
const SERVICES_HOST = 'services';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Reducer for `PRIVMSG MemoServ :<subcommand> <args…>`.
|
|
40
|
+
* Authority: the invoking connection's {@link ConnectionState}.
|
|
41
|
+
*/
|
|
42
|
+
export const memoservReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
|
|
43
|
+
state.lastSeen = ctx.clock.now();
|
|
44
|
+
|
|
45
|
+
const effects: EffectType[] = [];
|
|
46
|
+
|
|
47
|
+
if (ctx.services === undefined) {
|
|
48
|
+
effects.push(notice(state, 'Services are not available on this server.'));
|
|
49
|
+
return { state, effects };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const body = msg.params[1] ?? '';
|
|
53
|
+
// The subcommand is the first whitespace token; the remainder (incl. any
|
|
54
|
+
// embedded spaces) is left intact so memo bodies can contain spaces.
|
|
55
|
+
const firstSpace = body.search(/\s/u);
|
|
56
|
+
let subRaw: string;
|
|
57
|
+
let rest: string;
|
|
58
|
+
if (firstSpace === -1) {
|
|
59
|
+
subRaw = body;
|
|
60
|
+
rest = '';
|
|
61
|
+
} else {
|
|
62
|
+
subRaw = body.slice(0, firstSpace);
|
|
63
|
+
rest = body.slice(firstSpace + 1).trimStart();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (subRaw.length === 0) {
|
|
67
|
+
effects.push(helpNotice(state));
|
|
68
|
+
return { state, effects };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const sub = subRaw.toUpperCase();
|
|
72
|
+
|
|
73
|
+
switch (sub) {
|
|
74
|
+
case 'SEND':
|
|
75
|
+
return handleSend(state, rest, ctx, effects);
|
|
76
|
+
case 'LIST':
|
|
77
|
+
return handleList(state, ctx, effects);
|
|
78
|
+
case 'READ':
|
|
79
|
+
return handleRead(state, rest, ctx, effects);
|
|
80
|
+
case 'DEL':
|
|
81
|
+
return handleDel(state, rest, ctx, effects);
|
|
82
|
+
default:
|
|
83
|
+
effects.push(unknownNotice(state));
|
|
84
|
+
return { state, effects };
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// ============================================================================
|
|
89
|
+
// Queue delivery (NickServ IDENTIFY / SASL login)
|
|
90
|
+
// ============================================================================
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Delivers every queued unread memo for `account` as MemoServ NOTICEs on the
|
|
94
|
+
* given connection, marking each one read as it goes.
|
|
95
|
+
*
|
|
96
|
+
* Invoked from the NickServ `IDENTIFY` path and the SASL `applyResult`
|
|
97
|
+
* success path so an offline `SEND` reaches the recipient on their next
|
|
98
|
+
* login regardless of which authentication route they took. No-op when the
|
|
99
|
+
* account has no unread memos. Mutates `effects` in place by appending.
|
|
100
|
+
*/
|
|
101
|
+
export function deliverUnreadMemos(
|
|
102
|
+
state: ConnectionState,
|
|
103
|
+
services: ServicesStore,
|
|
104
|
+
account: string,
|
|
105
|
+
effects: EffectType[],
|
|
106
|
+
): void {
|
|
107
|
+
const memos = services.listMemos(account);
|
|
108
|
+
const unread = memos.filter((m) => !m.read);
|
|
109
|
+
for (const memo of unread) {
|
|
110
|
+
effects.push(memoNotice(state, memo));
|
|
111
|
+
services.markMemoRead(account, memo.id);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ============================================================================
|
|
116
|
+
// SEND
|
|
117
|
+
// ============================================================================
|
|
118
|
+
|
|
119
|
+
function handleSend(
|
|
120
|
+
state: ConnectionState,
|
|
121
|
+
rest: string,
|
|
122
|
+
ctx: Ctx,
|
|
123
|
+
effects: EffectType[],
|
|
124
|
+
): { state: ConnectionState; effects: EffectType[] } {
|
|
125
|
+
const services = ctx.services as ServicesStore;
|
|
126
|
+
const account = state.account;
|
|
127
|
+
|
|
128
|
+
if (account === undefined) {
|
|
129
|
+
effects.push(notice(state, 'You must identify before sending a memo.'));
|
|
130
|
+
return { state, effects };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Split into recipient + body. The body keeps its embedded spaces.
|
|
134
|
+
const sep = rest.search(/\s/u);
|
|
135
|
+
let recipientRaw: string;
|
|
136
|
+
let bodyText: string;
|
|
137
|
+
if (sep === -1) {
|
|
138
|
+
recipientRaw = rest;
|
|
139
|
+
bodyText = '';
|
|
140
|
+
} else {
|
|
141
|
+
recipientRaw = rest.slice(0, sep);
|
|
142
|
+
bodyText = rest.slice(sep + 1).trimStart();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (recipientRaw.length === 0 || bodyText.length === 0) {
|
|
146
|
+
effects.push(notice(state, 'Syntax: SEND <nick> <message>'));
|
|
147
|
+
return { state, effects };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (caseFold('rfc1459', recipientRaw) === caseFold('rfc1459', account)) {
|
|
151
|
+
effects.push(notice(state, 'You cannot send a memo to yourself.'));
|
|
152
|
+
return { state, effects };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (!services.isRegisteredNick(recipientRaw)) {
|
|
156
|
+
effects.push(notice(state, `Nick ${recipientRaw} is not registered.`));
|
|
157
|
+
return { state, effects };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
services.recordMemo({
|
|
161
|
+
from: account,
|
|
162
|
+
to: recipientRaw,
|
|
163
|
+
body: bodyText,
|
|
164
|
+
sentAt: ctx.clock.now(),
|
|
165
|
+
});
|
|
166
|
+
effects.push(notice(state, `Memo sent to ${recipientRaw}.`));
|
|
167
|
+
return { state, effects };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ============================================================================
|
|
171
|
+
// LIST
|
|
172
|
+
// ============================================================================
|
|
173
|
+
|
|
174
|
+
function handleList(
|
|
175
|
+
state: ConnectionState,
|
|
176
|
+
ctx: Ctx,
|
|
177
|
+
effects: EffectType[],
|
|
178
|
+
): { state: ConnectionState; effects: EffectType[] } {
|
|
179
|
+
const services = ctx.services as ServicesStore;
|
|
180
|
+
const account = state.account;
|
|
181
|
+
|
|
182
|
+
if (account === undefined) {
|
|
183
|
+
effects.push(notice(state, 'You must identify before listing memos.'));
|
|
184
|
+
return { state, effects };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const unread = services.listMemos(account).filter((m) => !m.read);
|
|
188
|
+
|
|
189
|
+
if (unread.length === 0) {
|
|
190
|
+
effects.push(notice(state, 'You have no memos.'));
|
|
191
|
+
return { state, effects };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
effects.push(notice(state, 'Memos:'));
|
|
195
|
+
for (const memo of unread) {
|
|
196
|
+
effects.push(notice(state, `#${memo.id} ${memo.from}: ${memo.body}`));
|
|
197
|
+
}
|
|
198
|
+
return { state, effects };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ============================================================================
|
|
202
|
+
// READ
|
|
203
|
+
// ============================================================================
|
|
204
|
+
|
|
205
|
+
function handleRead(
|
|
206
|
+
state: ConnectionState,
|
|
207
|
+
rest: string,
|
|
208
|
+
ctx: Ctx,
|
|
209
|
+
effects: EffectType[],
|
|
210
|
+
): { state: ConnectionState; effects: EffectType[] } {
|
|
211
|
+
const services = ctx.services as ServicesStore;
|
|
212
|
+
const account = state.account;
|
|
213
|
+
|
|
214
|
+
if (account === undefined) {
|
|
215
|
+
effects.push(notice(state, 'You must identify before reading memos.'));
|
|
216
|
+
return { state, effects };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const parsed = parseMemoNumber(rest);
|
|
220
|
+
if (parsed === undefined) {
|
|
221
|
+
effects.push(notice(state, 'Syntax: READ <number>'));
|
|
222
|
+
return { state, effects };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const memo = services.listMemos(account).find((m) => m.id === parsed);
|
|
226
|
+
if (memo === undefined) {
|
|
227
|
+
effects.push(notice(state, `No such memo #${parsed}.`));
|
|
228
|
+
return { state, effects };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
effects.push(memoNotice(state, memo));
|
|
232
|
+
services.markMemoRead(account, memo.id);
|
|
233
|
+
return { state, effects };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ============================================================================
|
|
237
|
+
// DEL
|
|
238
|
+
// ============================================================================
|
|
239
|
+
|
|
240
|
+
function handleDel(
|
|
241
|
+
state: ConnectionState,
|
|
242
|
+
rest: string,
|
|
243
|
+
ctx: Ctx,
|
|
244
|
+
effects: EffectType[],
|
|
245
|
+
): { state: ConnectionState; effects: EffectType[] } {
|
|
246
|
+
const services = ctx.services as ServicesStore;
|
|
247
|
+
const account = state.account;
|
|
248
|
+
|
|
249
|
+
if (account === undefined) {
|
|
250
|
+
effects.push(notice(state, 'You must identify before deleting memos.'));
|
|
251
|
+
return { state, effects };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const parsed = parseMemoNumber(rest);
|
|
255
|
+
if (parsed === undefined) {
|
|
256
|
+
effects.push(notice(state, 'Syntax: DEL <number>'));
|
|
257
|
+
return { state, effects };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const removed = services.deleteMemo(account, parsed);
|
|
261
|
+
if (!removed) {
|
|
262
|
+
effects.push(notice(state, `No such memo #${parsed}.`));
|
|
263
|
+
return { state, effects };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
effects.push(notice(state, `Deleted memo #${parsed}.`));
|
|
267
|
+
return { state, effects };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ============================================================================
|
|
271
|
+
// Helpers
|
|
272
|
+
// ============================================================================
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Parses a positive-integer memo number from the trailing argument.
|
|
276
|
+
* Returns `undefined` for empty / non-numeric / non-positive input.
|
|
277
|
+
*/
|
|
278
|
+
function parseMemoNumber(rest: string): number | undefined {
|
|
279
|
+
const trimmed = rest.trim();
|
|
280
|
+
if (trimmed.length === 0) return undefined;
|
|
281
|
+
if (!/^[1-9][0-9]*$/u.test(trimmed)) return undefined;
|
|
282
|
+
return Number.parseInt(trimmed, 10);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ============================================================================
|
|
286
|
+
// Notice helpers
|
|
287
|
+
// ============================================================================
|
|
288
|
+
|
|
289
|
+
/** Builds a `:MemoServ!MemoServ@services NOTICE <nick> :<text>` line. */
|
|
290
|
+
function notice(state: ConnectionState, text: string): EffectType {
|
|
291
|
+
return Effect.send(state.id, [servicesNotice(text, state.nick)]);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Builds a help/unknown `:MemoServ!MemoServ@services NOTICE <nick> :…` line. */
|
|
295
|
+
function helpNotice(state: ConnectionState): EffectType {
|
|
296
|
+
return notice(state, 'Available commands: SEND, LIST, READ, DEL');
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function unknownNotice(state: ConnectionState): EffectType {
|
|
300
|
+
return notice(state, 'Unknown command. Available: SEND, LIST, READ, DEL');
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Builds a `:MemoServ!MemoServ@services NOTICE <nick> :…` line for a memo. */
|
|
304
|
+
function memoNotice(state: ConnectionState, memo: MemoEntry): EffectType {
|
|
305
|
+
return Effect.send(state.id, [
|
|
306
|
+
servicesNotice(`[Memo #${memo.id} from ${memo.from}] ${memo.body}`, state.nick),
|
|
307
|
+
]);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Formats a services NOTICE line. The source hostmask is
|
|
312
|
+
* `<svc>!<svc>@services` regardless of the deployment's serverName so the
|
|
313
|
+
* pseudo-clients are stable across deployments and easy to identify in
|
|
314
|
+
* client UIs.
|
|
315
|
+
*/
|
|
316
|
+
function servicesNotice(text: string, nick: string | undefined): RawLine {
|
|
317
|
+
const target = nick ?? '*';
|
|
318
|
+
return { text: `:${MEMOSERV_NICK}!${MEMOSERV_NICK}@${SERVICES_HOST} NOTICE ${target} :${text}` };
|
|
319
|
+
}
|
|
@@ -43,6 +43,7 @@ import type {
|
|
|
43
43
|
import { hostmaskOf } from '../state/connection.js';
|
|
44
44
|
import type { ConnectionState } from '../state/connection.js';
|
|
45
45
|
import type { Ctx, Reducer } from '../types.js';
|
|
46
|
+
import { positiveMlockLetters } from './chanserv.js';
|
|
46
47
|
|
|
47
48
|
/** Channel-name grammar; mirrors {@link isValidChannelName} in `join.ts`. */
|
|
48
49
|
const CHANNEL_NAME_RE = /^[#&][^\s,:]+$/u;
|
|
@@ -77,9 +78,24 @@ const BOOLEAN_LETTERS: Readonly<Record<string, BooleanChannelMode>> = {
|
|
|
77
78
|
p: 'private',
|
|
78
79
|
};
|
|
79
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Services-only boolean channel mode letters. These appear in the 324 read
|
|
83
|
+
* reply and persist on the channel state, but {@link channelModeReducer}
|
|
84
|
+
* rejects any attempt to set them via `MODE` with `472 ERR_UNKNOWNMODE`
|
|
85
|
+
* (only ChanServ may toggle them via an `ApplyChannelDelta`).
|
|
86
|
+
*/
|
|
87
|
+
const SERVICES_BOOLEAN_LETTERS: Readonly<Record<string, BooleanChannelMode>> = {
|
|
88
|
+
r: 'registered',
|
|
89
|
+
R: 'blockUnidentified',
|
|
90
|
+
M: 'moderatedIdent',
|
|
91
|
+
};
|
|
92
|
+
|
|
80
93
|
/** Boolean channel mode letters in canonical 324 output order. */
|
|
81
94
|
const BOOLEAN_LETTER_ORDER = ['i', 't', 'n', 'm', 's', 'p'] as const;
|
|
82
95
|
|
|
96
|
+
/** Services-only boolean channel mode letters in canonical 324 output order. */
|
|
97
|
+
const SERVICES_BOOLEAN_LETTER_ORDER = ['r', 'R', 'M'] as const;
|
|
98
|
+
|
|
83
99
|
/** Prefix channel mode letters (always take a nick arg). */
|
|
84
100
|
const PREFIX_LETTERS = new Set<string>(['o', 'v']);
|
|
85
101
|
|
|
@@ -199,6 +215,13 @@ export const channelModeReducer: Reducer<ChannelState> = (state, msg, ctx) => {
|
|
|
199
215
|
effects.push(Effect.broadcast(state.name, [buildModeBroadcast(hostmask, state.name, applied)]));
|
|
200
216
|
}
|
|
201
217
|
|
|
218
|
+
// ChanServ MLOCK reapplication: after the op's MODE applied, check the
|
|
219
|
+
// registered channel's locked modes. Any positively-locked boolean mode
|
|
220
|
+
// that is now unset (whether by this MODE or by a prior state) is
|
|
221
|
+
// reapplied on top via a ChanServ ApplyChannelDelta + broadcast, so an
|
|
222
|
+
// op cannot bypass the lock.
|
|
223
|
+
effects.push(...reapplyMlock(state, ctx));
|
|
224
|
+
|
|
202
225
|
return { state, effects };
|
|
203
226
|
};
|
|
204
227
|
|
|
@@ -436,6 +459,64 @@ function buildModeBroadcast(hostmask: string, chanName: string, applied: Applied
|
|
|
436
459
|
return { text: `${lead} ${args.join(' ')}` };
|
|
437
460
|
}
|
|
438
461
|
|
|
462
|
+
// ============================================================================
|
|
463
|
+
// ChanServ MLOCK reapplication
|
|
464
|
+
// ============================================================================
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Hostmask used as the source of ChanServ-emitted MODE reapplies. Mirrors
|
|
468
|
+
* the `:ChanServ!ChanServ@services` pseudo-client source used by the
|
|
469
|
+
* ChanServ reducer's NOTICE lines.
|
|
470
|
+
*/
|
|
471
|
+
const CHANSERV_HOSTMASK = 'ChanServ!ChanServ@services';
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Returns the effects to emit when ChanServ's MLOCK must reapply locked
|
|
475
|
+
* boolean modes after an op's MODE has applied.
|
|
476
|
+
*
|
|
477
|
+
* Loads the channel's locked modestring from the bound {@link ServicesStore}
|
|
478
|
+
* (no-op when services are unbound, the channel is not registered, or the
|
|
479
|
+
* MLOCK is empty). For every positively-locked boolean mode letter that is
|
|
480
|
+
* currently unset on the channel state, emits:
|
|
481
|
+
* - an `ApplyChannelDelta` re-applying the mode,
|
|
482
|
+
* - a `Broadcast` of `:ChanServ!ChanServ@services MODE <chan> +<letters>`
|
|
483
|
+
* so clients observe ChanServ reasserting the lock.
|
|
484
|
+
*
|
|
485
|
+
* Services-only modes (r/R/M) are also defended here, so a registered
|
|
486
|
+
* channel's `+r` survives even if a future code path applies an `ApplyChannelDelta`
|
|
487
|
+
* clearing it.
|
|
488
|
+
*
|
|
489
|
+
* Returns an empty array when nothing needs to be reapplied.
|
|
490
|
+
*/
|
|
491
|
+
function reapplyMlock(state: ChannelState, ctx: Ctx): EffectType[] {
|
|
492
|
+
const services = ctx.services;
|
|
493
|
+
if (services === undefined) return [];
|
|
494
|
+
const rec = services.getChannel(state.nameLower);
|
|
495
|
+
if (rec === undefined) return [];
|
|
496
|
+
if (rec.mlock.length === 0) return [];
|
|
497
|
+
|
|
498
|
+
const locked = positiveMlockLetters(rec.mlock);
|
|
499
|
+
if (locked.size === 0) return [];
|
|
500
|
+
|
|
501
|
+
const toReapply: AppliedMode[] = [];
|
|
502
|
+
const delta: ChannelDelta = {};
|
|
503
|
+
for (const letter of locked) {
|
|
504
|
+
const field = BOOLEAN_LETTERS[letter] ?? SERVICES_BOOLEAN_LETTERS[letter];
|
|
505
|
+
if (field === undefined) continue;
|
|
506
|
+
if (state.modes[field]) continue;
|
|
507
|
+
state.modes[field] = true;
|
|
508
|
+
pushModeChange(delta, field, true);
|
|
509
|
+
toReapply.push({ sign: '+', letter });
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
if (toReapply.length === 0) return [];
|
|
513
|
+
|
|
514
|
+
return [
|
|
515
|
+
Effect.applyChannelDelta(state.name, delta),
|
|
516
|
+
Effect.broadcast(state.name, [buildModeBroadcast(CHANSERV_HOSTMASK, state.name, toReapply)]),
|
|
517
|
+
];
|
|
518
|
+
}
|
|
519
|
+
|
|
439
520
|
// ============================================================================
|
|
440
521
|
// Channel MODE — read / query helpers
|
|
441
522
|
// ============================================================================
|
|
@@ -479,6 +560,12 @@ function buildReadModeString(state: ChannelState): string {
|
|
|
479
560
|
const field = BOOLEAN_LETTERS[letter];
|
|
480
561
|
if (field !== undefined && state.modes[field]) modeStr += letter;
|
|
481
562
|
}
|
|
563
|
+
// Services-only boolean modes (r/R/M) surface in the read reply when
|
|
564
|
+
// ChanServ has set them; they cannot be toggled via MODE.
|
|
565
|
+
for (const letter of SERVICES_BOOLEAN_LETTER_ORDER) {
|
|
566
|
+
const field = SERVICES_BOOLEAN_LETTERS[letter];
|
|
567
|
+
if (field !== undefined && state.modes[field]) modeStr += letter;
|
|
568
|
+
}
|
|
482
569
|
if (state.modes.key !== undefined) {
|
|
483
570
|
modeStr += 'k';
|
|
484
571
|
args.push(state.modes.key);
|
|
@@ -528,7 +615,7 @@ const USER_MODE_LETTERS: Readonly<Record<string, keyof ConnectionState['userMode
|
|
|
528
615
|
};
|
|
529
616
|
|
|
530
617
|
/** User-mode letters in canonical 221 output order. */
|
|
531
|
-
const USER_MODE_LETTER_ORDER = ['i', 'w', 's', 'o', 'S'] as const;
|
|
618
|
+
const USER_MODE_LETTER_ORDER = ['i', 'w', 's', 'o', 'r', 'S'] as const;
|
|
532
619
|
|
|
533
620
|
/**
|
|
534
621
|
* Handles `MODE <nick> [<modestring>]`.
|
|
@@ -640,13 +727,18 @@ export const userModeReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
|
|
|
640
727
|
function buildUserModeString(state: ConnectionState): string {
|
|
641
728
|
let modeStr = '+';
|
|
642
729
|
for (const letter of USER_MODE_LETTER_ORDER) {
|
|
643
|
-
// `S` (TLS connected)
|
|
644
|
-
// (the write-side map the parser consults) so
|
|
645
|
-
//
|
|
730
|
+
// `S` (TLS connected) and `r` (registered) are read-only: neither is
|
|
731
|
+
// in USER_MODE_LETTERS (the write-side map the parser consults) so
|
|
732
|
+
// `MODE nick +S` / `MODE nick +r` reject with 501. Surface them on
|
|
733
|
+
// the read side directly from their fields.
|
|
646
734
|
if (letter === 'S') {
|
|
647
735
|
if (state.userModes.tls) modeStr += 'S';
|
|
648
736
|
continue;
|
|
649
737
|
}
|
|
738
|
+
if (letter === 'r') {
|
|
739
|
+
if (state.userModes.registered) modeStr += 'r';
|
|
740
|
+
continue;
|
|
741
|
+
}
|
|
650
742
|
const field = USER_MODE_LETTERS[letter];
|
|
651
743
|
if (field !== undefined && state.userModes[field]) modeStr += letter;
|
|
652
744
|
}
|