@openclaw/crabline 0.1.9 → 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 +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 +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 +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 +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 +194 -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.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 +46 -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 +811 -152
- 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,13 +1,29 @@
|
|
|
1
1
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { WebSocket, WebSocketServer } from "ws";
|
|
4
|
-
import { adminAuthError, hasAdminToken, jsonResponse,
|
|
5
|
-
import { recordServerEvent } from "./recorder.js";
|
|
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
|
-
|
|
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));
|
|
11
27
|
}
|
|
12
28
|
function authorized(request, token) {
|
|
13
29
|
const [scheme, value] = request.headers.authorization?.trim().split(/\s+/, 2) ?? [];
|
|
@@ -21,6 +37,14 @@ function mattermostError(message, status) {
|
|
|
21
37
|
status_code: status,
|
|
22
38
|
}, status);
|
|
23
39
|
}
|
|
40
|
+
function decodeMattermostPathSegment(value) {
|
|
41
|
+
try {
|
|
42
|
+
return decodeURIComponent(value);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return mattermostError("Invalid path parameter", 400);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
24
48
|
function eventBroadcast(params) {
|
|
25
49
|
return {
|
|
26
50
|
channel_id: params.channelId ?? "",
|
|
@@ -43,22 +67,87 @@ function postEvent(event, post, senderName, channelType) {
|
|
|
43
67
|
broadcast: eventBroadcast({ channelId: post.channel_id, userId: post.user_id }),
|
|
44
68
|
};
|
|
45
69
|
}
|
|
70
|
+
function webSocketEventBytes(event) {
|
|
71
|
+
return Buffer.byteLength(JSON.stringify({ ...event, seq: 0 }), "utf8");
|
|
72
|
+
}
|
|
46
73
|
function sendEvent(state, client, event) {
|
|
47
74
|
const seq = state.websocketClients.get(client);
|
|
48
75
|
if (seq === undefined || client.readyState !== WebSocket.OPEN) {
|
|
49
|
-
|
|
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;
|
|
50
93
|
}
|
|
51
|
-
client.send(JSON.stringify({ ...event, seq }));
|
|
52
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
|
+
}
|
|
53
118
|
}
|
|
54
|
-
function broadcast(state, event) {
|
|
55
|
-
if (state.
|
|
56
|
-
|
|
57
|
-
|
|
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;
|
|
58
129
|
}
|
|
130
|
+
let delivered = false;
|
|
59
131
|
for (const client of state.websocketClients.keys()) {
|
|
60
|
-
sendEvent(state, client, event);
|
|
132
|
+
delivered = sendEvent(state, client, event) || delivered;
|
|
61
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);
|
|
62
151
|
}
|
|
63
152
|
function createPost(params) {
|
|
64
153
|
const id = mattermostId(`post-${params.state.nextPost++}`);
|
|
@@ -74,20 +163,27 @@ function createPost(params) {
|
|
|
74
163
|
params.state.posts.set(id, post);
|
|
75
164
|
return post;
|
|
76
165
|
}
|
|
77
|
-
|
|
166
|
+
function handleAdminInbound(params) {
|
|
78
167
|
const channelId = readTrimmedString(params.body.channelId ?? params.body.channel_id);
|
|
79
168
|
const senderId = readTrimmedString(params.body.senderId ?? params.body.user_id);
|
|
80
169
|
const text = readTrimmedString(params.body.text ?? params.body.message);
|
|
81
170
|
if (!channelId || !senderId || !text) {
|
|
82
171
|
return jsonResponse({ error: "channelId, senderId, and text are required", ok: false }, 400);
|
|
83
172
|
}
|
|
84
|
-
|
|
85
|
-
|
|
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;
|
|
86
182
|
params.state.users.set(senderId, { id: senderId, update_at: Date.now(), username: senderName });
|
|
87
183
|
params.state.channels.set(channelId, {
|
|
88
|
-
display_name: channelId,
|
|
184
|
+
display_name: previousChannel?.display_name ?? channelId,
|
|
89
185
|
id: channelId,
|
|
90
|
-
name: channelId,
|
|
186
|
+
name: previousChannel?.name ?? channelId,
|
|
91
187
|
type: channelType,
|
|
92
188
|
});
|
|
93
189
|
const post = createPost({
|
|
@@ -97,7 +193,31 @@ async function handleAdminInbound(params) {
|
|
|
97
193
|
state: params.state,
|
|
98
194
|
userId: senderId,
|
|
99
195
|
});
|
|
100
|
-
|
|
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
|
+
}
|
|
101
221
|
return jsonResponse({ ok: true, post });
|
|
102
222
|
}
|
|
103
223
|
async function handleApi(params) {
|
|
@@ -107,7 +227,10 @@ async function handleApi(params) {
|
|
|
107
227
|
}
|
|
108
228
|
const usernameMatch = /^\/users\/username\/([^/]+)$/u.exec(apiPath);
|
|
109
229
|
if (method === "GET" && usernameMatch?.[1]) {
|
|
110
|
-
const username =
|
|
230
|
+
const username = decodeMattermostPathSegment(usernameMatch[1]);
|
|
231
|
+
if (username instanceof Response) {
|
|
232
|
+
return username;
|
|
233
|
+
}
|
|
111
234
|
const user = [...state.users.values()].find((entry) => entry.username === username);
|
|
112
235
|
return user ? jsonResponse(user) : mattermostError("User not found", 404);
|
|
113
236
|
}
|
|
@@ -122,20 +245,36 @@ async function handleApi(params) {
|
|
|
122
245
|
return channel ? jsonResponse(channel) : mattermostError("Channel not found", 404);
|
|
123
246
|
}
|
|
124
247
|
if (method === "POST" && apiPath === "/channels/direct") {
|
|
125
|
-
const userIds = Array.isArray(body) ? body : [];
|
|
126
|
-
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)) {
|
|
127
250
|
return mattermostError("Two user IDs are required", 400);
|
|
128
251
|
}
|
|
129
|
-
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(":")}`);
|
|
130
263
|
const channel = { display_name: "", id: channelId, name: channelId, type: "D" };
|
|
131
264
|
state.channels.set(channelId, channel);
|
|
132
265
|
return jsonResponse(channel, 201);
|
|
133
266
|
}
|
|
267
|
+
if (!isJsonObject(body)) {
|
|
268
|
+
return mattermostError("Request body must be a JSON object", 400);
|
|
269
|
+
}
|
|
134
270
|
if (method === "POST" && apiPath === "/users/me/typing") {
|
|
135
271
|
const channelId = readTrimmedString(body.channel_id);
|
|
136
272
|
if (!channelId) {
|
|
137
273
|
return mattermostError("channel_id is required", 400);
|
|
138
274
|
}
|
|
275
|
+
if (!state.channels.has(channelId)) {
|
|
276
|
+
return mattermostError("Channel not found", 404);
|
|
277
|
+
}
|
|
139
278
|
broadcast(state, {
|
|
140
279
|
broadcast: eventBroadcast({
|
|
141
280
|
channelId,
|
|
@@ -149,8 +288,8 @@ async function handleApi(params) {
|
|
|
149
288
|
user_id: state.botUserId,
|
|
150
289
|
},
|
|
151
290
|
event: "typing",
|
|
152
|
-
});
|
|
153
|
-
return jsonResponse({});
|
|
291
|
+
}, false);
|
|
292
|
+
return jsonResponse({ status: "OK" });
|
|
154
293
|
}
|
|
155
294
|
if (method === "POST" && apiPath === "/posts") {
|
|
156
295
|
const channelId = readTrimmedString(body.channel_id);
|
|
@@ -158,15 +297,28 @@ async function handleApi(params) {
|
|
|
158
297
|
if (!channelId || !message) {
|
|
159
298
|
return mattermostError("channel_id and message are required", 400);
|
|
160
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
|
+
}
|
|
161
314
|
const post = createPost({
|
|
162
315
|
channelId,
|
|
163
316
|
message,
|
|
164
|
-
rootId
|
|
317
|
+
rootId,
|
|
165
318
|
state,
|
|
166
319
|
userId: state.botUserId,
|
|
167
320
|
});
|
|
168
|
-
|
|
169
|
-
broadcast(state, postEvent("posted", post, state.botUsername, channelType));
|
|
321
|
+
broadcast(state, postEvent("posted", post, state.botUsername, channel.type), false);
|
|
170
322
|
return jsonResponse(post, 201);
|
|
171
323
|
}
|
|
172
324
|
const postMatch = /^\/posts\/([^/]+)$/u.exec(apiPath);
|
|
@@ -175,9 +327,12 @@ async function handleApi(params) {
|
|
|
175
327
|
if (!post) {
|
|
176
328
|
return mattermostError("Post not found", 404);
|
|
177
329
|
}
|
|
330
|
+
if (post.user_id !== state.botUserId) {
|
|
331
|
+
return mattermostError("You do not have permission to edit this post", 403);
|
|
332
|
+
}
|
|
178
333
|
const updated = { ...post, message: readTrimmedString(body.message) ?? post.message };
|
|
179
334
|
state.posts.set(updated.id, updated);
|
|
180
|
-
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);
|
|
181
336
|
return jsonResponse(updated);
|
|
182
337
|
}
|
|
183
338
|
if (postMatch?.[1] && method === "DELETE") {
|
|
@@ -185,9 +340,12 @@ async function handleApi(params) {
|
|
|
185
340
|
if (!post) {
|
|
186
341
|
return mattermostError("Post not found", 404);
|
|
187
342
|
}
|
|
343
|
+
if (post.user_id !== state.botUserId) {
|
|
344
|
+
return mattermostError("You do not have permission to delete this post", 403);
|
|
345
|
+
}
|
|
188
346
|
state.posts.delete(post.id);
|
|
189
|
-
broadcast(state, postEvent("post_deleted", post, state.botUsername, state.channels.get(post.channel_id)?.type ?? "O"));
|
|
190
|
-
return
|
|
347
|
+
broadcast(state, postEvent("post_deleted", post, state.botUsername, state.channels.get(post.channel_id)?.type ?? "O"), false);
|
|
348
|
+
return jsonResponse({ status: "OK" });
|
|
191
349
|
}
|
|
192
350
|
return mattermostError("Not found", 404);
|
|
193
351
|
}
|
|
@@ -196,9 +354,13 @@ async function handleRequest(request, state) {
|
|
|
196
354
|
const method = request.method ?? "GET";
|
|
197
355
|
if (url.pathname === "/crabline/mattermost/inbound" && method === "POST") {
|
|
198
356
|
if (!hasAdminToken(request, state.adminToken)) {
|
|
357
|
+
drainRequestBody(request);
|
|
199
358
|
return adminAuthError();
|
|
200
359
|
}
|
|
201
|
-
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
|
+
}
|
|
202
364
|
await appendEvent(state, {
|
|
203
365
|
at: new Date().toISOString(),
|
|
204
366
|
body,
|
|
@@ -207,27 +369,42 @@ async function handleRequest(request, state) {
|
|
|
207
369
|
query: queryRecord(url),
|
|
208
370
|
type: "admin",
|
|
209
371
|
});
|
|
210
|
-
return
|
|
372
|
+
return handleAdminInbound({ body, state });
|
|
211
373
|
}
|
|
212
374
|
if (!url.pathname.startsWith("/api/v4/")) {
|
|
213
375
|
return new Response("not found", { status: 404 });
|
|
214
376
|
}
|
|
215
377
|
if (!authorized(request, state.botToken)) {
|
|
378
|
+
drainRequestBody(request);
|
|
216
379
|
return mattermostError("Invalid or missing token", 401);
|
|
217
380
|
}
|
|
218
|
-
const body = method === "GET" || method === "DELETE" ? {} : await
|
|
219
|
-
|
|
381
|
+
const body = method === "GET" || method === "DELETE" ? {} : await parseUnknownRequestBody(request);
|
|
382
|
+
const event = {
|
|
220
383
|
at: new Date().toISOString(),
|
|
221
|
-
...(Object.keys(body).length > 0 ? { body } : {}),
|
|
384
|
+
...(typeof body === "object" && body !== null && Object.keys(body).length > 0 ? { body } : {}),
|
|
222
385
|
method,
|
|
223
386
|
path: url.pathname,
|
|
224
387
|
query: queryRecord(url),
|
|
225
388
|
type: "api",
|
|
389
|
+
};
|
|
390
|
+
const response = await handleApi({
|
|
391
|
+
body,
|
|
392
|
+
method,
|
|
393
|
+
path: url.pathname.slice("/api/v4".length),
|
|
394
|
+
state,
|
|
226
395
|
});
|
|
227
|
-
|
|
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;
|
|
228
401
|
}
|
|
229
402
|
function attachWebSocketServer(params) {
|
|
230
|
-
const websocketServer = new WebSocketServer({
|
|
403
|
+
const websocketServer = new WebSocketServer({
|
|
404
|
+
maxPayload: params.maxMessageBytes,
|
|
405
|
+
noServer: true,
|
|
406
|
+
});
|
|
407
|
+
const unauthenticatedClients = new Set();
|
|
231
408
|
const onUpgrade = (request, socket, head) => {
|
|
232
409
|
if (new URL(request.url ?? "/", "http://localhost").pathname !== "/api/v4/websocket") {
|
|
233
410
|
socket.destroy();
|
|
@@ -239,6 +416,21 @@ function attachWebSocketServer(params) {
|
|
|
239
416
|
};
|
|
240
417
|
params.server.on("upgrade", onUpgrade);
|
|
241
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();
|
|
242
434
|
client.on("message", (raw) => {
|
|
243
435
|
let message;
|
|
244
436
|
try {
|
|
@@ -248,19 +440,31 @@ function attachWebSocketServer(params) {
|
|
|
248
440
|
client.close(1003, "invalid json");
|
|
249
441
|
return;
|
|
250
442
|
}
|
|
443
|
+
if (!isJsonObject(message)) {
|
|
444
|
+
client.close(1003, "invalid json");
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
251
447
|
const seq = message.seq ?? 0;
|
|
252
448
|
if (!params.state.websocketClients.has(client)) {
|
|
449
|
+
if (!authenticationOpen || client.readyState !== WebSocket.OPEN) {
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
253
452
|
if (message.action !== "authentication_challenge" ||
|
|
254
453
|
message.data?.token !== params.state.botToken) {
|
|
255
|
-
|
|
454
|
+
sendControlMessage(params.state, client, {
|
|
256
455
|
error: { id: "api.context.unauthorized", message: "Authentication failed" },
|
|
257
456
|
seq_reply: seq,
|
|
258
457
|
status: "FAIL",
|
|
259
|
-
})
|
|
458
|
+
});
|
|
459
|
+
authenticationOpen = false;
|
|
460
|
+
client.close(4001, "authentication failed");
|
|
260
461
|
return;
|
|
261
462
|
}
|
|
463
|
+
clearTimeout(authenticationTimeout);
|
|
464
|
+
authenticationOpen = false;
|
|
465
|
+
unauthenticatedClients.delete(client);
|
|
262
466
|
params.state.websocketClients.set(client, 0);
|
|
263
|
-
|
|
467
|
+
sendControlMessage(params.state, client, { seq_reply: seq, status: "OK" });
|
|
264
468
|
sendEvent(params.state, client, {
|
|
265
469
|
broadcast: eventBroadcast({ userId: params.state.botUserId }),
|
|
266
470
|
data: {
|
|
@@ -269,55 +473,84 @@ function attachWebSocketServer(params) {
|
|
|
269
473
|
},
|
|
270
474
|
event: "hello",
|
|
271
475
|
});
|
|
272
|
-
|
|
273
|
-
|
|
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
|
+
}
|
|
274
482
|
}
|
|
275
483
|
return;
|
|
276
484
|
}
|
|
277
|
-
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
|
+
}
|
|
278
498
|
broadcast(params.state, {
|
|
279
499
|
broadcast: eventBroadcast({
|
|
280
|
-
channelId
|
|
500
|
+
channelId,
|
|
281
501
|
omitUsers: { [params.state.botUserId]: true },
|
|
282
502
|
}),
|
|
283
503
|
data: {
|
|
284
|
-
|
|
504
|
+
channel_id: channelId,
|
|
505
|
+
parent_id: readTrimmedString(message.data?.parent_id) ?? "",
|
|
285
506
|
user_id: params.state.botUserId,
|
|
286
507
|
},
|
|
287
508
|
event: "typing",
|
|
288
|
-
});
|
|
289
|
-
|
|
509
|
+
}, false);
|
|
510
|
+
sendControlMessage(params.state, client, { seq_reply: seq, status: "OK" });
|
|
290
511
|
return;
|
|
291
512
|
}
|
|
292
513
|
if (message.action === "ping") {
|
|
293
|
-
|
|
514
|
+
sendControlMessage(params.state, client, {
|
|
515
|
+
data: { text: "pong" },
|
|
516
|
+
seq_reply: seq,
|
|
517
|
+
status: "OK",
|
|
518
|
+
});
|
|
294
519
|
return;
|
|
295
520
|
}
|
|
296
|
-
|
|
521
|
+
sendControlMessage(params.state, client, {
|
|
297
522
|
error: { id: "api.websocket.invalid_action", message: "Unsupported action" },
|
|
298
523
|
seq_reply: seq,
|
|
299
524
|
status: "FAIL",
|
|
300
|
-
})
|
|
525
|
+
});
|
|
526
|
+
});
|
|
527
|
+
client.once("close", () => {
|
|
528
|
+
authenticationOpen = false;
|
|
529
|
+
clearTimeout(authenticationTimeout);
|
|
530
|
+
unauthenticatedClients.delete(client);
|
|
531
|
+
params.state.websocketClients.delete(client);
|
|
301
532
|
});
|
|
302
|
-
client.once("close", () => params.state.websocketClients.delete(client));
|
|
303
533
|
});
|
|
304
534
|
return async () => {
|
|
305
535
|
params.server.off("upgrade", onUpgrade);
|
|
306
|
-
|
|
307
|
-
client.close();
|
|
308
|
-
}
|
|
309
|
-
await new Promise((resolve, reject) => websocketServer.close((error) => (error ? reject(error) : resolve())));
|
|
536
|
+
await closeWebSocketServer(websocketServer);
|
|
310
537
|
};
|
|
311
538
|
}
|
|
312
539
|
export async function startMattermostServer(params = {}) {
|
|
540
|
+
const host = params.host ?? "127.0.0.1";
|
|
313
541
|
const botUserId = params.botUserId ?? mattermostId("crabline-mattermost-bot");
|
|
314
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);
|
|
315
545
|
const state = {
|
|
316
546
|
adminToken: params.adminToken ?? randomBytes(24).toString("base64url"),
|
|
317
|
-
botToken: params.botToken ??
|
|
547
|
+
botToken: params.botToken ??
|
|
548
|
+
(isLoopbackHost(host) ? "crabline-mattermost-token" : randomBytes(13).toString("hex")),
|
|
318
549
|
botUserId,
|
|
319
550
|
botUsername,
|
|
320
551
|
channels: new Map(),
|
|
552
|
+
maxPendingInboundEvents: resolveMaxPendingInboundEvents(params.maxPendingInboundEvents),
|
|
553
|
+
maxWebSocketBufferedBytes: resolvePositiveLimit(params.maxWebSocketBufferedBytes, "maxWebSocketBufferedBytes", DEFAULT_MAX_WEBSOCKET_BUFFERED_BYTES),
|
|
321
554
|
nextPost: 1,
|
|
322
555
|
onEvent: params.onEvent,
|
|
323
556
|
pendingEvents: [],
|
|
@@ -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: {
|