serverless-ircd 0.4.0 → 0.6.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 +34 -2
- package/.github/workflows/deploy-aws.yml +1 -2
- package/.github/workflows/deploy-cf-tcp.yml +1 -5
- package/.github/workflows/deploy-cf.yml +1 -2
- package/CHANGELOG.md +435 -0
- package/README.md +206 -27
- package/apps/aws-stack/README.md +37 -3
- package/apps/aws-stack/package.json +1 -1
- package/apps/aws-stack/src/aws-stack.ts +106 -13
- package/apps/aws-stack/tests/coverage-command.test.ts +155 -0
- package/apps/aws-stack/tests/stack.test.ts +49 -3
- package/apps/aws-stack/tests/synth-no-bundle.test.ts +129 -0
- package/apps/aws-stack/tests/vitest.setup.ts +18 -0
- package/apps/aws-stack/vitest.config.ts +3 -0
- package/apps/cf-tcp-container/package.json +1 -1
- package/apps/cf-tcp-container/src/config-loader.ts +6 -7
- package/apps/cf-tcp-container/src/container-server.ts +3 -0
- package/apps/cf-tcp-container/tests/config-loader.test.ts +10 -3
- package/apps/cf-tcp-container/wrangler.toml +1 -1
- package/apps/cf-worker/package.json +2 -1
- package/apps/cf-worker/vitest.config.ts +1 -1
- package/apps/cf-worker/wrangler.toml +5 -3
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +6 -6
- package/apps/local-cli/src/main.ts +20 -0
- package/apps/local-cli/src/server.ts +138 -28
- package/apps/local-cli/tests/e2e.test.ts +113 -29
- package/apps/local-cli/tests/tcp.test.ts +71 -19
- package/apps/local-cli/tests/ws-subprotocol.test.ts +262 -0
- package/biome.json +1 -0
- package/docs/ADR-001-pure-reducers-and-effect-system.md +74 -0
- package/docs/ADR-002-location-of-authority.md +82 -0
- package/docs/ADR-003-durable-object-sharding.md +110 -0
- package/docs/ADR-004-dynamodb-schema.md +96 -0
- package/docs/ADR-005-wss-only-transport-v1.md +83 -0
- package/docs/ADR-006-sasl-mechanism-scope.md +96 -0
- package/docs/ADR-007-deterministic-ports.md +82 -0
- package/docs/ADR-008-monorepo-tooling.md +60 -0
- package/docs/ADR-009-dual-transport-wss-and-irc-tls.md +138 -0
- package/docs/ADR-Index.md +15 -0
- package/docs/AWS-Adapter-Architecture.md +496 -0
- package/docs/AWS-Deployment.md +1275 -0
- package/docs/AWS-TCP-Deployment.md +410 -0
- package/docs/Cloudflare-Deployment-Guide.md +719 -0
- package/docs/Cloudflare-TCP-Deployment.md +510 -0
- package/docs/Home.md +15 -0
- package/docs/Observability.md +87 -0
- package/docs/PlanExtensions.md +812 -0
- package/docs/PlanIRCv3Websocket.md +503 -0
- package/docs/PlanWebClient.md +451 -0
- package/docs/Release-Process.md +443 -0
- package/package.json +2 -2
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/aws-runtime.ts +69 -0
- package/packages/aws-adapter/src/handlers/connect.ts +36 -5
- package/packages/aws-adapter/src/handlers/default.ts +66 -5
- package/packages/aws-adapter/src/handlers/index.ts +41 -2
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +18 -0
- package/packages/aws-adapter/src/index.ts +2 -0
- package/packages/aws-adapter/src/serialize.ts +40 -2
- package/packages/aws-adapter/src/stats.ts +80 -0
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +49 -8
- package/packages/aws-adapter/tests/account-store.test.ts +4 -7
- package/packages/aws-adapter/tests/aws-harness.ts +5 -2
- package/packages/aws-adapter/tests/aws-integration.test.ts +8 -4
- package/packages/aws-adapter/tests/aws-runtime.test.ts +140 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +11 -2
- package/packages/aws-adapter/tests/connect.test.ts +100 -4
- package/packages/aws-adapter/tests/disconnect-fanout.test.ts +3 -4
- package/packages/aws-adapter/tests/global-setup.ts +55 -30
- package/packages/aws-adapter/tests/gone-exception.test.ts +4 -9
- package/packages/aws-adapter/tests/handlers.test.ts +238 -4
- package/packages/aws-adapter/tests/message-store.test.ts +6 -5
- package/packages/aws-adapter/tests/nlb-stream.test.ts +62 -8
- package/packages/aws-adapter/tests/stats.test.ts +317 -0
- package/packages/cf-adapter/package.json +6 -1
- package/packages/cf-adapter/src/cf-runtime.ts +66 -1
- package/packages/cf-adapter/src/channel-do.ts +2 -2
- package/packages/cf-adapter/src/connection-do.ts +185 -54
- package/packages/cf-adapter/src/env.ts +25 -6
- package/packages/cf-adapter/src/index.ts +2 -0
- package/packages/cf-adapter/src/registry-do.ts +22 -3
- package/packages/cf-adapter/src/serialize.ts +25 -4
- package/packages/cf-adapter/src/sharding.ts +1 -2
- package/packages/cf-adapter/src/stats.ts +65 -0
- package/packages/cf-adapter/tests/cf-harness.ts +1 -1
- package/packages/cf-adapter/tests/cf-integration.test.ts +4 -4
- package/packages/cf-adapter/tests/cf-runtime.test.ts +307 -2
- package/packages/cf-adapter/tests/channel-do.test.ts +119 -2
- package/packages/cf-adapter/tests/config-loader.test.ts +49 -0
- package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +2 -2
- package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +2 -2
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +91 -0
- package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +289 -0
- package/packages/cf-adapter/tests/connection-do-ws-subprotocol.test.ts +184 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +27 -2
- package/packages/cf-adapter/tests/d1-account-store.test.ts +48 -0
- package/packages/cf-adapter/tests/registry-do.test.ts +108 -4
- package/packages/cf-adapter/tests/serialize.test.ts +308 -0
- package/packages/cf-adapter/tests/sharding.test.ts +1 -1
- package/packages/cf-adapter/tests/stats.test.ts +120 -0
- package/packages/cf-adapter/tests/worker/main.ts +7 -7
- package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +2 -2
- package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +8 -2
- package/packages/cf-adapter/vitest.config.ts +1 -1
- package/packages/cf-adapter/wrangler.test.toml +7 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +39 -0
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +259 -0
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/admission.ts +16 -15
- package/packages/irc-core/src/caps/capabilities.ts +38 -4
- package/packages/irc-core/src/caps/index.ts +1 -0
- package/packages/irc-core/src/caps/sts.ts +84 -0
- package/packages/irc-core/src/commands/account-notify.ts +53 -0
- package/packages/irc-core/src/commands/away.ts +9 -3
- package/packages/irc-core/src/commands/cap.ts +23 -5
- package/packages/irc-core/src/commands/chathistory.ts +40 -11
- package/packages/irc-core/src/commands/index.ts +42 -0
- package/packages/irc-core/src/commands/invite.ts +2 -4
- package/packages/irc-core/src/commands/isupport.ts +59 -2
- package/packages/irc-core/src/commands/kick.ts +2 -4
- package/packages/irc-core/src/commands/kill.ts +127 -0
- package/packages/irc-core/src/commands/list.ts +1 -1
- package/packages/irc-core/src/commands/lusers.ts +204 -0
- package/packages/irc-core/src/commands/mode.ts +12 -9
- package/packages/irc-core/src/commands/monitor.ts +327 -0
- package/packages/irc-core/src/commands/multiline.ts +256 -0
- package/packages/irc-core/src/commands/names.ts +3 -5
- package/packages/irc-core/src/commands/part.ts +2 -4
- package/packages/irc-core/src/commands/pre-away.ts +112 -0
- package/packages/irc-core/src/commands/privmsg.ts +19 -12
- package/packages/irc-core/src/commands/read-marker.ts +108 -0
- package/packages/irc-core/src/commands/registration.ts +8 -0
- package/packages/irc-core/src/commands/rehash.ts +119 -0
- package/packages/irc-core/src/commands/sasl.ts +24 -1
- package/packages/irc-core/src/commands/setname.ts +109 -0
- package/packages/irc-core/src/commands/stats.ts +152 -0
- package/packages/irc-core/src/commands/tagmsg.ts +71 -1
- package/packages/irc-core/src/commands/topic.ts +2 -4
- package/packages/irc-core/src/commands/trace.ts +137 -0
- package/packages/irc-core/src/commands/wallops.ts +118 -0
- package/packages/irc-core/src/commands/whois.ts +5 -0
- package/packages/irc-core/src/config.ts +72 -10
- package/packages/irc-core/src/effects.ts +41 -1
- package/packages/irc-core/src/index.ts +2 -0
- package/packages/irc-core/src/ports.ts +568 -0
- package/packages/irc-core/src/protocol/index.ts +14 -0
- package/packages/irc-core/src/protocol/labeled-response.ts +133 -0
- package/packages/irc-core/src/protocol/numerics.ts +57 -11
- package/packages/irc-core/src/protocol/outbound.ts +36 -4
- package/packages/irc-core/src/protocol/standard-replies.ts +277 -0
- package/packages/irc-core/src/state/connection.ts +32 -1
- package/packages/irc-core/src/types.ts +120 -1
- package/packages/irc-core/src/ws-framing.ts +132 -0
- package/packages/irc-core/src/ws-subprotocol.ts +66 -0
- package/packages/irc-core/stryker.commands.conf.json +1 -2
- package/packages/irc-core/tests/admission.test.ts +18 -0
- package/packages/irc-core/tests/away-store.test.ts +73 -0
- package/packages/irc-core/tests/caps/capabilities.test.ts +119 -2
- package/packages/irc-core/tests/caps/sts.test.ts +123 -0
- package/packages/irc-core/tests/commands/account-notify.test.ts +171 -0
- package/packages/irc-core/tests/commands/away.test.ts +85 -2
- package/packages/irc-core/tests/commands/cap.test.ts +165 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +148 -1
- package/packages/irc-core/tests/commands/echo-message.test.ts +9 -3
- package/packages/irc-core/tests/commands/isupport.test.ts +80 -0
- package/packages/irc-core/tests/commands/kill.test.ts +243 -0
- package/packages/irc-core/tests/commands/lusers.test.ts +368 -0
- package/packages/irc-core/tests/commands/mode.test.ts +129 -0
- package/packages/irc-core/tests/commands/monitor.test.ts +411 -0
- package/packages/irc-core/tests/commands/multiline.test.ts +428 -0
- package/packages/irc-core/tests/commands/pre-away.test.ts +241 -0
- package/packages/irc-core/tests/commands/privmsg.test.ts +154 -3
- package/packages/irc-core/tests/commands/read-marker.test.ts +211 -0
- package/packages/irc-core/tests/commands/registration.test.ts +64 -0
- package/packages/irc-core/tests/commands/rehash.test.ts +171 -0
- package/packages/irc-core/tests/commands/sasl.test.ts +116 -2
- package/packages/irc-core/tests/commands/setname.test.ts +225 -0
- package/packages/irc-core/tests/commands/stats.test.ts +294 -0
- package/packages/irc-core/tests/commands/tagmsg.test.ts +357 -0
- package/packages/irc-core/tests/commands/trace.test.ts +282 -0
- package/packages/irc-core/tests/commands/wallops.test.ts +231 -0
- package/packages/irc-core/tests/commands/whois.test.ts +34 -0
- package/packages/irc-core/tests/config.test.ts +103 -13
- package/packages/irc-core/tests/dropped-s2s-and-obsolete-verbs.test.ts +90 -0
- package/packages/irc-core/tests/effects.test.ts +14 -0
- package/packages/irc-core/tests/labeled-response.test.ts +181 -0
- package/packages/irc-core/tests/message-tags.test.ts +114 -0
- package/packages/irc-core/tests/monitor-store.test.ts +200 -0
- package/packages/irc-core/tests/numerics.test.ts +90 -0
- package/packages/irc-core/tests/outbound.test.ts +51 -0
- package/packages/irc-core/tests/ports.test.ts +22 -0
- package/packages/irc-core/tests/raw-modules.d.ts +11 -0
- package/packages/irc-core/tests/read-marker-store.test.ts +108 -0
- package/packages/irc-core/tests/standard-replies.test.ts +412 -0
- package/packages/irc-core/tests/stats-store.test.ts +222 -0
- package/packages/irc-core/tests/types.test.ts +27 -0
- package/packages/irc-core/tests/ws-framing.test.ts +213 -0
- package/packages/irc-core/tests/ws-subprotocol.test.ts +111 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +699 -19
- package/packages/irc-server/src/dispatch.ts +109 -16
- package/packages/irc-server/src/routing.ts +3 -0
- package/packages/irc-server/src/runtime.ts +31 -0
- package/packages/irc-server/src/transport.ts +10 -7
- package/packages/irc-server/tests/actor.test.ts +2523 -42
- package/packages/irc-server/tests/dispatch.test.ts +300 -2
- package/packages/irc-server/tests/raw-modules.d.ts +11 -0
- package/packages/irc-server/tests/routing.test.ts +1 -0
- package/packages/irc-server/tests/runtime.test.ts +7 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/index.ts +6 -0
- package/packages/irc-test-support/src/scenarios.ts +9 -1
- package/packages/irc-test-support/src/test-config.ts +54 -0
- package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +1 -1
- package/packages/irc-test-support/tests/test-config.test.ts +51 -0
- package/pnpm-workspace.yaml +1 -0
- package/tools/ci-hardening/package.json +1 -1
- package/tools/ci-hardening/tests/ddb-local.test.ts +82 -0
- package/tools/load-test/package.json +33 -0
- package/tools/load-test/src/client.ts +351 -0
- package/tools/load-test/src/config.ts +313 -0
- package/tools/load-test/src/harness.ts +116 -0
- package/tools/load-test/src/main.ts +120 -0
- package/tools/load-test/src/metrics.ts +168 -0
- package/tools/load-test/src/report.ts +106 -0
- package/tools/load-test/tests/client.test.ts +212 -0
- package/tools/load-test/tests/config.test.ts +152 -0
- package/tools/load-test/tests/framing.test.ts +37 -0
- package/tools/load-test/tests/harness.test.ts +165 -0
- package/tools/load-test/tests/metrics.test.ts +174 -0
- package/tools/load-test/tests/report.test.ts +161 -0
- package/tools/load-test/tests/smoke.test.ts +67 -0
- package/tools/load-test/tsconfig.build.json +12 -0
- package/tools/load-test/tsconfig.test.json +10 -0
- package/tools/load-test/vitest.config.ts +29 -0
- package/tools/package.json +6 -1
- package/tools/seed-cf-accounts.ts +4 -1
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/src/forwarder.ts +57 -9
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +34 -1
- package/tools/tcp-ws-forwarder/tests/framing.test.ts +65 -1
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure reducer for IRCv3 `draft/multiline` (multi-line `BATCH`).
|
|
3
|
+
*
|
|
4
|
+
* Spec: https://ircv3.net/specs/extensions/multiline
|
|
5
|
+
*
|
|
6
|
+
* A cap-enabled client opens a multi-line batch with
|
|
7
|
+
* `BATCH +id draft/multiline :<target>`
|
|
8
|
+
* followed by N `PRIVMSG`/`NOTICE` lines addressed to the same target, then
|
|
9
|
+
* closes it with `BATCH -id`. The server concatenates the per-line trailing
|
|
10
|
+
* parameters with `\n`, enforces the advertised byte budget, and fans the
|
|
11
|
+
* result out:
|
|
12
|
+
*
|
|
13
|
+
* - to peers that also negotiated `draft/multiline`: a replayed
|
|
14
|
+
* `BATCH +newid draft/multiline :<target>` containing the original
|
|
15
|
+
* per-line `PRIVMSG`/`NOTICE` messages;
|
|
16
|
+
* - to everyone else: a single joined `PRIVMSG`/`NOTICE` carrying the
|
|
17
|
+
* `+draft/multiline` client tag so message-tags-aware (but not
|
|
18
|
+
* multiline-aware) clients can recognise the joined form.
|
|
19
|
+
*
|
|
20
|
+
* This module owns the pure finalization step (concatenation, byte-budget
|
|
21
|
+
* enforcement, fanout construction). The stateful accumulation of the inner
|
|
22
|
+
* lines — buffering `PRIVMSG`/`NOTICE` between the `BATCH +` and `BATCH -`
|
|
23
|
+
* markers — lives in the actor layer, which calls
|
|
24
|
+
* {@link finalizeMultilineBatch} once the batch closes.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import {
|
|
28
|
+
DEFAULT_MULTILINE_MAX_BYTES,
|
|
29
|
+
MULTILINE_CAP_NAME,
|
|
30
|
+
multilineCapValue,
|
|
31
|
+
} from '../caps/capabilities.js';
|
|
32
|
+
import { Effect } from '../effects.js';
|
|
33
|
+
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
34
|
+
import { hostmaskOf } from '../state/connection.js';
|
|
35
|
+
import type { Ctx } from '../types.js';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Wire name of the IRCv3 `draft/multiline` capability. Aliased from
|
|
39
|
+
* {@link MULTILINE_CAP_NAME} (the canonical home in the cap data table) for
|
|
40
|
+
* ergonomic use inside the reducer.
|
|
41
|
+
*/
|
|
42
|
+
export const MULTILINE_CAP = MULTILINE_CAP_NAME;
|
|
43
|
+
|
|
44
|
+
export { DEFAULT_MULTILINE_MAX_BYTES, multilineCapValue };
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* One accumulated inner line of a multi-line batch. The actor collects these
|
|
48
|
+
* as the client streams `PRIVMSG`/`NOTICE` lines between the batch markers,
|
|
49
|
+
* then hands the list to {@link finalizeMultilineBatch}. Only the trailing
|
|
50
|
+
* parameter (message text) is stored per line: the spec requires every inner
|
|
51
|
+
* line of a batch to share both the command and the target, so those live on
|
|
52
|
+
* the batch as a whole and are passed to the reducer separately.
|
|
53
|
+
*/
|
|
54
|
+
export interface MultilineEntry {
|
|
55
|
+
/** The trailing parameter (message text) of the inner line. */
|
|
56
|
+
readonly text: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Result of {@link finalizeMultilineBatch}: either accepted or rejected. */
|
|
60
|
+
export type FinalizeMultilineResult =
|
|
61
|
+
| { readonly ok: true; readonly effects: EffectType[] }
|
|
62
|
+
| { readonly ok: false; readonly effects: EffectType[] };
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Concatenates the per-line texts of a multi-line batch with `\n`, per the
|
|
66
|
+
* IRCv3 spec. The joined value is what legacy (non-multiline) peers receive
|
|
67
|
+
* as the trailing parameter of the single relayed PRIVMSG/NOTICE.
|
|
68
|
+
*/
|
|
69
|
+
export function joinMultiline(entries: readonly MultilineEntry[]): string {
|
|
70
|
+
return entries.map((e) => e.text).join('\n');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Returns the byte length of the joined multi-line content as the byte budget
|
|
75
|
+
* sees it: the sum of every entry's UTF-8 byte length plus one byte per `\n`
|
|
76
|
+
* separator. This is the quantity compared against `draft/multiline=<max>`.
|
|
77
|
+
*/
|
|
78
|
+
export function multilineByteLength(entries: readonly MultilineEntry[]): number {
|
|
79
|
+
if (entries.length === 0) return 0;
|
|
80
|
+
let total = 0;
|
|
81
|
+
for (const entry of entries) {
|
|
82
|
+
total += utf8ByteLength(entry.text);
|
|
83
|
+
}
|
|
84
|
+
// `(n - 1)` separators join `n` entries.
|
|
85
|
+
total += entries.length - 1;
|
|
86
|
+
return total;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Shared `TextEncoder` for UTF-8 byte-length accounting (Web standard). */
|
|
90
|
+
const TEXT_ENCODER = new TextEncoder();
|
|
91
|
+
|
|
92
|
+
/** Returns the UTF-8 byte length of `s` (the wire encoding for IRC content). */
|
|
93
|
+
function utf8ByteLength(s: string): number {
|
|
94
|
+
return TEXT_ENCODER.encode(s).length;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Resolves the sender's hostmask, falling back to nick then `?`. */
|
|
98
|
+
function senderHostmask(conn: Ctx['connection']): string {
|
|
99
|
+
return hostmaskOf(conn) ?? conn.nick ?? '?';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Pre-built wire lines for a finalized multi-line batch, independent of the
|
|
104
|
+
* delivery mechanism (channel `Broadcast` vs. nick-target `Send`). The caller
|
|
105
|
+
* picks the appropriate variant per recipient based on the recipient's caps:
|
|
106
|
+
*
|
|
107
|
+
* - `draft/multiline` + `batch` → `batchedReplay` (replayed BATCH frame).
|
|
108
|
+
* - `message-tags` only → `taggedJoined` (joined PRIVMSG with
|
|
109
|
+
* `+draft/multiline` client tag).
|
|
110
|
+
* - neither → `bareJoined` (joined PRIVMSG, no tag).
|
|
111
|
+
*/
|
|
112
|
+
export interface MultilineFanout {
|
|
113
|
+
/** Replayed `BATCH +id draft/multiline <target>` … `BATCH -id`. */
|
|
114
|
+
readonly batchedReplay: RawLine[];
|
|
115
|
+
/** Joined PRIVMSG/NOTICE carrying the `+draft/multiline` client tag. */
|
|
116
|
+
readonly taggedJoined: RawLine;
|
|
117
|
+
/** Joined PRIVMSG/NOTICE with no tag prefix (for legacy peers). */
|
|
118
|
+
readonly bareJoined: RawLine;
|
|
119
|
+
/** The `\n`-joined trailing parameter (for chathistory recording). */
|
|
120
|
+
readonly joined: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Builds the wire-line variants for a finalized multi-line batch. Pure: no
|
|
125
|
+
* context, no effects — just string construction. Shared by the channel
|
|
126
|
+
* broadcast path ({@link finalizeMultilineBatch}) and the actor-side nick
|
|
127
|
+
* target path so both emit identical wire forms.
|
|
128
|
+
*
|
|
129
|
+
* @param target The batch target (channel name or nick).
|
|
130
|
+
* @param command The inner-line command (`PRIVMSG` or `NOTICE`).
|
|
131
|
+
* @param entries The accumulated inner lines in arrival order.
|
|
132
|
+
* @param hostmask The sender's `nick!user@host` (or fallback).
|
|
133
|
+
* @param batchId A fresh batch reference id for the replayed BATCH frame.
|
|
134
|
+
*/
|
|
135
|
+
export function buildMultilineFanout(
|
|
136
|
+
target: string,
|
|
137
|
+
command: 'PRIVMSG' | 'NOTICE',
|
|
138
|
+
entries: readonly MultilineEntry[],
|
|
139
|
+
hostmask: string,
|
|
140
|
+
batchId: string,
|
|
141
|
+
): MultilineFanout {
|
|
142
|
+
const joined = joinMultiline(entries);
|
|
143
|
+
|
|
144
|
+
const taggedJoined: RawLine = {
|
|
145
|
+
text: `@+draft/multiline :${hostmask} ${command} ${target} :${joined}`,
|
|
146
|
+
};
|
|
147
|
+
const bareJoined: RawLine = {
|
|
148
|
+
text: `:${hostmask} ${command} ${target} :${joined}`,
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const inner: RawLine[] = entries.map(
|
|
152
|
+
(e) => ({ text: `:${hostmask} ${command} ${target} :${e.text}` }) as RawLine,
|
|
153
|
+
);
|
|
154
|
+
const batchedReplay: RawLine[] = [
|
|
155
|
+
{ text: `BATCH +${batchId} draft/multiline ${target}` },
|
|
156
|
+
...inner,
|
|
157
|
+
{ text: `BATCH -${batchId}` },
|
|
158
|
+
];
|
|
159
|
+
|
|
160
|
+
return { batchedReplay, taggedJoined, bareJoined, joined };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Finalizes a closed multi-line batch: enforces the byte budget and builds
|
|
165
|
+
* the fanout effects for both multiline-capable and legacy peers.
|
|
166
|
+
*
|
|
167
|
+
* On success the joined body is also recorded into the bound
|
|
168
|
+
* {@link MessageStore} (if any) as a single entry carrying the
|
|
169
|
+
* `+draft/multiline` client tag, so `CHATHISTORY` playback returns exactly
|
|
170
|
+
* one entry per batch.
|
|
171
|
+
*
|
|
172
|
+
* @param target The batch's relay target (channel name). The actor
|
|
173
|
+
* validates that every inner line addressed this same target
|
|
174
|
+
* before calling.
|
|
175
|
+
* @param command The inner-line command (`PRIVMSG` or `NOTICE`); the actor
|
|
176
|
+
* guarantees every inner line used the same one.
|
|
177
|
+
* @param entries The accumulated inner lines in arrival order.
|
|
178
|
+
* @param maxBytes The advertised byte budget (`draft/multiline=<n>`).
|
|
179
|
+
* @param ctx Per-invocation context.
|
|
180
|
+
* @returns `{ ok: true, effects }` on success; `{ ok: false, effects }`
|
|
181
|
+
* when the joined content exceeds `maxBytes` (the effects
|
|
182
|
+
* carry the `FAIL` rejection back to the sender).
|
|
183
|
+
*/
|
|
184
|
+
export function finalizeMultilineBatch(
|
|
185
|
+
target: string,
|
|
186
|
+
command: 'PRIVMSG' | 'NOTICE',
|
|
187
|
+
entries: readonly MultilineEntry[],
|
|
188
|
+
maxBytes: number,
|
|
189
|
+
ctx: Ctx,
|
|
190
|
+
): FinalizeMultilineResult {
|
|
191
|
+
if (entries.length === 0) {
|
|
192
|
+
return { ok: true, effects: [] };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const bytes = multilineByteLength(entries);
|
|
196
|
+
if (bytes > maxBytes) {
|
|
197
|
+
return {
|
|
198
|
+
ok: false,
|
|
199
|
+
effects: [
|
|
200
|
+
Effect.send(ctx.connId, [
|
|
201
|
+
{
|
|
202
|
+
text: `:${ctx.serverName} FAIL BATCH MULTILINE_MAX_BYTES ${target} :Multi-line batch exceeds the ${maxBytes} byte limit`,
|
|
203
|
+
},
|
|
204
|
+
]),
|
|
205
|
+
],
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const hostmask = senderHostmask(ctx.connection);
|
|
210
|
+
const batchId = ctx.ids.batchId();
|
|
211
|
+
const fanout = buildMultilineFanout(target, command, entries, hostmask, batchId);
|
|
212
|
+
const msgid = ctx.ids.nonce();
|
|
213
|
+
recordMultilineMessage(ctx, target, command, fanout.joined, msgid);
|
|
214
|
+
|
|
215
|
+
const effects: EffectType[] = [
|
|
216
|
+
Effect.broadcast(
|
|
217
|
+
target,
|
|
218
|
+
[fanout.taggedJoined],
|
|
219
|
+
ctx.connId,
|
|
220
|
+
MULTILINE_CAP,
|
|
221
|
+
fanout.batchedReplay,
|
|
222
|
+
),
|
|
223
|
+
];
|
|
224
|
+
|
|
225
|
+
return { ok: true, effects };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Records one finalized multi-line batch into the bound {@link MessageStore}
|
|
230
|
+
* (when present) as a single chathistory entry. The joined body is stored
|
|
231
|
+
* verbatim, the assigned `msgid` is the chathistory pivot, and the
|
|
232
|
+
* `+draft/multiline` client tag is attached so the chathistory replay can
|
|
233
|
+
* mark the line as originating from a multi-line batch. Shared by the
|
|
234
|
+
* channel broadcast path ({@link finalizeMultilineBatch}) and the actor-side
|
|
235
|
+
* nick-target path.
|
|
236
|
+
*/
|
|
237
|
+
export function recordMultilineMessage(
|
|
238
|
+
ctx: Ctx,
|
|
239
|
+
target: string,
|
|
240
|
+
command: 'PRIVMSG' | 'NOTICE',
|
|
241
|
+
joined: string,
|
|
242
|
+
msgid: string,
|
|
243
|
+
): void {
|
|
244
|
+
if (ctx.messages === undefined) return;
|
|
245
|
+
ctx.messages.record({
|
|
246
|
+
msgid,
|
|
247
|
+
time: ctx.clock.now(),
|
|
248
|
+
chan: target.toLowerCase(),
|
|
249
|
+
command,
|
|
250
|
+
nick: (ctx.connection.nick as string) ?? '?',
|
|
251
|
+
user: ctx.connection.user as string,
|
|
252
|
+
host: ctx.connection.host as string,
|
|
253
|
+
text: joined,
|
|
254
|
+
clientTags: ['+draft/multiline'],
|
|
255
|
+
});
|
|
256
|
+
}
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* `@` (private `+p`). Private wins over secret when both are set, matching
|
|
15
15
|
* the JOIN reducer's NAMES reply and RFC 2812 §3.2.4.
|
|
16
16
|
*
|
|
17
|
-
* Visibility rules (PLAN §3
|
|
17
|
+
* Visibility rules (PLAN §3):
|
|
18
18
|
* - `+s` or `+p` set: requester must be a member to receive the NAMES list.
|
|
19
19
|
* Otherwise `442 ERR_NOTONCHANNEL`.
|
|
20
20
|
* - Otherwise: any connection may list.
|
|
@@ -44,12 +44,10 @@ function isValidChannelName(name: string, maxLen: number): boolean {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
/** Formats a numeric error/reply line addressed to the connection's nick. */
|
|
47
|
-
function numericErr(ctx: Ctx, code: number, trailing: string, middle
|
|
47
|
+
function numericErr(ctx: Ctx, code: number, trailing: string, middle: string): RawLine {
|
|
48
48
|
const nick = ctx.connection.nick ?? '*';
|
|
49
49
|
const codeStr = code.toString().padStart(3, '0');
|
|
50
|
-
const parts = [`:${ctx.serverName}`, codeStr, nick];
|
|
51
|
-
if (middle !== undefined) parts.push(middle);
|
|
52
|
-
parts.push(`:${trailing}`);
|
|
50
|
+
const parts = [`:${ctx.serverName}`, codeStr, nick, middle, `:${trailing}`];
|
|
53
51
|
return { text: parts.join(' ') };
|
|
54
52
|
}
|
|
55
53
|
|
|
@@ -33,12 +33,10 @@ function isValidChannelName(name: string, maxLen: number): boolean {
|
|
|
33
33
|
* nick (or `*` when unregistered). Optional `middle` is emitted before the
|
|
34
34
|
* trailing `:`-prefixed text.
|
|
35
35
|
*/
|
|
36
|
-
function numericErr(ctx: Ctx, code: number, trailing: string, middle
|
|
36
|
+
function numericErr(ctx: Ctx, code: number, trailing: string, middle: string): RawLine {
|
|
37
37
|
const nick = ctx.connection.nick ?? '*';
|
|
38
38
|
const codeStr = code.toString().padStart(3, '0');
|
|
39
|
-
const parts = [`:${ctx.serverName}`, codeStr, nick];
|
|
40
|
-
if (middle !== undefined) parts.push(middle);
|
|
41
|
-
parts.push(`:${trailing}`);
|
|
39
|
+
const parts = [`:${ctx.serverName}`, codeStr, nick, middle, `:${trailing}`];
|
|
42
40
|
return { text: parts.join(' ') };
|
|
43
41
|
}
|
|
44
42
|
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for IRCv3 `draft/pre-away` (persistent away status).
|
|
3
|
+
*
|
|
4
|
+
* Spec (work-in-progress): https://github.com/ircv3/ircv3-specifications/pull/415
|
|
5
|
+
*
|
|
6
|
+
* Flow:
|
|
7
|
+
* - The {@link awayReducer} sets/clears {@link ConnectionState.away} as
|
|
8
|
+
* usual. When an {@link AwayStore} is bound AND the connection is
|
|
9
|
+
* identified (has an `account`), the change is mirrored into the
|
|
10
|
+
* per-`account` record via {@link persistAway} (on set) or
|
|
11
|
+
* {@link clearPersistedAway} (on unset).
|
|
12
|
+
* - On SASL identify, the actor calls {@link replayPersistedAway} so a
|
|
13
|
+
* fresh connection's `away` is restored from the persisted reason and
|
|
14
|
+
* `306 RPL_NOWAWAY` is emitted as if the client had sent
|
|
15
|
+
* `AWAY :reason` themselves.
|
|
16
|
+
*
|
|
17
|
+
* Mirrors the {@link ReadMarkerStore} plumbing from the read-marker cap.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { PRE_AWAY_CAP_NAME } from '../caps/capabilities.js';
|
|
21
|
+
import type { AwayStore } from '../ports.js';
|
|
22
|
+
import { Numerics } from '../protocol/numerics.js';
|
|
23
|
+
import type { ConnectionState } from '../state/connection.js';
|
|
24
|
+
import type { Ctx } from '../types.js';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Wire name of the IRCv3 `draft/pre-away` capability. Aliased from
|
|
28
|
+
* {@link PRE_AWAY_CAP_NAME} (the canonical home in the cap data table)
|
|
29
|
+
* for ergonomic use inside the reducer.
|
|
30
|
+
*/
|
|
31
|
+
export const PRE_AWAY_CAP = PRE_AWAY_CAP_NAME;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Records the away reason for the connection's account and stamps the
|
|
35
|
+
* in-memory {@link ConnectionState.away} so the current session tracks
|
|
36
|
+
* the new reason.
|
|
37
|
+
*
|
|
38
|
+
* Mirrors {@link persistReadMarker}: the store write is gated on both an
|
|
39
|
+
* {@link AwayStore} being bound AND the connection being identified.
|
|
40
|
+
* Unidentified connections still get the in-memory write so the session's
|
|
41
|
+
* own view (numerics, WHO/WHOIS) stays consistent.
|
|
42
|
+
*/
|
|
43
|
+
export function persistAway(ctx: Ctx, reason: string): void {
|
|
44
|
+
ctx.connection.away = reason;
|
|
45
|
+
const store = ctx.away;
|
|
46
|
+
const account = ctx.connection.account;
|
|
47
|
+
if (store !== undefined && account !== undefined) {
|
|
48
|
+
store.set(account, reason);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Clears the persisted away reason for the connection's account and the
|
|
54
|
+
* in-memory {@link ConnectionState.away}.
|
|
55
|
+
*
|
|
56
|
+
* Mirrors {@link persistAway}'s gating: a store write (delete) is gated
|
|
57
|
+
* on both a store being bound AND the connection being identified. The
|
|
58
|
+
* in-memory clear is unconditional so the session's own view stays
|
|
59
|
+
* consistent regardless of identification.
|
|
60
|
+
*/
|
|
61
|
+
export function clearPersistedAway(ctx: Ctx): void {
|
|
62
|
+
// biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes forbids `= undefined`.
|
|
63
|
+
delete ctx.connection.away;
|
|
64
|
+
const store = ctx.away;
|
|
65
|
+
const account = ctx.connection.account;
|
|
66
|
+
if (store !== undefined && account !== undefined) {
|
|
67
|
+
store.clear(account);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Restores a fresh connection's {@link ConnectionState.away} from the
|
|
73
|
+
* persisted reason for `account`. Called at SASL identify so a reconnect
|
|
74
|
+
* resumes the user's away state.
|
|
75
|
+
*
|
|
76
|
+
* Existing in-memory `away` is preserved: a session may have already set
|
|
77
|
+
* a different reason, and the session's own value wins. Returns the
|
|
78
|
+
* persisted reason (or `undefined`) so the caller can emit the
|
|
79
|
+
* `306 RPL_NOWAWAY` numeric only when the replay actually applied a value.
|
|
80
|
+
*/
|
|
81
|
+
export function replayPersistedAway(
|
|
82
|
+
conn: ConnectionState,
|
|
83
|
+
store: AwayStore,
|
|
84
|
+
account: string,
|
|
85
|
+
): string | undefined {
|
|
86
|
+
if (conn.away !== undefined) return undefined;
|
|
87
|
+
const reason = store.get(account);
|
|
88
|
+
if (reason === undefined) return undefined;
|
|
89
|
+
conn.away = reason;
|
|
90
|
+
return reason;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Builds the `306 RPL_NOWAWAY` line emitted after a successful away
|
|
95
|
+
* replay at identify time. Mirrors the line the {@link awayReducer}
|
|
96
|
+
* emits when a client sets `AWAY :reason` themselves, so the
|
|
97
|
+
* `draft/pre-away` replay is wire-identical to a direct AWAY set as far
|
|
98
|
+
* as the identified user is concerned.
|
|
99
|
+
*
|
|
100
|
+
* The companion `away-notify` broadcast (`:<hostmask> AWAY :<reason>` to
|
|
101
|
+
* cap-enabled peers in shared channels) is intentionally NOT emitted
|
|
102
|
+
* here: at SASL identify time the connection has not yet joined any
|
|
103
|
+
* channels, so the broadcast would have no audience. Peers learn the
|
|
104
|
+
* away state through the normal `AWAY` / `WHO` / `WHOIS` queries once
|
|
105
|
+
* the user joins.
|
|
106
|
+
*/
|
|
107
|
+
export function nowAwayLine(conn: ConnectionState, serverName: string): { text: string } {
|
|
108
|
+
const nick = conn.nick ?? '*';
|
|
109
|
+
return {
|
|
110
|
+
text: `:${serverName} ${Numerics.RPL_NOWAWAY.toString().padStart(3, '0')} ${nick} :You have been marked as being away`,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
@@ -175,18 +175,18 @@ function buildChannelReducer(command: MessageCommand): Reducer<ChannelState> {
|
|
|
175
175
|
|
|
176
176
|
// All checks passed: persist the message for chathistory playback when a
|
|
177
177
|
// MessageStore is bound. The msgid/time are generated once here and travel
|
|
178
|
-
// on the StoredMessage
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
// change whatsoever.
|
|
178
|
+
// on the StoredMessage AND on the live fanout line (IRCv3 msgid). A single
|
|
179
|
+
// nonce is shared between the live tag and the record so the value a cap
|
|
180
|
+
// enabled peer sees live matches the value replayed by chathistory.
|
|
182
181
|
//
|
|
183
182
|
// By this point the sender's hostmask has already been computed and used
|
|
184
183
|
// in the `+b` ban check above, so the connection's nick/user/host are all
|
|
185
184
|
// defined for any registered PRIVMSG (registration sets all three); the
|
|
186
185
|
// fallbacks mirror `senderHostmask` purely for the type-checker.
|
|
186
|
+
const msgid = ctx.ids.nonce();
|
|
187
187
|
if (ctx.messages !== undefined) {
|
|
188
188
|
ctx.messages.record({
|
|
189
|
-
msgid
|
|
189
|
+
msgid,
|
|
190
190
|
time: ctx.clock.now(),
|
|
191
191
|
chan: state.nameLower,
|
|
192
192
|
command,
|
|
@@ -197,14 +197,21 @@ function buildChannelReducer(command: MessageCommand): Reducer<ChannelState> {
|
|
|
197
197
|
});
|
|
198
198
|
}
|
|
199
199
|
|
|
200
|
-
// All checks passed: broadcast to the channel, excluding the sender.
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
200
|
+
// All checks passed: broadcast to the channel, excluding the sender. The
|
|
201
|
+
// bare line goes to every member; the `@msgid=<id>` decorated line is sent
|
|
202
|
+
// only to members who negotiated the `msgid` cap (IRCv3 msgid). The
|
|
203
|
+
// dispatch layer's cap-split resolves per-recipient.
|
|
204
|
+
const body = `${command} ${state.name} :${text}`;
|
|
205
|
+
const line: RawLine = { text: `:${hostmask} ${body}` };
|
|
206
|
+
const msgidLine: RawLine = { text: `@msgid=${msgid} :${hostmask} ${body}` };
|
|
207
|
+
effects.push(Effect.broadcast(state.name, [line], ctx.connId, 'msgid', [msgidLine]));
|
|
208
|
+
|
|
209
|
+
// IRCv3 echo-message: echo the line back to the sender, after the
|
|
210
|
+
// broadcast effect so clients observe their own message last. A sender
|
|
211
|
+
// that also negotiated `msgid` sees the decorated echo.
|
|
206
212
|
if (ctx.connection.caps.has('echo-message')) {
|
|
207
|
-
|
|
213
|
+
const echoLine = ctx.connection.caps.has('msgid') ? msgidLine : line;
|
|
214
|
+
effects.push(Effect.send(ctx.connId, [echoLine]));
|
|
208
215
|
}
|
|
209
216
|
|
|
210
217
|
return { state, effects };
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for IRCv3 `draft/read-marker` (persistent last-read marker).
|
|
3
|
+
*
|
|
4
|
+
* Spec (work-in-progress): https://ircv3.net/specs/extensions/read-marker
|
|
5
|
+
*
|
|
6
|
+
* The serverless-ircd deployment diverges from the work-in-progress draft:
|
|
7
|
+
* instead of the draft's `MARKREAD` command + server-time timestamp, this
|
|
8
|
+
* implementation is **msgid-based** so it composes with the
|
|
9
|
+
* per-`(connection, channel)` last-read marker the chathistory command
|
|
10
|
+
* already maintains in {@link ConnectionState.lastReadMarkers}.
|
|
11
|
+
*
|
|
12
|
+
* Flow:
|
|
13
|
+
* - A cap-enabled client marks a message read by sending a
|
|
14
|
+
* `TAGMSG <target>` carrying `+draft/read-marker=<msgid>`. The
|
|
15
|
+
* {@link tagmsgChannelReducer} detects the tag, calls
|
|
16
|
+
* {@link persistReadMarker} to advance the per-`(account, channel)`
|
|
17
|
+
* record in the bound {@link ReadMarkerStore}, and fans the TAGMSG out
|
|
18
|
+
* (carrying the tag) so the user's other connections learn the new
|
|
19
|
+
* read position.
|
|
20
|
+
* - On SASL identify, the actor calls {@link seedReadMarkers} so a fresh
|
|
21
|
+
* connection's `lastReadMarkers` is restored from the stored values and
|
|
22
|
+
* a reconnect resumes at the user's last read position.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { READ_MARKER_CAP_NAME } from '../caps/capabilities.js';
|
|
26
|
+
import type { ReadMarkerStore } from '../ports.js';
|
|
27
|
+
import type { ConnectionState } from '../state/connection.js';
|
|
28
|
+
import type { Ctx } from '../types.js';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Wire name of the IRCv3 `draft/read-marker` capability. Aliased from
|
|
32
|
+
* {@link READ_MARKER_CAP_NAME} (the canonical home in the cap data table)
|
|
33
|
+
* for ergonomic use inside the reducer.
|
|
34
|
+
*/
|
|
35
|
+
export const READ_MARKER_CAP = READ_MARKER_CAP_NAME;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The client-tag key that carries a read-marker update. Presence of this key
|
|
39
|
+
* (with a non-empty value) on a `TAGMSG` identifies a read-marker update the
|
|
40
|
+
* server persists and propagates.
|
|
41
|
+
*/
|
|
42
|
+
export const READ_MARKER_TAG = '+draft/read-marker';
|
|
43
|
+
|
|
44
|
+
/** Returns true iff `tags` carries the `+draft/read-marker` client tag. */
|
|
45
|
+
export function isReadMarkerTagmsg(tags: Readonly<Record<string, string>>): boolean {
|
|
46
|
+
return READ_MARKER_TAG in tags;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Returns the msgid carried by the `+draft/read-marker` tag, or `undefined`
|
|
51
|
+
* when the tag is absent or valueless. A valueless tag carries no msgid and
|
|
52
|
+
* therefore no marker to persist.
|
|
53
|
+
*/
|
|
54
|
+
export function readMarkerMsgid(tags: Readonly<Record<string, string>>): string | undefined {
|
|
55
|
+
const value = tags[READ_MARKER_TAG];
|
|
56
|
+
if (value === undefined || value === '') return undefined;
|
|
57
|
+
return value;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Advances the per-`(account, channel)` last-read marker.
|
|
62
|
+
*
|
|
63
|
+
* Always advances the connection's in-memory {@link ConnectionState.lastReadMarkers}
|
|
64
|
+
* so the current session tracks the read position. When a {@link ReadMarkerStore}
|
|
65
|
+
* is bound AND the connection is identified (has an `account`), the marker is
|
|
66
|
+
* also persisted so it survives reconnects and is shared across the account's
|
|
67
|
+
* connections.
|
|
68
|
+
*
|
|
69
|
+
* `chanLower` is the lowercased channel name (the storage key); callers pass
|
|
70
|
+
* the authoritative `ChannelState.nameLower`.
|
|
71
|
+
*/
|
|
72
|
+
export function persistReadMarker(ctx: Ctx, chanLower: string, msgid: string): void {
|
|
73
|
+
if (ctx.connection.lastReadMarkers === undefined) {
|
|
74
|
+
ctx.connection.lastReadMarkers = new Map<string, string>();
|
|
75
|
+
}
|
|
76
|
+
ctx.connection.lastReadMarkers.set(chanLower, msgid);
|
|
77
|
+
|
|
78
|
+
const store = ctx.readMarkers;
|
|
79
|
+
const account = ctx.connection.account;
|
|
80
|
+
if (store !== undefined && account !== undefined) {
|
|
81
|
+
store.set(account, chanLower, msgid);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Seeds a fresh connection's `lastReadMarkers` from the persisted values for
|
|
87
|
+
* `account`. Called at SASL identify so a reconnect restores the user's last
|
|
88
|
+
* read position across every channel they have a marker for.
|
|
89
|
+
*
|
|
90
|
+
* Existing in-memory markers are preserved: a session may have already
|
|
91
|
+
* advanced past the stored value, and the in-memory (newer) marker wins.
|
|
92
|
+
*/
|
|
93
|
+
export function seedReadMarkers(
|
|
94
|
+
conn: ConnectionState,
|
|
95
|
+
store: ReadMarkerStore,
|
|
96
|
+
account: string,
|
|
97
|
+
): void {
|
|
98
|
+
const entries = store.forAccount(account);
|
|
99
|
+
if (entries.length === 0) return;
|
|
100
|
+
if (conn.lastReadMarkers === undefined) {
|
|
101
|
+
conn.lastReadMarkers = new Map<string, string>();
|
|
102
|
+
}
|
|
103
|
+
for (const entry of entries) {
|
|
104
|
+
if (!conn.lastReadMarkers.has(entry.channel)) {
|
|
105
|
+
conn.lastReadMarkers.set(entry.channel, entry.msgid);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -117,6 +117,14 @@ export function emitWelcomeIfReady(state: ConnectionState, ctx: Ctx): EffectType
|
|
|
117
117
|
// so the `001` hostmask and every downstream message uses the cloak.
|
|
118
118
|
applyCloakIfEnabled(state, ctx);
|
|
119
119
|
|
|
120
|
+
// Surface the transport's TLS fact as read-only user mode `S` so clients
|
|
121
|
+
// learn the connection is secure via `221` / `WHOIS`. The adapter sets
|
|
122
|
+
// `state.secure` from the platform's transport metadata at construction;
|
|
123
|
+
// the mode is not settable via `MODE` (the parser rejects `+S`/`-S`).
|
|
124
|
+
if (state.secure) {
|
|
125
|
+
state.userModes.tls = true;
|
|
126
|
+
}
|
|
127
|
+
|
|
120
128
|
state.registration = 'registered';
|
|
121
129
|
return [Effect.send(ctx.connId, buildWelcomeLines(state, ctx))];
|
|
122
130
|
}
|