serverless-ircd 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +96 -2
- package/.github/workflows/deploy-aws.yml +129 -0
- package/.github/workflows/deploy-cf.yml +0 -2
- package/.gitmodules +3 -0
- package/AGENTS.md +5 -0
- package/CHANGELOG.md +352 -0
- package/MOTD.txt +3 -0
- package/PLAN-FIXES.md +358 -0
- package/PLAN.md +420 -0
- package/README.md +115 -81
- package/apps/aws-stack/README.md +73 -0
- package/apps/aws-stack/bin/aws.ts +49 -0
- package/apps/aws-stack/cdk.json +10 -0
- package/apps/aws-stack/package.json +41 -0
- package/apps/aws-stack/scripts/smoke-helpers.d.mts +22 -0
- package/apps/aws-stack/scripts/smoke-helpers.mjs +89 -0
- package/apps/aws-stack/scripts/smoke.mjs +142 -0
- package/apps/aws-stack/src/aws-stack.ts +263 -0
- package/apps/aws-stack/src/tables.ts +10 -0
- package/apps/aws-stack/tests/localstack.test.ts +46 -0
- package/apps/aws-stack/tests/smoke-helpers.test.ts +98 -0
- package/apps/aws-stack/tests/stack.test.ts +464 -0
- package/apps/aws-stack/tsconfig.build.json +11 -0
- package/apps/aws-stack/tsconfig.test.json +8 -0
- package/apps/aws-stack/vitest.config.ts +20 -0
- package/apps/cf-worker/package.json +1 -1
- package/apps/cf-worker/wrangler.toml +25 -0
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +56 -0
- package/apps/local-cli/src/server.ts +241 -32
- package/apps/local-cli/tests/config-loader.test.ts +107 -0
- package/apps/local-cli/tests/e2e.test.ts +74 -0
- package/apps/local-cli/tests/security-e2e.test.ts +239 -0
- package/biome.json +3 -1
- package/dashboards/cloudwatch-irc.json +139 -0
- package/docs/AWS-Adapter-Architecture.md +494 -0
- package/docs/AWS-Deployment.md +1107 -0
- package/docs/Cloudflare-Deployment-Guide.md +660 -0
- package/docs/Home.md +1 -0
- package/docs/Observability.md +87 -0
- package/docs/PlanIRCv3Websocket.md +489 -0
- package/docs/PlanWebClient.md +451 -0
- package/docs/Release-Process.md +440 -0
- package/package.json +8 -2
- package/packages/aws-adapter/README.md +35 -0
- package/packages/aws-adapter/package.json +52 -0
- package/packages/aws-adapter/src/aws-runtime.ts +783 -0
- package/packages/aws-adapter/src/cdk-table-defs.ts +69 -0
- package/packages/aws-adapter/src/config-loader.ts +96 -0
- package/packages/aws-adapter/src/dynamo.ts +61 -0
- package/packages/aws-adapter/src/handlers/connect.ts +44 -0
- package/packages/aws-adapter/src/handlers/default.ts +322 -0
- package/packages/aws-adapter/src/handlers/disconnect.ts +29 -0
- package/packages/aws-adapter/src/handlers/index.ts +248 -0
- package/packages/aws-adapter/src/handlers/ping-checker.ts +217 -0
- package/packages/aws-adapter/src/handlers/state.ts +13 -0
- package/packages/aws-adapter/src/handlers/sweeper.ts +84 -0
- package/packages/aws-adapter/src/index.ts +67 -0
- package/packages/aws-adapter/src/message-store.ts +34 -0
- package/packages/aws-adapter/src/serialize.ts +283 -0
- package/packages/aws-adapter/src/tables.ts +53 -0
- package/packages/aws-adapter/tests/aws-harness.ts +408 -0
- package/packages/aws-adapter/tests/aws-integration.test.ts +17 -0
- package/packages/aws-adapter/tests/aws-runtime.test.ts +654 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +158 -0
- package/packages/aws-adapter/tests/dynamo.test.ts +57 -0
- package/packages/aws-adapter/tests/global-setup.ts +301 -0
- package/packages/aws-adapter/tests/gone-exception.test.ts +271 -0
- package/packages/aws-adapter/tests/handlers.test.ts +427 -0
- package/packages/aws-adapter/tests/message-store.test.ts +55 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +571 -0
- package/packages/aws-adapter/tests/serialize.test.ts +249 -0
- package/packages/aws-adapter/tests/smoke.test.ts +48 -0
- package/packages/aws-adapter/tests/sweeper.test.ts +420 -0
- package/packages/aws-adapter/tests/tables.test.ts +74 -0
- package/packages/aws-adapter/tests/transactions.test.ts +446 -0
- package/packages/aws-adapter/tsconfig.build.json +16 -0
- package/packages/aws-adapter/tsconfig.test.json +14 -0
- package/packages/aws-adapter/vitest.config.ts +23 -0
- package/packages/cf-adapter/package.json +2 -1
- package/packages/cf-adapter/src/config-loader.ts +106 -0
- package/packages/cf-adapter/src/connection-do.ts +34 -0
- package/packages/cf-adapter/tests/cf-harness.ts +2 -2
- package/packages/cf-adapter/tests/cf-integration.test.ts +2 -2
- package/packages/cf-adapter/tests/config-loader.test.ts +149 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +42 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +108 -3
- package/packages/in-memory-runtime/src/index.ts +1 -1
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +115 -0
- package/packages/irc-core/package.json +12 -2
- package/packages/irc-core/src/admission.ts +216 -0
- package/packages/irc-core/src/caps/capabilities.ts +1 -0
- package/packages/irc-core/src/cloak.ts +81 -0
- package/packages/irc-core/src/commands/cap.ts +1 -2
- package/packages/irc-core/src/commands/chathistory.ts +305 -0
- package/packages/irc-core/src/commands/index.ts +7 -0
- package/packages/irc-core/src/commands/join.ts +59 -7
- package/packages/irc-core/src/commands/privmsg.ts +24 -0
- package/packages/irc-core/src/commands/registration.ts +71 -14
- package/packages/irc-core/src/commands/sasl.ts +251 -0
- package/packages/irc-core/src/config.ts +247 -0
- package/packages/irc-core/src/flood-control.ts +175 -0
- package/packages/irc-core/src/index.ts +5 -0
- package/packages/irc-core/src/ports.ts +655 -0
- package/packages/irc-core/src/protocol/base64.ts +16 -0
- package/packages/irc-core/src/protocol/index.ts +2 -1
- package/packages/irc-core/src/protocol/numerics.ts +11 -0
- package/packages/irc-core/src/protocol/parser.ts +27 -2
- package/packages/irc-core/src/state/connection.ts +41 -0
- package/packages/irc-core/src/types.ts +66 -2
- package/packages/irc-core/stryker.commands.conf.json +41 -0
- package/packages/irc-core/stryker.protocol.conf.json +26 -0
- package/packages/irc-core/tests/admission.test.ts +229 -0
- package/packages/irc-core/tests/caps/capabilities.test.ts +22 -0
- package/packages/irc-core/tests/cloak.test.ts +78 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +996 -0
- package/packages/irc-core/tests/commands/join.test.ts +246 -2
- package/packages/irc-core/tests/commands/privmsg.test.ts +146 -2
- package/packages/irc-core/tests/commands/registration.test.ts +277 -9
- package/packages/irc-core/tests/commands/sasl.test.ts +536 -0
- package/packages/irc-core/tests/config.test.ts +646 -0
- package/packages/irc-core/tests/flood-control.test.ts +394 -0
- package/packages/irc-core/tests/message-store.test.ts +530 -0
- package/packages/irc-core/tests/parser.test.ts +44 -1
- package/packages/irc-core/tests/ports.test.ts +263 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +162 -44
- package/packages/irc-server/src/dispatch.ts +89 -5
- package/packages/irc-server/src/index.ts +2 -0
- package/packages/irc-server/src/routing.ts +151 -0
- package/packages/irc-server/tests/actor.test.ts +470 -14
- package/packages/irc-server/tests/dispatch.test.ts +84 -0
- package/packages/irc-server/tests/routing.test.ts +201 -0
- package/packages/irc-test-support/README.md +32 -0
- package/packages/irc-test-support/package.json +37 -0
- package/packages/{cf-adapter/tests/integration → irc-test-support/src}/in-memory-harness.ts +1 -1
- package/packages/irc-test-support/src/index.ts +24 -0
- package/packages/{cf-adapter/tests/integration/harness.test.ts → irc-test-support/tests/in-memory-harness.test.ts} +1 -1
- package/packages/{cf-adapter/tests/integration/scenarios.test.ts → irc-test-support/tests/in-memory-scenarios.test.ts} +1 -2
- package/packages/irc-test-support/tests/smoke.test.ts +11 -0
- package/packages/irc-test-support/tsconfig.build.json +16 -0
- package/packages/irc-test-support/tsconfig.test.json +14 -0
- package/packages/irc-test-support/vitest.config.ts +20 -0
- package/progress.md +107 -0
- package/tickets.md +2485 -0
- package/tools/ci-hardening/package.json +30 -0
- package/tools/ci-hardening/src/index.ts +6 -0
- package/tools/ci-hardening/src/validate.ts +103 -0
- package/tools/ci-hardening/tests/__no_thresholds__.txt +11 -0
- package/tools/ci-hardening/tests/__partial_thresholds__.txt +16 -0
- package/tools/ci-hardening/tests/__stryker_bad__.json +4 -0
- package/tools/ci-hardening/tests/validate.test.ts +177 -0
- package/tools/ci-hardening/tsconfig.build.json +12 -0
- package/tools/ci-hardening/tsconfig.test.json +10 -0
- package/tools/ci-hardening/vitest.config.ts +21 -0
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/src/forwarder.ts +34 -23
- package/tools/tcp-ws-forwarder/src/logger.ts +155 -0
- package/tools/tcp-ws-forwarder/src/main.ts +33 -14
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +86 -0
- package/tools/tcp-ws-forwarder/tests/logger.test.ts +136 -0
- package/webircgateway/LICENSE +201 -0
- package/webircgateway/Makefile +44 -0
- package/webircgateway/README.md +134 -0
- package/webircgateway/config.conf.example +135 -0
- package/webircgateway/go.mod +16 -0
- package/webircgateway/go.sum +89 -0
- package/webircgateway/main.go +118 -0
- package/webircgateway/pkg/dnsbl/dnsbl.go +121 -0
- package/webircgateway/pkg/identd/identd.go +86 -0
- package/webircgateway/pkg/identd/rpcclient.go +59 -0
- package/webircgateway/pkg/irc/isupport.go +56 -0
- package/webircgateway/pkg/irc/message.go +217 -0
- package/webircgateway/pkg/irc/state.go +79 -0
- package/webircgateway/pkg/proxy/proxy.go +129 -0
- package/webircgateway/pkg/proxy/server.go +237 -0
- package/webircgateway/pkg/recaptcha/recaptcha.go +59 -0
- package/webircgateway/pkg/webircgateway/client.go +741 -0
- package/webircgateway/pkg/webircgateway/client_command_handlers.go +495 -0
- package/webircgateway/pkg/webircgateway/config.go +385 -0
- package/webircgateway/pkg/webircgateway/gateway.go +278 -0
- package/webircgateway/pkg/webircgateway/gateway_utils.go +133 -0
- package/webircgateway/pkg/webircgateway/hooks.go +152 -0
- package/webircgateway/pkg/webircgateway/letsencrypt.go +41 -0
- package/webircgateway/pkg/webircgateway/messagetags.go +103 -0
- package/webircgateway/pkg/webircgateway/transport_kiwiirc.go +206 -0
- package/webircgateway/pkg/webircgateway/transport_sockjs.go +107 -0
- package/webircgateway/pkg/webircgateway/transport_tcp.go +113 -0
- package/webircgateway/pkg/webircgateway/transport_websocket.go +126 -0
- package/webircgateway/pkg/webircgateway/utils.go +147 -0
- package/webircgateway/plugins/example/plugin.go +11 -0
- package/webircgateway/plugins/stats/plugin.go +52 -0
- package/webircgateway/staticcheck.conf +1 -0
- package/webircgateway/webircgateway.svg +3 -0
- package/packages/cf-adapter/tests/integration/index.ts +0 -17
- /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/harness.ts +0 -0
- /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/scenarios.ts +0 -0
|
@@ -2,7 +2,14 @@ import { describe, expect, it } from 'vitest';
|
|
|
2
2
|
import { handleJoinZero, isValidChannelName, joinReducer } from '../../src/commands/join';
|
|
3
3
|
import { Effect } from '../../src/effects';
|
|
4
4
|
import type { Effect as EffectType, RawLine } from '../../src/effects';
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
EmptyMotdProvider,
|
|
7
|
+
FakeClock,
|
|
8
|
+
InMemoryMessageStore,
|
|
9
|
+
type MessageStore,
|
|
10
|
+
SequentialIdFactory,
|
|
11
|
+
type StoredMessage,
|
|
12
|
+
} from '../../src/ports';
|
|
6
13
|
import { type ChannelState, createChannel } from '../../src/state/channel';
|
|
7
14
|
import { type ConnectionState, createConnection } from '../../src/state/connection';
|
|
8
15
|
import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
|
|
@@ -19,13 +26,18 @@ const serverConfig: ServerConfig = {
|
|
|
19
26
|
quitMessage: 'Client Quit',
|
|
20
27
|
};
|
|
21
28
|
|
|
22
|
-
function makeCtx(
|
|
29
|
+
function makeCtx(
|
|
30
|
+
conn: ConnectionState,
|
|
31
|
+
clock = new FakeClock(1_000),
|
|
32
|
+
messages?: MessageStore,
|
|
33
|
+
): Ctx {
|
|
23
34
|
return buildCtx({
|
|
24
35
|
serverConfig,
|
|
25
36
|
clock,
|
|
26
37
|
ids: new SequentialIdFactory(),
|
|
27
38
|
motd: EmptyMotdProvider,
|
|
28
39
|
connection: conn,
|
|
40
|
+
...(messages !== undefined ? { messages } : {}),
|
|
29
41
|
});
|
|
30
42
|
}
|
|
31
43
|
|
|
@@ -685,3 +697,235 @@ describe('joinReducer — extended-join', () => {
|
|
|
685
697
|
expect(broadcast?.capLines).toBeUndefined();
|
|
686
698
|
});
|
|
687
699
|
});
|
|
700
|
+
|
|
701
|
+
// ============================================================================
|
|
702
|
+
// joinReducer — chathistory auto-playback
|
|
703
|
+
// ============================================================================
|
|
704
|
+
|
|
705
|
+
/** Connection negotiated draft/chathistory (+ the deps it implies). */
|
|
706
|
+
function makeCapConn(id = 'c1', nick = 'alice'): ConnectionState {
|
|
707
|
+
const conn = makeConn(id, nick);
|
|
708
|
+
conn.caps.add('draft/chathistory');
|
|
709
|
+
conn.caps.add('batch');
|
|
710
|
+
conn.caps.add('server-time');
|
|
711
|
+
conn.caps.add('message-tags');
|
|
712
|
+
return conn;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/** Records N messages into the store as if spoken by `bob`. */
|
|
716
|
+
function seedHistory(store: MessageStore, chan: string, count: number): StoredMessage[] {
|
|
717
|
+
const out: StoredMessage[] = [];
|
|
718
|
+
for (let i = 1; i <= count; i++) {
|
|
719
|
+
const m: StoredMessage = {
|
|
720
|
+
msgid: `m${i}`,
|
|
721
|
+
time: i * 1_000,
|
|
722
|
+
chan: chan.toLowerCase(),
|
|
723
|
+
command: 'PRIVMSG',
|
|
724
|
+
nick: 'bob',
|
|
725
|
+
user: 'bob',
|
|
726
|
+
host: 'ex.org',
|
|
727
|
+
text: `msg ${i}`,
|
|
728
|
+
};
|
|
729
|
+
store.record(m);
|
|
730
|
+
out.push(m);
|
|
731
|
+
}
|
|
732
|
+
return out;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
describe('joinReducer — chathistory auto-playback', () => {
|
|
736
|
+
it('prepends a chathistory BATCH before the JOIN/353/366 lines for a cap-enabled joiner', () => {
|
|
737
|
+
const chan = makeChan('#foo');
|
|
738
|
+
const store = new InMemoryMessageStore();
|
|
739
|
+
const seeded = seedHistory(store, '#foo', 2);
|
|
740
|
+
const conn = makeCapConn();
|
|
741
|
+
const ctx = makeCtx(conn, new FakeClock(10_000), store);
|
|
742
|
+
|
|
743
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
744
|
+
|
|
745
|
+
// The first client-visible line must be the BATCH +start marker.
|
|
746
|
+
const firstSend = out.effects.find(
|
|
747
|
+
(e): e is Extract<EffectType, { tag: 'Send' }> => e.tag === 'Send',
|
|
748
|
+
);
|
|
749
|
+
const firstLine = firstSend?.lines[0]?.text;
|
|
750
|
+
expect(firstLine).toBe('BATCH +batch-0 chathistory #foo');
|
|
751
|
+
|
|
752
|
+
// The chathistory batch body carries the two replayed messages.
|
|
753
|
+
expect(firstSend?.lines.map((l) => l.text)).toEqual([
|
|
754
|
+
'BATCH +batch-0 chathistory #foo',
|
|
755
|
+
`@time=1970-01-01T00:00:01.000Z;msgid=${seeded[0]?.msgid} :bob!bob@ex.org PRIVMSG #foo :msg 1`,
|
|
756
|
+
`@time=1970-01-01T00:00:02.000Z;msgid=${seeded[1]?.msgid} :bob!bob@ex.org PRIVMSG #foo :msg 2`,
|
|
757
|
+
'BATCH -batch-0',
|
|
758
|
+
]);
|
|
759
|
+
|
|
760
|
+
// The JOIN broadcast and NAMES follow the playback Send.
|
|
761
|
+
const tags = out.effects.map((e) => e.tag);
|
|
762
|
+
const sendIdx = tags.indexOf('Send');
|
|
763
|
+
const broadcastIdx = tags.indexOf('Broadcast');
|
|
764
|
+
expect(sendIdx).toBeLessThan(broadcastIdx);
|
|
765
|
+
expect(broadcastIdx).toBeGreaterThan(-1);
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
it('advances the last-read marker to the newest replayed msgid', () => {
|
|
769
|
+
const chan = makeChan('#foo');
|
|
770
|
+
const store = new InMemoryMessageStore();
|
|
771
|
+
seedHistory(store, '#foo', 3);
|
|
772
|
+
const conn = makeCapConn();
|
|
773
|
+
const ctx = makeCtx(conn, new FakeClock(10_000), store);
|
|
774
|
+
|
|
775
|
+
joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
776
|
+
|
|
777
|
+
expect(conn.lastReadMarkers?.get('#foo')).toBe('m3');
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
it('does NOT emit playback when the store is empty', () => {
|
|
781
|
+
const chan = makeChan('#foo');
|
|
782
|
+
const store = new InMemoryMessageStore();
|
|
783
|
+
const conn = makeCapConn();
|
|
784
|
+
const ctx = makeCtx(conn, new FakeClock(10_000), store);
|
|
785
|
+
|
|
786
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
787
|
+
|
|
788
|
+
// No Send carrying a BATCH chathistory start line.
|
|
789
|
+
const hasPlayback = out.effects.some(
|
|
790
|
+
(e) =>
|
|
791
|
+
e.tag === 'Send' &&
|
|
792
|
+
e.lines.some((l) => l.text.startsWith('BATCH +') && l.text.includes('chathistory')),
|
|
793
|
+
);
|
|
794
|
+
expect(hasPlayback).toBe(false);
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
it('does NOT emit playback when the connection lacks the chathistory cap', () => {
|
|
798
|
+
const chan = makeChan('#foo');
|
|
799
|
+
const store = new InMemoryMessageStore();
|
|
800
|
+
seedHistory(store, '#foo', 3);
|
|
801
|
+
const conn = makeConn(); // no chathistory cap
|
|
802
|
+
conn.caps.add('batch');
|
|
803
|
+
const ctx = makeCtx(conn, new FakeClock(10_000), store);
|
|
804
|
+
|
|
805
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
806
|
+
|
|
807
|
+
const hasPlayback = out.effects.some(
|
|
808
|
+
(e) =>
|
|
809
|
+
e.tag === 'Send' &&
|
|
810
|
+
e.lines.some((l) => l.text.startsWith('BATCH +') && l.text.includes('chathistory')),
|
|
811
|
+
);
|
|
812
|
+
expect(hasPlayback).toBe(false);
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
it('does NOT emit playback when no MessageStore is bound', () => {
|
|
816
|
+
const chan = makeChan('#foo');
|
|
817
|
+
const conn = makeCapConn();
|
|
818
|
+
const ctx = makeCtx(conn); // no store
|
|
819
|
+
|
|
820
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
821
|
+
|
|
822
|
+
const hasPlayback = out.effects.some(
|
|
823
|
+
(e) =>
|
|
824
|
+
e.tag === 'Send' &&
|
|
825
|
+
e.lines.some((l) => l.text.startsWith('BATCH +') && l.text.includes('chathistory')),
|
|
826
|
+
);
|
|
827
|
+
expect(hasPlayback).toBe(false);
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
it('replays nothing on re-JOIN when no new messages arrived (marker unchanged)', () => {
|
|
831
|
+
const chan = makeChan('#foo');
|
|
832
|
+
const store = new InMemoryMessageStore();
|
|
833
|
+
seedHistory(store, '#foo', 2);
|
|
834
|
+
const conn = makeCapConn();
|
|
835
|
+
const ctx = makeCtx(conn, new FakeClock(10_000), store);
|
|
836
|
+
|
|
837
|
+
// First join: replays both, marker → m2.
|
|
838
|
+
joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
839
|
+
expect(conn.lastReadMarkers?.get('#foo')).toBe('m2');
|
|
840
|
+
|
|
841
|
+
// Simulate PART (roster + joinedChannels cleared; marker persists).
|
|
842
|
+
chan.members.delete('c1');
|
|
843
|
+
conn.joinedChannels.delete('#foo');
|
|
844
|
+
|
|
845
|
+
// Re-JOIN with no new messages: marker still m2 → nothing newer.
|
|
846
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
847
|
+
const hasPlayback = out.effects.some(
|
|
848
|
+
(e) =>
|
|
849
|
+
e.tag === 'Send' &&
|
|
850
|
+
e.lines.some((l) => l.text.startsWith('BATCH +') && l.text.includes('chathistory')),
|
|
851
|
+
);
|
|
852
|
+
expect(hasPlayback).toBe(false);
|
|
853
|
+
expect(conn.lastReadMarkers?.get('#foo')).toBe('m2');
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
it('replays exactly the K new messages on re-JOIN after K new messages', () => {
|
|
857
|
+
const chan = makeChan('#foo');
|
|
858
|
+
const store = new InMemoryMessageStore();
|
|
859
|
+
seedHistory(store, '#foo', 2);
|
|
860
|
+
const conn = makeCapConn();
|
|
861
|
+
const ctx = makeCtx(conn, new FakeClock(10_000), store);
|
|
862
|
+
|
|
863
|
+
joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
864
|
+
expect(conn.lastReadMarkers?.get('#foo')).toBe('m2');
|
|
865
|
+
|
|
866
|
+
// PART.
|
|
867
|
+
chan.members.delete('c1');
|
|
868
|
+
conn.joinedChannels.delete('#foo');
|
|
869
|
+
|
|
870
|
+
// Two new messages arrive.
|
|
871
|
+
store.record({
|
|
872
|
+
msgid: 'm3',
|
|
873
|
+
time: 3_000,
|
|
874
|
+
chan: '#foo',
|
|
875
|
+
command: 'PRIVMSG',
|
|
876
|
+
nick: 'bob',
|
|
877
|
+
user: 'bob',
|
|
878
|
+
host: 'ex.org',
|
|
879
|
+
text: 'msg 3',
|
|
880
|
+
});
|
|
881
|
+
store.record({
|
|
882
|
+
msgid: 'm4',
|
|
883
|
+
time: 4_000,
|
|
884
|
+
chan: '#foo',
|
|
885
|
+
command: 'PRIVMSG',
|
|
886
|
+
nick: 'bob',
|
|
887
|
+
user: 'bob',
|
|
888
|
+
host: 'ex.org',
|
|
889
|
+
text: 'msg 4',
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
// Re-JOIN: replay exactly m3, m4 (the K=2 new), then advance marker to m4.
|
|
893
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
894
|
+
const send = out.effects.find(
|
|
895
|
+
(e): e is Extract<EffectType, { tag: 'Send' }> =>
|
|
896
|
+
e.tag === 'Send' && e.lines.some((l) => l.text.includes('chathistory')),
|
|
897
|
+
);
|
|
898
|
+
const msgids = send?.lines
|
|
899
|
+
.filter((l) => l.text.startsWith('@time='))
|
|
900
|
+
.map((l) => l.text.match(/msgid=([^ ;]+)/)?.[1]);
|
|
901
|
+
expect(msgids).toEqual(['m3', 'm4']);
|
|
902
|
+
expect(conn.lastReadMarkers?.get('#foo')).toBe('m4');
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
it('respects chatHistoryPlaybackLimit from ServerConfig', () => {
|
|
906
|
+
const chan = makeChan('#foo');
|
|
907
|
+
const store = new InMemoryMessageStore();
|
|
908
|
+
seedHistory(store, '#foo', 5);
|
|
909
|
+
const conn = makeCapConn();
|
|
910
|
+
const ctx = buildCtx({
|
|
911
|
+
serverConfig: { ...serverConfig, chatHistoryPlaybackLimit: 2 },
|
|
912
|
+
clock: new FakeClock(10_000),
|
|
913
|
+
ids: new SequentialIdFactory(),
|
|
914
|
+
motd: EmptyMotdProvider,
|
|
915
|
+
messages: store,
|
|
916
|
+
connection: conn,
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
920
|
+
const send = out.effects.find(
|
|
921
|
+
(e): e is Extract<EffectType, { tag: 'Send' }> =>
|
|
922
|
+
e.tag === 'Send' && e.lines.some((l) => l.text.includes('chathistory')),
|
|
923
|
+
);
|
|
924
|
+
const msgids = send?.lines
|
|
925
|
+
.filter((l) => l.text.startsWith('@time='))
|
|
926
|
+
.map((l) => l.text.match(/msgid=([^ ;]+)/)?.[1]);
|
|
927
|
+
// Only the most recent 2 (m4, m5) and marker advances to m5.
|
|
928
|
+
expect(msgids).toEqual(['m4', 'm5']);
|
|
929
|
+
expect(conn.lastReadMarkers?.get('#foo')).toBe('m5');
|
|
930
|
+
});
|
|
931
|
+
});
|
|
@@ -8,7 +8,14 @@ import {
|
|
|
8
8
|
} from '../../src/commands/privmsg';
|
|
9
9
|
import { Effect } from '../../src/effects';
|
|
10
10
|
import type { Effect as EffectType, RawLine } from '../../src/effects';
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
EmptyMotdProvider,
|
|
13
|
+
FakeClock,
|
|
14
|
+
InMemoryMessageStore,
|
|
15
|
+
type MessageStore,
|
|
16
|
+
SequentialIdFactory,
|
|
17
|
+
type StoredMessage,
|
|
18
|
+
} from '../../src/ports';
|
|
12
19
|
import { type ChannelState, createChannel } from '../../src/state/channel';
|
|
13
20
|
import { type ConnectionState, createConnection } from '../../src/state/connection';
|
|
14
21
|
import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
|
|
@@ -25,13 +32,18 @@ const serverConfig: ServerConfig = {
|
|
|
25
32
|
quitMessage: 'Client Quit',
|
|
26
33
|
};
|
|
27
34
|
|
|
28
|
-
function makeCtx(
|
|
35
|
+
function makeCtx(
|
|
36
|
+
conn: ConnectionState,
|
|
37
|
+
clock = new FakeClock(1_000),
|
|
38
|
+
messages?: MessageStore,
|
|
39
|
+
): Ctx {
|
|
29
40
|
return buildCtx({
|
|
30
41
|
serverConfig,
|
|
31
42
|
clock,
|
|
32
43
|
ids: new SequentialIdFactory(),
|
|
33
44
|
motd: EmptyMotdProvider,
|
|
34
45
|
connection: conn,
|
|
46
|
+
...(messages !== undefined ? { messages } : {}),
|
|
35
47
|
});
|
|
36
48
|
}
|
|
37
49
|
|
|
@@ -663,3 +675,135 @@ describe('noticeUserReducer', () => {
|
|
|
663
675
|
expect(out.effects).toEqual([]);
|
|
664
676
|
});
|
|
665
677
|
});
|
|
678
|
+
|
|
679
|
+
// ============================================================================
|
|
680
|
+
// chathistory recording — MessageStore.record is driven from the channel
|
|
681
|
+
// PRIVMSG/NOTICE reducer for every accepted message.
|
|
682
|
+
// ============================================================================
|
|
683
|
+
|
|
684
|
+
describe('privmsgChannelReducer — chathistory recording', () => {
|
|
685
|
+
it('records the accepted PRIVMSG into ctx.messages with a fresh msgid and time', () => {
|
|
686
|
+
const chan = makeChan('#foo');
|
|
687
|
+
addMember(chan, 'c1', 'alice');
|
|
688
|
+
const store = new InMemoryMessageStore();
|
|
689
|
+
const conn = makeConn();
|
|
690
|
+
const clock = new FakeClock(7_700);
|
|
691
|
+
const ctx = makeCtx(conn, clock, store);
|
|
692
|
+
|
|
693
|
+
privmsgChannelReducer(chan, { command: 'PRIVMSG', params: ['#foo', 'hi'], tags: {} }, ctx);
|
|
694
|
+
|
|
695
|
+
const recorded = store.query({ chan: '#foo', direction: 'latest', limit: 10 });
|
|
696
|
+
expect(recorded).toHaveLength(1);
|
|
697
|
+
const entry = recorded[0] as StoredMessage | undefined;
|
|
698
|
+
expect(entry).toBeDefined();
|
|
699
|
+
expect(entry?.msgid).toBe('nonce-0');
|
|
700
|
+
expect(entry?.time).toBe(7_700);
|
|
701
|
+
expect(entry?.chan).toBe('#foo');
|
|
702
|
+
expect(entry?.command).toBe('PRIVMSG');
|
|
703
|
+
expect(entry?.nick).toBe('alice');
|
|
704
|
+
expect(entry?.user).toBe('alice');
|
|
705
|
+
expect(entry?.host).toBe('example.com');
|
|
706
|
+
expect(entry?.text).toBe('hi');
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
it('records an accepted channel NOTICE too', () => {
|
|
710
|
+
const chan = makeChan('#foo');
|
|
711
|
+
addMember(chan, 'c1', 'alice');
|
|
712
|
+
const store = new InMemoryMessageStore();
|
|
713
|
+
const ctx = makeCtx(makeConn(), new FakeClock(1_000), store);
|
|
714
|
+
|
|
715
|
+
noticeChannelReducer(chan, { command: 'NOTICE', params: ['#foo', 'note'], tags: {} }, ctx);
|
|
716
|
+
|
|
717
|
+
const recorded = store.query({ chan: '#foo', direction: 'latest', limit: 10 });
|
|
718
|
+
expect(recorded).toHaveLength(1);
|
|
719
|
+
expect((recorded[0] as StoredMessage).command).toBe('NOTICE');
|
|
720
|
+
expect((recorded[0] as StoredMessage).text).toBe('note');
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
it('does NOT record when ctx.messages is absent (chathistory disabled)', () => {
|
|
724
|
+
const chan = makeChan('#foo');
|
|
725
|
+
addMember(chan, 'c1', 'alice');
|
|
726
|
+
const conn = makeConn();
|
|
727
|
+
const ctx = makeCtx(conn); // no store
|
|
728
|
+
|
|
729
|
+
// Should not throw and should still broadcast.
|
|
730
|
+
const out = privmsgChannelReducer(
|
|
731
|
+
chan,
|
|
732
|
+
{ command: 'PRIVMSG', params: ['#foo', 'hi'], tags: {} },
|
|
733
|
+
ctx,
|
|
734
|
+
);
|
|
735
|
+
expect(out.effects[0]?.tag).toBe('Broadcast');
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
it('does NOT record a user-target PRIVMSG (only channel messages are persisted)', () => {
|
|
739
|
+
const store = new InMemoryMessageStore();
|
|
740
|
+
const conn = makeConn();
|
|
741
|
+
const ctx = makeCtx(conn, new FakeClock(1_000), store);
|
|
742
|
+
|
|
743
|
+
privmsgUserReducer(conn, { command: 'PRIVMSG', params: ['bob', 'hi'], tags: {} }, ctx);
|
|
744
|
+
|
|
745
|
+
expect(store.targets(0, 100_000)).toEqual([]);
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
it('does NOT record a user-target NOTICE', () => {
|
|
749
|
+
const store = new InMemoryMessageStore();
|
|
750
|
+
const conn = makeConn();
|
|
751
|
+
const ctx = makeCtx(conn, new FakeClock(1_000), store);
|
|
752
|
+
|
|
753
|
+
noticeUserReducer(conn, { command: 'NOTICE', params: ['bob', 'hi'], tags: {} }, ctx);
|
|
754
|
+
|
|
755
|
+
expect(store.targets(0, 100_000)).toEqual([]);
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
it('does NOT record when the message is blocked by +n (no external messages)', () => {
|
|
759
|
+
const chan = makeChan('#foo');
|
|
760
|
+
chan.modes.noExternal = true;
|
|
761
|
+
// sender c1 is NOT a member
|
|
762
|
+
const store = new InMemoryMessageStore();
|
|
763
|
+
const ctx = makeCtx(makeConn(), new FakeClock(1_000), store);
|
|
764
|
+
|
|
765
|
+
privmsgChannelReducer(chan, { command: 'PRIVMSG', params: ['#foo', 'hi'], tags: {} }, ctx);
|
|
766
|
+
|
|
767
|
+
expect(store.query({ chan: '#foo', direction: 'latest', limit: 10 })).toEqual([]);
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
it('does NOT record when the message is blocked by +m (moderated, no voice)', () => {
|
|
771
|
+
const chan = makeChan('#foo');
|
|
772
|
+
chan.modes.moderated = true;
|
|
773
|
+
addMember(chan, 'c1', 'alice', false, false); // not op, not voiced
|
|
774
|
+
const store = new InMemoryMessageStore();
|
|
775
|
+
const ctx = makeCtx(makeConn(), new FakeClock(1_000), store);
|
|
776
|
+
|
|
777
|
+
privmsgChannelReducer(chan, { command: 'PRIVMSG', params: ['#foo', 'hi'], tags: {} }, ctx);
|
|
778
|
+
|
|
779
|
+
expect(store.query({ chan: '#foo', direction: 'latest', limit: 10 })).toEqual([]);
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
it('does NOT record when the sender is banned (+b)', () => {
|
|
783
|
+
const chan = makeChan('#foo');
|
|
784
|
+
addMember(chan, 'c1', 'alice');
|
|
785
|
+
chan.banMasks.add('*!*@evil.com');
|
|
786
|
+
const conn = makeConn('c1', 'alice');
|
|
787
|
+
conn.host = 'evil.com';
|
|
788
|
+
const store = new InMemoryMessageStore();
|
|
789
|
+
const ctx = makeCtx(conn, new FakeClock(1_000), store);
|
|
790
|
+
|
|
791
|
+
privmsgChannelReducer(chan, { command: 'PRIVMSG', params: ['#foo', 'hi'], tags: {} }, ctx);
|
|
792
|
+
|
|
793
|
+
expect(store.query({ chan: '#foo', direction: 'latest', limit: 10 })).toEqual([]);
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
it('uses a distinct msgid per recorded message (ctx.ids.nonce() each time)', () => {
|
|
797
|
+
const chan = makeChan('#foo');
|
|
798
|
+
addMember(chan, 'c1', 'alice');
|
|
799
|
+
const store = new InMemoryMessageStore();
|
|
800
|
+
const conn = makeConn();
|
|
801
|
+
const ctx = makeCtx(conn, new FakeClock(1_000), store);
|
|
802
|
+
|
|
803
|
+
privmsgChannelReducer(chan, { command: 'PRIVMSG', params: ['#foo', 'one'], tags: {} }, ctx);
|
|
804
|
+
privmsgChannelReducer(chan, { command: 'PRIVMSG', params: ['#foo', 'two'], tags: {} }, ctx);
|
|
805
|
+
|
|
806
|
+
const recorded = store.query({ chan: '#foo', direction: 'latest', limit: 10 });
|
|
807
|
+
expect(recorded.map((m) => m.msgid)).toEqual(['nonce-0', 'nonce-1']);
|
|
808
|
+
});
|
|
809
|
+
});
|