@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,7 +1,7 @@
|
|
|
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, drainRequestBody, hasAdminToken, InvalidJsonBodyError, isJsonObject, isLoopbackHost, jsonResponse, parseUnknownRequestBody, queryRecord,
|
|
4
|
+
import { adminAuthError, constantTimeTokenEqual, drainRequestBody, hasAdminToken, InvalidJsonBodyError, isJsonObject, isJsonMediaType, isLoopbackHost, jsonResponse, parseUnknownRequestBody, queryRecord, RequestBodyTooLargeError, startHttpJsonServer, } from "./http.js";
|
|
5
5
|
import { recordCommittedServerEvent, recordServerEvent, } from "./recorder.js";
|
|
6
6
|
import { resolveMaxPendingInboundEvents } from "./pending-events.js";
|
|
7
7
|
import { closeWebSocketServer } from "./websocket.js";
|
|
@@ -9,6 +9,15 @@ const DEFAULT_WEBSOCKET_AUTHENTICATION_TIMEOUT_MS = 5_000;
|
|
|
9
9
|
const DEFAULT_MAX_WEBSOCKET_BUFFERED_BYTES = 1024 * 1024;
|
|
10
10
|
const DEFAULT_MAX_WEBSOCKET_MESSAGE_BYTES = 64 * 1024;
|
|
11
11
|
const DEFAULT_MAX_UNAUTHENTICATED_WEBSOCKET_CLIENTS = 32;
|
|
12
|
+
const DEFAULT_MAX_COMMITTED_CHANNELS = 1_000;
|
|
13
|
+
const DEFAULT_MAX_COMMITTED_POSTS = 1_000;
|
|
14
|
+
const DEFAULT_MAX_COMMITTED_STATE_BYTES = 64 * 1024 * 1024;
|
|
15
|
+
const DEFAULT_MAX_COMMITTED_USERS = 1_000;
|
|
16
|
+
const DEFAULT_MAX_PENDING_INBOUND_BYTES = 64 * 1024 * 1024;
|
|
17
|
+
const MATTERMOST_ID_PATTERN = /^[a-z0-9]{26}(?![\s\S])/u;
|
|
18
|
+
const MATTERMOST_USERNAME_PATTERN = /^[a-z0-9._-]{1,64}$/u;
|
|
19
|
+
const MATTERMOST_CHANNEL_TYPES = new Set(["D", "G", "O", "P"]);
|
|
20
|
+
const MATTERMOST_RESTRICTED_USERNAMES = new Set(["all", "channel", "matterbot", "system"]);
|
|
12
21
|
function resolvePositiveLimit(value, name, fallback) {
|
|
13
22
|
if (value === undefined) {
|
|
14
23
|
return fallback;
|
|
@@ -26,17 +35,51 @@ async function appendEvent(state, event, committed = false) {
|
|
|
26
35
|
await (committed ? recordCommittedServerEvent(params) : recordServerEvent(params));
|
|
27
36
|
}
|
|
28
37
|
function authorized(request, token) {
|
|
29
|
-
const
|
|
30
|
-
return
|
|
38
|
+
const providedToken = /^Bearer ([^\s]+)$/iu.exec(request.headers.authorization ?? "")?.[1];
|
|
39
|
+
return providedToken ? constantTimeTokenEqual(providedToken, token) : false;
|
|
31
40
|
}
|
|
32
|
-
function mattermostError(message, status) {
|
|
41
|
+
function mattermostError(message, status, options = {}) {
|
|
33
42
|
return jsonResponse({
|
|
34
|
-
|
|
43
|
+
detailed_error: "",
|
|
44
|
+
id: options.id ?? `api.context.${status}.app_error`,
|
|
35
45
|
message,
|
|
36
|
-
request_id:
|
|
46
|
+
request_id: options.requestId ??
|
|
47
|
+
mattermostId(`request-${Date.now()}-${Math.random()}-${message}-${status}`),
|
|
37
48
|
status_code: status,
|
|
38
49
|
}, status);
|
|
39
50
|
}
|
|
51
|
+
function readMattermostString(value) {
|
|
52
|
+
if (typeof value !== "string") {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
const trimmed = value.trim();
|
|
56
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
57
|
+
}
|
|
58
|
+
function readMattermostMessage(value) {
|
|
59
|
+
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
|
|
60
|
+
}
|
|
61
|
+
function readMattermostId(value) {
|
|
62
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
63
|
+
}
|
|
64
|
+
function isMattermostId(value) {
|
|
65
|
+
return MATTERMOST_ID_PATTERN.test(value);
|
|
66
|
+
}
|
|
67
|
+
function normalizeMattermostUsername(value) {
|
|
68
|
+
if (typeof value !== "string" || value !== value.trim()) {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
const normalized = value.toLowerCase();
|
|
72
|
+
return MATTERMOST_USERNAME_PATTERN.test(normalized) &&
|
|
73
|
+
!MATTERMOST_RESTRICTED_USERNAMES.has(normalized)
|
|
74
|
+
? normalized
|
|
75
|
+
: undefined;
|
|
76
|
+
}
|
|
77
|
+
function requestHasJsonMediaType(request) {
|
|
78
|
+
const contentType = request.headers["content-type"];
|
|
79
|
+
return Array.isArray(contentType)
|
|
80
|
+
? contentType.some(isJsonMediaType)
|
|
81
|
+
: typeof contentType === "string" && isJsonMediaType(contentType);
|
|
82
|
+
}
|
|
40
83
|
function decodeMattermostPathSegment(value) {
|
|
41
84
|
try {
|
|
42
85
|
return decodeURIComponent(value);
|
|
@@ -53,22 +96,48 @@ function eventBroadcast(params) {
|
|
|
53
96
|
user_id: params.userId ?? "",
|
|
54
97
|
};
|
|
55
98
|
}
|
|
56
|
-
function postEvent(event, post, senderName,
|
|
99
|
+
function postEvent(event, post, senderName, channel) {
|
|
57
100
|
return {
|
|
58
101
|
event,
|
|
59
|
-
data:
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
102
|
+
data: event === "posted"
|
|
103
|
+
? {
|
|
104
|
+
channel_display_name: channel.display_name,
|
|
105
|
+
channel_name: channel.name,
|
|
106
|
+
channel_type: channel.type,
|
|
107
|
+
post: JSON.stringify(post),
|
|
108
|
+
sender_name: senderName,
|
|
109
|
+
set_online: true,
|
|
110
|
+
team_id: "",
|
|
111
|
+
}
|
|
112
|
+
: { post: JSON.stringify(post) },
|
|
113
|
+
broadcast: eventBroadcast({ channelId: post.channel_id }),
|
|
68
114
|
};
|
|
69
115
|
}
|
|
70
116
|
function webSocketEventBytes(event) {
|
|
71
|
-
return Buffer.byteLength(JSON.stringify({ ...event, seq:
|
|
117
|
+
return Buffer.byteLength(JSON.stringify({ ...event, seq: Number.MAX_SAFE_INTEGER }), "utf8");
|
|
118
|
+
}
|
|
119
|
+
function retainedValueBytes(value) {
|
|
120
|
+
const serialized = JSON.stringify(value);
|
|
121
|
+
return serialized === undefined ? 0 : Buffer.byteLength(serialized, "utf8");
|
|
122
|
+
}
|
|
123
|
+
function retainedValueDelta(previous, next) {
|
|
124
|
+
return retainedValueBytes(next) - retainedValueBytes(previous);
|
|
125
|
+
}
|
|
126
|
+
function canRetainCommittedValues(state, replacements) {
|
|
127
|
+
const delta = replacements.reduce((total, [previous, next]) => total + retainedValueDelta(previous, next), 0);
|
|
128
|
+
return state.committedStateBytes + delta <= state.maxCommittedStateBytes;
|
|
129
|
+
}
|
|
130
|
+
function setCommittedValue(state, values, key, value) {
|
|
131
|
+
state.committedStateBytes += retainedValueDelta(values.get(key), value);
|
|
132
|
+
values.set(key, value);
|
|
133
|
+
}
|
|
134
|
+
function deleteCommittedValue(state, values, key) {
|
|
135
|
+
const previous = values.get(key);
|
|
136
|
+
if (previous === undefined) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
state.committedStateBytes -= retainedValueBytes(previous);
|
|
140
|
+
values.delete(key);
|
|
72
141
|
}
|
|
73
142
|
function sendEvent(state, client, event) {
|
|
74
143
|
const seq = state.websocketClients.get(client);
|
|
@@ -77,8 +146,11 @@ function sendEvent(state, client, event) {
|
|
|
77
146
|
return false;
|
|
78
147
|
}
|
|
79
148
|
const payload = JSON.stringify({ ...event, seq });
|
|
80
|
-
|
|
81
|
-
|
|
149
|
+
const payloadBytes = Buffer.byteLength(payload, "utf8");
|
|
150
|
+
if (payloadBytes > state.maxWebSocketBufferedBytes) {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
if (client.bufferedAmount + payloadBytes > state.maxWebSocketBufferedBytes) {
|
|
82
154
|
state.websocketClients.delete(client);
|
|
83
155
|
client.close(1013, "client too slow");
|
|
84
156
|
return false;
|
|
@@ -121,10 +193,6 @@ function broadcast(state, event, queueIfUndelivered = true) {
|
|
|
121
193
|
return true;
|
|
122
194
|
}
|
|
123
195
|
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
196
|
return false;
|
|
129
197
|
}
|
|
130
198
|
let delivered = false;
|
|
@@ -140,7 +208,12 @@ function broadcast(state, event, queueIfUndelivered = true) {
|
|
|
140
208
|
if (state.pendingEvents.length >= state.maxPendingInboundEvents) {
|
|
141
209
|
return false;
|
|
142
210
|
}
|
|
211
|
+
const eventBytes = retainedValueBytes(event);
|
|
212
|
+
if (state.pendingEventBytes + eventBytes > state.maxPendingInboundBytes) {
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
143
215
|
state.pendingEvents.push(event);
|
|
216
|
+
state.pendingEventBytes += eventBytes;
|
|
144
217
|
return true;
|
|
145
218
|
}
|
|
146
219
|
function pendingQueueFullResponse(state) {
|
|
@@ -149,71 +222,207 @@ function pendingQueueFullResponse(state) {
|
|
|
149
222
|
ok: false,
|
|
150
223
|
}, 503);
|
|
151
224
|
}
|
|
152
|
-
function
|
|
153
|
-
|
|
154
|
-
|
|
225
|
+
function committedStateFullResponse(resource) {
|
|
226
|
+
return jsonResponse({
|
|
227
|
+
error: `Committed Mattermost ${resource} limit reached`,
|
|
228
|
+
ok: false,
|
|
229
|
+
}, 503);
|
|
230
|
+
}
|
|
231
|
+
function buildPost(params) {
|
|
232
|
+
const now = Date.now();
|
|
233
|
+
return {
|
|
155
234
|
channel_id: params.channelId,
|
|
156
|
-
create_at:
|
|
157
|
-
|
|
235
|
+
create_at: now,
|
|
236
|
+
delete_at: 0,
|
|
237
|
+
edit_at: 0,
|
|
238
|
+
id: params.id,
|
|
158
239
|
message: params.message,
|
|
159
240
|
root_id: params.rootId ?? "",
|
|
160
241
|
type: "",
|
|
242
|
+
update_at: now,
|
|
161
243
|
user_id: params.userId,
|
|
162
244
|
};
|
|
163
|
-
|
|
164
|
-
|
|
245
|
+
}
|
|
246
|
+
function buildNextPost(state, params, reservedId) {
|
|
247
|
+
let nextPost = state.nextPost;
|
|
248
|
+
let id;
|
|
249
|
+
do {
|
|
250
|
+
id = mattermostId(`post-${nextPost++}`);
|
|
251
|
+
} while (id === reservedId || state.posts.has(id));
|
|
252
|
+
return { nextPost, post: buildPost({ ...params, id }) };
|
|
253
|
+
}
|
|
254
|
+
function commitNextPost(state, pending) {
|
|
255
|
+
state.nextPost = pending.nextPost;
|
|
256
|
+
setCommittedValue(state, state.posts, pending.post.id, pending.post);
|
|
257
|
+
}
|
|
258
|
+
function commitNextPostIfCapacity(state, pending) {
|
|
259
|
+
if (!canRetainCommittedValues(state, [[undefined, pending.post]])) {
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
commitNextPost(state, pending);
|
|
263
|
+
return true;
|
|
264
|
+
}
|
|
265
|
+
function nextDirectChannelId(state, seed) {
|
|
266
|
+
let collision = 0;
|
|
267
|
+
let channelId;
|
|
268
|
+
do {
|
|
269
|
+
channelId = mattermostId(collision === 0 ? seed : `${seed}:${collision}`);
|
|
270
|
+
collision += 1;
|
|
271
|
+
} while (state.channels.has(channelId));
|
|
272
|
+
return channelId;
|
|
273
|
+
}
|
|
274
|
+
function webSocketEventTooLargeResponse() {
|
|
275
|
+
return mattermostError("WebSocket event is too large", 413);
|
|
165
276
|
}
|
|
166
277
|
function handleAdminInbound(params) {
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
278
|
+
for (const field of ["senderName", "channelType", "rootId", "root_id"]) {
|
|
279
|
+
if (params.body[field] !== undefined && typeof params.body[field] !== "string") {
|
|
280
|
+
return jsonResponse({ error: `${field} must be a string`, ok: false }, 400);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
const channelId = readMattermostId(params.body.channelId ?? params.body.channel_id);
|
|
284
|
+
const senderId = readMattermostId(params.body.senderId ?? params.body.user_id);
|
|
285
|
+
const text = readMattermostMessage(params.body.text ?? params.body.message);
|
|
170
286
|
if (!channelId || !senderId || !text) {
|
|
171
287
|
return jsonResponse({ error: "channelId, senderId, and text are required", ok: false }, 400);
|
|
172
288
|
}
|
|
289
|
+
if (!isMattermostId(channelId)) {
|
|
290
|
+
return jsonResponse({ error: "channelId must be a 26-character Mattermost ID", ok: false }, 400);
|
|
291
|
+
}
|
|
292
|
+
if (!isMattermostId(senderId)) {
|
|
293
|
+
return jsonResponse({ error: "senderId must be a 26-character Mattermost ID", ok: false }, 400);
|
|
294
|
+
}
|
|
295
|
+
const rootId = readMattermostId(params.body.rootId ?? params.body.root_id);
|
|
296
|
+
if (rootId && !isMattermostId(rootId)) {
|
|
297
|
+
return jsonResponse({ error: "rootId must be a 26-character Mattermost ID", ok: false }, 400);
|
|
298
|
+
}
|
|
299
|
+
for (const field of [
|
|
300
|
+
"channelName",
|
|
301
|
+
"channel_name",
|
|
302
|
+
"channelDisplayName",
|
|
303
|
+
"channel_display_name",
|
|
304
|
+
]) {
|
|
305
|
+
if (params.body[field] !== undefined && typeof params.body[field] !== "string") {
|
|
306
|
+
return jsonResponse({ error: `${field} must be a string`, ok: false }, 400);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
173
309
|
if (params.state.websocketClients.size === 0 &&
|
|
174
310
|
params.state.pendingEvents.length >= params.state.maxPendingInboundEvents) {
|
|
175
311
|
return pendingQueueFullResponse(params.state);
|
|
176
312
|
}
|
|
177
313
|
const previousUser = params.state.users.get(senderId);
|
|
178
314
|
const previousChannel = params.state.channels.get(channelId);
|
|
179
|
-
const senderName =
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
315
|
+
const senderName = params.body.senderName === undefined
|
|
316
|
+
? (previousUser?.username ?? senderId)
|
|
317
|
+
: normalizeMattermostUsername(params.body.senderName);
|
|
318
|
+
if (!senderName) {
|
|
319
|
+
return jsonResponse({ error: "senderName must be a valid Mattermost username", ok: false }, 400);
|
|
320
|
+
}
|
|
321
|
+
if (senderId === params.state.botUserId && senderName !== params.state.botUsername) {
|
|
322
|
+
return jsonResponse({ error: "senderName must match the configured bot username", ok: false }, 400);
|
|
323
|
+
}
|
|
324
|
+
const usernameOwner = params.state.userIdsByUsername.get(senderName);
|
|
325
|
+
if (usernameOwner && usernameOwner !== senderId) {
|
|
326
|
+
return jsonResponse({ error: "senderName is already in use", ok: false }, 400);
|
|
327
|
+
}
|
|
328
|
+
const channelType = params.body.channelType === undefined
|
|
329
|
+
? (previousChannel?.type ?? "D")
|
|
330
|
+
: readMattermostString(params.body.channelType);
|
|
331
|
+
if (!channelType || !MATTERMOST_CHANNEL_TYPES.has(channelType)) {
|
|
332
|
+
return jsonResponse({ error: "channelType is not supported", ok: false }, 400);
|
|
333
|
+
}
|
|
334
|
+
const channelName = readMattermostString(params.body.channelName ?? params.body.channel_name) ??
|
|
335
|
+
previousChannel?.name ??
|
|
336
|
+
channelId;
|
|
337
|
+
const channelDisplayName = readMattermostString(params.body.channelDisplayName ?? params.body.channel_display_name) ??
|
|
338
|
+
previousChannel?.display_name ??
|
|
339
|
+
channelName;
|
|
340
|
+
const channel = {
|
|
341
|
+
display_name: channelDisplayName,
|
|
185
342
|
id: channelId,
|
|
186
|
-
name:
|
|
343
|
+
name: channelName,
|
|
187
344
|
type: channelType,
|
|
188
|
-
}
|
|
189
|
-
const
|
|
345
|
+
};
|
|
346
|
+
const existingRoot = rootId ? params.state.posts.get(rootId) : undefined;
|
|
347
|
+
if (existingRoot && existingRoot.channel_id !== channelId) {
|
|
348
|
+
return jsonResponse({ error: "Root post belongs to another channel", ok: false }, 400);
|
|
349
|
+
}
|
|
350
|
+
if (existingRoot?.root_id) {
|
|
351
|
+
return jsonResponse({ error: "Root post is itself a reply", ok: false }, 400);
|
|
352
|
+
}
|
|
353
|
+
if (!previousUser && params.state.users.size >= params.state.maxCommittedUsers) {
|
|
354
|
+
return committedStateFullResponse("users");
|
|
355
|
+
}
|
|
356
|
+
if (!previousChannel && params.state.channels.size >= params.state.maxCommittedChannels) {
|
|
357
|
+
return committedStateFullResponse("channels");
|
|
358
|
+
}
|
|
359
|
+
// buildNextPost always allocates a fresh ID, including against a synthesized root.
|
|
360
|
+
const requiredPostSlots = 1 + (rootId && !existingRoot ? 1 : 0);
|
|
361
|
+
if (params.state.posts.size + requiredPostSlots > params.state.maxCommittedPosts) {
|
|
362
|
+
return committedStateFullResponse("posts");
|
|
363
|
+
}
|
|
364
|
+
const rootPost = rootId && !existingRoot
|
|
365
|
+
? buildPost({
|
|
366
|
+
channelId,
|
|
367
|
+
id: rootId,
|
|
368
|
+
message: "",
|
|
369
|
+
userId: senderId,
|
|
370
|
+
})
|
|
371
|
+
: undefined;
|
|
372
|
+
const pendingPost = buildNextPost(params.state, {
|
|
190
373
|
channelId,
|
|
191
374
|
message: text,
|
|
192
|
-
rootId
|
|
193
|
-
state: params.state,
|
|
375
|
+
rootId,
|
|
194
376
|
userId: senderId,
|
|
195
|
-
});
|
|
377
|
+
}, rootPost?.id);
|
|
378
|
+
const { post } = pendingPost;
|
|
379
|
+
const user = { id: senderId, update_at: Date.now(), username: senderName };
|
|
380
|
+
if (!canRetainCommittedValues(params.state, [
|
|
381
|
+
[previousUser, user],
|
|
382
|
+
[previousChannel, channel],
|
|
383
|
+
[undefined, rootPost],
|
|
384
|
+
[undefined, post],
|
|
385
|
+
])) {
|
|
386
|
+
return committedStateFullResponse("state");
|
|
387
|
+
}
|
|
388
|
+
const event = postEvent("posted", post, senderName, channel);
|
|
389
|
+
if (webSocketEventBytes(event) > params.state.maxWebSocketBufferedBytes) {
|
|
390
|
+
return jsonResponse({ error: "Inbound event is too large", ok: false }, 413);
|
|
391
|
+
}
|
|
392
|
+
const previousNextPost = params.state.nextPost;
|
|
393
|
+
if (previousUser?.username !== senderName) {
|
|
394
|
+
params.state.userIdsByUsername.delete(previousUser?.username ?? "");
|
|
395
|
+
}
|
|
396
|
+
params.state.userIdsByUsername.set(senderName, senderId);
|
|
397
|
+
setCommittedValue(params.state, params.state.users, senderId, user);
|
|
398
|
+
setCommittedValue(params.state, params.state.channels, channelId, channel);
|
|
399
|
+
if (rootPost) {
|
|
400
|
+
setCommittedValue(params.state, params.state.posts, rootPost.id, rootPost);
|
|
401
|
+
}
|
|
402
|
+
commitNextPost(params.state, pendingPost);
|
|
196
403
|
const rollback = () => {
|
|
197
|
-
params.state.posts
|
|
404
|
+
deleteCommittedValue(params.state, params.state.posts, post.id);
|
|
405
|
+
if (rootPost) {
|
|
406
|
+
deleteCommittedValue(params.state, params.state.posts, rootPost.id);
|
|
407
|
+
}
|
|
198
408
|
params.state.nextPost = previousNextPost;
|
|
199
409
|
if (previousUser) {
|
|
200
|
-
params.state.users
|
|
410
|
+
setCommittedValue(params.state, params.state.users, senderId, previousUser);
|
|
411
|
+
params.state.userIdsByUsername.set(previousUser.username, senderId);
|
|
201
412
|
}
|
|
202
413
|
else {
|
|
203
|
-
params.state.users
|
|
414
|
+
deleteCommittedValue(params.state, params.state.users, senderId);
|
|
415
|
+
}
|
|
416
|
+
if (previousUser?.username !== senderName) {
|
|
417
|
+
params.state.userIdsByUsername.delete(senderName);
|
|
204
418
|
}
|
|
205
419
|
if (previousChannel) {
|
|
206
|
-
params.state.channels
|
|
420
|
+
setCommittedValue(params.state, params.state.channels, channelId, previousChannel);
|
|
207
421
|
}
|
|
208
422
|
else {
|
|
209
|
-
params.state.channels
|
|
423
|
+
deleteCommittedValue(params.state, params.state.channels, channelId);
|
|
210
424
|
}
|
|
211
425
|
};
|
|
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
426
|
if (!broadcast(params.state, event)) {
|
|
218
427
|
rollback();
|
|
219
428
|
return pendingQueueFullResponse(params.state);
|
|
@@ -231,7 +440,8 @@ async function handleApi(params) {
|
|
|
231
440
|
if (username instanceof Response) {
|
|
232
441
|
return username;
|
|
233
442
|
}
|
|
234
|
-
const
|
|
443
|
+
const userId = state.userIdsByUsername.get(username.toLowerCase());
|
|
444
|
+
const user = userId ? state.users.get(userId) : undefined;
|
|
235
445
|
return user ? jsonResponse(user) : mattermostError("User not found", 404);
|
|
236
446
|
}
|
|
237
447
|
const userMatch = /^\/users\/([^/]+)$/u.exec(apiPath);
|
|
@@ -245,7 +455,7 @@ async function handleApi(params) {
|
|
|
245
455
|
return channel ? jsonResponse(channel) : mattermostError("Channel not found", 404);
|
|
246
456
|
}
|
|
247
457
|
if (method === "POST" && apiPath === "/channels/direct") {
|
|
248
|
-
const userIds = Array.isArray(body) ? body.map(
|
|
458
|
+
const userIds = Array.isArray(body) ? body.map(readMattermostId) : [];
|
|
249
459
|
if (userIds.length !== 2 || userIds.some((value) => !value)) {
|
|
250
460
|
return mattermostError("Two user IDs are required", 400);
|
|
251
461
|
}
|
|
@@ -259,22 +469,38 @@ async function handleApi(params) {
|
|
|
259
469
|
if (firstUserId !== state.botUserId && secondUserId !== state.botUserId) {
|
|
260
470
|
return mattermostError("Authenticated user must belong to the direct channel", 403);
|
|
261
471
|
}
|
|
262
|
-
const
|
|
263
|
-
const
|
|
264
|
-
state.channels.
|
|
472
|
+
const participantIds = [...userIds].sort();
|
|
473
|
+
const channelName = participantIds.join("__");
|
|
474
|
+
const existingChannel = [...state.channels.values()].find((channel) => channel.type === "D" && channel.name === channelName);
|
|
475
|
+
if (existingChannel) {
|
|
476
|
+
return jsonResponse(existingChannel, 201);
|
|
477
|
+
}
|
|
478
|
+
if (state.channels.size >= state.maxCommittedChannels) {
|
|
479
|
+
return mattermostError("Too many retained channels", 503);
|
|
480
|
+
}
|
|
481
|
+
const channelId = nextDirectChannelId(state, `dm:${participantIds.join(":")}`);
|
|
482
|
+
const channel = { display_name: "", id: channelId, name: channelName, type: "D" };
|
|
483
|
+
if (!canRetainCommittedValues(state, [[undefined, channel]])) {
|
|
484
|
+
return mattermostError("Too much retained state", 503);
|
|
485
|
+
}
|
|
486
|
+
setCommittedValue(state, state.channels, channelId, channel);
|
|
265
487
|
return jsonResponse(channel, 201);
|
|
266
488
|
}
|
|
267
489
|
if (!isJsonObject(body)) {
|
|
268
490
|
return mattermostError("Request body must be a JSON object", 400);
|
|
269
491
|
}
|
|
270
492
|
if (method === "POST" && apiPath === "/users/me/typing") {
|
|
271
|
-
const channelId =
|
|
493
|
+
const channelId = readMattermostId(body.channel_id);
|
|
272
494
|
if (!channelId) {
|
|
273
495
|
return mattermostError("channel_id is required", 400);
|
|
274
496
|
}
|
|
497
|
+
if (body.parent_id !== undefined && typeof body.parent_id !== "string") {
|
|
498
|
+
return mattermostError("parent_id must be a string", 400);
|
|
499
|
+
}
|
|
275
500
|
if (!state.channels.has(channelId)) {
|
|
276
501
|
return mattermostError("Channel not found", 404);
|
|
277
502
|
}
|
|
503
|
+
const parentId = readMattermostId(body.parent_id);
|
|
278
504
|
broadcast(state, {
|
|
279
505
|
broadcast: eventBroadcast({
|
|
280
506
|
channelId,
|
|
@@ -282,9 +508,7 @@ async function handleApi(params) {
|
|
|
282
508
|
}),
|
|
283
509
|
data: {
|
|
284
510
|
channel_id: channelId,
|
|
285
|
-
...(
|
|
286
|
-
? { parent_id: readTrimmedString(body.parent_id) }
|
|
287
|
-
: {}),
|
|
511
|
+
...(parentId ? { parent_id: parentId } : {}),
|
|
288
512
|
user_id: state.botUserId,
|
|
289
513
|
},
|
|
290
514
|
event: "typing",
|
|
@@ -292,16 +516,19 @@ async function handleApi(params) {
|
|
|
292
516
|
return jsonResponse({ status: "OK" });
|
|
293
517
|
}
|
|
294
518
|
if (method === "POST" && apiPath === "/posts") {
|
|
295
|
-
const channelId =
|
|
296
|
-
const message =
|
|
519
|
+
const channelId = readMattermostId(body.channel_id);
|
|
520
|
+
const message = readMattermostMessage(body.message);
|
|
297
521
|
if (!channelId || !message) {
|
|
298
522
|
return mattermostError("channel_id and message are required", 400);
|
|
299
523
|
}
|
|
524
|
+
const rootId = readMattermostId(body.root_id);
|
|
525
|
+
if (body.root_id !== undefined && typeof body.root_id !== "string") {
|
|
526
|
+
return mattermostError("root_id must be a string", 400);
|
|
527
|
+
}
|
|
300
528
|
const channel = state.channels.get(channelId);
|
|
301
529
|
if (!channel) {
|
|
302
530
|
return mattermostError("Channel not found", 404);
|
|
303
531
|
}
|
|
304
|
-
const rootId = readTrimmedString(body.root_id);
|
|
305
532
|
if (rootId) {
|
|
306
533
|
const rootPost = state.posts.get(rootId);
|
|
307
534
|
if (!rootPost) {
|
|
@@ -310,15 +537,25 @@ async function handleApi(params) {
|
|
|
310
537
|
if (rootPost.channel_id !== channelId) {
|
|
311
538
|
return mattermostError("Root post belongs to another channel", 400);
|
|
312
539
|
}
|
|
540
|
+
if (rootPost.root_id) {
|
|
541
|
+
return mattermostError("Root post is itself a reply", 400);
|
|
542
|
+
}
|
|
313
543
|
}
|
|
314
|
-
|
|
544
|
+
if (state.posts.size >= state.maxCommittedPosts) {
|
|
545
|
+
return mattermostError("Too many retained posts", 503);
|
|
546
|
+
}
|
|
547
|
+
const pendingPost = buildNextPost(state, {
|
|
315
548
|
channelId,
|
|
316
549
|
message,
|
|
317
550
|
rootId,
|
|
318
|
-
state,
|
|
319
551
|
userId: state.botUserId,
|
|
320
552
|
});
|
|
321
|
-
|
|
553
|
+
const { post } = pendingPost;
|
|
554
|
+
if (!commitNextPostIfCapacity(state, pendingPost)) {
|
|
555
|
+
return mattermostError("Too much retained state", 503);
|
|
556
|
+
}
|
|
557
|
+
const event = postEvent("posted", post, state.botUsername, channel);
|
|
558
|
+
broadcast(state, event, false);
|
|
322
559
|
return jsonResponse(post, 201);
|
|
323
560
|
}
|
|
324
561
|
const postMatch = /^\/posts\/([^/]+)$/u.exec(apiPath);
|
|
@@ -330,9 +567,21 @@ async function handleApi(params) {
|
|
|
330
567
|
if (post.user_id !== state.botUserId) {
|
|
331
568
|
return mattermostError("You do not have permission to edit this post", 403);
|
|
332
569
|
}
|
|
333
|
-
const
|
|
334
|
-
|
|
335
|
-
|
|
570
|
+
const message = body.message === undefined ? post.message : readMattermostMessage(body.message);
|
|
571
|
+
if (!message) {
|
|
572
|
+
return mattermostError("message must be a string", 400);
|
|
573
|
+
}
|
|
574
|
+
const editedAt = Math.max(Date.now(), post.update_at + 1);
|
|
575
|
+
const updated = { ...post, edit_at: editedAt, message, update_at: editedAt };
|
|
576
|
+
if (!canRetainCommittedValues(state, [[post, updated]])) {
|
|
577
|
+
return mattermostError("Too much retained state", 503);
|
|
578
|
+
}
|
|
579
|
+
const event = postEvent("post_edited", updated, state.botUsername, state.channels.get(updated.channel_id));
|
|
580
|
+
if (webSocketEventBytes(event) > state.maxWebSocketBufferedBytes) {
|
|
581
|
+
return webSocketEventTooLargeResponse();
|
|
582
|
+
}
|
|
583
|
+
setCommittedValue(state, state.posts, updated.id, updated);
|
|
584
|
+
broadcast(state, event, false);
|
|
336
585
|
return jsonResponse(updated);
|
|
337
586
|
}
|
|
338
587
|
if (postMatch?.[1] && method === "DELETE") {
|
|
@@ -343,8 +592,14 @@ async function handleApi(params) {
|
|
|
343
592
|
if (post.user_id !== state.botUserId) {
|
|
344
593
|
return mattermostError("You do not have permission to delete this post", 403);
|
|
345
594
|
}
|
|
346
|
-
|
|
347
|
-
|
|
595
|
+
const deletedAt = Math.max(Date.now(), post.update_at + 1);
|
|
596
|
+
const deletedPost = { ...post, delete_at: deletedAt, update_at: deletedAt };
|
|
597
|
+
const event = postEvent("post_deleted", deletedPost, state.botUsername, state.channels.get(deletedPost.channel_id));
|
|
598
|
+
if (webSocketEventBytes(event) > state.maxWebSocketBufferedBytes) {
|
|
599
|
+
return webSocketEventTooLargeResponse();
|
|
600
|
+
}
|
|
601
|
+
deleteCommittedValue(state, state.posts, post.id);
|
|
602
|
+
broadcast(state, event, false);
|
|
348
603
|
return jsonResponse({ status: "OK" });
|
|
349
604
|
}
|
|
350
605
|
return mattermostError("Not found", 404);
|
|
@@ -352,6 +607,7 @@ async function handleApi(params) {
|
|
|
352
607
|
async function handleRequest(request, state) {
|
|
353
608
|
const url = new URL(request.url ?? "/", "http://localhost");
|
|
354
609
|
const method = request.method ?? "GET";
|
|
610
|
+
const requestId = mattermostId(`request-${Date.now()}-${Math.random()}`);
|
|
355
611
|
if (url.pathname === "/crabline/mattermost/inbound" && method === "POST") {
|
|
356
612
|
if (!hasAdminToken(request, state.adminToken)) {
|
|
357
613
|
drainRequestBody(request);
|
|
@@ -371,14 +627,29 @@ async function handleRequest(request, state) {
|
|
|
371
627
|
});
|
|
372
628
|
return handleAdminInbound({ body, state });
|
|
373
629
|
}
|
|
374
|
-
if (!url.pathname.startsWith("/api/v4/")) {
|
|
375
|
-
|
|
630
|
+
if (url.pathname !== "/api/v4" && !url.pathname.startsWith("/api/v4/")) {
|
|
631
|
+
drainRequestBody(request);
|
|
632
|
+
return mattermostError("Sorry, we could not find the page.", 404, {
|
|
633
|
+
id: "api.context.404.app_error",
|
|
634
|
+
requestId,
|
|
635
|
+
});
|
|
376
636
|
}
|
|
377
637
|
if (!authorized(request, state.botToken)) {
|
|
378
638
|
drainRequestBody(request);
|
|
379
|
-
return mattermostError("Invalid or
|
|
639
|
+
return mattermostError("Invalid or expired session, please login again.", 401, {
|
|
640
|
+
id: "api.context.session_expired.app_error",
|
|
641
|
+
requestId,
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
const bodylessMethod = method === "GET" || method === "DELETE";
|
|
645
|
+
if (bodylessMethod) {
|
|
646
|
+
drainRequestBody(request);
|
|
380
647
|
}
|
|
381
|
-
const
|
|
648
|
+
const requiresJsonPost = method === "POST" && url.pathname === "/api/v4/posts" && !requestHasJsonMediaType(request);
|
|
649
|
+
if (requiresJsonPost) {
|
|
650
|
+
drainRequestBody(request);
|
|
651
|
+
}
|
|
652
|
+
const body = bodylessMethod || requiresJsonPost ? {} : await parseUnknownRequestBody(request);
|
|
382
653
|
const event = {
|
|
383
654
|
at: new Date().toISOString(),
|
|
384
655
|
...(typeof body === "object" && body !== null && Object.keys(body).length > 0 ? { body } : {}),
|
|
@@ -387,15 +658,21 @@ async function handleRequest(request, state) {
|
|
|
387
658
|
query: queryRecord(url),
|
|
388
659
|
type: "api",
|
|
389
660
|
};
|
|
390
|
-
const response =
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
661
|
+
const response = requiresJsonPost
|
|
662
|
+
? mattermostError("Content-Type must be application/json.", 415, {
|
|
663
|
+
id: "api.context.unsupported_content_type.app_error",
|
|
664
|
+
requestId,
|
|
665
|
+
})
|
|
666
|
+
: await handleApi({
|
|
667
|
+
body,
|
|
668
|
+
method,
|
|
669
|
+
path: url.pathname.slice("/api/v4".length),
|
|
670
|
+
state,
|
|
671
|
+
});
|
|
396
672
|
if (method === "POST" && url.pathname === "/api/v4/posts") {
|
|
397
673
|
event.accepted = response.status === 201;
|
|
398
674
|
}
|
|
675
|
+
event.accepted ??= response.ok;
|
|
399
676
|
await appendEvent(state, event, response.ok && method !== "GET");
|
|
400
677
|
return response;
|
|
401
678
|
}
|
|
@@ -450,7 +727,8 @@ function attachWebSocketServer(params) {
|
|
|
450
727
|
return;
|
|
451
728
|
}
|
|
452
729
|
if (message.action !== "authentication_challenge" ||
|
|
453
|
-
message.data?.token !==
|
|
730
|
+
typeof message.data?.token !== "string" ||
|
|
731
|
+
!constantTimeTokenEqual(message.data.token, params.state.botToken)) {
|
|
454
732
|
sendControlMessage(params.state, client, {
|
|
455
733
|
error: { id: "api.context.unauthorized", message: "Authentication failed" },
|
|
456
734
|
seq_reply: seq,
|
|
@@ -474,16 +752,19 @@ function attachWebSocketServer(params) {
|
|
|
474
752
|
event: "hello",
|
|
475
753
|
});
|
|
476
754
|
const pending = params.state.pendingEvents.splice(0);
|
|
755
|
+
params.state.pendingEventBytes = 0;
|
|
477
756
|
for (const [index, event] of pending.entries()) {
|
|
478
757
|
if (!sendEvent(params.state, client, event)) {
|
|
479
|
-
|
|
758
|
+
const remaining = pending.slice(index);
|
|
759
|
+
params.state.pendingEvents.unshift(...remaining);
|
|
760
|
+
params.state.pendingEventBytes = remaining.reduce((total, queuedEvent) => total + retainedValueBytes(queuedEvent), 0);
|
|
480
761
|
break;
|
|
481
762
|
}
|
|
482
763
|
}
|
|
483
764
|
return;
|
|
484
765
|
}
|
|
485
766
|
if (message.action === "user_typing") {
|
|
486
|
-
const channelId =
|
|
767
|
+
const channelId = readMattermostId(message.data?.channel_id);
|
|
487
768
|
if (!channelId || !params.state.channels.has(channelId)) {
|
|
488
769
|
sendControlMessage(params.state, client, {
|
|
489
770
|
error: {
|
|
@@ -502,7 +783,7 @@ function attachWebSocketServer(params) {
|
|
|
502
783
|
}),
|
|
503
784
|
data: {
|
|
504
785
|
channel_id: channelId,
|
|
505
|
-
parent_id:
|
|
786
|
+
parent_id: readMattermostId(message.data?.parent_id) ?? "",
|
|
506
787
|
user_id: params.state.botUserId,
|
|
507
788
|
},
|
|
508
789
|
event: "typing",
|
|
@@ -539,9 +820,18 @@ function attachWebSocketServer(params) {
|
|
|
539
820
|
export async function startMattermostServer(params = {}) {
|
|
540
821
|
const host = params.host ?? "127.0.0.1";
|
|
541
822
|
const botUserId = params.botUserId ?? mattermostId("crabline-mattermost-bot");
|
|
542
|
-
const botUsername = params.botUsername ?? "crabline_bot";
|
|
823
|
+
const botUsername = normalizeMattermostUsername(params.botUsername ?? "crabline_bot");
|
|
824
|
+
if (!botUsername) {
|
|
825
|
+
throw new Error("botUsername must be a valid Mattermost username.");
|
|
826
|
+
}
|
|
543
827
|
const maxWebSocketMessageBytes = resolvePositiveLimit(params.maxWebSocketMessageBytes, "maxWebSocketMessageBytes", DEFAULT_MAX_WEBSOCKET_MESSAGE_BYTES);
|
|
544
828
|
const maxUnauthenticatedWebSocketClients = resolvePositiveLimit(params.maxUnauthenticatedWebSocketClients, "maxUnauthenticatedWebSocketClients", DEFAULT_MAX_UNAUTHENTICATED_WEBSOCKET_CLIENTS);
|
|
829
|
+
const maxCommittedStateBytes = resolvePositiveLimit(params.maxCommittedStateBytes, "maxCommittedStateBytes", DEFAULT_MAX_COMMITTED_STATE_BYTES);
|
|
830
|
+
const botUser = { id: botUserId, update_at: Date.now(), username: botUsername };
|
|
831
|
+
const initialCommittedStateBytes = retainedValueBytes(botUser);
|
|
832
|
+
if (initialCommittedStateBytes > maxCommittedStateBytes) {
|
|
833
|
+
throw new Error("maxCommittedStateBytes cannot retain the configured bot user.");
|
|
834
|
+
}
|
|
545
835
|
const state = {
|
|
546
836
|
adminToken: params.adminToken ?? randomBytes(24).toString("base64url"),
|
|
547
837
|
botToken: params.botToken ??
|
|
@@ -549,14 +839,22 @@ export async function startMattermostServer(params = {}) {
|
|
|
549
839
|
botUserId,
|
|
550
840
|
botUsername,
|
|
551
841
|
channels: new Map(),
|
|
842
|
+
committedStateBytes: initialCommittedStateBytes,
|
|
843
|
+
maxCommittedChannels: resolvePositiveLimit(params.maxCommittedChannels, "maxCommittedChannels", DEFAULT_MAX_COMMITTED_CHANNELS),
|
|
844
|
+
maxCommittedPosts: resolvePositiveLimit(params.maxCommittedPosts, "maxCommittedPosts", DEFAULT_MAX_COMMITTED_POSTS),
|
|
845
|
+
maxCommittedStateBytes,
|
|
846
|
+
maxCommittedUsers: resolvePositiveLimit(params.maxCommittedUsers, "maxCommittedUsers", DEFAULT_MAX_COMMITTED_USERS),
|
|
847
|
+
maxPendingInboundBytes: resolvePositiveLimit(params.maxPendingInboundBytes, "maxPendingInboundBytes", DEFAULT_MAX_PENDING_INBOUND_BYTES),
|
|
552
848
|
maxPendingInboundEvents: resolveMaxPendingInboundEvents(params.maxPendingInboundEvents),
|
|
553
849
|
maxWebSocketBufferedBytes: resolvePositiveLimit(params.maxWebSocketBufferedBytes, "maxWebSocketBufferedBytes", DEFAULT_MAX_WEBSOCKET_BUFFERED_BYTES),
|
|
554
850
|
nextPost: 1,
|
|
555
851
|
onEvent: params.onEvent,
|
|
852
|
+
pendingEventBytes: 0,
|
|
556
853
|
pendingEvents: [],
|
|
557
854
|
posts: new Map(),
|
|
558
855
|
recorderPath: params.recorderPath ?? path.resolve(".crabline", "servers", "mattermost.jsonl"),
|
|
559
|
-
|
|
856
|
+
userIdsByUsername: new Map([[botUsername, botUserId]]),
|
|
857
|
+
users: new Map([[botUserId, botUser]]),
|
|
560
858
|
websocketClients: new Map(),
|
|
561
859
|
};
|
|
562
860
|
const httpServer = await startHttpJsonServer({
|