@openclaw/crabline 0.1.10 → 0.1.12
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 +99 -12
- package/dist/src/bin/crabline.js +3 -1
- package/dist/src/bin/crabline.js.map +1 -1
- package/dist/src/cli/program.d.ts +5 -1
- package/dist/src/cli/program.js +264 -50
- package/dist/src/cli/program.js.map +1 -1
- package/dist/src/config/load.js +39 -3
- package/dist/src/config/load.js.map +1 -1
- package/dist/src/config/schema.d.ts +20 -0
- package/dist/src/config/schema.js +163 -35
- package/dist/src/config/schema.js.map +1 -1
- package/dist/src/core/errors.d.ts +1 -0
- package/dist/src/core/errors.js +6 -3
- package/dist/src/core/errors.js.map +1 -1
- package/dist/src/core/http-path.d.ts +1 -0
- package/dist/src/core/http-path.js +14 -0
- package/dist/src/core/http-path.js.map +1 -0
- package/dist/src/core/matcher.d.ts +3 -1
- package/dist/src/core/matcher.js +58 -2
- package/dist/src/core/matcher.js.map +1 -1
- package/dist/src/core/nonces.d.ts +2 -0
- package/dist/src/core/nonces.js +11 -1
- package/dist/src/core/nonces.js.map +1 -1
- package/dist/src/core/reporters.d.ts +5 -0
- package/dist/src/core/reporters.js +26 -2
- package/dist/src/core/reporters.js.map +1 -1
- package/dist/src/core/run.d.ts +3 -1
- package/dist/src/core/run.js +178 -66
- package/dist/src/core/run.js.map +1 -1
- package/dist/src/matrix-ids.d.ts +5 -0
- package/dist/src/matrix-ids.js +83 -0
- package/dist/src/matrix-ids.js.map +1 -0
- package/dist/src/openclaw/artifact-generation.d.ts +12 -2
- package/dist/src/openclaw/artifact-generation.js +436 -15
- package/dist/src/openclaw/artifact-generation.js.map +1 -1
- package/dist/src/openclaw/bridges/matrix.js +21 -4
- package/dist/src/openclaw/bridges/matrix.js.map +1 -1
- package/dist/src/openclaw/bridges/mattermost.js +13 -8
- package/dist/src/openclaw/bridges/mattermost.js.map +1 -1
- package/dist/src/openclaw/bridges/probe-response.d.ts +1 -0
- package/dist/src/openclaw/bridges/probe-response.js +5 -0
- package/dist/src/openclaw/bridges/probe-response.js.map +1 -0
- package/dist/src/openclaw/bridges/signal.js +15 -6
- package/dist/src/openclaw/bridges/signal.js.map +1 -1
- package/dist/src/openclaw/bridges/slack.js +116 -23
- package/dist/src/openclaw/bridges/slack.js.map +1 -1
- package/dist/src/openclaw/bridges/telegram.js +45 -19
- package/dist/src/openclaw/bridges/telegram.js.map +1 -1
- package/dist/src/openclaw/bridges/whatsapp.js +32 -16
- package/dist/src/openclaw/bridges/whatsapp.js.map +1 -1
- package/dist/src/openclaw/bridges/zalo.js +8 -3
- package/dist/src/openclaw/bridges/zalo.js.map +1 -1
- package/dist/src/openclaw/private-file.d.ts +40 -3
- package/dist/src/openclaw/private-file.js +2070 -95
- package/dist/src/openclaw/private-file.js.map +1 -1
- package/dist/src/openclaw/shared.d.ts +1 -0
- package/dist/src/openclaw/shared.js +48 -13
- package/dist/src/openclaw/shared.js.map +1 -1
- package/dist/src/openclaw/smoke-lock.d.ts +9 -0
- package/dist/src/openclaw/smoke-lock.js +696 -187
- package/dist/src/openclaw/smoke-lock.js.map +1 -1
- package/dist/src/openclaw.js +352 -29
- package/dist/src/openclaw.js.map +1 -1
- package/dist/src/platform/process-owned-lock.d.ts +11 -0
- package/dist/src/platform/process-owned-lock.js +1698 -0
- package/dist/src/platform/process-owned-lock.js.map +1 -0
- package/dist/src/platform/recorder-directory.d.ts +1 -0
- package/dist/src/platform/recorder-directory.js +14 -0
- package/dist/src/platform/recorder-directory.js.map +1 -0
- package/dist/src/platform/windows-acl.d.ts +18 -0
- package/dist/src/platform/windows-acl.js +1367 -0
- package/dist/src/platform/windows-acl.js.map +1 -0
- package/dist/src/platform/windows-lock-root.d.ts +13 -0
- package/dist/src/platform/windows-lock-root.js +98 -0
- package/dist/src/platform/windows-lock-root.js.map +1 -0
- package/dist/src/providers/builtin/discord.d.ts +4 -0
- package/dist/src/providers/builtin/discord.js +52 -9
- package/dist/src/providers/builtin/discord.js.map +1 -1
- package/dist/src/providers/builtin/external-webhook-auth.d.ts +1 -0
- package/dist/src/providers/builtin/external-webhook-auth.js +38 -1
- package/dist/src/providers/builtin/external-webhook-auth.js.map +1 -1
- package/dist/src/providers/builtin/feishu.d.ts +1 -0
- package/dist/src/providers/builtin/feishu.js +96 -26
- package/dist/src/providers/builtin/feishu.js.map +1 -1
- package/dist/src/providers/builtin/googlechat.d.ts +1 -0
- package/dist/src/providers/builtin/googlechat.js +95 -26
- package/dist/src/providers/builtin/googlechat.js.map +1 -1
- package/dist/src/providers/builtin/imessage.js +60 -17
- package/dist/src/providers/builtin/imessage.js.map +1 -1
- package/dist/src/providers/builtin/loopback.js +88 -35
- package/dist/src/providers/builtin/loopback.js.map +1 -1
- package/dist/src/providers/builtin/matrix.d.ts +2 -2
- package/dist/src/providers/builtin/matrix.js +78 -21
- package/dist/src/providers/builtin/matrix.js.map +1 -1
- package/dist/src/providers/builtin/mattermost.d.ts +11 -7
- package/dist/src/providers/builtin/mattermost.js +138 -24
- package/dist/src/providers/builtin/mattermost.js.map +1 -1
- package/dist/src/providers/builtin/msteams.d.ts +1 -0
- package/dist/src/providers/builtin/msteams.js +118 -38
- package/dist/src/providers/builtin/msteams.js.map +1 -1
- package/dist/src/providers/builtin/native-local-mock.js +4 -4
- package/dist/src/providers/builtin/native-local-mock.js.map +1 -1
- package/dist/src/providers/builtin/script.js +1057 -146
- package/dist/src/providers/builtin/script.js.map +1 -1
- package/dist/src/providers/builtin/slack.d.ts +8 -1
- package/dist/src/providers/builtin/slack.js +327 -24
- package/dist/src/providers/builtin/slack.js.map +1 -1
- package/dist/src/providers/builtin/telegram.d.ts +1 -1
- package/dist/src/providers/builtin/telegram.js +55 -14
- package/dist/src/providers/builtin/telegram.js.map +1 -1
- package/dist/src/providers/builtin/whatsapp.d.ts +4 -3
- package/dist/src/providers/builtin/whatsapp.js +157 -29
- package/dist/src/providers/builtin/whatsapp.js.map +1 -1
- package/dist/src/providers/builtin/zalo.d.ts +5 -2
- package/dist/src/providers/builtin/zalo.js +31 -7
- package/dist/src/providers/builtin/zalo.js.map +1 -1
- package/dist/src/providers/catalog.d.ts +1 -1
- package/dist/src/providers/catalog.js +1 -1
- package/dist/src/providers/catalog.js.map +1 -1
- package/dist/src/providers/local-mock.d.ts +4 -0
- package/dist/src/providers/local-mock.js +162 -35
- package/dist/src/providers/local-mock.js.map +1 -1
- package/dist/src/providers/native-ids.d.ts +2 -0
- package/dist/src/providers/native-ids.js +3 -0
- package/dist/src/providers/native-ids.js.map +1 -1
- package/dist/src/providers/recorder.d.ts +14 -3
- package/dist/src/providers/recorder.js +532 -42
- package/dist/src/providers/recorder.js.map +1 -1
- package/dist/src/providers/registry.d.ts +1 -0
- package/dist/src/providers/registry.js +65 -20
- package/dist/src/providers/registry.js.map +1 -1
- package/dist/src/providers/signed-jwt.d.ts +6 -1
- package/dist/src/providers/signed-jwt.js +241 -50
- package/dist/src/providers/signed-jwt.js.map +1 -1
- package/dist/src/providers/slack-ids.d.ts +1 -0
- package/dist/src/providers/slack-ids.js +5 -0
- package/dist/src/providers/slack-ids.js.map +1 -1
- package/dist/src/providers/target-normalizers.d.ts +9 -1
- package/dist/src/providers/target-normalizers.js +177 -58
- package/dist/src/providers/target-normalizers.js.map +1 -1
- package/dist/src/providers/webhook-server.d.ts +3 -0
- package/dist/src/providers/webhook-server.js +268 -47
- package/dist/src/providers/webhook-server.js.map +1 -1
- package/dist/src/servers/http.d.ts +14 -2
- package/dist/src/servers/http.js +229 -58
- package/dist/src/servers/http.js.map +1 -1
- package/dist/src/servers/matrix.d.ts +3 -0
- package/dist/src/servers/matrix.js +268 -110
- package/dist/src/servers/matrix.js.map +1 -1
- package/dist/src/servers/mattermost.d.ts +5 -0
- package/dist/src/servers/mattermost.js +389 -91
- package/dist/src/servers/mattermost.js.map +1 -1
- package/dist/src/servers/recorder.d.ts +11 -0
- package/dist/src/servers/recorder.js +1241 -22
- package/dist/src/servers/recorder.js.map +1 -1
- package/dist/src/servers/signal.d.ts +1 -0
- package/dist/src/servers/signal.js +279 -44
- package/dist/src/servers/signal.js.map +1 -1
- package/dist/src/servers/slack.d.ts +15 -0
- package/dist/src/servers/slack.js +259 -51
- package/dist/src/servers/slack.js.map +1 -1
- package/dist/src/servers/telegram-identity.d.ts +6 -0
- package/dist/src/servers/telegram-identity.js +27 -0
- package/dist/src/servers/telegram-identity.js.map +1 -0
- package/dist/src/servers/telegram.d.ts +17 -6
- package/dist/src/servers/telegram.js +1193 -118
- package/dist/src/servers/telegram.js.map +1 -1
- package/dist/src/servers/webhook-target.d.ts +12 -4
- package/dist/src/servers/webhook-target.js +226 -28
- package/dist/src/servers/webhook-target.js.map +1 -1
- package/dist/src/servers/whatsapp-baileys-websocket.d.ts +60 -1
- package/dist/src/servers/whatsapp-baileys-websocket.js +782 -112
- package/dist/src/servers/whatsapp-baileys-websocket.js.map +1 -1
- package/dist/src/servers/whatsapp-jid.d.ts +1 -0
- package/dist/src/servers/whatsapp-jid.js +9 -5
- package/dist/src/servers/whatsapp-jid.js.map +1 -1
- package/dist/src/servers/whatsapp-wire/binary-node.d.ts +1 -0
- package/dist/src/servers/whatsapp-wire/binary-node.js +37 -13
- package/dist/src/servers/whatsapp-wire/binary-node.js.map +1 -1
- package/dist/src/servers/whatsapp-wire/crypto.d.ts +3 -0
- package/dist/src/servers/whatsapp-wire/crypto.js +23 -8
- package/dist/src/servers/whatsapp-wire/crypto.js.map +1 -1
- package/dist/src/servers/whatsapp-wire/handshake.d.ts +9 -3
- package/dist/src/servers/whatsapp-wire/handshake.js +173 -12
- package/dist/src/servers/whatsapp-wire/handshake.js.map +1 -1
- package/dist/src/servers/whatsapp-wire/signal.js +3 -3
- package/dist/src/servers/whatsapp-wire/signal.js.map +1 -1
- package/dist/src/servers/whatsapp.d.ts +7 -0
- package/dist/src/servers/whatsapp.js +149 -24
- package/dist/src/servers/whatsapp.js.map +1 -1
- package/dist/src/servers/zalo.d.ts +1 -0
- package/dist/src/servers/zalo.js +193 -82
- package/dist/src/servers/zalo.js.map +1 -1
- package/docs/channel-setup.md +185 -43
- package/fixtures/examples/crabline.example.yaml +3 -2
- package/package.json +18 -10
- package/fixtures/examples/openclaw-bridge.yaml +0 -125
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
2
|
import { createServer } from "node:http";
|
|
3
|
+
import { isIP } from "node:net";
|
|
3
4
|
import path from "node:path";
|
|
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";
|
|
5
|
+
import { adminAuthError, closeServer, drainRequestBody, formatUrlHost, hasAdminToken, InvalidJsonBodyError, isJsonObject, jsonResponse, parseUnknownRequestBody, queryRecord, readInteger, readTrimmedString, RequestBodyTooLargeError, writeResponse, } from "./http.js";
|
|
6
|
+
import { recordCommittedServerEvent, recordServerEvent, ServerRecorderCommittedError, } from "./recorder.js";
|
|
6
7
|
import { resolveMaxPendingInboundEvents } from "./pending-events.js";
|
|
7
8
|
const SIGNAL_CLI_SSE_KEEPALIVE_MS = 15_000;
|
|
8
9
|
const DEFAULT_MAX_SIGNAL_SSE_CLIENTS = 32;
|
|
9
10
|
const MAX_SIGNAL_SSE_BUFFER_BYTES = 2 * 1024 * 1024;
|
|
11
|
+
const SIGNAL_PHONE_NUMBER_RE = /^\+[1-9]\d{2,14}$/u;
|
|
12
|
+
const SIGNAL_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
|
|
10
13
|
async function appendEvent(state, event, committed = false) {
|
|
11
14
|
const params = { event, onEvent: state.onEvent, recorderPath: state.recorderPath };
|
|
12
15
|
await (committed ? recordCommittedServerEvent(params) : recordServerEvent(params));
|
|
@@ -25,12 +28,18 @@ function removeSignalClient(state, client, destroy) {
|
|
|
25
28
|
const buffer = state.clientBuffers.get(client);
|
|
26
29
|
state.clients.delete(client);
|
|
27
30
|
state.clientBuffers.delete(client);
|
|
31
|
+
const bufferedEvents = new Set([
|
|
32
|
+
...state.pendingEvents,
|
|
33
|
+
...[...state.clientBuffers.values()].flatMap((entry) => [...entry.inFlight, ...entry.events]),
|
|
34
|
+
...(buffer ? [...buffer.inFlight, ...buffer.events] : []),
|
|
35
|
+
]);
|
|
36
|
+
for (const event of bufferedEvents) {
|
|
37
|
+
event.recipients?.delete(client);
|
|
38
|
+
}
|
|
28
39
|
if (buffer) {
|
|
29
|
-
const
|
|
30
|
-
event.recipients?.
|
|
31
|
-
|
|
32
|
-
});
|
|
33
|
-
const restoredEvents = undeliveredEvents.filter((event) => !state.pendingEvents.includes(event));
|
|
40
|
+
const restoredEvents = [...new Set([...buffer.inFlight, ...buffer.events])].filter((event) => event.sequence !== undefined &&
|
|
41
|
+
event.recipients?.size === 0 &&
|
|
42
|
+
!isSignalEventBuffered(state, event));
|
|
34
43
|
if (restoredEvents.length > 0) {
|
|
35
44
|
state.pendingEvents.push(...restoredEvents);
|
|
36
45
|
state.pendingEvents.sort((left, right) => (left.sequence ?? Number.MAX_SAFE_INTEGER) - (right.sequence ?? 0));
|
|
@@ -223,14 +232,67 @@ function emitSignalEvent(state, payload) {
|
|
|
223
232
|
}
|
|
224
233
|
return delivered || queueSignalEvent(state, event);
|
|
225
234
|
}
|
|
235
|
+
function normalizeSignalPhoneNumber(value) {
|
|
236
|
+
const number = readTrimmedString(value)?.replace(/[\s().-]/gu, "");
|
|
237
|
+
return number && SIGNAL_PHONE_NUMBER_RE.test(number) ? number : undefined;
|
|
238
|
+
}
|
|
239
|
+
function isSignalDirectRecipient(value) {
|
|
240
|
+
const recipient = readSignalRpcString(value);
|
|
241
|
+
if (!recipient) {
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
if (SIGNAL_UUID_RE.test(recipient)) {
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
// signal-cli treats recognized prefixes as terminal instead of retrying them as phone numbers.
|
|
248
|
+
if (recipient.startsWith("PNI:")) {
|
|
249
|
+
return SIGNAL_UUID_RE.test(recipient.slice(4));
|
|
250
|
+
}
|
|
251
|
+
if (recipient.startsWith("u:")) {
|
|
252
|
+
return recipient.length > 2;
|
|
253
|
+
}
|
|
254
|
+
return SIGNAL_PHONE_NUMBER_RE.test(recipient);
|
|
255
|
+
}
|
|
256
|
+
function nextSignalTimestamp(state) {
|
|
257
|
+
return Math.max(state.nextTimestamp, (state.lastCommittedTimestamp ?? -1) + 1);
|
|
258
|
+
}
|
|
259
|
+
function commitSignalTimestamp(state, timestamp) {
|
|
260
|
+
state.lastCommittedTimestamp = timestamp;
|
|
261
|
+
state.nextTimestamp = Math.max(state.nextTimestamp, timestamp + 1);
|
|
262
|
+
}
|
|
226
263
|
async function handleAdminInbound(params) {
|
|
227
264
|
const text = typeof params.body.text === "string" ? params.body.text : undefined;
|
|
228
|
-
const
|
|
229
|
-
const
|
|
265
|
+
const sourceNumberValue = params.body.sourceNumber ?? params.body.senderId;
|
|
266
|
+
const sourceNumber = normalizeSignalPhoneNumber(sourceNumberValue);
|
|
267
|
+
const sourceUuidValue = readTrimmedString(params.body.sourceUuid);
|
|
268
|
+
const sourceUuid = sourceUuidValue && SIGNAL_UUID_RE.test(sourceUuidValue)
|
|
269
|
+
? sourceUuidValue.toLowerCase()
|
|
270
|
+
: undefined;
|
|
271
|
+
if (sourceNumberValue !== undefined && sourceNumber === undefined) {
|
|
272
|
+
return jsonResponse({ error: "sourceNumber must be an E.164 telephone number", ok: false }, 400);
|
|
273
|
+
}
|
|
274
|
+
if (params.body.sourceUuid !== undefined && sourceUuid === undefined) {
|
|
275
|
+
return jsonResponse({ error: "sourceUuid must be a UUID", ok: false }, 400);
|
|
276
|
+
}
|
|
230
277
|
if (!text || text.trim().length === 0 || (!sourceNumber && !sourceUuid)) {
|
|
231
278
|
return jsonResponse({ error: "text and at least one source identity are required", ok: false }, 400);
|
|
232
279
|
}
|
|
233
|
-
const
|
|
280
|
+
const suppliedTimestamp = params.body.timestamp === undefined ? undefined : readInteger(params.body.timestamp);
|
|
281
|
+
if (params.body.timestamp !== undefined &&
|
|
282
|
+
(suppliedTimestamp === undefined ||
|
|
283
|
+
suppliedTimestamp < 0 ||
|
|
284
|
+
suppliedTimestamp >= Number.MAX_SAFE_INTEGER)) {
|
|
285
|
+
return jsonResponse({ error: "timestamp must be a non-negative safe integer with room to advance", ok: false }, 400);
|
|
286
|
+
}
|
|
287
|
+
if (suppliedTimestamp !== undefined &&
|
|
288
|
+
params.state.lastCommittedTimestamp !== undefined &&
|
|
289
|
+
suppliedTimestamp <= params.state.lastCommittedTimestamp) {
|
|
290
|
+
return jsonResponse({ error: "timestamp must be greater than the last committed timestamp", ok: false }, 409);
|
|
291
|
+
}
|
|
292
|
+
const timestamp = suppliedTimestamp ?? nextSignalTimestamp(params.state);
|
|
293
|
+
if (timestamp >= Number.MAX_SAFE_INTEGER) {
|
|
294
|
+
return jsonResponse({ error: "timestamp capacity exhausted", ok: false }, 503);
|
|
295
|
+
}
|
|
234
296
|
const groupId = readTrimmedString(params.body.groupId);
|
|
235
297
|
const payload = {
|
|
236
298
|
envelope: {
|
|
@@ -251,6 +313,7 @@ async function handleAdminInbound(params) {
|
|
|
251
313
|
ok: false,
|
|
252
314
|
}, 503);
|
|
253
315
|
}
|
|
316
|
+
commitSignalTimestamp(params.state, timestamp);
|
|
254
317
|
return jsonResponse({ event: payload, ok: true });
|
|
255
318
|
}
|
|
256
319
|
async function handleRpc(params) {
|
|
@@ -259,7 +322,9 @@ async function handleRpc(params) {
|
|
|
259
322
|
const validId = !hasId ||
|
|
260
323
|
id === null ||
|
|
261
324
|
typeof id === "string" ||
|
|
262
|
-
(typeof id === "number" &&
|
|
325
|
+
(typeof id === "number" &&
|
|
326
|
+
Number.isFinite(id) &&
|
|
327
|
+
(!Number.isInteger(id) || Number.isSafeInteger(id)));
|
|
263
328
|
const method = params.body.method;
|
|
264
329
|
if (typeof method !== "string" || !validId) {
|
|
265
330
|
return {
|
|
@@ -301,16 +366,36 @@ async function handleRpc(params) {
|
|
|
301
366
|
record: false,
|
|
302
367
|
response: notification
|
|
303
368
|
? new Response(null, { status: 204 })
|
|
304
|
-
: rpcError(-32602, "Invalid params", id),
|
|
369
|
+
: rpcError(-32602, "Invalid params", id, 200),
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
const account = params.body.params.account;
|
|
373
|
+
if (typeof account === "string" && account !== params.state.account) {
|
|
374
|
+
return {
|
|
375
|
+
accepted: false,
|
|
376
|
+
record: false,
|
|
377
|
+
response: notification
|
|
378
|
+
? new Response(null, { status: 204 })
|
|
379
|
+
: rpcError(-32602, "Specified account does not exist", id, 200),
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
const timestamp = nextSignalTimestamp(params.state);
|
|
383
|
+
if (timestamp >= Number.MAX_SAFE_INTEGER) {
|
|
384
|
+
return {
|
|
385
|
+
accepted: false,
|
|
386
|
+
record: false,
|
|
387
|
+
response: notification
|
|
388
|
+
? new Response(null, { status: 204 })
|
|
389
|
+
: rpcError(-32603, "Timestamp capacity exhausted", id, 200),
|
|
305
390
|
};
|
|
306
391
|
}
|
|
307
|
-
const timestamp = params.state.nextTimestamp++;
|
|
308
392
|
return {
|
|
309
393
|
accepted: true,
|
|
310
394
|
record: true,
|
|
311
395
|
response: notification
|
|
312
396
|
? new Response(null, { status: 204 })
|
|
313
397
|
: rpcResponse(id, method === "sendTyping" ? {} : { timestamp }),
|
|
398
|
+
timestamp,
|
|
314
399
|
};
|
|
315
400
|
}
|
|
316
401
|
return {
|
|
@@ -360,34 +445,115 @@ function hasStringArray(value) {
|
|
|
360
445
|
value.length > 0 &&
|
|
361
446
|
value.every((entry) => typeof entry === "string" && entry.trim().length > 0));
|
|
362
447
|
}
|
|
448
|
+
function readSignalRpcString(value) {
|
|
449
|
+
if (typeof value !== "string") {
|
|
450
|
+
return undefined;
|
|
451
|
+
}
|
|
452
|
+
const trimmed = value.trim();
|
|
453
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
454
|
+
}
|
|
455
|
+
function hasValidSignalRecipientFieldTypes(params) {
|
|
456
|
+
return ["recipient", "recipients", "groupId", "groupIds", "username", "usernames"].every((field) => {
|
|
457
|
+
const value = params[field];
|
|
458
|
+
return (value === undefined ||
|
|
459
|
+
(typeof value === "string" && value.trim().length > 0) ||
|
|
460
|
+
hasStringArray(value));
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
function hasValidOptionalSignalString(params, field) {
|
|
464
|
+
return params[field] === undefined || typeof params[field] === "string";
|
|
465
|
+
}
|
|
466
|
+
function hasValidSignalDirectRecipients(params) {
|
|
467
|
+
return ["recipient", "recipients"].every((field) => {
|
|
468
|
+
const value = params[field];
|
|
469
|
+
return (value === undefined ||
|
|
470
|
+
(Array.isArray(value)
|
|
471
|
+
? value.length > 0 && value.every(isSignalDirectRecipient)
|
|
472
|
+
: isSignalDirectRecipient(value)));
|
|
473
|
+
});
|
|
474
|
+
}
|
|
363
475
|
function validSignalRpcParams(method, value) {
|
|
364
|
-
if (!isJsonObject(value)
|
|
476
|
+
if (!isJsonObject(value) ||
|
|
477
|
+
!hasValidSignalRecipientFieldTypes(value) ||
|
|
478
|
+
!hasValidSignalDirectRecipients(value) ||
|
|
479
|
+
!hasValidOptionalSignalString(value, "account") ||
|
|
480
|
+
(value.noteToSelf !== undefined && typeof value.noteToSelf !== "boolean")) {
|
|
365
481
|
return false;
|
|
366
482
|
}
|
|
367
483
|
if (method === "send") {
|
|
368
|
-
return (
|
|
369
|
-
(
|
|
370
|
-
|
|
484
|
+
return (hasValidOptionalSignalString(value, "message") &&
|
|
485
|
+
hasValidOptionalSignalString(value, "attachment") &&
|
|
486
|
+
(value.attachments === undefined || hasStringArray(value.attachments)) &&
|
|
487
|
+
hasRecipients(value) &&
|
|
488
|
+
(readSignalRpcString(value.message) !== undefined ||
|
|
489
|
+
readSignalRpcString(value.attachment) !== undefined ||
|
|
371
490
|
hasStringArray(value.attachments)));
|
|
372
491
|
}
|
|
373
492
|
if (method === "sendReaction") {
|
|
374
|
-
return (
|
|
375
|
-
|
|
376
|
-
|
|
493
|
+
return (hasValidOptionalSignalString(value, "emoji") &&
|
|
494
|
+
hasValidOptionalSignalString(value, "targetAuthor") &&
|
|
495
|
+
hasRecipients(value) &&
|
|
496
|
+
readSignalRpcString(value.emoji) !== undefined &&
|
|
497
|
+
isSignalDirectRecipient(value.targetAuthor) &&
|
|
377
498
|
validTimestamp(value.targetTimestamp));
|
|
378
499
|
}
|
|
379
500
|
if (method === "sendReceipt") {
|
|
380
501
|
const targetTimestamps = value.targetTimestamps ?? value.targetTimestamp;
|
|
381
502
|
const timestamps = Array.isArray(targetTimestamps) ? targetTimestamps : [targetTimestamps];
|
|
382
|
-
return ((
|
|
383
|
-
|
|
384
|
-
|
|
503
|
+
return (hasValidOptionalSignalString(value, "type") &&
|
|
504
|
+
(readSignalRpcString(value.recipient) !== undefined ||
|
|
505
|
+
readSignalRpcString(value.username) !== undefined ||
|
|
506
|
+
hasStringArray(value.usernames)) &&
|
|
385
507
|
timestamps.length > 0 &&
|
|
386
508
|
timestamps.every(validTimestamp) &&
|
|
387
509
|
(value.type === undefined || value.type === "read" || value.type === "viewed"));
|
|
388
510
|
}
|
|
389
511
|
return method === "sendTyping" && hasTypingRecipients(value);
|
|
390
512
|
}
|
|
513
|
+
function serializeSignalTimestampCommit(state, operation) {
|
|
514
|
+
const run = state.pendingTimestampCommits.catch(() => { }).then(operation);
|
|
515
|
+
state.pendingTimestampCommits = run.then(() => undefined, () => undefined);
|
|
516
|
+
return run;
|
|
517
|
+
}
|
|
518
|
+
function processSignalAdminInbound(params) {
|
|
519
|
+
return serializeSignalTimestampCommit(params.state, () => handleAdminInbound(params));
|
|
520
|
+
}
|
|
521
|
+
async function processSignalRpc(params) {
|
|
522
|
+
return serializeSignalTimestampCommit(params.state, async () => {
|
|
523
|
+
const rpc = await handleRpc({ body: params.body, state: params.state });
|
|
524
|
+
if (rpc.record) {
|
|
525
|
+
const event = {
|
|
526
|
+
accepted: rpc.accepted,
|
|
527
|
+
at: new Date().toISOString(),
|
|
528
|
+
body: params.body,
|
|
529
|
+
method: "POST",
|
|
530
|
+
path: params.url.pathname,
|
|
531
|
+
query: queryRecord(params.url),
|
|
532
|
+
type: "api",
|
|
533
|
+
};
|
|
534
|
+
if (rpc.accepted) {
|
|
535
|
+
try {
|
|
536
|
+
await appendEvent(params.state, event);
|
|
537
|
+
}
|
|
538
|
+
catch (error) {
|
|
539
|
+
if (!(error instanceof ServerRecorderCommittedError)) {
|
|
540
|
+
throw error;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
commitSignalTimestamp(params.state, rpc.timestamp);
|
|
544
|
+
}
|
|
545
|
+
else {
|
|
546
|
+
await appendEvent(params.state, event, rpc.response.status === 204);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
return rpc.response;
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
function hasSignalJsonContentType(request) {
|
|
553
|
+
const contentType = request.headers["content-type"];
|
|
554
|
+
const values = Array.isArray(contentType) ? contentType : [contentType];
|
|
555
|
+
return values.some((value) => value?.split(";", 1)[0]?.trim().toLowerCase() === "application/json");
|
|
556
|
+
}
|
|
391
557
|
async function handleRequest(params) {
|
|
392
558
|
const url = new URL(params.request.url ?? "/", "http://localhost");
|
|
393
559
|
if (url.pathname === "/api/v1/events" && params.request.method === "GET") {
|
|
@@ -416,7 +582,6 @@ async function handleRequest(params) {
|
|
|
416
582
|
connection: "keep-alive",
|
|
417
583
|
"content-type": "text/event-stream",
|
|
418
584
|
});
|
|
419
|
-
params.response.flushHeaders();
|
|
420
585
|
params.response.once("close", () => {
|
|
421
586
|
removeSignalClient(params.state, params.response, false);
|
|
422
587
|
});
|
|
@@ -427,6 +592,7 @@ async function handleRequest(params) {
|
|
|
427
592
|
events: [],
|
|
428
593
|
inFlight: [],
|
|
429
594
|
});
|
|
595
|
+
params.response.flushHeaders();
|
|
430
596
|
replayExclusiveSignalEvents(params.state, params.response);
|
|
431
597
|
if (params.state.clients.has(params.response)) {
|
|
432
598
|
flushPendingSignalEvents(params.state);
|
|
@@ -454,11 +620,12 @@ async function handleRequest(params) {
|
|
|
454
620
|
query: queryRecord(url),
|
|
455
621
|
type: "admin",
|
|
456
622
|
});
|
|
457
|
-
fetchResponse = await
|
|
623
|
+
fetchResponse = await processSignalAdminInbound({ body, state: params.state });
|
|
458
624
|
}
|
|
459
625
|
}
|
|
460
626
|
else if (url.pathname === "/api/v1/check" && params.request.method === "GET") {
|
|
461
627
|
await appendEvent(params.state, {
|
|
628
|
+
accepted: true,
|
|
462
629
|
at: new Date().toISOString(),
|
|
463
630
|
method: "GET",
|
|
464
631
|
path: url.pathname,
|
|
@@ -468,37 +635,93 @@ async function handleRequest(params) {
|
|
|
468
635
|
fetchResponse = new Response(null, { status: 200 });
|
|
469
636
|
}
|
|
470
637
|
else if (url.pathname === "/api/v1/rpc" && params.request.method === "POST") {
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
return;
|
|
638
|
+
if (!hasSignalJsonContentType(params.request)) {
|
|
639
|
+
drainRequestBody(params.request);
|
|
640
|
+
fetchResponse = new Response(null, { status: 415 });
|
|
475
641
|
}
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
path: url.pathname,
|
|
484
|
-
query: queryRecord(url),
|
|
485
|
-
type: "api",
|
|
486
|
-
};
|
|
487
|
-
await appendEvent(params.state, event, rpc.accepted || rpc.response.status === 204);
|
|
642
|
+
else {
|
|
643
|
+
const body = await parseUnknownRequestBody(params.request);
|
|
644
|
+
if (!isJsonObject(body)) {
|
|
645
|
+
await writeResponse(params.response, rpcError(-32600, "Invalid Request"));
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
fetchResponse = await processSignalRpc({ body, state: params.state, url });
|
|
488
649
|
}
|
|
489
|
-
fetchResponse = rpc.response;
|
|
490
650
|
}
|
|
491
651
|
else {
|
|
492
652
|
fetchResponse = new Response("not found", { status: 404 });
|
|
493
653
|
}
|
|
494
654
|
await writeResponse(params.response, fetchResponse);
|
|
495
655
|
}
|
|
656
|
+
function normalizeSignalHost(value) {
|
|
657
|
+
const normalized = value
|
|
658
|
+
.trim()
|
|
659
|
+
.replace(/^\[(.*)\]$/u, "$1")
|
|
660
|
+
.toLowerCase()
|
|
661
|
+
.replace(/\.$/u, "");
|
|
662
|
+
if (isIP(normalized) !== 6) {
|
|
663
|
+
return normalized;
|
|
664
|
+
}
|
|
665
|
+
const canonical = new URL(`http://[${normalized}]`).hostname.slice(1, -1);
|
|
666
|
+
const mapped = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/u.exec(canonical);
|
|
667
|
+
if (!mapped?.[1] || !mapped[2]) {
|
|
668
|
+
return canonical;
|
|
669
|
+
}
|
|
670
|
+
const high = Number.parseInt(mapped[1], 16);
|
|
671
|
+
const low = Number.parseInt(mapped[2], 16);
|
|
672
|
+
return `${high >>> 8}.${high & 0xff}.${low >>> 8}.${low & 0xff}`;
|
|
673
|
+
}
|
|
674
|
+
function parseSignalHostHeader(value) {
|
|
675
|
+
const match = value.startsWith("[")
|
|
676
|
+
? /^\[([^\]]+)\](?::(\d{1,5}))?$/u.exec(value)
|
|
677
|
+
: /^([^:]+)(?::(\d{1,5}))?$/u.exec(value);
|
|
678
|
+
if (!match?.[1]) {
|
|
679
|
+
return undefined;
|
|
680
|
+
}
|
|
681
|
+
const port = match[2] === undefined ? undefined : Number(match[2]);
|
|
682
|
+
if (port !== undefined && port > 65_535) {
|
|
683
|
+
return undefined;
|
|
684
|
+
}
|
|
685
|
+
const hostname = normalizeSignalHost(match[1]);
|
|
686
|
+
if (isIP(hostname) !== 0) {
|
|
687
|
+
return hostname;
|
|
688
|
+
}
|
|
689
|
+
const labels = hostname.split(".");
|
|
690
|
+
return hostname.length <= 253 &&
|
|
691
|
+
labels.every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u.test(label))
|
|
692
|
+
? hostname
|
|
693
|
+
: undefined;
|
|
694
|
+
}
|
|
695
|
+
function signalRequestHostAllowed(request, bindHost, allowedHosts) {
|
|
696
|
+
const hostHeader = request.headers.host;
|
|
697
|
+
if (!hostHeader) {
|
|
698
|
+
return false;
|
|
699
|
+
}
|
|
700
|
+
const requestedHost = parseSignalHostHeader(hostHeader);
|
|
701
|
+
if (requestedHost === undefined) {
|
|
702
|
+
return false;
|
|
703
|
+
}
|
|
704
|
+
const normalizedBindHost = normalizeSignalHost(bindHost);
|
|
705
|
+
if (allowedHosts.has(requestedHost)) {
|
|
706
|
+
return true;
|
|
707
|
+
}
|
|
708
|
+
if (normalizedBindHost !== "0.0.0.0" && normalizedBindHost !== "::") {
|
|
709
|
+
return false;
|
|
710
|
+
}
|
|
711
|
+
const localAddress = normalizeSignalHost(request.socket.localAddress ?? "");
|
|
712
|
+
return isIP(requestedHost) !== 0 && requestedHost === localAddress;
|
|
713
|
+
}
|
|
496
714
|
export async function startSignalServer(params = {}) {
|
|
715
|
+
const account = normalizeSignalPhoneNumber(params.account ?? "+15550000000");
|
|
716
|
+
if (!account) {
|
|
717
|
+
throw new Error("account must be an E.164 telephone number.");
|
|
718
|
+
}
|
|
497
719
|
const state = {
|
|
498
|
-
account
|
|
720
|
+
account,
|
|
499
721
|
adminToken: params.adminToken ?? randomBytes(24).toString("base64url"),
|
|
500
722
|
clients: new Set(),
|
|
501
723
|
clientBuffers: new Map(),
|
|
724
|
+
lastCommittedTimestamp: undefined,
|
|
502
725
|
maxPendingInboundEvents: resolveMaxPendingInboundEvents(params.maxPendingInboundEvents),
|
|
503
726
|
maxSseClients: Number.isSafeInteger(params.maxSseClients) && (params.maxSseClients ?? 0) > 0
|
|
504
727
|
? params.maxSseClients
|
|
@@ -508,11 +731,22 @@ export async function startSignalServer(params = {}) {
|
|
|
508
731
|
onEvent: params.onEvent,
|
|
509
732
|
pendingEventBytes: 0,
|
|
510
733
|
pendingEvents: [],
|
|
734
|
+
pendingTimestampCommits: Promise.resolve(),
|
|
511
735
|
pendingSseClients: 0,
|
|
512
736
|
recorderPath: params.recorderPath ?? path.resolve(".crabline", "servers", "signal.jsonl"),
|
|
513
737
|
};
|
|
514
738
|
const host = params.host ?? "127.0.0.1";
|
|
739
|
+
const normalizedHost = normalizeSignalHost(host);
|
|
740
|
+
const allowedHosts = new Set([
|
|
741
|
+
...(normalizedHost === "0.0.0.0" || normalizedHost === "::" ? [] : [normalizedHost]),
|
|
742
|
+
...(params.allowedHosts ?? []).map((allowedHost) => normalizeSignalHost(allowedHost)),
|
|
743
|
+
]);
|
|
515
744
|
const server = createServer((request, response) => {
|
|
745
|
+
if (!signalRequestHostAllowed(request, host, allowedHosts)) {
|
|
746
|
+
drainRequestBody(request);
|
|
747
|
+
void writeResponse(response, jsonResponse({ error: "Host header is not allowed", ok: false }, 400)).catch(() => response.destroy());
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
516
750
|
void handleRequest({ request, response, state }).catch(async (error) => {
|
|
517
751
|
if (!response.headersSent) {
|
|
518
752
|
let errorResponse;
|
|
@@ -560,7 +794,8 @@ export async function startSignalServer(params = {}) {
|
|
|
560
794
|
}
|
|
561
795
|
}, SIGNAL_CLI_SSE_KEEPALIVE_MS);
|
|
562
796
|
keepalive.unref();
|
|
563
|
-
const
|
|
797
|
+
const advertisedHost = normalizedHost === "0.0.0.0" ? "127.0.0.1" : normalizedHost === "::" ? "::1" : host;
|
|
798
|
+
const baseUrl = `http://${formatUrlHost(advertisedHost)}:${address.port}`;
|
|
564
799
|
return {
|
|
565
800
|
async close() {
|
|
566
801
|
clearInterval(keepalive);
|