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.
Files changed (165) hide show
  1. package/.github/workflows/ci.yml +47 -0
  2. package/.github/workflows/deploy-cf.yml +97 -0
  3. package/LICENSE +29 -0
  4. package/README.md +323 -0
  5. package/apps/cf-worker/package.json +37 -0
  6. package/apps/cf-worker/scripts/smoke.mjs +175 -0
  7. package/apps/cf-worker/src/worker.ts +59 -0
  8. package/apps/cf-worker/tests/smoke.test.ts +150 -0
  9. package/apps/cf-worker/tsconfig.build.json +17 -0
  10. package/apps/cf-worker/tsconfig.test.json +18 -0
  11. package/apps/cf-worker/vitest.config.ts +31 -0
  12. package/apps/cf-worker/wrangler.test.toml +33 -0
  13. package/apps/cf-worker/wrangler.toml +98 -0
  14. package/apps/local-cli/package.json +35 -0
  15. package/apps/local-cli/src/line-scanner.ts +60 -0
  16. package/apps/local-cli/src/main.ts +145 -0
  17. package/apps/local-cli/src/motd-file.ts +45 -0
  18. package/apps/local-cli/src/server.ts +409 -0
  19. package/apps/local-cli/tests/e2e.test.ts +346 -0
  20. package/apps/local-cli/tests/id-factory.test.ts +25 -0
  21. package/apps/local-cli/tests/line-scanner.test.ts +81 -0
  22. package/apps/local-cli/tests/motd-file.test.ts +71 -0
  23. package/apps/local-cli/tests/tcp.test.ts +358 -0
  24. package/apps/local-cli/tsconfig.build.json +17 -0
  25. package/apps/local-cli/tsconfig.test.json +15 -0
  26. package/apps/local-cli/vitest.config.ts +24 -0
  27. package/biome.json +52 -0
  28. package/package.json +35 -0
  29. package/packages/cf-adapter/package.json +38 -0
  30. package/packages/cf-adapter/src/cf-runtime.ts +308 -0
  31. package/packages/cf-adapter/src/channel-do.ts +374 -0
  32. package/packages/cf-adapter/src/connection-do.ts +542 -0
  33. package/packages/cf-adapter/src/env.ts +67 -0
  34. package/packages/cf-adapter/src/index.ts +38 -0
  35. package/packages/cf-adapter/src/registry-do.ts +130 -0
  36. package/packages/cf-adapter/src/serialize.ts +142 -0
  37. package/packages/cf-adapter/src/sharding.ts +101 -0
  38. package/packages/cf-adapter/tests/cf-harness.ts +264 -0
  39. package/packages/cf-adapter/tests/cf-integration.test.ts +73 -0
  40. package/packages/cf-adapter/tests/cf-runtime.test.ts +527 -0
  41. package/packages/cf-adapter/tests/channel-do.test.ts +480 -0
  42. package/packages/cf-adapter/tests/connection-do.test.ts +329 -0
  43. package/packages/cf-adapter/tests/integration/harness.test.ts +229 -0
  44. package/packages/cf-adapter/tests/integration/harness.ts +102 -0
  45. package/packages/cf-adapter/tests/integration/in-memory-harness.ts +242 -0
  46. package/packages/cf-adapter/tests/integration/index.ts +17 -0
  47. package/packages/cf-adapter/tests/integration/scenarios.test.ts +20 -0
  48. package/packages/cf-adapter/tests/integration/scenarios.ts +495 -0
  49. package/packages/cf-adapter/tests/registry-do.test.ts +335 -0
  50. package/packages/cf-adapter/tests/sharding.test.ts +100 -0
  51. package/packages/cf-adapter/tests/worker/main.ts +33 -0
  52. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +50 -0
  53. package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +57 -0
  54. package/packages/cf-adapter/tsconfig.build.json +16 -0
  55. package/packages/cf-adapter/tsconfig.test.json +18 -0
  56. package/packages/cf-adapter/vitest.config.ts +32 -0
  57. package/packages/cf-adapter/wrangler.test.toml +50 -0
  58. package/packages/in-memory-runtime/package.json +29 -0
  59. package/packages/in-memory-runtime/src/in-memory-runtime.ts +245 -0
  60. package/packages/in-memory-runtime/src/index.ts +9 -0
  61. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +502 -0
  62. package/packages/in-memory-runtime/tsconfig.build.json +16 -0
  63. package/packages/in-memory-runtime/tsconfig.test.json +14 -0
  64. package/packages/in-memory-runtime/vitest.config.ts +21 -0
  65. package/packages/irc-core/package.json +29 -0
  66. package/packages/irc-core/src/caps/capabilities.ts +96 -0
  67. package/packages/irc-core/src/caps/index.ts +1 -0
  68. package/packages/irc-core/src/commands/away.ts +82 -0
  69. package/packages/irc-core/src/commands/cap.ts +257 -0
  70. package/packages/irc-core/src/commands/chghost.ts +30 -0
  71. package/packages/irc-core/src/commands/index.ts +40 -0
  72. package/packages/irc-core/src/commands/invite.ts +156 -0
  73. package/packages/irc-core/src/commands/isupport.ts +133 -0
  74. package/packages/irc-core/src/commands/join.ts +309 -0
  75. package/packages/irc-core/src/commands/kick.ts +162 -0
  76. package/packages/irc-core/src/commands/list.ts +126 -0
  77. package/packages/irc-core/src/commands/mode.ts +655 -0
  78. package/packages/irc-core/src/commands/motd.ts +146 -0
  79. package/packages/irc-core/src/commands/names.ts +164 -0
  80. package/packages/irc-core/src/commands/part.ts +118 -0
  81. package/packages/irc-core/src/commands/ping.ts +47 -0
  82. package/packages/irc-core/src/commands/privmsg.ts +256 -0
  83. package/packages/irc-core/src/commands/quit.ts +70 -0
  84. package/packages/irc-core/src/commands/registration.ts +251 -0
  85. package/packages/irc-core/src/commands/topic.ts +171 -0
  86. package/packages/irc-core/src/commands/who.ts +169 -0
  87. package/packages/irc-core/src/commands/whois.ts +165 -0
  88. package/packages/irc-core/src/effects.ts +184 -0
  89. package/packages/irc-core/src/index.ts +20 -0
  90. package/packages/irc-core/src/ports.ts +153 -0
  91. package/packages/irc-core/src/protocol/batch.ts +85 -0
  92. package/packages/irc-core/src/protocol/index.ts +18 -0
  93. package/packages/irc-core/src/protocol/messages.ts +36 -0
  94. package/packages/irc-core/src/protocol/numerics.ts +155 -0
  95. package/packages/irc-core/src/protocol/outbound.ts +145 -0
  96. package/packages/irc-core/src/protocol/parser.ts +175 -0
  97. package/packages/irc-core/src/protocol/serializer.ts +71 -0
  98. package/packages/irc-core/src/state/channel.ts +293 -0
  99. package/packages/irc-core/src/state/connection.ts +153 -0
  100. package/packages/irc-core/src/state/index.ts +25 -0
  101. package/packages/irc-core/src/state/registry.ts +22 -0
  102. package/packages/irc-core/src/types.ts +83 -0
  103. package/packages/irc-core/tests/batch.test.ts +79 -0
  104. package/packages/irc-core/tests/caps/capabilities.test.ts +148 -0
  105. package/packages/irc-core/tests/commands/away.test.ts +233 -0
  106. package/packages/irc-core/tests/commands/batch.test.ts +178 -0
  107. package/packages/irc-core/tests/commands/cap.test.ts +499 -0
  108. package/packages/irc-core/tests/commands/chghost.test.ts +186 -0
  109. package/packages/irc-core/tests/commands/echo-message.test.ts +212 -0
  110. package/packages/irc-core/tests/commands/extended-join.test.ts +147 -0
  111. package/packages/irc-core/tests/commands/invite-notify.test.ts +160 -0
  112. package/packages/irc-core/tests/commands/invite.test.ts +321 -0
  113. package/packages/irc-core/tests/commands/isupport.test.ts +209 -0
  114. package/packages/irc-core/tests/commands/join.test.ts +687 -0
  115. package/packages/irc-core/tests/commands/kick.test.ts +316 -0
  116. package/packages/irc-core/tests/commands/list.test.ts +452 -0
  117. package/packages/irc-core/tests/commands/mode.test.ts +1048 -0
  118. package/packages/irc-core/tests/commands/motd.test.ts +322 -0
  119. package/packages/irc-core/tests/commands/names.test.ts +342 -0
  120. package/packages/irc-core/tests/commands/part.test.ts +265 -0
  121. package/packages/irc-core/tests/commands/ping.test.ts +144 -0
  122. package/packages/irc-core/tests/commands/privmsg.test.ts +665 -0
  123. package/packages/irc-core/tests/commands/quit.test.ts +220 -0
  124. package/packages/irc-core/tests/commands/registration.test.ts +599 -0
  125. package/packages/irc-core/tests/commands/topic.test.ts +337 -0
  126. package/packages/irc-core/tests/commands/who.test.ts +441 -0
  127. package/packages/irc-core/tests/commands/whois.test.ts +422 -0
  128. package/packages/irc-core/tests/effects.test.ts +147 -0
  129. package/packages/irc-core/tests/message-tags.test.ts +182 -0
  130. package/packages/irc-core/tests/numerics.test.ts +98 -0
  131. package/packages/irc-core/tests/outbound.test.ts +232 -0
  132. package/packages/irc-core/tests/parser.test.ts +162 -0
  133. package/packages/irc-core/tests/ports.test.ts +101 -0
  134. package/packages/irc-core/tests/serializer.test.ts +164 -0
  135. package/packages/irc-core/tests/state/channel.test.ts +351 -0
  136. package/packages/irc-core/tests/state/connection.test.ts +122 -0
  137. package/packages/irc-core/tests/state/registry.test.ts +20 -0
  138. package/packages/irc-core/tests/types.test.ts +127 -0
  139. package/packages/irc-core/tsconfig.build.json +12 -0
  140. package/packages/irc-core/tsconfig.test.json +10 -0
  141. package/packages/irc-core/vitest.config.ts +21 -0
  142. package/packages/irc-server/package.json +28 -0
  143. package/packages/irc-server/src/actor.ts +449 -0
  144. package/packages/irc-server/src/dispatch.ts +120 -0
  145. package/packages/irc-server/src/index.ts +11 -0
  146. package/packages/irc-server/src/runtime.ts +61 -0
  147. package/packages/irc-server/tests/actor.test.ts +816 -0
  148. package/packages/irc-server/tests/dispatch.test.ts +512 -0
  149. package/packages/irc-server/tests/runtime.test.ts +52 -0
  150. package/packages/irc-server/tsconfig.build.json +13 -0
  151. package/packages/irc-server/tsconfig.test.json +11 -0
  152. package/packages/irc-server/vitest.config.ts +21 -0
  153. package/pnpm-workspace.yaml +4 -0
  154. package/tools/tcp-ws-forwarder/package.json +32 -0
  155. package/tools/tcp-ws-forwarder/src/forwarder.ts +244 -0
  156. package/tools/tcp-ws-forwarder/src/line-scanner.ts +55 -0
  157. package/tools/tcp-ws-forwarder/src/main.ts +114 -0
  158. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +618 -0
  159. package/tools/tcp-ws-forwarder/tests/framing.test.ts +51 -0
  160. package/tools/tcp-ws-forwarder/tests/line-scanner.test.ts +75 -0
  161. package/tools/tcp-ws-forwarder/tsconfig.build.json +12 -0
  162. package/tools/tcp-ws-forwarder/tsconfig.test.json +10 -0
  163. package/tools/tcp-ws-forwarder/vitest.config.ts +27 -0
  164. package/tsconfig.base.json +25 -0
  165. package/turbo.json +29 -0
@@ -0,0 +1,186 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { emitChghost } from '../../src/commands/chghost';
3
+ import { Effect } from '../../src/effects';
4
+ import type { Effect as EffectType, RawLine } from '../../src/effects';
5
+ import { type ConnectionState, createConnection } from '../../src/state/connection';
6
+
7
+ const L = (text: string): RawLine => ({ text });
8
+
9
+ function makeConn(id = 'c1', nick = 'alice'): ConnectionState {
10
+ const s = createConnection({ id, connectedSince: 0 });
11
+ s.nick = nick;
12
+ s.user = 'olduser';
13
+ s.host = 'oldhost.example.com';
14
+ s.realname = 'Alice';
15
+ s.registration = 'registered';
16
+ return s;
17
+ }
18
+
19
+ describe('emitChghost', () => {
20
+ it('emits a cap-split Broadcast per joined channel for a host change', () => {
21
+ const conn = makeConn();
22
+ conn.joinedChannels.add('#foo');
23
+ conn.joinedChannels.add('#bar');
24
+
25
+ const effects = emitChghost({
26
+ conn,
27
+ oldUser: 'olduser',
28
+ oldHost: 'oldhost.example.com',
29
+ newUser: 'newuser',
30
+ newHost: 'newhost.example.com',
31
+ });
32
+
33
+ expect(effects).toEqual<EffectType[]>([
34
+ Effect.broadcast('#foo', [], undefined, 'chghost', [
35
+ L(':alice!olduser@oldhost.example.com CHGHOST newuser :newhost.example.com'),
36
+ ]),
37
+ Effect.broadcast('#bar', [], undefined, 'chghost', [
38
+ L(':alice!olduser@oldhost.example.com CHGHOST newuser :newhost.example.com'),
39
+ ]),
40
+ ]);
41
+ });
42
+
43
+ it('emits a CHGHOST when only the host changes', () => {
44
+ const conn = makeConn();
45
+ conn.joinedChannels.add('#foo');
46
+
47
+ const effects = emitChghost({
48
+ conn,
49
+ oldUser: 'olduser',
50
+ oldHost: 'oldhost.example.com',
51
+ newUser: 'olduser',
52
+ newHost: 'cloaked.example.com',
53
+ });
54
+
55
+ expect(effects).toHaveLength(1);
56
+ const bcast = effects[0];
57
+ expect(bcast?.tag).toBe('Broadcast');
58
+ if (bcast?.tag === 'Broadcast') {
59
+ expect(bcast.capLines?.[0]?.text).toBe(
60
+ ':alice!olduser@oldhost.example.com CHGHOST olduser :cloaked.example.com',
61
+ );
62
+ }
63
+ });
64
+
65
+ it('emits a CHGHOST when only the user changes', () => {
66
+ const conn = makeConn();
67
+ conn.joinedChannels.add('#foo');
68
+
69
+ const effects = emitChghost({
70
+ conn,
71
+ oldUser: 'olduser',
72
+ oldHost: 'oldhost.example.com',
73
+ newUser: 'newuser',
74
+ newHost: 'oldhost.example.com',
75
+ });
76
+
77
+ expect(effects).toHaveLength(1);
78
+ const bcast = effects[0];
79
+ if (bcast?.tag === 'Broadcast') {
80
+ expect(bcast.capLines?.[0]?.text).toBe(
81
+ ':alice!olduser@oldhost.example.com CHGHOST newuser :oldhost.example.com',
82
+ );
83
+ }
84
+ });
85
+
86
+ it('emits nothing when neither user nor host changed', () => {
87
+ const conn = makeConn();
88
+ conn.joinedChannels.add('#foo');
89
+
90
+ const effects = emitChghost({
91
+ conn,
92
+ oldUser: 'olduser',
93
+ oldHost: 'oldhost.example.com',
94
+ newUser: 'olduser',
95
+ newHost: 'oldhost.example.com',
96
+ });
97
+
98
+ expect(effects).toEqual([]);
99
+ });
100
+
101
+ it('emits nothing before registration completes', () => {
102
+ const conn = makeConn();
103
+ conn.registration = 'pre-registration';
104
+ conn.joinedChannels.add('#foo');
105
+
106
+ const effects = emitChghost({
107
+ conn,
108
+ oldUser: 'olduser',
109
+ oldHost: 'oldhost.example.com',
110
+ newUser: 'newuser',
111
+ newHost: 'newhost.example.com',
112
+ });
113
+
114
+ expect(effects).toEqual([]);
115
+ });
116
+
117
+ it('emits nothing during registration', () => {
118
+ const conn = makeConn();
119
+ conn.registration = 'registering';
120
+ conn.joinedChannels.add('#foo');
121
+
122
+ const effects = emitChghost({
123
+ conn,
124
+ oldUser: 'olduser',
125
+ oldHost: 'oldhost.example.com',
126
+ newUser: 'newuser',
127
+ newHost: 'newhost.example.com',
128
+ });
129
+
130
+ expect(effects).toEqual([]);
131
+ });
132
+
133
+ it('emits nothing when the connection has no joined channels', () => {
134
+ const conn = makeConn();
135
+
136
+ const effects = emitChghost({
137
+ conn,
138
+ oldUser: 'olduser',
139
+ oldHost: 'oldhost.example.com',
140
+ newUser: 'newuser',
141
+ newHost: 'newhost.example.com',
142
+ });
143
+
144
+ expect(effects).toEqual([]);
145
+ });
146
+
147
+ it('uses empty lines so non-cap peers receive nothing', () => {
148
+ const conn = makeConn();
149
+ conn.joinedChannels.add('#foo');
150
+
151
+ const effects = emitChghost({
152
+ conn,
153
+ oldUser: 'olduser',
154
+ oldHost: 'oldhost.example.com',
155
+ newUser: 'newuser',
156
+ newHost: 'newhost.example.com',
157
+ });
158
+
159
+ const bcast = effects[0];
160
+ if (bcast?.tag === 'Broadcast') {
161
+ expect(bcast.lines).toEqual([]);
162
+ expect(bcast.cap).toBe('chghost');
163
+ }
164
+ });
165
+
166
+ it('falls back to ? for nick when absent', () => {
167
+ const conn = createConnection({ id: 'c1', connectedSince: 0 });
168
+ conn.registration = 'registered';
169
+ conn.joinedChannels.add('#foo');
170
+
171
+ const effects = emitChghost({
172
+ conn,
173
+ oldUser: 'olduser',
174
+ oldHost: 'oldhost.example.com',
175
+ newUser: 'newuser',
176
+ newHost: 'newhost.example.com',
177
+ });
178
+
179
+ const bcast = effects[0];
180
+ if (bcast?.tag === 'Broadcast') {
181
+ expect(bcast.capLines?.[0]?.text).toBe(
182
+ ':?!olduser@oldhost.example.com CHGHOST newuser :newhost.example.com',
183
+ );
184
+ }
185
+ });
186
+ });
@@ -0,0 +1,212 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ noticeChannelReducer,
4
+ noticeUserReducer,
5
+ privmsgChannelReducer,
6
+ privmsgUserReducer,
7
+ } from '../../src/commands/privmsg';
8
+ import { Effect } from '../../src/effects';
9
+ import type { Effect as EffectType, RawLine } from '../../src/effects';
10
+ import { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';
11
+ import { type ChannelState, createChannel } from '../../src/state/channel';
12
+ import { type ConnectionState, createConnection } from '../../src/state/connection';
13
+ import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
14
+
15
+ const serverConfig: ServerConfig = {
16
+ serverName: 'irc.example.com',
17
+ networkName: 'ExampleNet',
18
+ maxChannelsPerUser: 30,
19
+ maxTargetsPerCommand: 10,
20
+ maxListEntries: 50,
21
+ nickLen: 30,
22
+ channelLen: 50,
23
+ topicLen: 390,
24
+ quitMessage: 'Client Quit',
25
+ };
26
+
27
+ function makeCtx(conn: ConnectionState, clock = new FakeClock(1_000)): Ctx {
28
+ return buildCtx({
29
+ serverConfig,
30
+ clock,
31
+ ids: new SequentialIdFactory(),
32
+ motd: EmptyMotdProvider,
33
+ connection: conn,
34
+ });
35
+ }
36
+
37
+ function makeConn(id = 'c1', nick = 'alice', caps: ReadonlyArray<string> = []): ConnectionState {
38
+ const s = createConnection({ id, connectedSince: 0 });
39
+ s.nick = nick;
40
+ s.user = nick;
41
+ s.host = 'example.com';
42
+ s.realname = nick.charAt(0).toUpperCase() + nick.slice(1);
43
+ s.registration = 'registered';
44
+ for (const c of caps) s.caps.add(c);
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(chan: ChannelState, connId: string, nick: string): void {
53
+ chan.members.set(connId, { conn: connId, nick, op: false, voice: false });
54
+ }
55
+
56
+ const L = (text: string): RawLine => ({ text });
57
+
58
+ // ============================================================================
59
+ // echo-message — channel path
60
+ // ============================================================================
61
+
62
+ describe('echo-message — channel PRIVMSG', () => {
63
+ it('does NOT echo when the sender lacks the echo-message cap', () => {
64
+ const chan = makeChan('#foo');
65
+ addMember(chan, 'c1', 'alice');
66
+ const conn = makeConn('c1', 'alice');
67
+ const ctx = makeCtx(conn);
68
+
69
+ const out = privmsgChannelReducer(
70
+ chan,
71
+ { command: 'PRIVMSG', params: ['#foo', 'hi'], tags: {} },
72
+ ctx,
73
+ );
74
+
75
+ expect(out.effects).toEqual<EffectType[]>([
76
+ Effect.broadcast('#foo', [L(':alice!alice@example.com PRIVMSG #foo :hi')], 'c1'),
77
+ ]);
78
+ });
79
+
80
+ it('echoes the PRIVMSG back to the sender AFTER the broadcast when the cap is set', () => {
81
+ const chan = makeChan('#foo');
82
+ addMember(chan, 'c1', 'alice');
83
+ const conn = makeConn('c1', 'alice', ['echo-message']);
84
+ const ctx = makeCtx(conn);
85
+
86
+ const out = privmsgChannelReducer(
87
+ chan,
88
+ { command: 'PRIVMSG', params: ['#foo', 'hi'], tags: {} },
89
+ ctx,
90
+ );
91
+
92
+ expect(out.effects).toEqual<EffectType[]>([
93
+ Effect.broadcast('#foo', [L(':alice!alice@example.com PRIVMSG #foo :hi')], 'c1'),
94
+ Effect.send('c1', [L(':alice!alice@example.com PRIVMSG #foo :hi')]),
95
+ ]);
96
+ });
97
+
98
+ it('echoes NOTICE back to the sender when the cap is set', () => {
99
+ const chan = makeChan('#foo');
100
+ addMember(chan, 'c1', 'alice');
101
+ const conn = makeConn('c1', 'alice', ['echo-message']);
102
+ const ctx = makeCtx(conn);
103
+
104
+ const out = noticeChannelReducer(
105
+ chan,
106
+ { command: 'NOTICE', params: ['#foo', 'hi'], tags: {} },
107
+ ctx,
108
+ );
109
+
110
+ expect(out.effects).toEqual<EffectType[]>([
111
+ Effect.broadcast('#foo', [L(':alice!alice@example.com NOTICE #foo :hi')], 'c1'),
112
+ Effect.send('c1', [L(':alice!alice@example.com NOTICE #foo :hi')]),
113
+ ]);
114
+ });
115
+
116
+ it('does not echo on error paths (no recipient)', () => {
117
+ const chan = makeChan('#foo');
118
+ addMember(chan, 'c1', 'alice');
119
+ const conn = makeConn('c1', 'alice', ['echo-message']);
120
+ const ctx = makeCtx(conn);
121
+
122
+ const out = privmsgChannelReducer(chan, { command: 'PRIVMSG', params: [], tags: {} }, ctx);
123
+
124
+ // Only the 411 error; no broadcast, no echo.
125
+ expect(out.effects.length).toBe(1);
126
+ expect(out.effects[0]?.tag).toBe('Send');
127
+ });
128
+
129
+ it('does not echo on +m rejection', () => {
130
+ const chan = makeChan('#foo');
131
+ chan.modes.moderated = true;
132
+ addMember(chan, 'c1', 'alice'); // not opped/voiced
133
+ const conn = makeConn('c1', 'alice', ['echo-message']);
134
+ const ctx = makeCtx(conn);
135
+
136
+ const out = privmsgChannelReducer(
137
+ chan,
138
+ { command: 'PRIVMSG', params: ['#foo', 'hi'], tags: {} },
139
+ ctx,
140
+ );
141
+
142
+ // Only the 404 error; no broadcast, no echo.
143
+ expect(out.effects.length).toBe(1);
144
+ expect(out.effects[0]?.tag).toBe('Send');
145
+ });
146
+ });
147
+
148
+ // ============================================================================
149
+ // echo-message — user (PM) path
150
+ // ============================================================================
151
+
152
+ describe('echo-message — user PRIVMSG', () => {
153
+ it('does NOT echo when the sender lacks the echo-message cap', () => {
154
+ const conn = makeConn('c1', 'alice');
155
+ const ctx = makeCtx(conn);
156
+
157
+ const out = privmsgUserReducer(
158
+ conn,
159
+ { command: 'PRIVMSG', params: ['bob', 'hi'], tags: {} },
160
+ ctx,
161
+ );
162
+
163
+ // Just SendToNick; no echo.
164
+ expect(out.effects.length).toBe(1);
165
+ expect(out.effects[0]?.tag).toBe('SendToNick');
166
+ });
167
+
168
+ it('echoes the PRIVMSG back to the sender AFTER SendToNick when the cap is set', () => {
169
+ const conn = makeConn('c1', 'alice', ['echo-message']);
170
+ const ctx = makeCtx(conn);
171
+
172
+ const out = privmsgUserReducer(
173
+ conn,
174
+ { command: 'PRIVMSG', params: ['bob', 'hi'], tags: {} },
175
+ ctx,
176
+ );
177
+
178
+ expect(out.effects.length).toBe(2);
179
+ expect(out.effects[0]?.tag).toBe('SendToNick');
180
+ expect(out.effects[1]).toEqual(
181
+ Effect.send('c1', [L(':alice!alice@example.com PRIVMSG bob :hi')]),
182
+ );
183
+ });
184
+
185
+ it('echoes NOTICE back to the sender when the cap is set', () => {
186
+ const conn = makeConn('c1', 'alice', ['echo-message']);
187
+ const ctx = makeCtx(conn);
188
+
189
+ const out = noticeUserReducer(
190
+ conn,
191
+ { command: 'NOTICE', params: ['bob', 'hi'], tags: {} },
192
+ ctx,
193
+ );
194
+
195
+ expect(out.effects.length).toBe(2);
196
+ expect(out.effects[0]?.tag).toBe('SendToNick');
197
+ expect(out.effects[1]).toEqual(
198
+ Effect.send('c1', [L(':alice!alice@example.com NOTICE bob :hi')]),
199
+ );
200
+ });
201
+
202
+ it('does not echo on missing-text error path', () => {
203
+ const conn = makeConn('c1', 'alice', ['echo-message']);
204
+ const ctx = makeCtx(conn);
205
+
206
+ const out = privmsgUserReducer(conn, { command: 'PRIVMSG', params: ['bob'], tags: {} }, ctx);
207
+
208
+ // Only the 412 error; no SendToNick, no echo.
209
+ expect(out.effects.length).toBe(1);
210
+ expect(out.effects[0]?.tag).toBe('Send');
211
+ });
212
+ });
@@ -0,0 +1,147 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { joinReducer } from '../../src/commands/join';
3
+ import { Effect } from '../../src/effects';
4
+ import type { Effect as EffectType, RawLine } from '../../src/effects';
5
+ import { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';
6
+ import { type ChannelState, createChannel } from '../../src/state/channel';
7
+ import { type ConnectionState, createConnection } from '../../src/state/connection';
8
+ import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
9
+
10
+ const serverConfig: ServerConfig = {
11
+ serverName: 'irc.example.com',
12
+ networkName: 'ExampleNet',
13
+ maxChannelsPerUser: 30,
14
+ maxTargetsPerCommand: 10,
15
+ maxListEntries: 50,
16
+ nickLen: 30,
17
+ channelLen: 50,
18
+ topicLen: 390,
19
+ quitMessage: 'Client Quit',
20
+ };
21
+
22
+ function makeCtx(conn: ConnectionState, clock = new FakeClock(1_000)): Ctx {
23
+ return buildCtx({
24
+ serverConfig,
25
+ clock,
26
+ ids: new SequentialIdFactory(),
27
+ motd: EmptyMotdProvider,
28
+ connection: conn,
29
+ });
30
+ }
31
+
32
+ function makeConn(id = 'c1', nick = 'alice'): ConnectionState {
33
+ const s = createConnection({ id, connectedSince: 0 });
34
+ s.nick = nick;
35
+ s.user = nick;
36
+ s.host = 'example.com';
37
+ s.realname = nick.charAt(0).toUpperCase() + nick.slice(1);
38
+ s.registration = 'registered';
39
+ return s;
40
+ }
41
+
42
+ function makeChan(name = '#foo'): ChannelState {
43
+ return createChannel({ name, nameLower: name.toLowerCase(), createdAt: 0 });
44
+ }
45
+
46
+ const L = (text: string): RawLine => ({ text });
47
+
48
+ describe('extended-join — JOIN broadcast format', () => {
49
+ it('emits a cap-split broadcast with the extended JOIN line for cap-enabled recipients', () => {
50
+ const chan = makeChan('#foo');
51
+ const conn = makeConn();
52
+ const ctx = makeCtx(conn);
53
+
54
+ const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
55
+
56
+ expect(out.effects).toContainEqual<EffectType>(
57
+ Effect.broadcast(
58
+ '#foo',
59
+ [L(':alice!alice@example.com JOIN #foo')],
60
+ undefined,
61
+ 'extended-join',
62
+ [L(':alice!alice@example.com JOIN #foo _ :Alice')],
63
+ ),
64
+ );
65
+ });
66
+
67
+ it('uses the authenticated account name when the joiner has one', () => {
68
+ const chan = makeChan('#foo');
69
+ const conn = makeConn();
70
+ conn.account = 'alice-account';
71
+ const ctx = makeCtx(conn);
72
+
73
+ const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
74
+
75
+ expect(out.effects).toContainEqual<EffectType>(
76
+ Effect.broadcast(
77
+ '#foo',
78
+ [L(':alice!alice@example.com JOIN #foo')],
79
+ undefined,
80
+ 'extended-join',
81
+ [L(':alice!alice@example.com JOIN #foo alice-account :Alice')],
82
+ ),
83
+ );
84
+ });
85
+
86
+ it('uses _ as the account when the joiner is not authenticated', () => {
87
+ const chan = makeChan('#foo');
88
+ const conn = makeConn();
89
+ const ctx = makeCtx(conn);
90
+ const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
91
+ const broadcast = out.effects.find(
92
+ (e): e is Extract<EffectType, { tag: 'Broadcast' }> => e.tag === 'Broadcast',
93
+ );
94
+ expect(broadcast?.cap).toBe('extended-join');
95
+ expect(broadcast?.capLines?.[0]?.text).toBe(':alice!alice@example.com JOIN #foo _ :Alice');
96
+ });
97
+
98
+ it('still emits the legacy JOIN line for non-cap recipients', () => {
99
+ const chan = makeChan('#foo');
100
+ const conn = makeConn();
101
+ const ctx = makeCtx(conn);
102
+ const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
103
+ const broadcast = out.effects.find(
104
+ (e): e is Extract<EffectType, { tag: 'Broadcast' }> => e.tag === 'Broadcast',
105
+ );
106
+ expect(broadcast?.lines).toEqual<RawLine[]>([L(':alice!alice@example.com JOIN #foo')]);
107
+ });
108
+
109
+ it('includes the joiner in the broadcast (no except)', () => {
110
+ const chan = makeChan('#foo');
111
+ const conn = makeConn();
112
+ conn.caps.add('extended-join');
113
+ const ctx = makeCtx(conn);
114
+ const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
115
+ const broadcast = out.effects.find(
116
+ (e): e is Extract<EffectType, { tag: 'Broadcast' }> => e.tag === 'Broadcast',
117
+ );
118
+ expect(broadcast?.except).toBeUndefined();
119
+ });
120
+
121
+ it('omits @host from the extended JOIN prefix when host is absent', () => {
122
+ const chan = makeChan('#foo');
123
+ const conn = makeConn();
124
+ // biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes forbids `= undefined`.
125
+ delete conn.host;
126
+ const ctx = makeCtx(conn);
127
+ const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
128
+ const broadcast = out.effects.find(
129
+ (e): e is Extract<EffectType, { tag: 'Broadcast' }> => e.tag === 'Broadcast',
130
+ );
131
+ expect(broadcast?.capLines?.[0]?.text).toBe(':alice!alice JOIN #foo _ :Alice');
132
+ });
133
+
134
+ it('falls back to legacy-only broadcast when realname is absent', () => {
135
+ const chan = makeChan('#foo');
136
+ const conn = createConnection({ id: 'c1', connectedSince: 0 });
137
+ conn.nick = '?';
138
+ const ctx = makeCtx(conn);
139
+ const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
140
+ const broadcast = out.effects.find(
141
+ (e): e is Extract<EffectType, { tag: 'Broadcast' }> => e.tag === 'Broadcast',
142
+ );
143
+ expect(broadcast?.cap).toBeUndefined();
144
+ expect(broadcast?.capLines).toBeUndefined();
145
+ expect(broadcast?.lines).toEqual<RawLine[]>([L(':? JOIN #foo')]);
146
+ });
147
+ });