@openclaw/crabline 0.1.9 → 0.1.11
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/README.md +47 -22
- package/assets/crabline-banner.svg +20 -0
- package/dist/src/bin/crabline.js.map +1 -1
- package/dist/src/cli/program.d.ts +25 -1
- package/dist/src/cli/program.js +579 -82
- package/dist/src/cli/program.js.map +1 -1
- package/dist/src/config/load.js +59 -6
- package/dist/src/config/load.js.map +1 -1
- package/dist/src/config/schema.d.ts +116 -92
- package/dist/src/config/schema.js +179 -73
- package/dist/src/config/schema.js.map +1 -1
- package/dist/src/core/errors.js +9 -2
- package/dist/src/core/errors.js.map +1 -1
- package/dist/src/core/exit-codes.js.map +1 -1
- package/dist/src/core/matcher.js +7 -6
- package/dist/src/core/matcher.js.map +1 -1
- package/dist/src/core/message-template.js.map +1 -1
- package/dist/src/core/nonces.d.ts +1 -0
- package/dist/src/core/nonces.js +4 -2
- package/dist/src/core/nonces.js.map +1 -1
- package/dist/src/core/reporters.d.ts +1 -0
- package/dist/src/core/reporters.js +42 -4
- package/dist/src/core/reporters.js.map +1 -1
- package/dist/src/core/run.d.ts +3 -0
- package/dist/src/core/run.js +415 -107
- package/dist/src/core/run.js.map +1 -1
- package/dist/src/core/safe-regex.d.ts +3 -0
- package/dist/src/core/safe-regex.js +21 -0
- package/dist/src/core/safe-regex.js.map +1 -0
- package/dist/src/index.d.ts +2 -2
- package/dist/src/index.js +1 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/openclaw/artifact-generation.d.ts +43 -0
- package/dist/src/openclaw/artifact-generation.js +363 -0
- package/dist/src/openclaw/artifact-generation.js.map +1 -0
- package/dist/src/openclaw/bridges/matrix.js +97 -12
- package/dist/src/openclaw/bridges/matrix.js.map +1 -1
- package/dist/src/openclaw/bridges/mattermost.js +32 -10
- package/dist/src/openclaw/bridges/mattermost.js.map +1 -1
- package/dist/src/openclaw/bridges/signal.js +55 -18
- package/dist/src/openclaw/bridges/signal.js.map +1 -1
- package/dist/src/openclaw/bridges/slack.js +85 -7
- package/dist/src/openclaw/bridges/slack.js.map +1 -1
- package/dist/src/openclaw/bridges/telegram.js +124 -30
- package/dist/src/openclaw/bridges/telegram.js.map +1 -1
- package/dist/src/openclaw/bridges/whatsapp.js +78 -17
- package/dist/src/openclaw/bridges/whatsapp.js.map +1 -1
- package/dist/src/openclaw/bridges/zalo.js +14 -5
- package/dist/src/openclaw/bridges/zalo.js.map +1 -1
- package/dist/src/openclaw/outbound-contract.d.ts +5 -0
- package/dist/src/openclaw/outbound-contract.js +40 -0
- package/dist/src/openclaw/outbound-contract.js.map +1 -0
- package/dist/src/openclaw/private-file.d.ts +28 -0
- package/dist/src/openclaw/private-file.js +465 -0
- package/dist/src/openclaw/private-file.js.map +1 -0
- package/dist/src/openclaw/shared.d.ts +21 -2
- package/dist/src/openclaw/shared.js +115 -18
- package/dist/src/openclaw/shared.js.map +1 -1
- package/dist/src/openclaw/smoke-lock.d.ts +63 -0
- package/dist/src/openclaw/smoke-lock.js +1004 -0
- package/dist/src/openclaw/smoke-lock.js.map +1 -0
- package/dist/src/openclaw.d.ts +16 -8
- package/dist/src/openclaw.js +334 -52
- package/dist/src/openclaw.js.map +1 -1
- package/dist/src/providers/builtin/discord.d.ts +10 -1
- package/dist/src/providers/builtin/discord.js +144 -18
- package/dist/src/providers/builtin/discord.js.map +1 -1
- package/dist/src/providers/builtin/external-webhook-auth.d.ts +11 -0
- package/dist/src/providers/builtin/external-webhook-auth.js +10 -0
- package/dist/src/providers/builtin/external-webhook-auth.js.map +1 -0
- package/dist/src/providers/builtin/feishu.d.ts +19 -2
- package/dist/src/providers/builtin/feishu.js +307 -25
- package/dist/src/providers/builtin/feishu.js.map +1 -1
- package/dist/src/providers/builtin/googlechat.d.ts +20 -1
- package/dist/src/providers/builtin/googlechat.js +178 -19
- package/dist/src/providers/builtin/googlechat.js.map +1 -1
- package/dist/src/providers/builtin/imessage.d.ts +4 -0
- package/dist/src/providers/builtin/imessage.js +26 -11
- package/dist/src/providers/builtin/imessage.js.map +1 -1
- package/dist/src/providers/builtin/loopback.js +92 -30
- package/dist/src/providers/builtin/loopback.js.map +1 -1
- package/dist/src/providers/builtin/matrix.d.ts +11 -1
- package/dist/src/providers/builtin/matrix.js +53 -29
- package/dist/src/providers/builtin/matrix.js.map +1 -1
- package/dist/src/providers/builtin/mattermost.d.ts +10 -0
- package/dist/src/providers/builtin/mattermost.js +38 -15
- package/dist/src/providers/builtin/mattermost.js.map +1 -1
- package/dist/src/providers/builtin/msteams.d.ts +17 -1
- package/dist/src/providers/builtin/msteams.js +113 -14
- package/dist/src/providers/builtin/msteams.js.map +1 -1
- package/dist/src/providers/builtin/native-local-mock.d.ts +2 -9
- package/dist/src/providers/builtin/native-local-mock.js +12 -35
- package/dist/src/providers/builtin/native-local-mock.js.map +1 -1
- package/dist/src/providers/builtin/script.d.ts +11 -15
- package/dist/src/providers/builtin/script.js +566 -104
- package/dist/src/providers/builtin/script.js.map +1 -1
- package/dist/src/providers/builtin/slack.d.ts +6 -0
- package/dist/src/providers/builtin/slack.js +99 -40
- package/dist/src/providers/builtin/slack.js.map +1 -1
- package/dist/src/providers/builtin/telegram.d.ts +6 -0
- package/dist/src/providers/builtin/telegram.js +64 -43
- package/dist/src/providers/builtin/telegram.js.map +1 -1
- package/dist/src/providers/builtin/whatsapp.d.ts +28 -8
- package/dist/src/providers/builtin/whatsapp.js +567 -30
- package/dist/src/providers/builtin/whatsapp.js.map +1 -1
- package/dist/src/providers/builtin/zalo.js +30 -19
- package/dist/src/providers/builtin/zalo.js.map +1 -1
- package/dist/src/providers/catalog.d.ts +3 -3
- package/dist/src/providers/catalog.js +3 -3
- package/dist/src/providers/catalog.js.map +1 -1
- package/dist/src/providers/local-mock.d.ts +11 -2
- package/dist/src/providers/local-mock.js +322 -96
- package/dist/src/providers/local-mock.js.map +1 -1
- package/dist/src/providers/native-ids.d.ts +1 -0
- package/dist/src/providers/native-ids.js +6 -1
- package/dist/src/providers/native-ids.js.map +1 -1
- package/dist/src/providers/recorder.d.ts +30 -2
- package/dist/src/providers/recorder.js +549 -70
- package/dist/src/providers/recorder.js.map +1 -1
- package/dist/src/providers/registry.d.ts +25 -1
- package/dist/src/providers/registry.js +280 -40
- package/dist/src/providers/registry.js.map +1 -1
- package/dist/src/providers/signed-jwt.d.ts +29 -0
- package/dist/src/providers/signed-jwt.js +202 -0
- package/dist/src/providers/signed-jwt.js.map +1 -0
- package/dist/src/providers/slack-ids.js.map +1 -1
- package/dist/src/providers/target-normalizers.d.ts +26 -0
- package/dist/src/providers/target-normalizers.js +276 -0
- package/dist/src/providers/target-normalizers.js.map +1 -0
- package/dist/src/providers/types.d.ts +5 -0
- package/dist/src/providers/types.js.map +1 -1
- package/dist/src/providers/webhook-server.d.ts +2 -0
- package/dist/src/providers/webhook-server.js +72 -25
- package/dist/src/providers/webhook-server.js.map +1 -1
- package/dist/src/servers/http.d.ts +23 -3
- package/dist/src/servers/http.js +213 -20
- package/dist/src/servers/http.js.map +1 -1
- package/dist/src/servers/index.js +20 -21
- package/dist/src/servers/index.js.map +1 -1
- package/dist/src/servers/matrix.js +465 -51
- package/dist/src/servers/matrix.js.map +1 -1
- package/dist/src/servers/mattermost.d.ts +5 -0
- package/dist/src/servers/mattermost.js +306 -58
- package/dist/src/servers/mattermost.js.map +1 -1
- package/dist/src/servers/pending-events.d.ts +2 -0
- package/dist/src/servers/pending-events.js +11 -0
- package/dist/src/servers/pending-events.js.map +1 -0
- package/dist/src/servers/recorder.d.ts +5 -0
- package/dist/src/servers/recorder.js +262 -3
- package/dist/src/servers/recorder.js.map +1 -1
- package/dist/src/servers/signal.d.ts +2 -0
- package/dist/src/servers/signal.js +444 -56
- package/dist/src/servers/signal.js.map +1 -1
- package/dist/src/servers/slack.d.ts +5 -0
- package/dist/src/servers/slack.js +515 -49
- package/dist/src/servers/slack.js.map +1 -1
- package/dist/src/servers/telegram.d.ts +111 -0
- package/dist/src/servers/telegram.js +830 -158
- package/dist/src/servers/telegram.js.map +1 -1
- package/dist/src/servers/webhook-target.d.ts +40 -0
- package/dist/src/servers/webhook-target.js +349 -0
- package/dist/src/servers/webhook-target.js.map +1 -0
- package/dist/src/servers/websocket.d.ts +2 -0
- package/dist/src/servers/websocket.js +29 -0
- package/dist/src/servers/websocket.js.map +1 -0
- package/dist/src/servers/whatsapp-baileys-websocket.d.ts +65 -1
- package/dist/src/servers/whatsapp-baileys-websocket.js +723 -99
- package/dist/src/servers/whatsapp-baileys-websocket.js.map +1 -1
- package/dist/src/servers/whatsapp-jid.d.ts +5 -0
- package/dist/src/servers/whatsapp-jid.js +32 -0
- package/dist/src/servers/whatsapp-jid.js.map +1 -0
- package/dist/src/servers/whatsapp-wire/binary-node.d.ts +5 -0
- package/dist/src/servers/whatsapp-wire/binary-node.js +87 -20
- package/dist/src/servers/whatsapp-wire/binary-node.js.map +1 -1
- package/dist/src/servers/whatsapp-wire/crypto.d.ts +6 -1
- package/dist/src/servers/whatsapp-wire/crypto.js +99 -45
- package/dist/src/servers/whatsapp-wire/crypto.js.map +1 -1
- package/dist/src/servers/whatsapp-wire/handshake.js +39 -4
- package/dist/src/servers/whatsapp-wire/handshake.js.map +1 -1
- package/dist/src/servers/whatsapp-wire/signal.js.map +1 -1
- package/dist/src/servers/whatsapp.d.ts +12 -7
- package/dist/src/servers/whatsapp.js +269 -107
- package/dist/src/servers/whatsapp.js.map +1 -1
- package/dist/src/servers/zalo.d.ts +14 -0
- package/dist/src/servers/zalo.js +521 -88
- package/dist/src/servers/zalo.js.map +1 -1
- package/docs/channel-setup.md +197 -19
- package/fixtures/examples/crabline.example.yaml +10 -0
- package/package.json +20 -15
|
@@ -1,37 +1,242 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
2
|
import { createServer } from "node:http";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import { adminAuthError, closeServer, hasAdminToken, jsonResponse,
|
|
5
|
-
import { recordServerEvent } from "./recorder.js";
|
|
4
|
+
import { adminAuthError, closeServer, drainRequestBody, hasAdminToken, InvalidJsonBodyError, isJsonObject, jsonResponse, parseUnknownRequestBody, queryRecord, readInteger, readTrimmedString, RequestBodyTooLargeError, writeResponse, } from "./http.js";
|
|
5
|
+
import { recordCommittedServerEvent, recordServerEvent, } from "./recorder.js";
|
|
6
|
+
import { resolveMaxPendingInboundEvents } from "./pending-events.js";
|
|
6
7
|
const SIGNAL_CLI_SSE_KEEPALIVE_MS = 15_000;
|
|
7
|
-
|
|
8
|
-
|
|
8
|
+
const DEFAULT_MAX_SIGNAL_SSE_CLIENTS = 32;
|
|
9
|
+
const MAX_SIGNAL_SSE_BUFFER_BYTES = 2 * 1024 * 1024;
|
|
10
|
+
async function appendEvent(state, event, committed = false) {
|
|
11
|
+
const params = { event, onEvent: state.onEvent, recorderPath: state.recorderPath };
|
|
12
|
+
await (committed ? recordCommittedServerEvent(params) : recordServerEvent(params));
|
|
9
13
|
}
|
|
10
14
|
function rpcResponse(id, result) {
|
|
11
15
|
return jsonResponse({ id: id ?? null, jsonrpc: "2.0", result });
|
|
12
16
|
}
|
|
13
|
-
function
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
+
function rpcError(code, message, id = null, status = 400) {
|
|
18
|
+
return jsonResponse({
|
|
19
|
+
error: { code, message },
|
|
20
|
+
id: id ?? null,
|
|
21
|
+
jsonrpc: "2.0",
|
|
22
|
+
}, status);
|
|
23
|
+
}
|
|
24
|
+
function removeSignalClient(state, client, destroy) {
|
|
25
|
+
const buffer = state.clientBuffers.get(client);
|
|
26
|
+
state.clients.delete(client);
|
|
27
|
+
state.clientBuffers.delete(client);
|
|
28
|
+
if (buffer) {
|
|
29
|
+
const undeliveredEvents = [...buffer.inFlight, ...buffer.events].filter((event) => {
|
|
30
|
+
event.recipients?.delete(client);
|
|
31
|
+
return event.sequence !== undefined && event.recipients?.size === 0;
|
|
32
|
+
});
|
|
33
|
+
const restoredEvents = undeliveredEvents.filter((event) => !state.pendingEvents.includes(event));
|
|
34
|
+
if (restoredEvents.length > 0) {
|
|
35
|
+
state.pendingEvents.push(...restoredEvents);
|
|
36
|
+
state.pendingEvents.sort((left, right) => (left.sequence ?? Number.MAX_SAFE_INTEGER) - (right.sequence ?? 0));
|
|
37
|
+
state.pendingEventBytes += restoredEvents.reduce((total, event) => total + Buffer.byteLength(event.data), 0);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (destroy) {
|
|
41
|
+
client.destroy();
|
|
42
|
+
}
|
|
43
|
+
if (state.pendingEvents.length > 0 && state.clients.size > 0) {
|
|
44
|
+
queueMicrotask(() => flushPendingSignalEvents(state));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function evictSignalClient(state, client) {
|
|
48
|
+
removeSignalClient(state, client, true);
|
|
49
|
+
}
|
|
50
|
+
function scheduleSignalDrain(state, client) {
|
|
51
|
+
const buffer = state.clientBuffers.get(client);
|
|
52
|
+
if (!buffer || buffer.draining || client.destroyed || client.writableEnded) {
|
|
17
53
|
return;
|
|
18
54
|
}
|
|
55
|
+
buffer.draining = true;
|
|
56
|
+
client.once("drain", () => {
|
|
57
|
+
const current = state.clientBuffers.get(client);
|
|
58
|
+
if (current) {
|
|
59
|
+
current.bytes -= current.inFlight.reduce((total, event) => total + Buffer.byteLength(event.data), 0);
|
|
60
|
+
current.inFlight.length = 0;
|
|
61
|
+
current.draining = false;
|
|
62
|
+
flushSignalClientEvents(state, client);
|
|
63
|
+
const remaining = state.clientBuffers.get(client);
|
|
64
|
+
if (remaining && remaining.events.length === 0 && !client.writableNeedDrain) {
|
|
65
|
+
flushPendingSignalEvents(state);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
function queueSignalClientEvent(state, client, event) {
|
|
71
|
+
const buffer = state.clientBuffers.get(client);
|
|
72
|
+
const eventBytes = Buffer.byteLength(event.data);
|
|
73
|
+
const alreadyBuffered = isSignalEventBuffered(state, event);
|
|
74
|
+
const pendingEventBytes = state.pendingEvents.includes(event) ? 0 : eventBytes;
|
|
75
|
+
if (!buffer ||
|
|
76
|
+
(!alreadyBuffered && signalBufferedEventCount(state) >= state.maxPendingInboundEvents) ||
|
|
77
|
+
state.pendingEventBytes + buffer.bytes + pendingEventBytes > MAX_SIGNAL_SSE_BUFFER_BYTES) {
|
|
78
|
+
evictSignalClient(state, client);
|
|
79
|
+
return "rejected";
|
|
80
|
+
}
|
|
81
|
+
buffer.events.push(event);
|
|
82
|
+
buffer.bytes += eventBytes;
|
|
83
|
+
event.recipients?.add(client);
|
|
84
|
+
scheduleSignalDrain(state, client);
|
|
85
|
+
return "queued";
|
|
86
|
+
}
|
|
87
|
+
function writeSignalSse(state, client, event) {
|
|
88
|
+
if (client.destroyed) {
|
|
89
|
+
removeSignalClient(state, client, false);
|
|
90
|
+
return "rejected";
|
|
91
|
+
}
|
|
92
|
+
if (client.writableEnded) {
|
|
93
|
+
evictSignalClient(state, client);
|
|
94
|
+
return "rejected";
|
|
95
|
+
}
|
|
96
|
+
const eventBytes = Buffer.byteLength(event.data);
|
|
97
|
+
if (eventBytes > MAX_SIGNAL_SSE_BUFFER_BYTES) {
|
|
98
|
+
return "rejected";
|
|
99
|
+
}
|
|
100
|
+
const buffer = state.clientBuffers.get(client);
|
|
101
|
+
if (client.writableNeedDrain || buffer?.draining || buffer?.events.length) {
|
|
102
|
+
return queueSignalClientEvent(state, client, event);
|
|
103
|
+
}
|
|
104
|
+
if (client.writableLength + eventBytes > MAX_SIGNAL_SSE_BUFFER_BYTES) {
|
|
105
|
+
evictSignalClient(state, client);
|
|
106
|
+
return "rejected";
|
|
107
|
+
}
|
|
108
|
+
const accepted = client.write(event.data);
|
|
109
|
+
event.recipients?.add(client);
|
|
110
|
+
if (!accepted) {
|
|
111
|
+
if (buffer) {
|
|
112
|
+
buffer.inFlight.push(event);
|
|
113
|
+
buffer.bytes += eventBytes;
|
|
114
|
+
}
|
|
115
|
+
scheduleSignalDrain(state, client);
|
|
116
|
+
}
|
|
117
|
+
return "accepted";
|
|
118
|
+
}
|
|
119
|
+
function maxSignalClientBufferBytes(state) {
|
|
120
|
+
let maxBytes = 0;
|
|
121
|
+
for (const buffer of state.clientBuffers.values()) {
|
|
122
|
+
maxBytes = Math.max(maxBytes, buffer.bytes);
|
|
123
|
+
}
|
|
124
|
+
return maxBytes;
|
|
125
|
+
}
|
|
126
|
+
function isSignalEventBuffered(state, event) {
|
|
127
|
+
return (state.pendingEvents.includes(event) ||
|
|
128
|
+
[...state.clientBuffers.values()].some((buffer) => buffer.inFlight.includes(event) || buffer.events.includes(event)));
|
|
129
|
+
}
|
|
130
|
+
function signalBufferedEventCount(state) {
|
|
131
|
+
const sequences = new Set();
|
|
132
|
+
for (const event of state.pendingEvents) {
|
|
133
|
+
if (event.sequence !== undefined) {
|
|
134
|
+
sequences.add(event.sequence);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
for (const buffer of state.clientBuffers.values()) {
|
|
138
|
+
for (const event of [...buffer.inFlight, ...buffer.events]) {
|
|
139
|
+
if (event.sequence !== undefined) {
|
|
140
|
+
sequences.add(event.sequence);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return sequences.size;
|
|
145
|
+
}
|
|
146
|
+
function queueSignalEvent(state, event) {
|
|
147
|
+
const eventBytes = Buffer.byteLength(event.data);
|
|
148
|
+
if (eventBytes > MAX_SIGNAL_SSE_BUFFER_BYTES ||
|
|
149
|
+
state.pendingEventBytes + maxSignalClientBufferBytes(state) + eventBytes >
|
|
150
|
+
MAX_SIGNAL_SSE_BUFFER_BYTES ||
|
|
151
|
+
(!isSignalEventBuffered(state, event) &&
|
|
152
|
+
signalBufferedEventCount(state) >= state.maxPendingInboundEvents)) {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
state.pendingEvents.push(event);
|
|
156
|
+
state.pendingEventBytes += eventBytes;
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
function replayExclusiveSignalEvents(state, client) {
|
|
160
|
+
const events = new Map();
|
|
161
|
+
for (const [owner, buffer] of state.clientBuffers) {
|
|
162
|
+
if (owner === client) {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
for (const event of [...buffer.inFlight, ...buffer.events]) {
|
|
166
|
+
if (event.sequence !== undefined &&
|
|
167
|
+
event.recipients?.size === 1 &&
|
|
168
|
+
event.recipients.has(owner)) {
|
|
169
|
+
events.set(event.sequence, event);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
for (const event of [...events.values()].sort((left, right) => left.sequence - right.sequence)) {
|
|
174
|
+
if (writeSignalSse(state, client, event) === "rejected") {
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
function flushSignalClientEvents(state, client) {
|
|
180
|
+
while (!client.writableNeedDrain) {
|
|
181
|
+
const buffer = state.clientBuffers.get(client);
|
|
182
|
+
if (!buffer || buffer.events.length === 0) {
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
const event = buffer.events.shift();
|
|
186
|
+
if (!client.write(event.data)) {
|
|
187
|
+
buffer.inFlight.push(event);
|
|
188
|
+
scheduleSignalDrain(state, client);
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
buffer.bytes -= Buffer.byteLength(event.data);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
function flushPendingSignalEvents(state) {
|
|
195
|
+
while (state.pendingEvents.length > 0 && state.clients.size > 0) {
|
|
196
|
+
const event = state.pendingEvents[0];
|
|
197
|
+
for (const client of [...state.clients]) {
|
|
198
|
+
if (!event.recipients?.has(client)) {
|
|
199
|
+
writeSignalSse(state, client, event);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (state.clients.size === 0 ||
|
|
203
|
+
[...state.clients].some((client) => !event.recipients?.has(client))) {
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
state.pendingEvents.shift();
|
|
207
|
+
state.pendingEventBytes -= Buffer.byteLength(event.data);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
function emitSignalEvent(state, payload) {
|
|
211
|
+
const event = {
|
|
212
|
+
data: `event:receive\ndata:${JSON.stringify(payload)}\n\n`,
|
|
213
|
+
recipients: new Set(),
|
|
214
|
+
sequence: state.nextEventSequence++,
|
|
215
|
+
};
|
|
216
|
+
if (state.clients.size === 0 || state.pendingEvents.length > 0) {
|
|
217
|
+
return queueSignalEvent(state, event);
|
|
218
|
+
}
|
|
219
|
+
let delivered = false;
|
|
19
220
|
for (const client of state.clients) {
|
|
20
|
-
client
|
|
221
|
+
const result = writeSignalSse(state, client, event);
|
|
222
|
+
delivered = result === "accepted" || result === "queued" || delivered;
|
|
21
223
|
}
|
|
224
|
+
return delivered || queueSignalEvent(state, event);
|
|
22
225
|
}
|
|
23
226
|
async function handleAdminInbound(params) {
|
|
24
|
-
const text =
|
|
227
|
+
const text = typeof params.body.text === "string" ? params.body.text : undefined;
|
|
25
228
|
const sourceNumber = readTrimmedString(params.body.sourceNumber ?? params.body.senderId);
|
|
26
|
-
|
|
27
|
-
|
|
229
|
+
const sourceUuid = readTrimmedString(params.body.sourceUuid);
|
|
230
|
+
if (!text || text.trim().length === 0 || (!sourceNumber && !sourceUuid)) {
|
|
231
|
+
return jsonResponse({ error: "text and at least one source identity are required", ok: false }, 400);
|
|
28
232
|
}
|
|
29
233
|
const timestamp = readInteger(params.body.timestamp) ?? params.state.nextTimestamp++;
|
|
30
234
|
const groupId = readTrimmedString(params.body.groupId);
|
|
31
235
|
const payload = {
|
|
32
236
|
envelope: {
|
|
33
237
|
sourceName: readTrimmedString(params.body.sourceName ?? params.body.senderName),
|
|
34
|
-
sourceNumber,
|
|
238
|
+
...(sourceNumber ? { sourceNumber } : {}),
|
|
239
|
+
...(sourceUuid ? { sourceUuid } : {}),
|
|
35
240
|
timestamp,
|
|
36
241
|
dataMessage: {
|
|
37
242
|
message: text,
|
|
@@ -40,61 +245,207 @@ async function handleAdminInbound(params) {
|
|
|
40
245
|
},
|
|
41
246
|
},
|
|
42
247
|
};
|
|
43
|
-
emitSignalEvent(params.state, payload)
|
|
248
|
+
if (!emitSignalEvent(params.state, payload)) {
|
|
249
|
+
return jsonResponse({
|
|
250
|
+
error: `Pending inbound queue is full (${params.state.maxPendingInboundEvents} events)`,
|
|
251
|
+
ok: false,
|
|
252
|
+
}, 503);
|
|
253
|
+
}
|
|
44
254
|
return jsonResponse({ event: payload, ok: true });
|
|
45
255
|
}
|
|
46
256
|
async function handleRpc(params) {
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
257
|
+
const hasId = Object.hasOwn(params.body, "id");
|
|
258
|
+
const id = params.body.id;
|
|
259
|
+
const validId = !hasId ||
|
|
260
|
+
id === null ||
|
|
261
|
+
typeof id === "string" ||
|
|
262
|
+
(typeof id === "number" && Number.isFinite(id));
|
|
263
|
+
const method = params.body.method;
|
|
264
|
+
if (typeof method !== "string" || !validId) {
|
|
265
|
+
return {
|
|
266
|
+
accepted: false,
|
|
267
|
+
record: false,
|
|
268
|
+
response: rpcError(-32600, "Invalid Request", validId ? id : null),
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
if (params.body.jsonrpc !== "2.0") {
|
|
272
|
+
return {
|
|
273
|
+
accepted: false,
|
|
274
|
+
record: false,
|
|
275
|
+
response: rpcError(-32600, "Invalid Request", id),
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
const notification = !hasId;
|
|
279
|
+
if (params.body.params !== undefined &&
|
|
280
|
+
!Array.isArray(params.body.params) &&
|
|
281
|
+
!isJsonObject(params.body.params)) {
|
|
282
|
+
return {
|
|
283
|
+
accepted: false,
|
|
284
|
+
record: false,
|
|
285
|
+
response: rpcError(-32600, "Invalid Request", id),
|
|
286
|
+
};
|
|
54
287
|
}
|
|
55
288
|
if (method === "version") {
|
|
56
|
-
return
|
|
289
|
+
return {
|
|
290
|
+
accepted: false,
|
|
291
|
+
record: true,
|
|
292
|
+
response: notification
|
|
293
|
+
? new Response(null, { status: 204 })
|
|
294
|
+
: rpcResponse(id, { version: "crabline-signal-1" }),
|
|
295
|
+
};
|
|
57
296
|
}
|
|
58
297
|
if (["send", "sendReaction", "sendReceipt", "sendTyping"].includes(method)) {
|
|
298
|
+
if (!validSignalRpcParams(method, params.body.params)) {
|
|
299
|
+
return {
|
|
300
|
+
accepted: false,
|
|
301
|
+
record: false,
|
|
302
|
+
response: notification
|
|
303
|
+
? new Response(null, { status: 204 })
|
|
304
|
+
: rpcError(-32602, "Invalid params", id),
|
|
305
|
+
};
|
|
306
|
+
}
|
|
59
307
|
const timestamp = params.state.nextTimestamp++;
|
|
60
|
-
return
|
|
308
|
+
return {
|
|
309
|
+
accepted: true,
|
|
310
|
+
record: true,
|
|
311
|
+
response: notification
|
|
312
|
+
? new Response(null, { status: 204 })
|
|
313
|
+
: rpcResponse(id, method === "sendTyping" ? {} : { timestamp }),
|
|
314
|
+
};
|
|
61
315
|
}
|
|
62
|
-
return
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
316
|
+
return {
|
|
317
|
+
accepted: false,
|
|
318
|
+
record: false,
|
|
319
|
+
response: notification
|
|
320
|
+
? new Response(null, { status: 204 })
|
|
321
|
+
: jsonResponse({
|
|
322
|
+
error: { code: -32601, message: `Method not found: ${method}` },
|
|
323
|
+
id: id ?? null,
|
|
324
|
+
jsonrpc: "2.0",
|
|
325
|
+
}),
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
function hasRecipients(params) {
|
|
329
|
+
const values = [
|
|
330
|
+
params.recipient,
|
|
331
|
+
params.recipients,
|
|
332
|
+
params.groupId,
|
|
333
|
+
params.groupIds,
|
|
334
|
+
params.username,
|
|
335
|
+
params.usernames,
|
|
336
|
+
];
|
|
337
|
+
return (params.noteToSelf === true ||
|
|
338
|
+
values.some((value) => (typeof value === "string" && value.trim().length > 0) ||
|
|
339
|
+
(Array.isArray(value) &&
|
|
340
|
+
value.length > 0 &&
|
|
341
|
+
value.every((entry) => typeof entry === "string" && entry.trim().length > 0))));
|
|
342
|
+
}
|
|
343
|
+
function hasTypingRecipients(params) {
|
|
344
|
+
if (params.noteToSelf !== undefined ||
|
|
345
|
+
params.username !== undefined ||
|
|
346
|
+
params.usernames !== undefined ||
|
|
347
|
+
(params.stop !== undefined && typeof params.stop !== "boolean")) {
|
|
348
|
+
return false;
|
|
349
|
+
}
|
|
350
|
+
return [params.recipient, params.recipients, params.groupId, params.groupIds].some((value) => (typeof value === "string" && value.trim().length > 0) ||
|
|
351
|
+
(Array.isArray(value) &&
|
|
352
|
+
value.length > 0 &&
|
|
353
|
+
value.every((entry) => typeof entry === "string" && entry.trim().length > 0)));
|
|
354
|
+
}
|
|
355
|
+
function validTimestamp(value) {
|
|
356
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
357
|
+
}
|
|
358
|
+
function hasStringArray(value) {
|
|
359
|
+
return (Array.isArray(value) &&
|
|
360
|
+
value.length > 0 &&
|
|
361
|
+
value.every((entry) => typeof entry === "string" && entry.trim().length > 0));
|
|
362
|
+
}
|
|
363
|
+
function validSignalRpcParams(method, value) {
|
|
364
|
+
if (!isJsonObject(value)) {
|
|
365
|
+
return false;
|
|
366
|
+
}
|
|
367
|
+
if (method === "send") {
|
|
368
|
+
return (hasRecipients(value) &&
|
|
369
|
+
(readTrimmedString(value.message) !== undefined ||
|
|
370
|
+
readTrimmedString(value.attachment) !== undefined ||
|
|
371
|
+
hasStringArray(value.attachments)));
|
|
372
|
+
}
|
|
373
|
+
if (method === "sendReaction") {
|
|
374
|
+
return (hasRecipients(value) &&
|
|
375
|
+
readTrimmedString(value.emoji) !== undefined &&
|
|
376
|
+
readTrimmedString(value.targetAuthor) !== undefined &&
|
|
377
|
+
validTimestamp(value.targetTimestamp));
|
|
378
|
+
}
|
|
379
|
+
if (method === "sendReceipt") {
|
|
380
|
+
const targetTimestamps = value.targetTimestamps ?? value.targetTimestamp;
|
|
381
|
+
const timestamps = Array.isArray(targetTimestamps) ? targetTimestamps : [targetTimestamps];
|
|
382
|
+
return ((readTrimmedString(value.recipient) !== undefined ||
|
|
383
|
+
readTrimmedString(value.username) !== undefined ||
|
|
384
|
+
hasStringArray(value.usernames)) &&
|
|
385
|
+
timestamps.length > 0 &&
|
|
386
|
+
timestamps.every(validTimestamp) &&
|
|
387
|
+
(value.type === undefined || value.type === "read" || value.type === "viewed"));
|
|
388
|
+
}
|
|
389
|
+
return method === "sendTyping" && hasTypingRecipients(value);
|
|
67
390
|
}
|
|
68
391
|
async function handleRequest(params) {
|
|
69
392
|
const url = new URL(params.request.url ?? "/", "http://localhost");
|
|
70
393
|
if (url.pathname === "/api/v1/events" && params.request.method === "GET") {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
394
|
+
if (params.state.clients.size + params.state.pendingSseClients >= params.state.maxSseClients) {
|
|
395
|
+
await writeResponse(params.response, jsonResponse({ error: "Too many event stream clients", ok: false }, 503));
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
params.state.pendingSseClients += 1;
|
|
399
|
+
try {
|
|
400
|
+
await appendEvent(params.state, {
|
|
401
|
+
at: new Date().toISOString(),
|
|
402
|
+
method: "GET",
|
|
403
|
+
path: url.pathname,
|
|
404
|
+
query: queryRecord(url),
|
|
405
|
+
type: "api",
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
finally {
|
|
409
|
+
params.state.pendingSseClients -= 1;
|
|
410
|
+
}
|
|
411
|
+
if (params.request.destroyed || params.response.destroyed) {
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
78
414
|
params.response.writeHead(200, {
|
|
79
415
|
"cache-control": "no-cache",
|
|
80
416
|
connection: "keep-alive",
|
|
81
417
|
"content-type": "text/event-stream",
|
|
82
418
|
});
|
|
83
419
|
params.response.flushHeaders();
|
|
420
|
+
params.response.once("close", () => {
|
|
421
|
+
removeSignalClient(params.state, params.response, false);
|
|
422
|
+
});
|
|
84
423
|
params.state.clients.add(params.response);
|
|
85
|
-
|
|
86
|
-
|
|
424
|
+
params.state.clientBuffers.set(params.response, {
|
|
425
|
+
bytes: 0,
|
|
426
|
+
draining: false,
|
|
427
|
+
events: [],
|
|
428
|
+
inFlight: [],
|
|
429
|
+
});
|
|
430
|
+
replayExclusiveSignalEvents(params.state, params.response);
|
|
431
|
+
if (params.state.clients.has(params.response)) {
|
|
432
|
+
flushPendingSignalEvents(params.state);
|
|
87
433
|
}
|
|
88
|
-
params.response.once("close", () => params.state.clients.delete(params.response));
|
|
89
434
|
return;
|
|
90
435
|
}
|
|
91
436
|
let fetchResponse;
|
|
92
437
|
if (url.pathname === "/crabline/signal/inbound" && params.request.method === "POST") {
|
|
93
438
|
if (!hasAdminToken(params.request, params.state.adminToken)) {
|
|
439
|
+
drainRequestBody(params.request);
|
|
94
440
|
fetchResponse = adminAuthError();
|
|
95
441
|
}
|
|
96
442
|
else {
|
|
97
|
-
const body = await
|
|
443
|
+
const body = await parseUnknownRequestBody(params.request);
|
|
444
|
+
if (!isJsonObject(body)) {
|
|
445
|
+
fetchResponse = jsonResponse({ error: "Request body must be a JSON object", ok: false }, 400);
|
|
446
|
+
await writeResponse(params.response, fetchResponse);
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
98
449
|
await appendEvent(params.state, {
|
|
99
450
|
at: new Date().toISOString(),
|
|
100
451
|
body,
|
|
@@ -117,16 +468,25 @@ async function handleRequest(params) {
|
|
|
117
468
|
fetchResponse = new Response(null, { status: 200 });
|
|
118
469
|
}
|
|
119
470
|
else if (url.pathname === "/api/v1/rpc" && params.request.method === "POST") {
|
|
120
|
-
const body = await
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
471
|
+
const body = await parseUnknownRequestBody(params.request);
|
|
472
|
+
if (!isJsonObject(body)) {
|
|
473
|
+
await writeResponse(params.response, rpcError(-32600, "Invalid Request"));
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
const rpc = await handleRpc({ body, state: params.state });
|
|
477
|
+
if (rpc.record) {
|
|
478
|
+
const event = {
|
|
479
|
+
accepted: rpc.accepted,
|
|
480
|
+
at: new Date().toISOString(),
|
|
481
|
+
body,
|
|
482
|
+
method: "POST",
|
|
483
|
+
path: url.pathname,
|
|
484
|
+
query: queryRecord(url),
|
|
485
|
+
type: "api",
|
|
486
|
+
};
|
|
487
|
+
await appendEvent(params.state, event, rpc.accepted || rpc.response.status === 204);
|
|
488
|
+
}
|
|
489
|
+
fetchResponse = rpc.response;
|
|
130
490
|
}
|
|
131
491
|
else {
|
|
132
492
|
fetchResponse = new Response("not found", { status: 404 });
|
|
@@ -138,28 +498,50 @@ export async function startSignalServer(params = {}) {
|
|
|
138
498
|
account: params.account ?? "+15550000000",
|
|
139
499
|
adminToken: params.adminToken ?? randomBytes(24).toString("base64url"),
|
|
140
500
|
clients: new Set(),
|
|
501
|
+
clientBuffers: new Map(),
|
|
502
|
+
maxPendingInboundEvents: resolveMaxPendingInboundEvents(params.maxPendingInboundEvents),
|
|
503
|
+
maxSseClients: Number.isSafeInteger(params.maxSseClients) && (params.maxSseClients ?? 0) > 0
|
|
504
|
+
? params.maxSseClients
|
|
505
|
+
: DEFAULT_MAX_SIGNAL_SSE_CLIENTS,
|
|
506
|
+
nextEventSequence: 1,
|
|
141
507
|
nextTimestamp: Date.now(),
|
|
142
508
|
onEvent: params.onEvent,
|
|
509
|
+
pendingEventBytes: 0,
|
|
143
510
|
pendingEvents: [],
|
|
511
|
+
pendingSseClients: 0,
|
|
144
512
|
recorderPath: params.recorderPath ?? path.resolve(".crabline", "servers", "signal.jsonl"),
|
|
145
513
|
};
|
|
146
514
|
const host = params.host ?? "127.0.0.1";
|
|
147
515
|
const server = createServer((request, response) => {
|
|
148
516
|
void handleRequest({ request, response, state }).catch(async (error) => {
|
|
149
517
|
if (!response.headersSent) {
|
|
150
|
-
|
|
518
|
+
let errorResponse;
|
|
519
|
+
const isAdminRequest = new URL(request.url ?? "/", "http://localhost").pathname === "/crabline/signal/inbound";
|
|
520
|
+
if (error instanceof InvalidJsonBodyError) {
|
|
521
|
+
errorResponse = isAdminRequest
|
|
522
|
+
? jsonResponse({ error: "Request body is not valid JSON", ok: false }, 400)
|
|
523
|
+
: rpcError(-32700, "Parse error");
|
|
524
|
+
}
|
|
525
|
+
else if (error instanceof RequestBodyTooLargeError) {
|
|
526
|
+
errorResponse = isAdminRequest
|
|
527
|
+
? jsonResponse({ error: "Request body is too large", ok: false }, 413)
|
|
528
|
+
: rpcError(-32600, "Request body is too large", null, 413);
|
|
529
|
+
}
|
|
530
|
+
else {
|
|
531
|
+
errorResponse = jsonResponse({ error: "internal server error", ok: false }, 500);
|
|
532
|
+
}
|
|
533
|
+
try {
|
|
534
|
+
await writeResponse(response, errorResponse);
|
|
535
|
+
}
|
|
536
|
+
catch (writeError) {
|
|
537
|
+
response.destroy(writeError instanceof Error ? writeError : new Error(String(writeError)));
|
|
538
|
+
}
|
|
151
539
|
}
|
|
152
540
|
else {
|
|
153
541
|
response.destroy(error instanceof Error ? error : new Error(String(error)));
|
|
154
542
|
}
|
|
155
543
|
});
|
|
156
544
|
});
|
|
157
|
-
const keepalive = setInterval(() => {
|
|
158
|
-
for (const client of state.clients) {
|
|
159
|
-
client.write(":\n");
|
|
160
|
-
}
|
|
161
|
-
}, SIGNAL_CLI_SSE_KEEPALIVE_MS);
|
|
162
|
-
keepalive.unref();
|
|
163
545
|
await new Promise((resolve, reject) => {
|
|
164
546
|
server.once("error", reject);
|
|
165
547
|
server.listen(params.port ?? 0, host, () => {
|
|
@@ -172,6 +554,12 @@ export async function startSignalServer(params = {}) {
|
|
|
172
554
|
await closeServer(server);
|
|
173
555
|
throw new Error("Unable to resolve Signal local server address.");
|
|
174
556
|
}
|
|
557
|
+
const keepalive = setInterval(() => {
|
|
558
|
+
for (const client of state.clients) {
|
|
559
|
+
writeSignalSse(state, client, { data: ":\n" });
|
|
560
|
+
}
|
|
561
|
+
}, SIGNAL_CLI_SSE_KEEPALIVE_MS);
|
|
562
|
+
keepalive.unref();
|
|
175
563
|
const baseUrl = `http://${host.includes(":") ? `[${host}]` : host}:${address.port}`;
|
|
176
564
|
return {
|
|
177
565
|
async close() {
|