serverless-ircd 0.2.0 → 0.3.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 (134) hide show
  1. package/CHANGELOG.md +84 -0
  2. package/README.md +28 -19
  3. package/apps/cf-worker/package.json +1 -1
  4. package/apps/cf-worker/scripts/smoke.mjs +0 -0
  5. package/apps/cf-worker/src/worker.ts +8 -2
  6. package/apps/cf-worker/tests/smoke.test.ts +1 -0
  7. package/apps/cf-worker/wrangler.test.toml +5 -1
  8. package/apps/cf-worker/wrangler.toml +41 -17
  9. package/apps/local-cli/package.json +1 -1
  10. package/apps/local-cli/src/config-loader.ts +1 -0
  11. package/apps/local-cli/src/main.ts +1 -1
  12. package/apps/local-cli/src/server.ts +28 -2
  13. package/apps/local-cli/tests/e2e.test.ts +52 -0
  14. package/docs/ADR-001-pure-reducers-and-effect-system.md +74 -0
  15. package/docs/ADR-002-location-of-authority.md +82 -0
  16. package/docs/ADR-003-durable-object-sharding.md +93 -0
  17. package/docs/ADR-004-dynamodb-schema.md +96 -0
  18. package/docs/ADR-005-wss-only-transport-v1.md +83 -0
  19. package/docs/ADR-006-sasl-mechanism-scope.md +86 -0
  20. package/docs/ADR-007-deterministic-ports.md +82 -0
  21. package/docs/ADR-008-monorepo-tooling.md +60 -0
  22. package/docs/AWS-Adapter-Architecture.md +6 -4
  23. package/docs/AWS-Deployment.md +86 -7
  24. package/docs/Cloudflare-Deployment-Guide.md +5 -5
  25. package/docs/Home.md +11 -1
  26. package/docs/Release-Process.md +9 -6
  27. package/package.json +14 -15
  28. package/packages/aws-adapter/package.json +1 -1
  29. package/packages/aws-adapter/src/account-store.ts +121 -0
  30. package/packages/aws-adapter/src/aws-runtime.ts +118 -30
  31. package/packages/aws-adapter/src/cdk-table-defs.ts +5 -1
  32. package/packages/aws-adapter/src/config-loader.ts +30 -0
  33. package/packages/aws-adapter/src/dynamo-account-store.ts +156 -0
  34. package/packages/aws-adapter/src/handlers/default.ts +8 -0
  35. package/packages/aws-adapter/src/handlers/disconnect.ts +27 -8
  36. package/packages/aws-adapter/src/handlers/index.ts +51 -6
  37. package/packages/aws-adapter/src/index.ts +7 -0
  38. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +171 -0
  39. package/packages/aws-adapter/tests/account-store.test.ts +280 -0
  40. package/packages/aws-adapter/tests/config-loader.test.ts +55 -0
  41. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +336 -0
  42. package/packages/aws-adapter/tests/handlers.test.ts +46 -0
  43. package/packages/cf-adapter/package.json +1 -1
  44. package/packages/cf-adapter/src/cf-runtime.ts +42 -22
  45. package/packages/cf-adapter/src/channel-do.ts +40 -1
  46. package/packages/cf-adapter/src/channel-registry-do.ts +58 -0
  47. package/packages/cf-adapter/src/config-loader.ts +29 -0
  48. package/packages/cf-adapter/src/connection-do.ts +85 -0
  49. package/packages/cf-adapter/src/env.ts +27 -1
  50. package/packages/cf-adapter/src/index.ts +2 -1
  51. package/packages/cf-adapter/tests/cf-runtime.test.ts +91 -0
  52. package/packages/cf-adapter/tests/connection-do.test.ts +40 -0
  53. package/packages/cf-adapter/tests/worker/main.ts +2 -1
  54. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +7 -1
  55. package/packages/cf-adapter/wrangler.test.toml +10 -1
  56. package/packages/in-memory-runtime/package.json +1 -1
  57. package/packages/in-memory-runtime/src/in-memory-runtime.ts +14 -28
  58. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +19 -0
  59. package/packages/irc-core/package.json +3 -2
  60. package/packages/irc-core/src/case-fold.ts +64 -0
  61. package/packages/irc-core/src/commands/index.ts +2 -0
  62. package/packages/irc-core/src/commands/invite.ts +6 -9
  63. package/packages/irc-core/src/commands/isupport.ts +1 -1
  64. package/packages/irc-core/src/commands/join.ts +4 -3
  65. package/packages/irc-core/src/commands/kick.ts +4 -11
  66. package/packages/irc-core/src/commands/list.ts +5 -4
  67. package/packages/irc-core/src/commands/mode.ts +5 -9
  68. package/packages/irc-core/src/commands/motd-lines.ts +127 -0
  69. package/packages/irc-core/src/commands/motd.ts +6 -110
  70. package/packages/irc-core/src/commands/oper.ts +151 -0
  71. package/packages/irc-core/src/commands/registration.ts +8 -7
  72. package/packages/irc-core/src/commands/tagmsg.ts +205 -0
  73. package/packages/irc-core/src/config.ts +26 -3
  74. package/packages/irc-core/src/index.ts +2 -0
  75. package/packages/irc-core/src/ports.ts +68 -4
  76. package/packages/irc-core/src/types.ts +22 -0
  77. package/packages/irc-core/tests/account-store.test.ts +88 -0
  78. package/packages/irc-core/tests/case-fold.test.ts +80 -0
  79. package/packages/irc-core/tests/commands/kick.test.ts +15 -0
  80. package/packages/irc-core/tests/commands/oper.test.ts +257 -0
  81. package/packages/irc-core/tests/commands/registration.test.ts +106 -14
  82. package/packages/irc-core/tests/commands/tagmsg.test.ts +688 -0
  83. package/packages/irc-core/tests/commands/who.test.ts +22 -4
  84. package/packages/irc-core/tests/commands/whois.test.ts +8 -1
  85. package/packages/irc-core/tests/config.test.ts +75 -0
  86. package/packages/irc-server/package.json +1 -1
  87. package/packages/irc-server/src/actor.ts +24 -0
  88. package/packages/irc-server/src/routing.ts +9 -0
  89. package/packages/irc-server/tests/actor.test.ts +220 -1
  90. package/packages/irc-server/tests/routing.test.ts +3 -0
  91. package/packages/irc-test-support/package.json +1 -1
  92. package/packages/irc-test-support/src/in-memory-harness.ts +5 -4
  93. package/pnpm-workspace.yaml +1 -0
  94. package/tools/seed-aws-accounts.ts +80 -0
  95. package/AGENTS.md +0 -5
  96. package/MOTD.txt +0 -3
  97. package/PLAN-FIXES.md +0 -358
  98. package/PLAN.md +0 -420
  99. package/dashboards/cloudwatch-irc.json +0 -139
  100. package/progress.md +0 -107
  101. package/tickets.md +0 -2485
  102. package/webircgateway/LICENSE +0 -201
  103. package/webircgateway/Makefile +0 -44
  104. package/webircgateway/README.md +0 -134
  105. package/webircgateway/config.conf.example +0 -135
  106. package/webircgateway/go.mod +0 -16
  107. package/webircgateway/go.sum +0 -89
  108. package/webircgateway/main.go +0 -118
  109. package/webircgateway/pkg/dnsbl/dnsbl.go +0 -121
  110. package/webircgateway/pkg/identd/identd.go +0 -86
  111. package/webircgateway/pkg/identd/rpcclient.go +0 -59
  112. package/webircgateway/pkg/irc/isupport.go +0 -56
  113. package/webircgateway/pkg/irc/message.go +0 -217
  114. package/webircgateway/pkg/irc/state.go +0 -79
  115. package/webircgateway/pkg/proxy/proxy.go +0 -129
  116. package/webircgateway/pkg/proxy/server.go +0 -237
  117. package/webircgateway/pkg/recaptcha/recaptcha.go +0 -59
  118. package/webircgateway/pkg/webircgateway/client.go +0 -741
  119. package/webircgateway/pkg/webircgateway/client_command_handlers.go +0 -495
  120. package/webircgateway/pkg/webircgateway/config.go +0 -385
  121. package/webircgateway/pkg/webircgateway/gateway.go +0 -278
  122. package/webircgateway/pkg/webircgateway/gateway_utils.go +0 -133
  123. package/webircgateway/pkg/webircgateway/hooks.go +0 -152
  124. package/webircgateway/pkg/webircgateway/letsencrypt.go +0 -41
  125. package/webircgateway/pkg/webircgateway/messagetags.go +0 -103
  126. package/webircgateway/pkg/webircgateway/transport_kiwiirc.go +0 -206
  127. package/webircgateway/pkg/webircgateway/transport_sockjs.go +0 -107
  128. package/webircgateway/pkg/webircgateway/transport_tcp.go +0 -113
  129. package/webircgateway/pkg/webircgateway/transport_websocket.go +0 -126
  130. package/webircgateway/pkg/webircgateway/utils.go +0 -147
  131. package/webircgateway/plugins/example/plugin.go +0 -11
  132. package/webircgateway/plugins/stats/plugin.go +0 -52
  133. package/webircgateway/staticcheck.conf +0 -1
  134. package/webircgateway/webircgateway.svg +0 -3
@@ -0,0 +1,688 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { tagmsgChannelReducer, tagmsgUserReducer } from '../../src/commands/tagmsg';
3
+ import { Effect } from '../../src/effects';
4
+ import type { Effect as EffectType, RawLine } from '../../src/effects';
5
+ import {
6
+ EmptyMotdProvider,
7
+ FakeClock,
8
+ InMemoryMessageStore,
9
+ type MessageStore,
10
+ SequentialIdFactory,
11
+ type StoredMessage,
12
+ } from '../../src/ports';
13
+ import { type ChannelState, createChannel } from '../../src/state/channel';
14
+ import { type ConnectionState, createConnection } from '../../src/state/connection';
15
+ import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
16
+
17
+ const serverConfig: ServerConfig = {
18
+ serverName: 'irc.example.com',
19
+ networkName: 'ExampleNet',
20
+ maxChannelsPerUser: 30,
21
+ maxTargetsPerCommand: 10,
22
+ maxListEntries: 50,
23
+ nickLen: 30,
24
+ channelLen: 50,
25
+ topicLen: 390,
26
+ quitMessage: 'Client Quit',
27
+ };
28
+
29
+ function makeCtx(
30
+ conn: ConnectionState,
31
+ clock = new FakeClock(1_000),
32
+ messages?: MessageStore,
33
+ ): Ctx {
34
+ return buildCtx({
35
+ serverConfig,
36
+ clock,
37
+ ids: new SequentialIdFactory(),
38
+ motd: EmptyMotdProvider,
39
+ connection: conn,
40
+ ...(messages !== undefined ? { messages } : {}),
41
+ });
42
+ }
43
+
44
+ function makeConn(id = 'c1', nick = 'alice'): ConnectionState {
45
+ const s = createConnection({ id, connectedSince: 0 });
46
+ s.nick = nick;
47
+ s.user = nick;
48
+ s.host = 'example.com';
49
+ s.realname = nick.charAt(0).toUpperCase() + nick.slice(1);
50
+ s.registration = 'registered';
51
+ s.caps.add('message-tags');
52
+ return s;
53
+ }
54
+
55
+ function makeChan(name = '#foo'): ChannelState {
56
+ return createChannel({ name, nameLower: name.toLowerCase(), createdAt: 0 });
57
+ }
58
+
59
+ function addMember(
60
+ chan: ChannelState,
61
+ connId: string,
62
+ nick: string,
63
+ op = false,
64
+ voice = false,
65
+ ): void {
66
+ chan.members.set(connId, { conn: connId, nick, op, voice });
67
+ }
68
+
69
+ const L = (text: string): RawLine => ({ text });
70
+
71
+ // ============================================================================
72
+ // tagmsgChannelReducer — success path
73
+ // ============================================================================
74
+
75
+ describe('tagmsgChannelReducer — success', () => {
76
+ it('broadcasts TAGMSG to channel members (excluding sender) with the tag prefix', () => {
77
+ const chan = makeChan('#foo');
78
+ addMember(chan, 'c1', 'alice');
79
+ addMember(chan, 'c2', 'bob');
80
+ const conn = makeConn();
81
+ const ctx = makeCtx(conn);
82
+
83
+ const out = tagmsgChannelReducer(
84
+ chan,
85
+ { command: 'TAGMSG', params: ['#foo'], tags: { '+typing': 'active' } },
86
+ ctx,
87
+ );
88
+
89
+ expect(out.effects).toEqual<EffectType[]>([
90
+ Effect.broadcast(
91
+ '#foo',
92
+ [L('@+typing=active :alice!alice@example.com TAGMSG #foo')],
93
+ 'c1',
94
+ 'message-tags',
95
+ ),
96
+ ]);
97
+ });
98
+
99
+ it('broadcasts without an @ prefix when no tags are present', () => {
100
+ const chan = makeChan('#foo');
101
+ addMember(chan, 'c1', 'alice');
102
+ const conn = makeConn();
103
+ const ctx = makeCtx(conn);
104
+
105
+ const out = tagmsgChannelReducer(
106
+ chan,
107
+ { command: 'TAGMSG', params: ['#foo'], tags: {} },
108
+ ctx,
109
+ );
110
+
111
+ expect(out.effects).toEqual<EffectType[]>([
112
+ Effect.broadcast('#foo', [L(':alice!alice@example.com TAGMSG #foo')], 'c1', 'message-tags'),
113
+ ]);
114
+ });
115
+
116
+ it('serializes multiple tags correctly', () => {
117
+ const chan = makeChan('#foo');
118
+ addMember(chan, 'c1', 'alice');
119
+ const conn = makeConn();
120
+ const ctx = makeCtx(conn);
121
+
122
+ const out = tagmsgChannelReducer(
123
+ chan,
124
+ { command: 'TAGMSG', params: ['#foo'], tags: { '+typing': 'active', 'vendor/foo': 'bar' } },
125
+ ctx,
126
+ );
127
+
128
+ expect(out.effects).toEqual<EffectType[]>([
129
+ Effect.broadcast(
130
+ '#foo',
131
+ [L('@+typing=active;vendor/foo=bar :alice!alice@example.com TAGMSG #foo')],
132
+ 'c1',
133
+ 'message-tags',
134
+ ),
135
+ ]);
136
+ });
137
+
138
+ it('serializes a valueless tag as a bare key (no =suffix)', () => {
139
+ const chan = makeChan('#foo');
140
+ addMember(chan, 'c1', 'alice');
141
+ const conn = makeConn();
142
+ const ctx = makeCtx(conn);
143
+
144
+ const out = tagmsgChannelReducer(
145
+ chan,
146
+ { command: 'TAGMSG', params: ['#foo'], tags: { '+ack': '' } },
147
+ ctx,
148
+ );
149
+
150
+ expect(out.effects).toEqual<EffectType[]>([
151
+ Effect.broadcast(
152
+ '#foo',
153
+ [L('@+ack :alice!alice@example.com TAGMSG #foo')],
154
+ 'c1',
155
+ 'message-tags',
156
+ ),
157
+ ]);
158
+ });
159
+
160
+ it('updates the connection lastSeen to ctx.clock.now()', () => {
161
+ const chan = makeChan('#foo');
162
+ addMember(chan, 'c1', 'alice');
163
+ const conn = makeConn();
164
+ const clock = new FakeClock(8_800);
165
+ const ctx = makeCtx(conn, clock);
166
+
167
+ tagmsgChannelReducer(chan, { command: 'TAGMSG', params: ['#foo'], tags: {} }, ctx);
168
+
169
+ expect(conn.lastSeen).toBe(8_800);
170
+ });
171
+
172
+ it('returns the same state reference (no mutation to channel state)', () => {
173
+ const chan = makeChan('#foo');
174
+ addMember(chan, 'c1', 'alice');
175
+ const conn = makeConn();
176
+ const ctx = makeCtx(conn);
177
+
178
+ const out = tagmsgChannelReducer(
179
+ chan,
180
+ { command: 'TAGMSG', params: ['#foo'], tags: { '+typing': 'active' } },
181
+ ctx,
182
+ );
183
+
184
+ expect(out.state).toBe(chan);
185
+ });
186
+ });
187
+
188
+ // ============================================================================
189
+ // tagmsgChannelReducer — cap gate
190
+ // ============================================================================
191
+
192
+ describe('tagmsgChannelReducer — cap gate', () => {
193
+ it('emits 421 ERR_UNKNOWNCOMMAND when message-tags cap is not negotiated', () => {
194
+ const chan = makeChan('#foo');
195
+ addMember(chan, 'c1', 'alice');
196
+ const conn = makeConn();
197
+ conn.caps.delete('message-tags');
198
+ const ctx = makeCtx(conn);
199
+
200
+ const out = tagmsgChannelReducer(
201
+ chan,
202
+ { command: 'TAGMSG', params: ['#foo'], tags: { '+typing': 'active' } },
203
+ ctx,
204
+ );
205
+
206
+ expect(out.effects).toEqual<EffectType[]>([
207
+ Effect.send('c1', [L(':irc.example.com 421 alice TAGMSG :Unknown command')]),
208
+ ]);
209
+ });
210
+
211
+ it('uses * as the nick in 421 when the connection has no nick', () => {
212
+ const chan = makeChan('#foo');
213
+ addMember(chan, 'c2', 'bob');
214
+ const conn = createConnection({ id: 'c1', connectedSince: 0 });
215
+ const ctx = makeCtx(conn);
216
+
217
+ const out = tagmsgChannelReducer(
218
+ chan,
219
+ { command: 'TAGMSG', params: ['#foo'], tags: { '+typing': 'active' } },
220
+ ctx,
221
+ );
222
+
223
+ expect(out.effects).toEqual<EffectType[]>([
224
+ Effect.send('c1', [L(':irc.example.com 421 * TAGMSG :Unknown command')]),
225
+ ]);
226
+ });
227
+ });
228
+
229
+ // ============================================================================
230
+ // tagmsgChannelReducer — NOTICE-style silent rejections
231
+ // ============================================================================
232
+
233
+ describe('tagmsgChannelReducer — silent rejections', () => {
234
+ it('MUST NOT produce any numeric reply when target is missing', () => {
235
+ const chan = makeChan('#foo');
236
+ addMember(chan, 'c1', 'alice');
237
+ const conn = makeConn();
238
+ const ctx = makeCtx(conn);
239
+
240
+ const out = tagmsgChannelReducer(
241
+ chan,
242
+ { command: 'TAGMSG', params: [], tags: { '+typing': 'active' } },
243
+ ctx,
244
+ );
245
+
246
+ expect(out.effects).toEqual([]);
247
+ });
248
+
249
+ it('MUST NOT produce any numeric reply when rejected by +n (no external)', () => {
250
+ const chan = makeChan('#foo');
251
+ chan.modes.noExternal = true;
252
+ addMember(chan, 'c2', 'bob');
253
+ const conn = makeConn();
254
+ const ctx = makeCtx(conn);
255
+
256
+ const out = tagmsgChannelReducer(
257
+ chan,
258
+ { command: 'TAGMSG', params: ['#foo'], tags: { '+typing': 'active' } },
259
+ ctx,
260
+ );
261
+
262
+ expect(out.effects).toEqual([]);
263
+ });
264
+
265
+ it('MUST NOT produce any numeric reply when rejected by +m (moderated)', () => {
266
+ const chan = makeChan('#foo');
267
+ chan.modes.moderated = true;
268
+ addMember(chan, 'c1', 'alice', false, false);
269
+ const conn = makeConn();
270
+ const ctx = makeCtx(conn);
271
+
272
+ const out = tagmsgChannelReducer(
273
+ chan,
274
+ { command: 'TAGMSG', params: ['#foo'], tags: { '+typing': 'active' } },
275
+ ctx,
276
+ );
277
+
278
+ expect(out.effects).toEqual([]);
279
+ });
280
+
281
+ it('MUST NOT produce any numeric reply when +m rejects a non-member (no +n)', () => {
282
+ const chan = makeChan('#foo');
283
+ chan.modes.moderated = true;
284
+ addMember(chan, 'c2', 'bob', true, false);
285
+ const conn = makeConn();
286
+ const ctx = makeCtx(conn);
287
+
288
+ const out = tagmsgChannelReducer(
289
+ chan,
290
+ { command: 'TAGMSG', params: ['#foo'], tags: { '+typing': 'active' } },
291
+ ctx,
292
+ );
293
+
294
+ expect(out.effects).toEqual([]);
295
+ });
296
+
297
+ it('still broadcasts when the sender is an op of a +m channel', () => {
298
+ const chan = makeChan('#foo');
299
+ chan.modes.moderated = true;
300
+ addMember(chan, 'c1', 'alice', true, false);
301
+ const conn = makeConn();
302
+ const ctx = makeCtx(conn);
303
+
304
+ const out = tagmsgChannelReducer(
305
+ chan,
306
+ { command: 'TAGMSG', params: ['#foo'], tags: { '+typing': 'active' } },
307
+ ctx,
308
+ );
309
+
310
+ expect(out.effects).toHaveLength(1);
311
+ expect(out.effects[0]?.tag).toBe('Broadcast');
312
+ });
313
+
314
+ it('still broadcasts when the sender is voiced on a +m channel', () => {
315
+ const chan = makeChan('#foo');
316
+ chan.modes.moderated = true;
317
+ addMember(chan, 'c1', 'alice', false, true);
318
+ const conn = makeConn();
319
+ const ctx = makeCtx(conn);
320
+
321
+ const out = tagmsgChannelReducer(
322
+ chan,
323
+ { command: 'TAGMSG', params: ['#foo'], tags: { '+typing': 'active' } },
324
+ ctx,
325
+ );
326
+
327
+ expect(out.effects).toHaveLength(1);
328
+ expect(out.effects[0]?.tag).toBe('Broadcast');
329
+ });
330
+
331
+ it('MUST NOT produce any numeric reply when banned (+b)', () => {
332
+ const chan = makeChan('#foo');
333
+ chan.banMasks.add('*!*@example.com');
334
+ addMember(chan, 'c1', 'alice');
335
+ const conn = makeConn();
336
+ const ctx = makeCtx(conn);
337
+
338
+ const out = tagmsgChannelReducer(
339
+ chan,
340
+ { command: 'TAGMSG', params: ['#foo'], tags: { '+typing': 'active' } },
341
+ ctx,
342
+ );
343
+
344
+ expect(out.effects).toEqual([]);
345
+ });
346
+
347
+ it('honors the ? wildcard in ban masks', () => {
348
+ const chan = makeChan('#foo');
349
+ chan.banMasks.add('alice!?????@example.com');
350
+ addMember(chan, 'c1', 'alice');
351
+ const conn = makeConn();
352
+ const ctx = makeCtx(conn);
353
+
354
+ const out = tagmsgChannelReducer(
355
+ chan,
356
+ { command: 'TAGMSG', params: ['#foo'], tags: { '+typing': 'active' } },
357
+ ctx,
358
+ );
359
+
360
+ expect(out.effects).toEqual([]);
361
+ });
362
+
363
+ it('proceeds with the broadcast when the ban list is non-empty but no mask matches', () => {
364
+ const chan = makeChan('#foo');
365
+ chan.banMasks.add('*!*@baddomain.example');
366
+ chan.banMasks.add('evil!*@*');
367
+ addMember(chan, 'c1', 'alice');
368
+ const conn = makeConn();
369
+ const ctx = makeCtx(conn);
370
+
371
+ const out = tagmsgChannelReducer(
372
+ chan,
373
+ { command: 'TAGMSG', params: ['#foo'], tags: { '+typing': 'active' } },
374
+ ctx,
375
+ );
376
+
377
+ expect(out.effects).toHaveLength(1);
378
+ expect(out.effects[0]?.tag).toBe('Broadcast');
379
+ });
380
+ });
381
+
382
+ // ============================================================================
383
+ // tagmsgChannelReducer — echo-message
384
+ // ============================================================================
385
+
386
+ describe('tagmsgChannelReducer — echo-message', () => {
387
+ it('echoes the TAGMSG back to the sender when echo-message cap is present', () => {
388
+ const chan = makeChan('#foo');
389
+ addMember(chan, 'c1', 'alice');
390
+ const conn = makeConn();
391
+ conn.caps.add('echo-message');
392
+ const ctx = makeCtx(conn);
393
+
394
+ const out = tagmsgChannelReducer(
395
+ chan,
396
+ { command: 'TAGMSG', params: ['#foo'], tags: { '+typing': 'active' } },
397
+ ctx,
398
+ );
399
+
400
+ const line = L('@+typing=active :alice!alice@example.com TAGMSG #foo');
401
+ expect(out.effects).toEqual<EffectType[]>([
402
+ Effect.broadcast('#foo', [line], 'c1', 'message-tags'),
403
+ Effect.send('c1', [line]),
404
+ ]);
405
+ });
406
+
407
+ it('does NOT echo when echo-message cap is absent', () => {
408
+ const chan = makeChan('#foo');
409
+ addMember(chan, 'c1', 'alice');
410
+ const conn = makeConn();
411
+ const ctx = makeCtx(conn);
412
+
413
+ const out = tagmsgChannelReducer(
414
+ chan,
415
+ { command: 'TAGMSG', params: ['#foo'], tags: { '+typing': 'active' } },
416
+ ctx,
417
+ );
418
+
419
+ expect(out.effects).toHaveLength(1);
420
+ expect(out.effects[0]?.tag).toBe('Broadcast');
421
+ });
422
+ });
423
+
424
+ // ============================================================================
425
+ // tagmsgChannelReducer — chathistory recording
426
+ // ============================================================================
427
+
428
+ describe('tagmsgChannelReducer — chathistory recording', () => {
429
+ it('records the TAGMSG into ctx.messages with a fresh msgid and time', () => {
430
+ const chan = makeChan('#foo');
431
+ addMember(chan, 'c1', 'alice');
432
+ const store = new InMemoryMessageStore();
433
+ const conn = makeConn();
434
+ const clock = new FakeClock(7_700);
435
+ const ctx = makeCtx(conn, clock, store);
436
+
437
+ tagmsgChannelReducer(
438
+ chan,
439
+ { command: 'TAGMSG', params: ['#foo'], tags: { '+typing': 'active' } },
440
+ ctx,
441
+ );
442
+
443
+ const recorded = store.query({ chan: '#foo', direction: 'latest', limit: 10 });
444
+ expect(recorded).toHaveLength(1);
445
+ const entry = recorded[0] as StoredMessage | undefined;
446
+ expect(entry).toBeDefined();
447
+ expect(entry?.msgid).toBe('nonce-0');
448
+ expect(entry?.time).toBe(7_700);
449
+ expect(entry?.chan).toBe('#foo');
450
+ expect(entry?.command).toBe('TAGMSG');
451
+ expect(entry?.nick).toBe('alice');
452
+ expect(entry?.user).toBe('alice');
453
+ expect(entry?.host).toBe('example.com');
454
+ expect(entry?.text).toBe('');
455
+ });
456
+
457
+ it('does NOT crash when no MessageStore is bound', () => {
458
+ const chan = makeChan('#foo');
459
+ addMember(chan, 'c1', 'alice');
460
+ const conn = makeConn();
461
+ const ctx = makeCtx(conn);
462
+
463
+ const out = tagmsgChannelReducer(
464
+ chan,
465
+ { command: 'TAGMSG', params: ['#foo'], tags: { '+typing': 'active' } },
466
+ ctx,
467
+ );
468
+
469
+ expect(out.effects[0]?.tag).toBe('Broadcast');
470
+ });
471
+
472
+ it('does NOT record when rejected by +n', () => {
473
+ const chan = makeChan('#foo');
474
+ chan.modes.noExternal = true;
475
+ const store = new InMemoryMessageStore();
476
+ const ctx = makeCtx(makeConn(), new FakeClock(1_000), store);
477
+
478
+ tagmsgChannelReducer(
479
+ chan,
480
+ { command: 'TAGMSG', params: ['#foo'], tags: { '+typing': 'active' } },
481
+ ctx,
482
+ );
483
+
484
+ expect(store.query({ chan: '#foo', direction: 'latest', limit: 10 })).toEqual([]);
485
+ });
486
+
487
+ it('does NOT record when rejected by +b', () => {
488
+ const chan = makeChan('#foo');
489
+ chan.banMasks.add('*!*@example.com');
490
+ addMember(chan, 'c1', 'alice');
491
+ const store = new InMemoryMessageStore();
492
+ const ctx = makeCtx(makeConn(), new FakeClock(1_000), store);
493
+
494
+ tagmsgChannelReducer(
495
+ chan,
496
+ { command: 'TAGMSG', params: ['#foo'], tags: { '+typing': 'active' } },
497
+ ctx,
498
+ );
499
+
500
+ expect(store.query({ chan: '#foo', direction: 'latest', limit: 10 })).toEqual([]);
501
+ });
502
+ });
503
+
504
+ // ============================================================================
505
+ // tagmsgChannelReducer — defensive hostmask fallbacks
506
+ // ============================================================================
507
+
508
+ describe('tagmsgChannelReducer — defensive hostmask', () => {
509
+ it('falls back to the bare nick when user/host are absent', () => {
510
+ const chan = makeChan('#foo');
511
+ addMember(chan, 'c2', 'bob');
512
+ const conn = createConnection({ id: 'c1', connectedSince: 0 });
513
+ conn.nick = 'alice';
514
+ conn.realname = 'Alice';
515
+ conn.registration = 'registered';
516
+ conn.caps.add('message-tags');
517
+ const ctx = makeCtx(conn);
518
+
519
+ const out = tagmsgChannelReducer(
520
+ chan,
521
+ { command: 'TAGMSG', params: ['#foo'], tags: {} },
522
+ ctx,
523
+ );
524
+
525
+ const broadcast = out.effects[0];
526
+ expect(broadcast?.tag).toBe('Broadcast');
527
+ const lines = (broadcast as { lines: RawLine[] } | undefined)?.lines;
528
+ expect(lines?.[0]?.text).toBe(':alice TAGMSG #foo');
529
+ });
530
+
531
+ it('falls back to ? when the connection has no nick at all', () => {
532
+ const chan = makeChan('#foo');
533
+ addMember(chan, 'c2', 'bob');
534
+ const conn = createConnection({ id: 'c1', connectedSince: 0 });
535
+ conn.caps.add('message-tags');
536
+ const ctx = makeCtx(conn);
537
+
538
+ const out = tagmsgChannelReducer(
539
+ chan,
540
+ { command: 'TAGMSG', params: ['#foo'], tags: {} },
541
+ ctx,
542
+ );
543
+
544
+ const broadcast = out.effects[0];
545
+ expect(broadcast?.tag).toBe('Broadcast');
546
+ const lines = (broadcast as { lines: RawLine[] } | undefined)?.lines;
547
+ expect(lines?.[0]?.text).toBe(':? TAGMSG #foo');
548
+ });
549
+ });
550
+
551
+ // ============================================================================
552
+ // tagmsgUserReducer
553
+ // ============================================================================
554
+
555
+ describe('tagmsgUserReducer', () => {
556
+ it('routes a private TAGMSG via SendToNick', () => {
557
+ const conn = makeConn();
558
+ const ctx = makeCtx(conn);
559
+
560
+ const out = tagmsgUserReducer(
561
+ conn,
562
+ { command: 'TAGMSG', params: ['bob'], tags: { '+typing': 'active' } },
563
+ ctx,
564
+ );
565
+
566
+ expect(out.effects).toEqual<EffectType[]>([
567
+ Effect.sendToNick(
568
+ 'bob',
569
+ 'c1',
570
+ [L('@+typing=active :alice!alice@example.com TAGMSG bob')],
571
+ ),
572
+ ]);
573
+ });
574
+
575
+ it('has no notFoundLines for an offline target (silent like NOTICE)', () => {
576
+ const conn = makeConn();
577
+ const ctx = makeCtx(conn);
578
+
579
+ const out = tagmsgUserReducer(
580
+ conn,
581
+ { command: 'TAGMSG', params: ['nobody'], tags: { '+typing': 'active' } },
582
+ ctx,
583
+ );
584
+
585
+ expect(out.effects).toEqual<EffectType[]>([
586
+ Effect.sendToNick(
587
+ 'nobody',
588
+ 'c1',
589
+ [L('@+typing=active :alice!alice@example.com TAGMSG nobody')],
590
+ ),
591
+ ]);
592
+ });
593
+
594
+ it('emits 421 ERR_UNKNOWNCOMMAND when message-tags cap is not negotiated', () => {
595
+ const conn = makeConn();
596
+ conn.caps.delete('message-tags');
597
+ const ctx = makeCtx(conn);
598
+
599
+ const out = tagmsgUserReducer(
600
+ conn,
601
+ { command: 'TAGMSG', params: ['bob'], tags: { '+typing': 'active' } },
602
+ ctx,
603
+ );
604
+
605
+ expect(out.effects).toEqual<EffectType[]>([
606
+ Effect.send('c1', [L(':irc.example.com 421 alice TAGMSG :Unknown command')]),
607
+ ]);
608
+ });
609
+
610
+ it('MUST NOT produce any numeric reply when target is missing', () => {
611
+ const conn = makeConn();
612
+ const ctx = makeCtx(conn);
613
+
614
+ const out = tagmsgUserReducer(
615
+ conn,
616
+ { command: 'TAGMSG', params: [], tags: { '+typing': 'active' } },
617
+ ctx,
618
+ );
619
+
620
+ expect(out.effects).toEqual([]);
621
+ });
622
+
623
+ it('echoes back to the sender when echo-message cap is present', () => {
624
+ const conn = makeConn();
625
+ conn.caps.add('echo-message');
626
+ const ctx = makeCtx(conn);
627
+
628
+ const out = tagmsgUserReducer(
629
+ conn,
630
+ { command: 'TAGMSG', params: ['bob'], tags: { '+typing': 'active' } },
631
+ ctx,
632
+ );
633
+
634
+ const line = L('@+typing=active :alice!alice@example.com TAGMSG bob');
635
+ expect(out.effects).toEqual<EffectType[]>([
636
+ Effect.sendToNick('bob', 'c1', [line]),
637
+ Effect.send('c1', [line]),
638
+ ]);
639
+ });
640
+
641
+ it('works without tags (no @ prefix)', () => {
642
+ const conn = makeConn();
643
+ const ctx = makeCtx(conn);
644
+
645
+ const out = tagmsgUserReducer(
646
+ conn,
647
+ { command: 'TAGMSG', params: ['bob'], tags: {} },
648
+ ctx,
649
+ );
650
+
651
+ expect(out.effects).toEqual<EffectType[]>([
652
+ Effect.sendToNick('bob', 'c1', [L(':alice!alice@example.com TAGMSG bob')]),
653
+ ]);
654
+ });
655
+
656
+ it('updates lastSeen to ctx.clock.now()', () => {
657
+ const conn = makeConn();
658
+ const clock = new FakeClock(12_000);
659
+ const ctx = makeCtx(conn, clock);
660
+
661
+ tagmsgUserReducer(conn, { command: 'TAGMSG', params: ['bob'], tags: {} }, ctx);
662
+
663
+ expect(conn.lastSeen).toBe(12_000);
664
+ });
665
+
666
+ it('returns the same state reference', () => {
667
+ const conn = makeConn();
668
+ const ctx = makeCtx(conn);
669
+
670
+ const out = tagmsgUserReducer(
671
+ conn,
672
+ { command: 'TAGMSG', params: ['bob'], tags: {} },
673
+ ctx,
674
+ );
675
+
676
+ expect(out.state).toBe(conn);
677
+ });
678
+
679
+ it('does NOT record a user-target TAGMSG', () => {
680
+ const store = new InMemoryMessageStore();
681
+ const conn = makeConn();
682
+ const ctx = makeCtx(conn, new FakeClock(1_000), store);
683
+
684
+ tagmsgUserReducer(conn, { command: 'TAGMSG', params: ['bob'], tags: {} }, ctx);
685
+
686
+ expect(store.targets(0, 100_000)).toEqual([]);
687
+ });
688
+ });