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