@openclaw/crabline 0.1.8 → 0.1.10
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 +52 -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 +4 -2
- package/dist/src/index.js +1 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/openclaw/artifact-generation.d.ts +38 -0
- package/dist/src/openclaw/artifact-generation.js +314 -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 +26 -0
- package/dist/src/openclaw/private-file.js +411 -0
- package/dist/src/openclaw/private-file.js.map +1 -0
- package/dist/src/openclaw/shared.d.ts +23 -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 +949 -0
- package/dist/src/openclaw/smoke-lock.js.map +1 -0
- package/dist/src/openclaw.d.ts +16 -8
- package/dist/src/openclaw.js +195 -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 +539 -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.d.ts +2 -0
- package/dist/src/servers/matrix.js +466 -52
- package/dist/src/servers/matrix.js.map +1 -1
- package/dist/src/servers/mattermost.d.ts +7 -0
- package/dist/src/servers/mattermost.js +307 -59
- 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 +12 -0
- package/dist/src/servers/recorder.js +51 -0
- package/dist/src/servers/recorder.js.map +1 -0
- package/dist/src/servers/signal.d.ts +4 -0
- package/dist/src/servers/signal.js +445 -57
- package/dist/src/servers/signal.js.map +1 -1
- package/dist/src/servers/slack.d.ts +7 -0
- package/dist/src/servers/slack.js +516 -50
- package/dist/src/servers/slack.js.map +1 -1
- package/dist/src/servers/telegram.d.ts +113 -0
- package/dist/src/servers/telegram.js +814 -155
- 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 +14 -7
- package/dist/src/servers/whatsapp.js +272 -110
- package/dist/src/servers/whatsapp.js.map +1 -1
- package/dist/src/servers/zalo.d.ts +16 -0
- package/dist/src/servers/zalo.js +524 -91
- 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,14 +1,29 @@
|
|
|
1
1
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
-
import fs from "node:fs/promises";
|
|
3
2
|
import path from "node:path";
|
|
4
3
|
import { WebSocket, WebSocketServer } from "ws";
|
|
5
|
-
import { adminAuthError, hasAdminToken, jsonResponse,
|
|
4
|
+
import { adminAuthError, drainRequestBody, hasAdminToken, InvalidJsonBodyError, isJsonObject, isLoopbackHost, jsonResponse, parseUnknownRequestBody, queryRecord, readTrimmedString, RequestBodyTooLargeError, startHttpJsonServer, } from "./http.js";
|
|
5
|
+
import { recordCommittedServerEvent, recordServerEvent, } from "./recorder.js";
|
|
6
|
+
import { resolveMaxPendingInboundEvents } from "./pending-events.js";
|
|
7
|
+
import { closeWebSocketServer } from "./websocket.js";
|
|
8
|
+
const DEFAULT_WEBSOCKET_AUTHENTICATION_TIMEOUT_MS = 5_000;
|
|
9
|
+
const DEFAULT_MAX_WEBSOCKET_BUFFERED_BYTES = 1024 * 1024;
|
|
10
|
+
const DEFAULT_MAX_WEBSOCKET_MESSAGE_BYTES = 64 * 1024;
|
|
11
|
+
const DEFAULT_MAX_UNAUTHENTICATED_WEBSOCKET_CLIENTS = 32;
|
|
12
|
+
function resolvePositiveLimit(value, name, fallback) {
|
|
13
|
+
if (value === undefined) {
|
|
14
|
+
return fallback;
|
|
15
|
+
}
|
|
16
|
+
if (!Number.isSafeInteger(value) || value < 1) {
|
|
17
|
+
throw new Error(`${name} must be a positive safe integer.`);
|
|
18
|
+
}
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
6
21
|
export function mattermostId(value) {
|
|
7
22
|
return createHash("sha256").update(value).digest("hex").slice(0, 26);
|
|
8
23
|
}
|
|
9
|
-
async function appendEvent(state, event) {
|
|
10
|
-
|
|
11
|
-
await
|
|
24
|
+
async function appendEvent(state, event, committed = false) {
|
|
25
|
+
const params = { event, onEvent: state.onEvent, recorderPath: state.recorderPath };
|
|
26
|
+
await (committed ? recordCommittedServerEvent(params) : recordServerEvent(params));
|
|
12
27
|
}
|
|
13
28
|
function authorized(request, token) {
|
|
14
29
|
const [scheme, value] = request.headers.authorization?.trim().split(/\s+/, 2) ?? [];
|
|
@@ -22,6 +37,14 @@ function mattermostError(message, status) {
|
|
|
22
37
|
status_code: status,
|
|
23
38
|
}, status);
|
|
24
39
|
}
|
|
40
|
+
function decodeMattermostPathSegment(value) {
|
|
41
|
+
try {
|
|
42
|
+
return decodeURIComponent(value);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return mattermostError("Invalid path parameter", 400);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
25
48
|
function eventBroadcast(params) {
|
|
26
49
|
return {
|
|
27
50
|
channel_id: params.channelId ?? "",
|
|
@@ -44,22 +67,87 @@ function postEvent(event, post, senderName, channelType) {
|
|
|
44
67
|
broadcast: eventBroadcast({ channelId: post.channel_id, userId: post.user_id }),
|
|
45
68
|
};
|
|
46
69
|
}
|
|
70
|
+
function webSocketEventBytes(event) {
|
|
71
|
+
return Buffer.byteLength(JSON.stringify({ ...event, seq: 0 }), "utf8");
|
|
72
|
+
}
|
|
47
73
|
function sendEvent(state, client, event) {
|
|
48
74
|
const seq = state.websocketClients.get(client);
|
|
49
75
|
if (seq === undefined || client.readyState !== WebSocket.OPEN) {
|
|
50
|
-
|
|
76
|
+
state.websocketClients.delete(client);
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
const payload = JSON.stringify({ ...event, seq });
|
|
80
|
+
if (client.bufferedAmount + Buffer.byteLength(payload, "utf8") >
|
|
81
|
+
state.maxWebSocketBufferedBytes) {
|
|
82
|
+
state.websocketClients.delete(client);
|
|
83
|
+
client.close(1013, "client too slow");
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
client.send(payload);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
state.websocketClients.delete(client);
|
|
91
|
+
client.terminate();
|
|
92
|
+
return false;
|
|
51
93
|
}
|
|
52
|
-
client.send(JSON.stringify({ ...event, seq }));
|
|
53
94
|
state.websocketClients.set(client, seq + 1);
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
function sendControlMessage(state, client, value) {
|
|
98
|
+
if (client.readyState !== WebSocket.OPEN) {
|
|
99
|
+
state.websocketClients.delete(client);
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
const payload = JSON.stringify(value);
|
|
103
|
+
if (client.bufferedAmount + Buffer.byteLength(payload, "utf8") >
|
|
104
|
+
state.maxWebSocketBufferedBytes) {
|
|
105
|
+
state.websocketClients.delete(client);
|
|
106
|
+
client.close(1013, "client too slow");
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
client.send(payload);
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
state.websocketClients.delete(client);
|
|
115
|
+
client.terminate();
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
54
118
|
}
|
|
55
|
-
function broadcast(state, event) {
|
|
56
|
-
if (state.
|
|
57
|
-
|
|
58
|
-
|
|
119
|
+
function broadcast(state, event, queueIfUndelivered = true) {
|
|
120
|
+
if (event.broadcast.omit_users?.[state.botUserId]) {
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
if (webSocketEventBytes(event) > state.maxWebSocketBufferedBytes) {
|
|
124
|
+
for (const client of state.websocketClients.keys()) {
|
|
125
|
+
state.websocketClients.delete(client);
|
|
126
|
+
client.close(1013, "client too slow");
|
|
127
|
+
}
|
|
128
|
+
return false;
|
|
59
129
|
}
|
|
130
|
+
let delivered = false;
|
|
60
131
|
for (const client of state.websocketClients.keys()) {
|
|
61
|
-
sendEvent(state, client, event);
|
|
132
|
+
delivered = sendEvent(state, client, event) || delivered;
|
|
62
133
|
}
|
|
134
|
+
if (delivered) {
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
if (!queueIfUndelivered) {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
if (state.pendingEvents.length >= state.maxPendingInboundEvents) {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
state.pendingEvents.push(event);
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
function pendingQueueFullResponse(state) {
|
|
147
|
+
return jsonResponse({
|
|
148
|
+
error: `Pending inbound queue is full (${state.maxPendingInboundEvents} events)`,
|
|
149
|
+
ok: false,
|
|
150
|
+
}, 503);
|
|
63
151
|
}
|
|
64
152
|
function createPost(params) {
|
|
65
153
|
const id = mattermostId(`post-${params.state.nextPost++}`);
|
|
@@ -75,20 +163,27 @@ function createPost(params) {
|
|
|
75
163
|
params.state.posts.set(id, post);
|
|
76
164
|
return post;
|
|
77
165
|
}
|
|
78
|
-
|
|
166
|
+
function handleAdminInbound(params) {
|
|
79
167
|
const channelId = readTrimmedString(params.body.channelId ?? params.body.channel_id);
|
|
80
168
|
const senderId = readTrimmedString(params.body.senderId ?? params.body.user_id);
|
|
81
169
|
const text = readTrimmedString(params.body.text ?? params.body.message);
|
|
82
170
|
if (!channelId || !senderId || !text) {
|
|
83
171
|
return jsonResponse({ error: "channelId, senderId, and text are required", ok: false }, 400);
|
|
84
172
|
}
|
|
85
|
-
|
|
86
|
-
|
|
173
|
+
if (params.state.websocketClients.size === 0 &&
|
|
174
|
+
params.state.pendingEvents.length >= params.state.maxPendingInboundEvents) {
|
|
175
|
+
return pendingQueueFullResponse(params.state);
|
|
176
|
+
}
|
|
177
|
+
const previousUser = params.state.users.get(senderId);
|
|
178
|
+
const previousChannel = params.state.channels.get(channelId);
|
|
179
|
+
const senderName = readTrimmedString(params.body.senderName) ?? previousUser?.username ?? senderId;
|
|
180
|
+
const channelType = readTrimmedString(params.body.channelType) ?? previousChannel?.type ?? "D";
|
|
181
|
+
const previousNextPost = params.state.nextPost;
|
|
87
182
|
params.state.users.set(senderId, { id: senderId, update_at: Date.now(), username: senderName });
|
|
88
183
|
params.state.channels.set(channelId, {
|
|
89
|
-
display_name: channelId,
|
|
184
|
+
display_name: previousChannel?.display_name ?? channelId,
|
|
90
185
|
id: channelId,
|
|
91
|
-
name: channelId,
|
|
186
|
+
name: previousChannel?.name ?? channelId,
|
|
92
187
|
type: channelType,
|
|
93
188
|
});
|
|
94
189
|
const post = createPost({
|
|
@@ -98,7 +193,31 @@ async function handleAdminInbound(params) {
|
|
|
98
193
|
state: params.state,
|
|
99
194
|
userId: senderId,
|
|
100
195
|
});
|
|
101
|
-
|
|
196
|
+
const rollback = () => {
|
|
197
|
+
params.state.posts.delete(post.id);
|
|
198
|
+
params.state.nextPost = previousNextPost;
|
|
199
|
+
if (previousUser) {
|
|
200
|
+
params.state.users.set(senderId, previousUser);
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
params.state.users.delete(senderId);
|
|
204
|
+
}
|
|
205
|
+
if (previousChannel) {
|
|
206
|
+
params.state.channels.set(channelId, previousChannel);
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
params.state.channels.delete(channelId);
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
const event = postEvent("posted", post, senderName, channelType);
|
|
213
|
+
if (webSocketEventBytes(event) > params.state.maxWebSocketBufferedBytes) {
|
|
214
|
+
rollback();
|
|
215
|
+
return jsonResponse({ error: "Inbound event is too large", ok: false }, 413);
|
|
216
|
+
}
|
|
217
|
+
if (!broadcast(params.state, event)) {
|
|
218
|
+
rollback();
|
|
219
|
+
return pendingQueueFullResponse(params.state);
|
|
220
|
+
}
|
|
102
221
|
return jsonResponse({ ok: true, post });
|
|
103
222
|
}
|
|
104
223
|
async function handleApi(params) {
|
|
@@ -108,7 +227,10 @@ async function handleApi(params) {
|
|
|
108
227
|
}
|
|
109
228
|
const usernameMatch = /^\/users\/username\/([^/]+)$/u.exec(apiPath);
|
|
110
229
|
if (method === "GET" && usernameMatch?.[1]) {
|
|
111
|
-
const username =
|
|
230
|
+
const username = decodeMattermostPathSegment(usernameMatch[1]);
|
|
231
|
+
if (username instanceof Response) {
|
|
232
|
+
return username;
|
|
233
|
+
}
|
|
112
234
|
const user = [...state.users.values()].find((entry) => entry.username === username);
|
|
113
235
|
return user ? jsonResponse(user) : mattermostError("User not found", 404);
|
|
114
236
|
}
|
|
@@ -123,20 +245,36 @@ async function handleApi(params) {
|
|
|
123
245
|
return channel ? jsonResponse(channel) : mattermostError("Channel not found", 404);
|
|
124
246
|
}
|
|
125
247
|
if (method === "POST" && apiPath === "/channels/direct") {
|
|
126
|
-
const userIds = Array.isArray(body) ? body : [];
|
|
127
|
-
if (userIds.length !== 2 || userIds.some((value) =>
|
|
248
|
+
const userIds = Array.isArray(body) ? body.map(readTrimmedString) : [];
|
|
249
|
+
if (userIds.length !== 2 || userIds.some((value) => !value)) {
|
|
128
250
|
return mattermostError("Two user IDs are required", 400);
|
|
129
251
|
}
|
|
130
|
-
const
|
|
252
|
+
const [firstUserId, secondUserId] = userIds;
|
|
253
|
+
if (firstUserId === secondUserId) {
|
|
254
|
+
return mattermostError("Direct channel users must be distinct", 400);
|
|
255
|
+
}
|
|
256
|
+
if (!state.users.has(firstUserId) || !state.users.has(secondUserId)) {
|
|
257
|
+
return mattermostError("User not found", 404);
|
|
258
|
+
}
|
|
259
|
+
if (firstUserId !== state.botUserId && secondUserId !== state.botUserId) {
|
|
260
|
+
return mattermostError("Authenticated user must belong to the direct channel", 403);
|
|
261
|
+
}
|
|
262
|
+
const channelId = mattermostId(`dm:${userIds.sort().join(":")}`);
|
|
131
263
|
const channel = { display_name: "", id: channelId, name: channelId, type: "D" };
|
|
132
264
|
state.channels.set(channelId, channel);
|
|
133
265
|
return jsonResponse(channel, 201);
|
|
134
266
|
}
|
|
267
|
+
if (!isJsonObject(body)) {
|
|
268
|
+
return mattermostError("Request body must be a JSON object", 400);
|
|
269
|
+
}
|
|
135
270
|
if (method === "POST" && apiPath === "/users/me/typing") {
|
|
136
271
|
const channelId = readTrimmedString(body.channel_id);
|
|
137
272
|
if (!channelId) {
|
|
138
273
|
return mattermostError("channel_id is required", 400);
|
|
139
274
|
}
|
|
275
|
+
if (!state.channels.has(channelId)) {
|
|
276
|
+
return mattermostError("Channel not found", 404);
|
|
277
|
+
}
|
|
140
278
|
broadcast(state, {
|
|
141
279
|
broadcast: eventBroadcast({
|
|
142
280
|
channelId,
|
|
@@ -150,8 +288,8 @@ async function handleApi(params) {
|
|
|
150
288
|
user_id: state.botUserId,
|
|
151
289
|
},
|
|
152
290
|
event: "typing",
|
|
153
|
-
});
|
|
154
|
-
return jsonResponse({});
|
|
291
|
+
}, false);
|
|
292
|
+
return jsonResponse({ status: "OK" });
|
|
155
293
|
}
|
|
156
294
|
if (method === "POST" && apiPath === "/posts") {
|
|
157
295
|
const channelId = readTrimmedString(body.channel_id);
|
|
@@ -159,15 +297,28 @@ async function handleApi(params) {
|
|
|
159
297
|
if (!channelId || !message) {
|
|
160
298
|
return mattermostError("channel_id and message are required", 400);
|
|
161
299
|
}
|
|
300
|
+
const channel = state.channels.get(channelId);
|
|
301
|
+
if (!channel) {
|
|
302
|
+
return mattermostError("Channel not found", 404);
|
|
303
|
+
}
|
|
304
|
+
const rootId = readTrimmedString(body.root_id);
|
|
305
|
+
if (rootId) {
|
|
306
|
+
const rootPost = state.posts.get(rootId);
|
|
307
|
+
if (!rootPost) {
|
|
308
|
+
return mattermostError("Root post not found", 404);
|
|
309
|
+
}
|
|
310
|
+
if (rootPost.channel_id !== channelId) {
|
|
311
|
+
return mattermostError("Root post belongs to another channel", 400);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
162
314
|
const post = createPost({
|
|
163
315
|
channelId,
|
|
164
316
|
message,
|
|
165
|
-
rootId
|
|
317
|
+
rootId,
|
|
166
318
|
state,
|
|
167
319
|
userId: state.botUserId,
|
|
168
320
|
});
|
|
169
|
-
|
|
170
|
-
broadcast(state, postEvent("posted", post, state.botUsername, channelType));
|
|
321
|
+
broadcast(state, postEvent("posted", post, state.botUsername, channel.type), false);
|
|
171
322
|
return jsonResponse(post, 201);
|
|
172
323
|
}
|
|
173
324
|
const postMatch = /^\/posts\/([^/]+)$/u.exec(apiPath);
|
|
@@ -176,9 +327,12 @@ async function handleApi(params) {
|
|
|
176
327
|
if (!post) {
|
|
177
328
|
return mattermostError("Post not found", 404);
|
|
178
329
|
}
|
|
330
|
+
if (post.user_id !== state.botUserId) {
|
|
331
|
+
return mattermostError("You do not have permission to edit this post", 403);
|
|
332
|
+
}
|
|
179
333
|
const updated = { ...post, message: readTrimmedString(body.message) ?? post.message };
|
|
180
334
|
state.posts.set(updated.id, updated);
|
|
181
|
-
broadcast(state, postEvent("post_edited", updated, state.botUsername, state.channels.get(updated.channel_id)?.type ?? "O"));
|
|
335
|
+
broadcast(state, postEvent("post_edited", updated, state.botUsername, state.channels.get(updated.channel_id)?.type ?? "O"), false);
|
|
182
336
|
return jsonResponse(updated);
|
|
183
337
|
}
|
|
184
338
|
if (postMatch?.[1] && method === "DELETE") {
|
|
@@ -186,9 +340,12 @@ async function handleApi(params) {
|
|
|
186
340
|
if (!post) {
|
|
187
341
|
return mattermostError("Post not found", 404);
|
|
188
342
|
}
|
|
343
|
+
if (post.user_id !== state.botUserId) {
|
|
344
|
+
return mattermostError("You do not have permission to delete this post", 403);
|
|
345
|
+
}
|
|
189
346
|
state.posts.delete(post.id);
|
|
190
|
-
broadcast(state, postEvent("post_deleted", post, state.botUsername, state.channels.get(post.channel_id)?.type ?? "O"));
|
|
191
|
-
return
|
|
347
|
+
broadcast(state, postEvent("post_deleted", post, state.botUsername, state.channels.get(post.channel_id)?.type ?? "O"), false);
|
|
348
|
+
return jsonResponse({ status: "OK" });
|
|
192
349
|
}
|
|
193
350
|
return mattermostError("Not found", 404);
|
|
194
351
|
}
|
|
@@ -197,9 +354,13 @@ async function handleRequest(request, state) {
|
|
|
197
354
|
const method = request.method ?? "GET";
|
|
198
355
|
if (url.pathname === "/crabline/mattermost/inbound" && method === "POST") {
|
|
199
356
|
if (!hasAdminToken(request, state.adminToken)) {
|
|
357
|
+
drainRequestBody(request);
|
|
200
358
|
return adminAuthError();
|
|
201
359
|
}
|
|
202
|
-
const body = await
|
|
360
|
+
const body = await parseUnknownRequestBody(request);
|
|
361
|
+
if (!isJsonObject(body)) {
|
|
362
|
+
return jsonResponse({ error: "Request body must be a JSON object", ok: false }, 400);
|
|
363
|
+
}
|
|
203
364
|
await appendEvent(state, {
|
|
204
365
|
at: new Date().toISOString(),
|
|
205
366
|
body,
|
|
@@ -208,27 +369,42 @@ async function handleRequest(request, state) {
|
|
|
208
369
|
query: queryRecord(url),
|
|
209
370
|
type: "admin",
|
|
210
371
|
});
|
|
211
|
-
return
|
|
372
|
+
return handleAdminInbound({ body, state });
|
|
212
373
|
}
|
|
213
374
|
if (!url.pathname.startsWith("/api/v4/")) {
|
|
214
375
|
return new Response("not found", { status: 404 });
|
|
215
376
|
}
|
|
216
377
|
if (!authorized(request, state.botToken)) {
|
|
378
|
+
drainRequestBody(request);
|
|
217
379
|
return mattermostError("Invalid or missing token", 401);
|
|
218
380
|
}
|
|
219
|
-
const body = method === "GET" || method === "DELETE" ? {} : await
|
|
220
|
-
|
|
381
|
+
const body = method === "GET" || method === "DELETE" ? {} : await parseUnknownRequestBody(request);
|
|
382
|
+
const event = {
|
|
221
383
|
at: new Date().toISOString(),
|
|
222
|
-
...(Object.keys(body).length > 0 ? { body } : {}),
|
|
384
|
+
...(typeof body === "object" && body !== null && Object.keys(body).length > 0 ? { body } : {}),
|
|
223
385
|
method,
|
|
224
386
|
path: url.pathname,
|
|
225
387
|
query: queryRecord(url),
|
|
226
388
|
type: "api",
|
|
389
|
+
};
|
|
390
|
+
const response = await handleApi({
|
|
391
|
+
body,
|
|
392
|
+
method,
|
|
393
|
+
path: url.pathname.slice("/api/v4".length),
|
|
394
|
+
state,
|
|
227
395
|
});
|
|
228
|
-
|
|
396
|
+
if (method === "POST" && url.pathname === "/api/v4/posts") {
|
|
397
|
+
event.accepted = response.status === 201;
|
|
398
|
+
}
|
|
399
|
+
await appendEvent(state, event, response.ok && method !== "GET");
|
|
400
|
+
return response;
|
|
229
401
|
}
|
|
230
402
|
function attachWebSocketServer(params) {
|
|
231
|
-
const websocketServer = new WebSocketServer({
|
|
403
|
+
const websocketServer = new WebSocketServer({
|
|
404
|
+
maxPayload: params.maxMessageBytes,
|
|
405
|
+
noServer: true,
|
|
406
|
+
});
|
|
407
|
+
const unauthenticatedClients = new Set();
|
|
232
408
|
const onUpgrade = (request, socket, head) => {
|
|
233
409
|
if (new URL(request.url ?? "/", "http://localhost").pathname !== "/api/v4/websocket") {
|
|
234
410
|
socket.destroy();
|
|
@@ -240,6 +416,21 @@ function attachWebSocketServer(params) {
|
|
|
240
416
|
};
|
|
241
417
|
params.server.on("upgrade", onUpgrade);
|
|
242
418
|
websocketServer.on("connection", (client) => {
|
|
419
|
+
client.on("error", () => {
|
|
420
|
+
unauthenticatedClients.delete(client);
|
|
421
|
+
params.state.websocketClients.delete(client);
|
|
422
|
+
});
|
|
423
|
+
if (unauthenticatedClients.size >= params.maxUnauthenticatedClients) {
|
|
424
|
+
client.terminate();
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
unauthenticatedClients.add(client);
|
|
428
|
+
let authenticationOpen = true;
|
|
429
|
+
const authenticationTimeout = setTimeout(() => {
|
|
430
|
+
authenticationOpen = false;
|
|
431
|
+
client.close(4001, "authentication timeout");
|
|
432
|
+
}, params.authenticationTimeoutMs);
|
|
433
|
+
authenticationTimeout.unref();
|
|
243
434
|
client.on("message", (raw) => {
|
|
244
435
|
let message;
|
|
245
436
|
try {
|
|
@@ -249,19 +440,31 @@ function attachWebSocketServer(params) {
|
|
|
249
440
|
client.close(1003, "invalid json");
|
|
250
441
|
return;
|
|
251
442
|
}
|
|
443
|
+
if (!isJsonObject(message)) {
|
|
444
|
+
client.close(1003, "invalid json");
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
252
447
|
const seq = message.seq ?? 0;
|
|
253
448
|
if (!params.state.websocketClients.has(client)) {
|
|
449
|
+
if (!authenticationOpen || client.readyState !== WebSocket.OPEN) {
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
254
452
|
if (message.action !== "authentication_challenge" ||
|
|
255
453
|
message.data?.token !== params.state.botToken) {
|
|
256
|
-
|
|
454
|
+
sendControlMessage(params.state, client, {
|
|
257
455
|
error: { id: "api.context.unauthorized", message: "Authentication failed" },
|
|
258
456
|
seq_reply: seq,
|
|
259
457
|
status: "FAIL",
|
|
260
|
-
})
|
|
458
|
+
});
|
|
459
|
+
authenticationOpen = false;
|
|
460
|
+
client.close(4001, "authentication failed");
|
|
261
461
|
return;
|
|
262
462
|
}
|
|
463
|
+
clearTimeout(authenticationTimeout);
|
|
464
|
+
authenticationOpen = false;
|
|
465
|
+
unauthenticatedClients.delete(client);
|
|
263
466
|
params.state.websocketClients.set(client, 0);
|
|
264
|
-
|
|
467
|
+
sendControlMessage(params.state, client, { seq_reply: seq, status: "OK" });
|
|
265
468
|
sendEvent(params.state, client, {
|
|
266
469
|
broadcast: eventBroadcast({ userId: params.state.botUserId }),
|
|
267
470
|
data: {
|
|
@@ -270,56 +473,86 @@ function attachWebSocketServer(params) {
|
|
|
270
473
|
},
|
|
271
474
|
event: "hello",
|
|
272
475
|
});
|
|
273
|
-
|
|
274
|
-
|
|
476
|
+
const pending = params.state.pendingEvents.splice(0);
|
|
477
|
+
for (const [index, event] of pending.entries()) {
|
|
478
|
+
if (!sendEvent(params.state, client, event)) {
|
|
479
|
+
params.state.pendingEvents.unshift(...pending.slice(index));
|
|
480
|
+
break;
|
|
481
|
+
}
|
|
275
482
|
}
|
|
276
483
|
return;
|
|
277
484
|
}
|
|
278
|
-
if (message.action === "user_typing"
|
|
485
|
+
if (message.action === "user_typing") {
|
|
486
|
+
const channelId = readTrimmedString(message.data?.channel_id);
|
|
487
|
+
if (!channelId || !params.state.channels.has(channelId)) {
|
|
488
|
+
sendControlMessage(params.state, client, {
|
|
489
|
+
error: {
|
|
490
|
+
id: "api.channel.get.find.app_error",
|
|
491
|
+
message: "Channel not found",
|
|
492
|
+
},
|
|
493
|
+
seq_reply: seq,
|
|
494
|
+
status: "FAIL",
|
|
495
|
+
});
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
279
498
|
broadcast(params.state, {
|
|
280
499
|
broadcast: eventBroadcast({
|
|
281
|
-
channelId
|
|
500
|
+
channelId,
|
|
282
501
|
omitUsers: { [params.state.botUserId]: true },
|
|
283
502
|
}),
|
|
284
503
|
data: {
|
|
285
|
-
|
|
504
|
+
channel_id: channelId,
|
|
505
|
+
parent_id: readTrimmedString(message.data?.parent_id) ?? "",
|
|
286
506
|
user_id: params.state.botUserId,
|
|
287
507
|
},
|
|
288
508
|
event: "typing",
|
|
289
|
-
});
|
|
290
|
-
|
|
509
|
+
}, false);
|
|
510
|
+
sendControlMessage(params.state, client, { seq_reply: seq, status: "OK" });
|
|
291
511
|
return;
|
|
292
512
|
}
|
|
293
513
|
if (message.action === "ping") {
|
|
294
|
-
|
|
514
|
+
sendControlMessage(params.state, client, {
|
|
515
|
+
data: { text: "pong" },
|
|
516
|
+
seq_reply: seq,
|
|
517
|
+
status: "OK",
|
|
518
|
+
});
|
|
295
519
|
return;
|
|
296
520
|
}
|
|
297
|
-
|
|
521
|
+
sendControlMessage(params.state, client, {
|
|
298
522
|
error: { id: "api.websocket.invalid_action", message: "Unsupported action" },
|
|
299
523
|
seq_reply: seq,
|
|
300
524
|
status: "FAIL",
|
|
301
|
-
})
|
|
525
|
+
});
|
|
526
|
+
});
|
|
527
|
+
client.once("close", () => {
|
|
528
|
+
authenticationOpen = false;
|
|
529
|
+
clearTimeout(authenticationTimeout);
|
|
530
|
+
unauthenticatedClients.delete(client);
|
|
531
|
+
params.state.websocketClients.delete(client);
|
|
302
532
|
});
|
|
303
|
-
client.once("close", () => params.state.websocketClients.delete(client));
|
|
304
533
|
});
|
|
305
534
|
return async () => {
|
|
306
535
|
params.server.off("upgrade", onUpgrade);
|
|
307
|
-
|
|
308
|
-
client.close();
|
|
309
|
-
}
|
|
310
|
-
await new Promise((resolve, reject) => websocketServer.close((error) => (error ? reject(error) : resolve())));
|
|
536
|
+
await closeWebSocketServer(websocketServer);
|
|
311
537
|
};
|
|
312
538
|
}
|
|
313
539
|
export async function startMattermostServer(params = {}) {
|
|
540
|
+
const host = params.host ?? "127.0.0.1";
|
|
314
541
|
const botUserId = params.botUserId ?? mattermostId("crabline-mattermost-bot");
|
|
315
542
|
const botUsername = params.botUsername ?? "crabline_bot";
|
|
543
|
+
const maxWebSocketMessageBytes = resolvePositiveLimit(params.maxWebSocketMessageBytes, "maxWebSocketMessageBytes", DEFAULT_MAX_WEBSOCKET_MESSAGE_BYTES);
|
|
544
|
+
const maxUnauthenticatedWebSocketClients = resolvePositiveLimit(params.maxUnauthenticatedWebSocketClients, "maxUnauthenticatedWebSocketClients", DEFAULT_MAX_UNAUTHENTICATED_WEBSOCKET_CLIENTS);
|
|
316
545
|
const state = {
|
|
317
546
|
adminToken: params.adminToken ?? randomBytes(24).toString("base64url"),
|
|
318
|
-
botToken: params.botToken ??
|
|
547
|
+
botToken: params.botToken ??
|
|
548
|
+
(isLoopbackHost(host) ? "crabline-mattermost-token" : randomBytes(13).toString("hex")),
|
|
319
549
|
botUserId,
|
|
320
550
|
botUsername,
|
|
321
551
|
channels: new Map(),
|
|
552
|
+
maxPendingInboundEvents: resolveMaxPendingInboundEvents(params.maxPendingInboundEvents),
|
|
553
|
+
maxWebSocketBufferedBytes: resolvePositiveLimit(params.maxWebSocketBufferedBytes, "maxWebSocketBufferedBytes", DEFAULT_MAX_WEBSOCKET_BUFFERED_BYTES),
|
|
322
554
|
nextPost: 1,
|
|
555
|
+
onEvent: params.onEvent,
|
|
323
556
|
pendingEvents: [],
|
|
324
557
|
posts: new Map(),
|
|
325
558
|
recorderPath: params.recorderPath ?? path.resolve(".crabline", "servers", "mattermost.jsonl"),
|
|
@@ -328,14 +561,29 @@ export async function startMattermostServer(params = {}) {
|
|
|
328
561
|
};
|
|
329
562
|
const httpServer = await startHttpJsonServer({
|
|
330
563
|
handle: (request) => handleRequest(request, state),
|
|
331
|
-
|
|
564
|
+
handleError: (error) => {
|
|
565
|
+
if (error instanceof InvalidJsonBodyError) {
|
|
566
|
+
return mattermostError("Request body is not valid JSON", 400);
|
|
567
|
+
}
|
|
568
|
+
if (error instanceof RequestBodyTooLargeError) {
|
|
569
|
+
return mattermostError("Request body is too large", 413);
|
|
570
|
+
}
|
|
571
|
+
return undefined;
|
|
572
|
+
},
|
|
573
|
+
host,
|
|
332
574
|
port: params.port ?? 0,
|
|
333
575
|
serverName: "Mattermost",
|
|
334
576
|
});
|
|
335
|
-
const
|
|
577
|
+
const closeMattermostWebSocketServer = attachWebSocketServer({
|
|
578
|
+
authenticationTimeoutMs: params.websocketAuthenticationTimeoutMs ?? DEFAULT_WEBSOCKET_AUTHENTICATION_TIMEOUT_MS,
|
|
579
|
+
maxMessageBytes: maxWebSocketMessageBytes,
|
|
580
|
+
maxUnauthenticatedClients: maxUnauthenticatedWebSocketClients,
|
|
581
|
+
server: httpServer.server,
|
|
582
|
+
state,
|
|
583
|
+
});
|
|
336
584
|
return {
|
|
337
585
|
async close() {
|
|
338
|
-
await
|
|
586
|
+
await closeMattermostWebSocketServer();
|
|
339
587
|
await httpServer.close();
|
|
340
588
|
},
|
|
341
589
|
manifest: {
|