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,665 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
isChannelTarget,
|
|
4
|
+
noticeChannelReducer,
|
|
5
|
+
noticeUserReducer,
|
|
6
|
+
privmsgChannelReducer,
|
|
7
|
+
privmsgUserReducer,
|
|
8
|
+
} from '../../src/commands/privmsg';
|
|
9
|
+
import { Effect } from '../../src/effects';
|
|
10
|
+
import type { Effect as EffectType, RawLine } from '../../src/effects';
|
|
11
|
+
import { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';
|
|
12
|
+
import { type ChannelState, createChannel } from '../../src/state/channel';
|
|
13
|
+
import { type ConnectionState, createConnection } from '../../src/state/connection';
|
|
14
|
+
import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
|
|
15
|
+
|
|
16
|
+
const serverConfig: ServerConfig = {
|
|
17
|
+
serverName: 'irc.example.com',
|
|
18
|
+
networkName: 'ExampleNet',
|
|
19
|
+
maxChannelsPerUser: 30,
|
|
20
|
+
maxTargetsPerCommand: 10,
|
|
21
|
+
maxListEntries: 50,
|
|
22
|
+
nickLen: 30,
|
|
23
|
+
channelLen: 50,
|
|
24
|
+
topicLen: 390,
|
|
25
|
+
quitMessage: 'Client Quit',
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function makeCtx(conn: ConnectionState, clock = new FakeClock(1_000)): Ctx {
|
|
29
|
+
return buildCtx({
|
|
30
|
+
serverConfig,
|
|
31
|
+
clock,
|
|
32
|
+
ids: new SequentialIdFactory(),
|
|
33
|
+
motd: EmptyMotdProvider,
|
|
34
|
+
connection: conn,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function makeConn(id = 'c1', nick = 'alice'): ConnectionState {
|
|
39
|
+
const s = createConnection({ id, connectedSince: 0 });
|
|
40
|
+
s.nick = nick;
|
|
41
|
+
s.user = nick;
|
|
42
|
+
s.host = 'example.com';
|
|
43
|
+
s.realname = nick.charAt(0).toUpperCase() + nick.slice(1);
|
|
44
|
+
s.registration = 'registered';
|
|
45
|
+
return s;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function makeChan(name = '#foo'): ChannelState {
|
|
49
|
+
return createChannel({ name, nameLower: name.toLowerCase(), createdAt: 0 });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function addMember(
|
|
53
|
+
chan: ChannelState,
|
|
54
|
+
connId: string,
|
|
55
|
+
nick: string,
|
|
56
|
+
op = false,
|
|
57
|
+
voice = false,
|
|
58
|
+
): void {
|
|
59
|
+
chan.members.set(connId, { conn: connId, nick, op, voice });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const L = (text: string): RawLine => ({ text });
|
|
63
|
+
|
|
64
|
+
// ============================================================================
|
|
65
|
+
// isChannelTarget
|
|
66
|
+
// ============================================================================
|
|
67
|
+
|
|
68
|
+
describe('isChannelTarget', () => {
|
|
69
|
+
it('returns true for a # channel name', () => {
|
|
70
|
+
expect(isChannelTarget('#foo')).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('returns true for a & channel name', () => {
|
|
74
|
+
expect(isChannelTarget('&foo')).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('returns false for a nick name', () => {
|
|
78
|
+
expect(isChannelTarget('alice')).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('returns false for an empty string', () => {
|
|
82
|
+
expect(isChannelTarget('')).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('returns false for a name with a space', () => {
|
|
86
|
+
expect(isChannelTarget('foo bar')).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// ============================================================================
|
|
91
|
+
// privmsgChannelReducer — success path
|
|
92
|
+
// ============================================================================
|
|
93
|
+
|
|
94
|
+
describe('privmsgChannelReducer — success', () => {
|
|
95
|
+
it('broadcasts PRIVMSG to all channel members except the sender', () => {
|
|
96
|
+
const chan = makeChan('#foo');
|
|
97
|
+
addMember(chan, 'c1', 'alice');
|
|
98
|
+
addMember(chan, 'c2', 'bob');
|
|
99
|
+
addMember(chan, 'c3', 'carol');
|
|
100
|
+
const conn = makeConn();
|
|
101
|
+
const ctx = makeCtx(conn);
|
|
102
|
+
|
|
103
|
+
const out = privmsgChannelReducer(
|
|
104
|
+
chan,
|
|
105
|
+
{ command: 'PRIVMSG', params: ['#foo', 'hello world'], tags: {} },
|
|
106
|
+
ctx,
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
110
|
+
Effect.broadcast('#foo', [L(':alice!alice@example.com PRIVMSG #foo :hello world')], 'c1'),
|
|
111
|
+
]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('broadcasts a multi-word trailing parameter verbatim', () => {
|
|
115
|
+
const chan = makeChan('#foo');
|
|
116
|
+
addMember(chan, 'c1', 'alice');
|
|
117
|
+
const conn = makeConn();
|
|
118
|
+
const ctx = makeCtx(conn);
|
|
119
|
+
|
|
120
|
+
const out = privmsgChannelReducer(
|
|
121
|
+
chan,
|
|
122
|
+
{ command: 'PRIVMSG', params: ['#foo', 'hi there everyone'], tags: {} },
|
|
123
|
+
ctx,
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
expect(out.effects).toContainEqual<EffectType>(
|
|
127
|
+
Effect.broadcast(
|
|
128
|
+
'#foo',
|
|
129
|
+
[L(':alice!alice@example.com PRIVMSG #foo :hi there everyone')],
|
|
130
|
+
'c1',
|
|
131
|
+
),
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('allows a non-member to message the channel when +n is not set (default)', () => {
|
|
136
|
+
const chan = makeChan('#foo');
|
|
137
|
+
addMember(chan, 'c2', 'bob');
|
|
138
|
+
const conn = makeConn();
|
|
139
|
+
const ctx = makeCtx(conn);
|
|
140
|
+
|
|
141
|
+
const out = privmsgChannelReducer(
|
|
142
|
+
chan,
|
|
143
|
+
{ command: 'PRIVMSG', params: ['#foo', 'hi'], tags: {} },
|
|
144
|
+
ctx,
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
expect(out.effects).toHaveLength(1);
|
|
148
|
+
expect(out.effects[0]?.tag).toBe('Broadcast');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('allows a voiced non-op to message a +m channel', () => {
|
|
152
|
+
const chan = makeChan('#foo');
|
|
153
|
+
chan.modes.moderated = true;
|
|
154
|
+
addMember(chan, 'c1', 'alice', false, true);
|
|
155
|
+
const conn = makeConn();
|
|
156
|
+
const ctx = makeCtx(conn);
|
|
157
|
+
|
|
158
|
+
const out = privmsgChannelReducer(
|
|
159
|
+
chan,
|
|
160
|
+
{ command: 'PRIVMSG', params: ['#foo', 'hi'], tags: {} },
|
|
161
|
+
ctx,
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
expect(out.effects).toHaveLength(1);
|
|
165
|
+
expect(out.effects[0]?.tag).toBe('Broadcast');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('allows an op to message a +m channel even without voice', () => {
|
|
169
|
+
const chan = makeChan('#foo');
|
|
170
|
+
chan.modes.moderated = true;
|
|
171
|
+
addMember(chan, 'c1', 'alice', true, false);
|
|
172
|
+
const conn = makeConn();
|
|
173
|
+
const ctx = makeCtx(conn);
|
|
174
|
+
|
|
175
|
+
const out = privmsgChannelReducer(
|
|
176
|
+
chan,
|
|
177
|
+
{ command: 'PRIVMSG', params: ['#foo', 'hi'], tags: {} },
|
|
178
|
+
ctx,
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
expect(out.effects).toHaveLength(1);
|
|
182
|
+
expect(out.effects[0]?.tag).toBe('Broadcast');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('updates the connection lastSeen to ctx.clock.now()', () => {
|
|
186
|
+
const chan = makeChan('#foo');
|
|
187
|
+
addMember(chan, 'c1', 'alice');
|
|
188
|
+
const conn = makeConn();
|
|
189
|
+
const clock = new FakeClock(8_800);
|
|
190
|
+
const ctx = makeCtx(conn, clock);
|
|
191
|
+
|
|
192
|
+
privmsgChannelReducer(chan, { command: 'PRIVMSG', params: ['#foo', 'hi'], tags: {} }, ctx);
|
|
193
|
+
|
|
194
|
+
expect(conn.lastSeen).toBe(8_800);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('returns the same state reference (no mutation to channel state)', () => {
|
|
198
|
+
const chan = makeChan('#foo');
|
|
199
|
+
addMember(chan, 'c1', 'alice');
|
|
200
|
+
const conn = makeConn();
|
|
201
|
+
const ctx = makeCtx(conn);
|
|
202
|
+
|
|
203
|
+
const out = privmsgChannelReducer(
|
|
204
|
+
chan,
|
|
205
|
+
{ command: 'PRIVMSG', params: ['#foo', 'hi'], tags: {} },
|
|
206
|
+
ctx,
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
expect(out.state).toBe(chan);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// ============================================================================
|
|
214
|
+
// privmsgChannelReducer — rejections
|
|
215
|
+
// ============================================================================
|
|
216
|
+
|
|
217
|
+
describe('privmsgChannelReducer — rejections', () => {
|
|
218
|
+
it('emits 411 ERR_NORECIPIENT when no target is supplied', () => {
|
|
219
|
+
const chan = makeChan('#foo');
|
|
220
|
+
addMember(chan, 'c1', 'alice');
|
|
221
|
+
const conn = makeConn();
|
|
222
|
+
const ctx = makeCtx(conn);
|
|
223
|
+
|
|
224
|
+
const out = privmsgChannelReducer(chan, { command: 'PRIVMSG', params: [], tags: {} }, ctx);
|
|
225
|
+
|
|
226
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
227
|
+
Effect.send('c1', [L(':irc.example.com 411 alice :No recipient given (PRIVMSG)')]),
|
|
228
|
+
]);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('emits 412 ERR_NOTEXTTOSEND when no text is supplied', () => {
|
|
232
|
+
const chan = makeChan('#foo');
|
|
233
|
+
addMember(chan, 'c1', 'alice');
|
|
234
|
+
const conn = makeConn();
|
|
235
|
+
const ctx = makeCtx(conn);
|
|
236
|
+
|
|
237
|
+
const out = privmsgChannelReducer(
|
|
238
|
+
chan,
|
|
239
|
+
{ command: 'PRIVMSG', params: ['#foo'], tags: {} },
|
|
240
|
+
ctx,
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
244
|
+
Effect.send('c1', [L(':irc.example.com 412 alice :No text to send')]),
|
|
245
|
+
]);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('emits 412 ERR_NOTEXTTOSEND when the text param is empty', () => {
|
|
249
|
+
const chan = makeChan('#foo');
|
|
250
|
+
addMember(chan, 'c1', 'alice');
|
|
251
|
+
const conn = makeConn();
|
|
252
|
+
const ctx = makeCtx(conn);
|
|
253
|
+
|
|
254
|
+
const out = privmsgChannelReducer(
|
|
255
|
+
chan,
|
|
256
|
+
{ command: 'PRIVMSG', params: ['#foo', ''], tags: {} },
|
|
257
|
+
ctx,
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
261
|
+
Effect.send('c1', [L(':irc.example.com 412 alice :No text to send')]),
|
|
262
|
+
]);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('emits 404 ERR_CANNOTSENDTOCHAN when a non-member sends to a +n channel', () => {
|
|
266
|
+
const chan = makeChan('#foo');
|
|
267
|
+
chan.modes.noExternal = true;
|
|
268
|
+
addMember(chan, 'c2', 'bob');
|
|
269
|
+
const conn = makeConn();
|
|
270
|
+
const ctx = makeCtx(conn);
|
|
271
|
+
|
|
272
|
+
const out = privmsgChannelReducer(
|
|
273
|
+
chan,
|
|
274
|
+
{ command: 'PRIVMSG', params: ['#foo', 'hi'], tags: {} },
|
|
275
|
+
ctx,
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
279
|
+
Effect.send('c1', [L(':irc.example.com 404 alice #foo :Cannot send to channel')]),
|
|
280
|
+
]);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('emits 404 ERR_CANNOTSENDTOCHAN when a non-voiced non-op sends to a +m channel', () => {
|
|
284
|
+
const chan = makeChan('#foo');
|
|
285
|
+
chan.modes.moderated = true;
|
|
286
|
+
addMember(chan, 'c1', 'alice', false, false);
|
|
287
|
+
const conn = makeConn();
|
|
288
|
+
const ctx = makeCtx(conn);
|
|
289
|
+
|
|
290
|
+
const out = privmsgChannelReducer(
|
|
291
|
+
chan,
|
|
292
|
+
{ command: 'PRIVMSG', params: ['#foo', 'hi'], tags: {} },
|
|
293
|
+
ctx,
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
297
|
+
Effect.send('c1', [L(':irc.example.com 404 alice #foo :Cannot send to channel')]),
|
|
298
|
+
]);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('emits 404 ERR_CANNOTSENDTOCHAN when +m is set and the sender is not a member at all', () => {
|
|
302
|
+
const chan = makeChan('#foo');
|
|
303
|
+
chan.modes.moderated = true;
|
|
304
|
+
// +n NOT set, so the +n check passes; the +m check then rejects a non-member.
|
|
305
|
+
addMember(chan, 'c2', 'bob', true, false);
|
|
306
|
+
const conn = makeConn();
|
|
307
|
+
const ctx = makeCtx(conn);
|
|
308
|
+
|
|
309
|
+
const out = privmsgChannelReducer(
|
|
310
|
+
chan,
|
|
311
|
+
{ command: 'PRIVMSG', params: ['#foo', 'hi'], tags: {} },
|
|
312
|
+
ctx,
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
316
|
+
Effect.send('c1', [L(':irc.example.com 404 alice #foo :Cannot send to channel')]),
|
|
317
|
+
]);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('emits 404 ERR_CANNOTSENDTOCHAN when a banned member sends to the channel', () => {
|
|
321
|
+
const chan = makeChan('#foo');
|
|
322
|
+
chan.banMasks.add('*!*@example.com');
|
|
323
|
+
addMember(chan, 'c1', 'alice');
|
|
324
|
+
const conn = makeConn();
|
|
325
|
+
const ctx = makeCtx(conn);
|
|
326
|
+
|
|
327
|
+
const out = privmsgChannelReducer(
|
|
328
|
+
chan,
|
|
329
|
+
{ command: 'PRIVMSG', params: ['#foo', 'hi'], tags: {} },
|
|
330
|
+
ctx,
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
334
|
+
Effect.send('c1', [L(':irc.example.com 404 alice #foo :Cannot send to channel')]),
|
|
335
|
+
]);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it('honors the ? wildcard in ban masks when checking the sender hostmask', () => {
|
|
339
|
+
const chan = makeChan('#foo');
|
|
340
|
+
// Five `?` match the 5-char user segment `alice` in alice!alice@example.com.
|
|
341
|
+
chan.banMasks.add('alice!?????@example.com');
|
|
342
|
+
addMember(chan, 'c1', 'alice');
|
|
343
|
+
const conn = makeConn();
|
|
344
|
+
const ctx = makeCtx(conn);
|
|
345
|
+
|
|
346
|
+
const out = privmsgChannelReducer(
|
|
347
|
+
chan,
|
|
348
|
+
{ command: 'PRIVMSG', params: ['#foo', 'hi'], tags: {} },
|
|
349
|
+
ctx,
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
353
|
+
Effect.send('c1', [L(':irc.example.com 404 alice #foo :Cannot send to channel')]),
|
|
354
|
+
]);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it('proceeds with the broadcast when the ban list is non-empty but no mask matches', () => {
|
|
358
|
+
const chan = makeChan('#foo');
|
|
359
|
+
chan.banMasks.add('*!*@baddomain.example');
|
|
360
|
+
chan.banMasks.add('evil!*@*');
|
|
361
|
+
addMember(chan, 'c1', 'alice');
|
|
362
|
+
const conn = makeConn();
|
|
363
|
+
const ctx = makeCtx(conn);
|
|
364
|
+
|
|
365
|
+
const out = privmsgChannelReducer(
|
|
366
|
+
chan,
|
|
367
|
+
{ command: 'PRIVMSG', params: ['#foo', 'hi'], tags: {} },
|
|
368
|
+
ctx,
|
|
369
|
+
);
|
|
370
|
+
|
|
371
|
+
expect(out.effects).toHaveLength(1);
|
|
372
|
+
expect(out.effects[0]?.tag).toBe('Broadcast');
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it('uses * in numeric replies when the connection has no nick (defensive)', () => {
|
|
376
|
+
const chan = makeChan('#foo');
|
|
377
|
+
addMember(chan, 'c1', 'alice');
|
|
378
|
+
const conn = createConnection({ id: 'c1', connectedSince: 0 });
|
|
379
|
+
const ctx = makeCtx(conn);
|
|
380
|
+
|
|
381
|
+
const out = privmsgChannelReducer(
|
|
382
|
+
chan,
|
|
383
|
+
{ command: 'PRIVMSG', params: ['#foo'], tags: {} },
|
|
384
|
+
ctx,
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
388
|
+
Effect.send('c1', [L(':irc.example.com 412 * :No text to send')]),
|
|
389
|
+
]);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it('falls back to ? as the PRIVMSG source when the connection has no nick (defensive)', () => {
|
|
393
|
+
const chan = makeChan('#foo');
|
|
394
|
+
// +n not set so a non-member can send; sender has no nick at all.
|
|
395
|
+
addMember(chan, 'c2', 'bob');
|
|
396
|
+
const conn = createConnection({ id: 'c1', connectedSince: 0 });
|
|
397
|
+
const ctx = makeCtx(conn);
|
|
398
|
+
|
|
399
|
+
const out = privmsgChannelReducer(
|
|
400
|
+
chan,
|
|
401
|
+
{ command: 'PRIVMSG', params: ['#foo', 'hi'], tags: {} },
|
|
402
|
+
ctx,
|
|
403
|
+
);
|
|
404
|
+
|
|
405
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
406
|
+
Effect.broadcast('#foo', [L(':? PRIVMSG #foo :hi')], 'c1'),
|
|
407
|
+
]);
|
|
408
|
+
});
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
// ============================================================================
|
|
412
|
+
// noticeChannelReducer
|
|
413
|
+
// ============================================================================
|
|
414
|
+
|
|
415
|
+
describe('noticeChannelReducer', () => {
|
|
416
|
+
it('broadcasts NOTICE to all channel members except the sender', () => {
|
|
417
|
+
const chan = makeChan('#foo');
|
|
418
|
+
addMember(chan, 'c1', 'alice');
|
|
419
|
+
addMember(chan, 'c2', 'bob');
|
|
420
|
+
const conn = makeConn();
|
|
421
|
+
const ctx = makeCtx(conn);
|
|
422
|
+
|
|
423
|
+
const out = noticeChannelReducer(
|
|
424
|
+
chan,
|
|
425
|
+
{ command: 'NOTICE', params: ['#foo', 'psst'], tags: {} },
|
|
426
|
+
ctx,
|
|
427
|
+
);
|
|
428
|
+
|
|
429
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
430
|
+
Effect.broadcast('#foo', [L(':alice!alice@example.com NOTICE #foo :psst')], 'c1'),
|
|
431
|
+
]);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it('MUST NOT produce any numeric reply when target is missing (RFC-critical)', () => {
|
|
435
|
+
const chan = makeChan('#foo');
|
|
436
|
+
addMember(chan, 'c1', 'alice');
|
|
437
|
+
const conn = makeConn();
|
|
438
|
+
const ctx = makeCtx(conn);
|
|
439
|
+
|
|
440
|
+
const out = noticeChannelReducer(chan, { command: 'NOTICE', params: [], tags: {} }, ctx);
|
|
441
|
+
|
|
442
|
+
expect(out.effects).toEqual([]);
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it('MUST NOT produce any numeric reply when text is missing (RFC-critical)', () => {
|
|
446
|
+
const chan = makeChan('#foo');
|
|
447
|
+
addMember(chan, 'c1', 'alice');
|
|
448
|
+
const conn = makeConn();
|
|
449
|
+
const ctx = makeCtx(conn);
|
|
450
|
+
|
|
451
|
+
const out = noticeChannelReducer(chan, { command: 'NOTICE', params: ['#foo'], tags: {} }, ctx);
|
|
452
|
+
|
|
453
|
+
expect(out.effects).toEqual([]);
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it('MUST NOT produce any numeric reply when banned (RFC-critical)', () => {
|
|
457
|
+
const chan = makeChan('#foo');
|
|
458
|
+
chan.banMasks.add('*!*@example.com');
|
|
459
|
+
addMember(chan, 'c1', 'alice');
|
|
460
|
+
const conn = makeConn();
|
|
461
|
+
const ctx = makeCtx(conn);
|
|
462
|
+
|
|
463
|
+
const out = noticeChannelReducer(
|
|
464
|
+
chan,
|
|
465
|
+
{ command: 'NOTICE', params: ['#foo', 'hi'], tags: {} },
|
|
466
|
+
ctx,
|
|
467
|
+
);
|
|
468
|
+
|
|
469
|
+
expect(out.effects).toEqual([]);
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
it('MUST NOT produce any numeric reply when rejected by +n (RFC-critical)', () => {
|
|
473
|
+
const chan = makeChan('#foo');
|
|
474
|
+
chan.modes.noExternal = true;
|
|
475
|
+
addMember(chan, 'c2', 'bob');
|
|
476
|
+
const conn = makeConn();
|
|
477
|
+
const ctx = makeCtx(conn);
|
|
478
|
+
|
|
479
|
+
const out = noticeChannelReducer(
|
|
480
|
+
chan,
|
|
481
|
+
{ command: 'NOTICE', params: ['#foo', 'hi'], tags: {} },
|
|
482
|
+
ctx,
|
|
483
|
+
);
|
|
484
|
+
|
|
485
|
+
expect(out.effects).toEqual([]);
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it('MUST NOT produce any numeric reply when rejected by +m (RFC-critical)', () => {
|
|
489
|
+
const chan = makeChan('#foo');
|
|
490
|
+
chan.modes.moderated = true;
|
|
491
|
+
addMember(chan, 'c1', 'alice', false, false);
|
|
492
|
+
const conn = makeConn();
|
|
493
|
+
const ctx = makeCtx(conn);
|
|
494
|
+
|
|
495
|
+
const out = noticeChannelReducer(
|
|
496
|
+
chan,
|
|
497
|
+
{ command: 'NOTICE', params: ['#foo', 'hi'], tags: {} },
|
|
498
|
+
ctx,
|
|
499
|
+
);
|
|
500
|
+
|
|
501
|
+
expect(out.effects).toEqual([]);
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
it('still broadcasts when the sender is an op of a +m channel', () => {
|
|
505
|
+
const chan = makeChan('#foo');
|
|
506
|
+
chan.modes.moderated = true;
|
|
507
|
+
addMember(chan, 'c1', 'alice', true, false);
|
|
508
|
+
const conn = makeConn();
|
|
509
|
+
const ctx = makeCtx(conn);
|
|
510
|
+
|
|
511
|
+
const out = noticeChannelReducer(
|
|
512
|
+
chan,
|
|
513
|
+
{ command: 'NOTICE', params: ['#foo', 'hi'], tags: {} },
|
|
514
|
+
ctx,
|
|
515
|
+
);
|
|
516
|
+
|
|
517
|
+
expect(out.effects).toHaveLength(1);
|
|
518
|
+
expect(out.effects[0]?.tag).toBe('Broadcast');
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
it('proceeds with the broadcast when the ban list is non-empty but no mask matches', () => {
|
|
522
|
+
const chan = makeChan('#foo');
|
|
523
|
+
chan.banMasks.add('*!*@baddomain.example');
|
|
524
|
+
chan.banMasks.add('evil!*@*');
|
|
525
|
+
addMember(chan, 'c1', 'alice');
|
|
526
|
+
const conn = makeConn();
|
|
527
|
+
const ctx = makeCtx(conn);
|
|
528
|
+
|
|
529
|
+
const out = noticeChannelReducer(
|
|
530
|
+
chan,
|
|
531
|
+
{ command: 'NOTICE', params: ['#foo', 'hi'], tags: {} },
|
|
532
|
+
ctx,
|
|
533
|
+
);
|
|
534
|
+
|
|
535
|
+
expect(out.effects).toHaveLength(1);
|
|
536
|
+
expect(out.effects[0]?.tag).toBe('Broadcast');
|
|
537
|
+
});
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
// ============================================================================
|
|
541
|
+
// privmsgUserReducer
|
|
542
|
+
// ============================================================================
|
|
543
|
+
|
|
544
|
+
describe('privmsgUserReducer', () => {
|
|
545
|
+
it('emits a SendToNick effect for a private PRIVMSG to an online user', () => {
|
|
546
|
+
const conn = makeConn();
|
|
547
|
+
const ctx = makeCtx(conn);
|
|
548
|
+
|
|
549
|
+
const out = privmsgUserReducer(
|
|
550
|
+
conn,
|
|
551
|
+
{ command: 'PRIVMSG', params: ['bob', 'hi bob'], tags: {} },
|
|
552
|
+
ctx,
|
|
553
|
+
);
|
|
554
|
+
|
|
555
|
+
// PRIVMSG always carries the 401 fallback — the dispatch layer decides
|
|
556
|
+
// whether to send it based on the lookup result.
|
|
557
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
558
|
+
Effect.sendToNick(
|
|
559
|
+
'bob',
|
|
560
|
+
'c1',
|
|
561
|
+
[L(':alice!alice@example.com PRIVMSG bob :hi bob')],
|
|
562
|
+
[L(':irc.example.com 401 alice bob :No such nick/channel')],
|
|
563
|
+
),
|
|
564
|
+
]);
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
it('emits 411 ERR_NORECIPIENT when no target is supplied', () => {
|
|
568
|
+
const conn = makeConn();
|
|
569
|
+
const ctx = makeCtx(conn);
|
|
570
|
+
|
|
571
|
+
const out = privmsgUserReducer(conn, { command: 'PRIVMSG', params: [], tags: {} }, ctx);
|
|
572
|
+
|
|
573
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
574
|
+
Effect.send('c1', [L(':irc.example.com 411 alice :No recipient given (PRIVMSG)')]),
|
|
575
|
+
]);
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
it('emits 412 ERR_NOTEXTTOSEND when no text is supplied', () => {
|
|
579
|
+
const conn = makeConn();
|
|
580
|
+
const ctx = makeCtx(conn);
|
|
581
|
+
|
|
582
|
+
const out = privmsgUserReducer(conn, { command: 'PRIVMSG', params: ['bob'], tags: {} }, ctx);
|
|
583
|
+
|
|
584
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
585
|
+
Effect.send('c1', [L(':irc.example.com 412 alice :No text to send')]),
|
|
586
|
+
]);
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
it('updates lastSeen to ctx.clock.now()', () => {
|
|
590
|
+
const conn = makeConn();
|
|
591
|
+
const clock = new FakeClock(12_000);
|
|
592
|
+
const ctx = makeCtx(conn, clock);
|
|
593
|
+
|
|
594
|
+
privmsgUserReducer(conn, { command: 'PRIVMSG', params: ['bob', 'hi'], tags: {} }, ctx);
|
|
595
|
+
|
|
596
|
+
expect(conn.lastSeen).toBe(12_000);
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
it('returns the same state reference (mutation permitted, no copy)', () => {
|
|
600
|
+
const conn = makeConn();
|
|
601
|
+
const ctx = makeCtx(conn);
|
|
602
|
+
|
|
603
|
+
const out = privmsgUserReducer(
|
|
604
|
+
conn,
|
|
605
|
+
{ command: 'PRIVMSG', params: ['bob', 'hi'], tags: {} },
|
|
606
|
+
ctx,
|
|
607
|
+
);
|
|
608
|
+
|
|
609
|
+
expect(out.state).toBe(conn);
|
|
610
|
+
});
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
// ============================================================================
|
|
614
|
+
// noticeUserReducer
|
|
615
|
+
// ============================================================================
|
|
616
|
+
|
|
617
|
+
describe('noticeUserReducer', () => {
|
|
618
|
+
it('emits a SendToNick effect for a private NOTICE to an online user', () => {
|
|
619
|
+
const conn = makeConn();
|
|
620
|
+
const ctx = makeCtx(conn);
|
|
621
|
+
|
|
622
|
+
const out = noticeUserReducer(
|
|
623
|
+
conn,
|
|
624
|
+
{ command: 'NOTICE', params: ['bob', 'psst'], tags: {} },
|
|
625
|
+
ctx,
|
|
626
|
+
);
|
|
627
|
+
|
|
628
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
629
|
+
Effect.sendToNick('bob', 'c1', [L(':alice!alice@example.com NOTICE bob :psst')]),
|
|
630
|
+
]);
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
it('MUST NOT emit any not-found reply for an offline recipient (RFC-critical)', () => {
|
|
634
|
+
const conn = makeConn();
|
|
635
|
+
const ctx = makeCtx(conn);
|
|
636
|
+
|
|
637
|
+
const out = noticeUserReducer(
|
|
638
|
+
conn,
|
|
639
|
+
{ command: 'NOTICE', params: ['nobody', 'hi'], tags: {} },
|
|
640
|
+
ctx,
|
|
641
|
+
);
|
|
642
|
+
|
|
643
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
644
|
+
Effect.sendToNick('nobody', 'c1', [L(':alice!alice@example.com NOTICE nobody :hi')]),
|
|
645
|
+
]);
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
it('MUST NOT produce any numeric reply when target is missing (RFC-critical)', () => {
|
|
649
|
+
const conn = makeConn();
|
|
650
|
+
const ctx = makeCtx(conn);
|
|
651
|
+
|
|
652
|
+
const out = noticeUserReducer(conn, { command: 'NOTICE', params: [], tags: {} }, ctx);
|
|
653
|
+
|
|
654
|
+
expect(out.effects).toEqual([]);
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
it('MUST NOT produce any numeric reply when text is missing (RFC-critical)', () => {
|
|
658
|
+
const conn = makeConn();
|
|
659
|
+
const ctx = makeCtx(conn);
|
|
660
|
+
|
|
661
|
+
const out = noticeUserReducer(conn, { command: 'NOTICE', params: ['bob'], tags: {} }, ctx);
|
|
662
|
+
|
|
663
|
+
expect(out.effects).toEqual([]);
|
|
664
|
+
});
|
|
665
|
+
});
|