@openclaw/crabline 0.1.9 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (189) hide show
  1. package/README.md +47 -22
  2. package/assets/crabline-banner.svg +20 -0
  3. package/dist/src/bin/crabline.js.map +1 -1
  4. package/dist/src/cli/program.d.ts +25 -1
  5. package/dist/src/cli/program.js +579 -82
  6. package/dist/src/cli/program.js.map +1 -1
  7. package/dist/src/config/load.js +59 -6
  8. package/dist/src/config/load.js.map +1 -1
  9. package/dist/src/config/schema.d.ts +116 -92
  10. package/dist/src/config/schema.js +179 -73
  11. package/dist/src/config/schema.js.map +1 -1
  12. package/dist/src/core/errors.js +9 -2
  13. package/dist/src/core/errors.js.map +1 -1
  14. package/dist/src/core/exit-codes.js.map +1 -1
  15. package/dist/src/core/matcher.js +7 -6
  16. package/dist/src/core/matcher.js.map +1 -1
  17. package/dist/src/core/message-template.js.map +1 -1
  18. package/dist/src/core/nonces.d.ts +1 -0
  19. package/dist/src/core/nonces.js +4 -2
  20. package/dist/src/core/nonces.js.map +1 -1
  21. package/dist/src/core/reporters.d.ts +1 -0
  22. package/dist/src/core/reporters.js +42 -4
  23. package/dist/src/core/reporters.js.map +1 -1
  24. package/dist/src/core/run.d.ts +3 -0
  25. package/dist/src/core/run.js +415 -107
  26. package/dist/src/core/run.js.map +1 -1
  27. package/dist/src/core/safe-regex.d.ts +3 -0
  28. package/dist/src/core/safe-regex.js +21 -0
  29. package/dist/src/core/safe-regex.js.map +1 -0
  30. package/dist/src/index.d.ts +2 -2
  31. package/dist/src/index.js +1 -1
  32. package/dist/src/index.js.map +1 -1
  33. package/dist/src/openclaw/artifact-generation.d.ts +43 -0
  34. package/dist/src/openclaw/artifact-generation.js +363 -0
  35. package/dist/src/openclaw/artifact-generation.js.map +1 -0
  36. package/dist/src/openclaw/bridges/matrix.js +97 -12
  37. package/dist/src/openclaw/bridges/matrix.js.map +1 -1
  38. package/dist/src/openclaw/bridges/mattermost.js +32 -10
  39. package/dist/src/openclaw/bridges/mattermost.js.map +1 -1
  40. package/dist/src/openclaw/bridges/signal.js +55 -18
  41. package/dist/src/openclaw/bridges/signal.js.map +1 -1
  42. package/dist/src/openclaw/bridges/slack.js +85 -7
  43. package/dist/src/openclaw/bridges/slack.js.map +1 -1
  44. package/dist/src/openclaw/bridges/telegram.js +124 -30
  45. package/dist/src/openclaw/bridges/telegram.js.map +1 -1
  46. package/dist/src/openclaw/bridges/whatsapp.js +78 -17
  47. package/dist/src/openclaw/bridges/whatsapp.js.map +1 -1
  48. package/dist/src/openclaw/bridges/zalo.js +14 -5
  49. package/dist/src/openclaw/bridges/zalo.js.map +1 -1
  50. package/dist/src/openclaw/outbound-contract.d.ts +5 -0
  51. package/dist/src/openclaw/outbound-contract.js +40 -0
  52. package/dist/src/openclaw/outbound-contract.js.map +1 -0
  53. package/dist/src/openclaw/private-file.d.ts +28 -0
  54. package/dist/src/openclaw/private-file.js +465 -0
  55. package/dist/src/openclaw/private-file.js.map +1 -0
  56. package/dist/src/openclaw/shared.d.ts +21 -2
  57. package/dist/src/openclaw/shared.js +115 -18
  58. package/dist/src/openclaw/shared.js.map +1 -1
  59. package/dist/src/openclaw/smoke-lock.d.ts +63 -0
  60. package/dist/src/openclaw/smoke-lock.js +1004 -0
  61. package/dist/src/openclaw/smoke-lock.js.map +1 -0
  62. package/dist/src/openclaw.d.ts +16 -8
  63. package/dist/src/openclaw.js +334 -52
  64. package/dist/src/openclaw.js.map +1 -1
  65. package/dist/src/providers/builtin/discord.d.ts +10 -1
  66. package/dist/src/providers/builtin/discord.js +144 -18
  67. package/dist/src/providers/builtin/discord.js.map +1 -1
  68. package/dist/src/providers/builtin/external-webhook-auth.d.ts +11 -0
  69. package/dist/src/providers/builtin/external-webhook-auth.js +10 -0
  70. package/dist/src/providers/builtin/external-webhook-auth.js.map +1 -0
  71. package/dist/src/providers/builtin/feishu.d.ts +19 -2
  72. package/dist/src/providers/builtin/feishu.js +307 -25
  73. package/dist/src/providers/builtin/feishu.js.map +1 -1
  74. package/dist/src/providers/builtin/googlechat.d.ts +20 -1
  75. package/dist/src/providers/builtin/googlechat.js +178 -19
  76. package/dist/src/providers/builtin/googlechat.js.map +1 -1
  77. package/dist/src/providers/builtin/imessage.d.ts +4 -0
  78. package/dist/src/providers/builtin/imessage.js +26 -11
  79. package/dist/src/providers/builtin/imessage.js.map +1 -1
  80. package/dist/src/providers/builtin/loopback.js +92 -30
  81. package/dist/src/providers/builtin/loopback.js.map +1 -1
  82. package/dist/src/providers/builtin/matrix.d.ts +11 -1
  83. package/dist/src/providers/builtin/matrix.js +53 -29
  84. package/dist/src/providers/builtin/matrix.js.map +1 -1
  85. package/dist/src/providers/builtin/mattermost.d.ts +10 -0
  86. package/dist/src/providers/builtin/mattermost.js +38 -15
  87. package/dist/src/providers/builtin/mattermost.js.map +1 -1
  88. package/dist/src/providers/builtin/msteams.d.ts +17 -1
  89. package/dist/src/providers/builtin/msteams.js +113 -14
  90. package/dist/src/providers/builtin/msteams.js.map +1 -1
  91. package/dist/src/providers/builtin/native-local-mock.d.ts +2 -9
  92. package/dist/src/providers/builtin/native-local-mock.js +12 -35
  93. package/dist/src/providers/builtin/native-local-mock.js.map +1 -1
  94. package/dist/src/providers/builtin/script.d.ts +11 -15
  95. package/dist/src/providers/builtin/script.js +566 -104
  96. package/dist/src/providers/builtin/script.js.map +1 -1
  97. package/dist/src/providers/builtin/slack.d.ts +6 -0
  98. package/dist/src/providers/builtin/slack.js +99 -40
  99. package/dist/src/providers/builtin/slack.js.map +1 -1
  100. package/dist/src/providers/builtin/telegram.d.ts +6 -0
  101. package/dist/src/providers/builtin/telegram.js +64 -43
  102. package/dist/src/providers/builtin/telegram.js.map +1 -1
  103. package/dist/src/providers/builtin/whatsapp.d.ts +28 -8
  104. package/dist/src/providers/builtin/whatsapp.js +567 -30
  105. package/dist/src/providers/builtin/whatsapp.js.map +1 -1
  106. package/dist/src/providers/builtin/zalo.js +30 -19
  107. package/dist/src/providers/builtin/zalo.js.map +1 -1
  108. package/dist/src/providers/catalog.d.ts +3 -3
  109. package/dist/src/providers/catalog.js +3 -3
  110. package/dist/src/providers/catalog.js.map +1 -1
  111. package/dist/src/providers/local-mock.d.ts +11 -2
  112. package/dist/src/providers/local-mock.js +322 -96
  113. package/dist/src/providers/local-mock.js.map +1 -1
  114. package/dist/src/providers/native-ids.d.ts +1 -0
  115. package/dist/src/providers/native-ids.js +6 -1
  116. package/dist/src/providers/native-ids.js.map +1 -1
  117. package/dist/src/providers/recorder.d.ts +30 -2
  118. package/dist/src/providers/recorder.js +549 -70
  119. package/dist/src/providers/recorder.js.map +1 -1
  120. package/dist/src/providers/registry.d.ts +25 -1
  121. package/dist/src/providers/registry.js +280 -40
  122. package/dist/src/providers/registry.js.map +1 -1
  123. package/dist/src/providers/signed-jwt.d.ts +29 -0
  124. package/dist/src/providers/signed-jwt.js +202 -0
  125. package/dist/src/providers/signed-jwt.js.map +1 -0
  126. package/dist/src/providers/slack-ids.js.map +1 -1
  127. package/dist/src/providers/target-normalizers.d.ts +26 -0
  128. package/dist/src/providers/target-normalizers.js +276 -0
  129. package/dist/src/providers/target-normalizers.js.map +1 -0
  130. package/dist/src/providers/types.d.ts +5 -0
  131. package/dist/src/providers/types.js.map +1 -1
  132. package/dist/src/providers/webhook-server.d.ts +2 -0
  133. package/dist/src/providers/webhook-server.js +72 -25
  134. package/dist/src/providers/webhook-server.js.map +1 -1
  135. package/dist/src/servers/http.d.ts +23 -3
  136. package/dist/src/servers/http.js +213 -20
  137. package/dist/src/servers/http.js.map +1 -1
  138. package/dist/src/servers/index.js +20 -21
  139. package/dist/src/servers/index.js.map +1 -1
  140. package/dist/src/servers/matrix.js +465 -51
  141. package/dist/src/servers/matrix.js.map +1 -1
  142. package/dist/src/servers/mattermost.d.ts +5 -0
  143. package/dist/src/servers/mattermost.js +306 -58
  144. package/dist/src/servers/mattermost.js.map +1 -1
  145. package/dist/src/servers/pending-events.d.ts +2 -0
  146. package/dist/src/servers/pending-events.js +11 -0
  147. package/dist/src/servers/pending-events.js.map +1 -0
  148. package/dist/src/servers/recorder.d.ts +5 -0
  149. package/dist/src/servers/recorder.js +262 -3
  150. package/dist/src/servers/recorder.js.map +1 -1
  151. package/dist/src/servers/signal.d.ts +2 -0
  152. package/dist/src/servers/signal.js +444 -56
  153. package/dist/src/servers/signal.js.map +1 -1
  154. package/dist/src/servers/slack.d.ts +5 -0
  155. package/dist/src/servers/slack.js +515 -49
  156. package/dist/src/servers/slack.js.map +1 -1
  157. package/dist/src/servers/telegram.d.ts +111 -0
  158. package/dist/src/servers/telegram.js +830 -158
  159. package/dist/src/servers/telegram.js.map +1 -1
  160. package/dist/src/servers/webhook-target.d.ts +40 -0
  161. package/dist/src/servers/webhook-target.js +349 -0
  162. package/dist/src/servers/webhook-target.js.map +1 -0
  163. package/dist/src/servers/websocket.d.ts +2 -0
  164. package/dist/src/servers/websocket.js +29 -0
  165. package/dist/src/servers/websocket.js.map +1 -0
  166. package/dist/src/servers/whatsapp-baileys-websocket.d.ts +65 -1
  167. package/dist/src/servers/whatsapp-baileys-websocket.js +723 -99
  168. package/dist/src/servers/whatsapp-baileys-websocket.js.map +1 -1
  169. package/dist/src/servers/whatsapp-jid.d.ts +5 -0
  170. package/dist/src/servers/whatsapp-jid.js +32 -0
  171. package/dist/src/servers/whatsapp-jid.js.map +1 -0
  172. package/dist/src/servers/whatsapp-wire/binary-node.d.ts +5 -0
  173. package/dist/src/servers/whatsapp-wire/binary-node.js +87 -20
  174. package/dist/src/servers/whatsapp-wire/binary-node.js.map +1 -1
  175. package/dist/src/servers/whatsapp-wire/crypto.d.ts +6 -1
  176. package/dist/src/servers/whatsapp-wire/crypto.js +99 -45
  177. package/dist/src/servers/whatsapp-wire/crypto.js.map +1 -1
  178. package/dist/src/servers/whatsapp-wire/handshake.js +39 -4
  179. package/dist/src/servers/whatsapp-wire/handshake.js.map +1 -1
  180. package/dist/src/servers/whatsapp-wire/signal.js.map +1 -1
  181. package/dist/src/servers/whatsapp.d.ts +12 -7
  182. package/dist/src/servers/whatsapp.js +269 -107
  183. package/dist/src/servers/whatsapp.js.map +1 -1
  184. package/dist/src/servers/zalo.d.ts +14 -0
  185. package/dist/src/servers/zalo.js +521 -88
  186. package/dist/src/servers/zalo.js.map +1 -1
  187. package/docs/channel-setup.md +197 -19
  188. package/fixtures/examples/crabline.example.yaml +10 -0
  189. package/package.json +20 -15
@@ -1,8 +1,12 @@
1
- import { randomBytes } from "node:crypto";
1
+ import { createHmac, randomBytes, randomInt, timingSafeEqual } from "node:crypto";
2
2
  import path from "node:path";
3
- import { adminAuthError, hasAdminToken, jsonResponse, parseRequestBody, queryRecord, readInteger, readTrimmedString, startHttpJsonServer, } from "./http.js";
3
+ import { adminAuthError, hasAdminToken, InvalidJsonBodyError, isJsonObject, isLoopbackHost, jsonResponse, parseUnknownRequestBody, queryRecord, readBody, readInteger, readTrimmedString, RequestBodyTooLargeError, startHttpJsonServer, } from "./http.js";
4
4
  import { SLACK_CHANNEL_ID_RULE, SLACK_SEND_TARGET_ID_RULE, SLACK_TS_RULE, SLACK_USER_ID_RULE, } from "../providers/slack-ids.js";
5
- import { recordServerEvent } from "./recorder.js";
5
+ import { recordCommittedServerEvent, recordServerEvent, } from "./recorder.js";
6
+ const SLACK_SIGNATURE_TOLERANCE_SECONDS = 5 * 60;
7
+ const SLACK_EVENT_MAX_RETRIES = 3;
8
+ const SLACK_EVENT_MAX_REDIRECTS = 2;
9
+ const SLACK_EVENT_RETRY_DELAYS_MS = [0, 60_000, 5 * 60_000];
6
10
  function slackOk(result = {}) {
7
11
  return jsonResponse({ ok: true, ...result });
8
12
  }
@@ -18,10 +22,22 @@ function slackRateLimited(retryAfterSeconds = 1) {
18
22
  status: 429,
19
23
  });
20
24
  }
25
+ function resolveChatPostMessageRateLimit(value) {
26
+ if (value === undefined) {
27
+ return undefined;
28
+ }
29
+ if (!Number.isSafeInteger(value.remaining) || value.remaining < 0) {
30
+ throw new Error("chatPostMessageRateLimit.remaining must be a non-negative safe integer.");
31
+ }
32
+ if (!Number.isSafeInteger(value.retryAfterSeconds) || value.retryAfterSeconds < 1) {
33
+ throw new Error("chatPostMessageRateLimit.retryAfterSeconds must be a positive safe integer.");
34
+ }
35
+ return { ...value };
36
+ }
21
37
  function requireSlackToken(request, body, state) {
22
38
  const authorization = request.headers.authorization;
23
- const tokenFromHeader = typeof authorization === "string" && authorization.startsWith("Bearer ")
24
- ? authorization.slice("Bearer ".length).trim()
39
+ const tokenFromHeader = typeof authorization === "string"
40
+ ? /^Bearer\s+(.+)$/iu.exec(authorization.trim())?.[1]?.trim()
25
41
  : undefined;
26
42
  const token = tokenFromHeader ?? readTrimmedString(body.token);
27
43
  if (!token) {
@@ -76,6 +92,9 @@ function readBoolean(value) {
76
92
  }
77
93
  return undefined;
78
94
  }
95
+ function readSlackText(value) {
96
+ return typeof value === "string" ? value : undefined;
97
+ }
79
98
  function readStructuredValue(value) {
80
99
  if (typeof value !== "string") {
81
100
  return value;
@@ -91,6 +110,34 @@ function readStructuredValue(value) {
91
110
  return value;
92
111
  }
93
112
  }
113
+ function readStructuredArray(value, error) {
114
+ if (value === undefined) {
115
+ return undefined;
116
+ }
117
+ const parsed = readStructuredValue(value);
118
+ return Array.isArray(parsed) ? parsed : slackError(error);
119
+ }
120
+ function readSlackMetadata(value) {
121
+ if (value === undefined) {
122
+ return undefined;
123
+ }
124
+ let parsed = value;
125
+ if (typeof value === "string") {
126
+ try {
127
+ parsed = JSON.parse(value);
128
+ }
129
+ catch {
130
+ return slackError("invalid_metadata_format");
131
+ }
132
+ }
133
+ if (!isJsonObject(parsed)) {
134
+ return slackError("invalid_metadata_format");
135
+ }
136
+ if (!readTrimmedString(parsed.event_type) || !isJsonObject(parsed.event_payload)) {
137
+ return slackError("invalid_metadata_schema");
138
+ }
139
+ return parsed;
140
+ }
94
141
  function hasStructuredMessageContent(value) {
95
142
  if (Array.isArray(value)) {
96
143
  return value.length > 0;
@@ -101,16 +148,27 @@ function messagesForChannel(state, channel) {
101
148
  return state.messagesByChannel.get(channel) ?? [];
102
149
  }
103
150
  function hasThreadParent(state, params) {
104
- return messagesForChannel(state, params.channel).some((message) => message.ts === params.threadTs || message.thread_ts === params.threadTs);
151
+ return messagesForChannel(state, params.channel).some((message) => message.ts === params.threadTs && message.thread_ts === undefined);
152
+ }
153
+ function resolveThreadTs(state, params) {
154
+ const message = messagesForChannel(state, params.channel).find((candidate) => candidate.ts === params.ts);
155
+ return message?.thread_ts ?? message?.ts;
105
156
  }
106
157
  function nextSlackTs(state) {
107
158
  const index = state.nextTsIndex++;
108
159
  return `${1_700_000_000 + Math.floor(index / 1_000_000)}.${String(index % 1_000_000).padStart(6, "0")}`;
109
160
  }
161
+ function randomDecimalDigits(length) {
162
+ return Array.from({ length }, () => randomInt(10)).join("");
163
+ }
110
164
  function nextDmChannelId(state) {
111
165
  const index = state.nextDmIndex++;
112
166
  return `D${String(index).padStart(9, "0")}`;
113
167
  }
168
+ function nextMpimChannelId(state) {
169
+ const index = state.nextMpimIndex++;
170
+ return `G${String(index).padStart(9, "0")}`;
171
+ }
114
172
  function dmChannelForUser(state, userId) {
115
173
  const existing = state.userDmChannels.get(userId);
116
174
  if (existing) {
@@ -120,14 +178,78 @@ function dmChannelForUser(state, userId) {
120
178
  state.userDmChannels.set(userId, channelId);
121
179
  return channelId;
122
180
  }
181
+ function mpimChannelForUsers(state, users) {
182
+ const key = [...users].sort().join(",");
183
+ const existing = state.userMpimChannels.get(key);
184
+ if (existing) {
185
+ return existing;
186
+ }
187
+ const channel = { id: nextMpimChannelId(state), users: [...users] };
188
+ state.userMpimChannels.set(key, channel);
189
+ return channel;
190
+ }
191
+ function requireSlackUsers(value, botUserId) {
192
+ const rawUsers = readTrimmedString(value);
193
+ if (!rawUsers) {
194
+ return slackError("users_list_not_supplied");
195
+ }
196
+ const users = rawUsers.split(",").map((user) => user.trim());
197
+ if (users.length > 8) {
198
+ return slackError("too_many_users");
199
+ }
200
+ if (users.some((user) => !SLACK_USER_ID_RULE.pattern.test(user))) {
201
+ return slackError("user_not_found");
202
+ }
203
+ if (new Set(users).size !== users.length) {
204
+ return slackError("invalid_user_combination");
205
+ }
206
+ if (users.includes(botUserId)) {
207
+ return slackError("invalid_user_combination");
208
+ }
209
+ return users;
210
+ }
211
+ function requireSlackLimit(value) {
212
+ const limit = value === undefined ? 100 : readInteger(value);
213
+ if (limit === undefined || limit < 1 || limit > 1_000) {
214
+ return slackError("invalid_limit");
215
+ }
216
+ return limit;
217
+ }
218
+ function decodeSlackCursor(value, kind) {
219
+ if (value === undefined || value === "") {
220
+ return undefined;
221
+ }
222
+ if (typeof value !== "string" || !/^[A-Za-z0-9_-]+$/u.test(value)) {
223
+ return slackError("invalid_cursor");
224
+ }
225
+ const cursor = value;
226
+ try {
227
+ const decoded = Buffer.from(cursor, "base64url").toString("utf8");
228
+ const prefix = `${kind}:`;
229
+ if (!decoded.startsWith(prefix)) {
230
+ return slackError("invalid_cursor");
231
+ }
232
+ const boundary = decoded.slice(prefix.length);
233
+ return SLACK_TS_RULE.pattern.test(boundary) && encodeSlackCursor(kind, boundary) === cursor
234
+ ? boundary
235
+ : slackError("invalid_cursor");
236
+ }
237
+ catch {
238
+ return slackError("invalid_cursor");
239
+ }
240
+ }
241
+ function encodeSlackCursor(kind, boundary) {
242
+ return Buffer.from(`${kind}:${boundary}`, "utf8").toString("base64url");
243
+ }
123
244
  function appendMessage(state, message) {
124
245
  const messages = state.messagesByChannel.get(message.channel) ?? [];
125
246
  messages.push(message);
126
247
  state.messagesByChannel.set(message.channel, messages);
127
248
  return message;
128
249
  }
129
- async function appendEvent(state, event) {
130
- await recordServerEvent({ event, onEvent: state.onEvent, recorderPath: state.recorderPath });
250
+ async function appendEvent(state, event, committed = false) {
251
+ const params = { event, onEvent: state.onEvent, recorderPath: state.recorderPath };
252
+ await (committed ? recordCommittedServerEvent(params) : recordServerEvent(params));
131
253
  }
132
254
  function redactSlackAuthFields(value) {
133
255
  return Object.fromEntries(Object.entries(value).map(([key, entry]) => [
@@ -171,11 +293,175 @@ function asSlackEventCallback(message) {
171
293
  type: "event_callback",
172
294
  };
173
295
  }
174
- async function handleSlackApi(params) {
175
- const authError = requireSlackToken(params.request, params.body, params.state);
176
- if (authError) {
177
- return authError;
296
+ function slackRequestSignature(signingSecret, timestamp, body) {
297
+ const digest = createHmac("sha256", signingSecret)
298
+ .update(`v0:${timestamp}:${body}`)
299
+ .digest("hex");
300
+ return `v0=${digest}`;
301
+ }
302
+ function authenticateSlackEventsRequest(request, state, rawBody) {
303
+ const timestampHeader = request.headers["x-slack-request-timestamp"];
304
+ const signatureHeader = request.headers["x-slack-signature"];
305
+ const timestamp = Array.isArray(timestampHeader) ? timestampHeader[0] : timestampHeader;
306
+ const signature = Array.isArray(signatureHeader) ? signatureHeader[0] : signatureHeader;
307
+ const timestampSeconds = timestamp ? Number(timestamp) : Number.NaN;
308
+ if (!timestamp ||
309
+ !signature ||
310
+ !Number.isSafeInteger(timestampSeconds) ||
311
+ Math.abs(Date.now() / 1000 - timestampSeconds) > SLACK_SIGNATURE_TOLERANCE_SECONDS) {
312
+ return new Response("unauthorized", { status: 401 });
313
+ }
314
+ const expected = Buffer.from(slackRequestSignature(state.signingSecret, timestamp, rawBody));
315
+ const actual = Buffer.from(signature);
316
+ if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) {
317
+ return new Response("unauthorized", { status: 401 });
318
+ }
319
+ return undefined;
320
+ }
321
+ function errorChain(error) {
322
+ const chain = [];
323
+ let current = error;
324
+ while (current && typeof current === "object" && chain.length < 4) {
325
+ const entry = current;
326
+ chain.push(entry);
327
+ current = entry.cause;
328
+ }
329
+ return chain;
330
+ }
331
+ function classifySlackRetryReason(error) {
332
+ const chain = errorChain(error);
333
+ const names = chain.map((entry) => String(entry.name ?? ""));
334
+ const codes = chain.map((entry) => String(entry.code ?? "").toUpperCase());
335
+ const messages = chain.map((entry) => String(entry.message ?? "").toLowerCase());
336
+ if (codes.includes("UND_ERR_REDIRECT") ||
337
+ messages.some((message) => message.includes("redirect count exceeded"))) {
338
+ return "too_many_redirects";
339
+ }
340
+ if (names.some((name) => name === "AbortError" || name === "TimeoutError") ||
341
+ codes.some((code) => ["ETIMEDOUT", "UND_ERR_BODY_TIMEOUT", "UND_ERR_HEADERS_TIMEOUT"].includes(code))) {
342
+ return "http_timeout";
343
+ }
344
+ if (codes.some((code) => code.includes("CERT") ||
345
+ code.includes("SSL") ||
346
+ code.includes("TLS") ||
347
+ code === "UNABLE_TO_VERIFY_LEAF_SIGNATURE")) {
348
+ return "ssl_error";
178
349
  }
350
+ if (codes.some((code) => [
351
+ "EAI_AGAIN",
352
+ "ECONNREFUSED",
353
+ "ECONNRESET",
354
+ "EHOSTUNREACH",
355
+ "ENETUNREACH",
356
+ "ENOTFOUND",
357
+ "UND_ERR_CONNECT_TIMEOUT",
358
+ "UND_ERR_SOCKET",
359
+ ].includes(code))) {
360
+ return "connection_failed";
361
+ }
362
+ return "unknown_error";
363
+ }
364
+ async function waitForSlackRetry(delayMs, signal) {
365
+ if (signal.aborted) {
366
+ return false;
367
+ }
368
+ return await new Promise((resolve) => {
369
+ let settled = false;
370
+ const finish = (completed) => {
371
+ if (settled) {
372
+ return;
373
+ }
374
+ settled = true;
375
+ clearTimeout(timer);
376
+ signal.removeEventListener("abort", onAbort);
377
+ resolve(completed);
378
+ };
379
+ const onAbort = () => finish(false);
380
+ const timer = setTimeout(() => finish(true), delayMs);
381
+ timer.unref();
382
+ signal.addEventListener("abort", onAbort, { once: true });
383
+ if (signal.aborted) {
384
+ onAbort();
385
+ }
386
+ });
387
+ }
388
+ async function deliverSlackEvent(state, event, lifecycleSignal) {
389
+ if (!state.eventsRequestUrl) {
390
+ return undefined;
391
+ }
392
+ const body = JSON.stringify(event);
393
+ let retryReason;
394
+ for (let attempt = 0; attempt <= SLACK_EVENT_MAX_RETRIES; attempt += 1) {
395
+ if (lifecycleSignal.aborted) {
396
+ return;
397
+ }
398
+ try {
399
+ const timestamp = Math.floor(Date.now() / 1000).toString();
400
+ const headers = {
401
+ "content-type": "application/json",
402
+ ...(attempt > 0
403
+ ? {
404
+ "x-slack-retry-num": String(attempt),
405
+ "x-slack-retry-reason": retryReason ?? "unknown_error",
406
+ }
407
+ : {}),
408
+ "x-slack-request-timestamp": timestamp,
409
+ "x-slack-signature": slackRequestSignature(state.signingSecret, timestamp, body),
410
+ };
411
+ const signal = AbortSignal.any([lifecycleSignal, AbortSignal.timeout(3_000)]);
412
+ let requestUrl = state.eventsRequestUrl;
413
+ let response;
414
+ for (let redirectCount = 0;; redirectCount += 1) {
415
+ response = await fetch(requestUrl, {
416
+ body,
417
+ headers,
418
+ method: "POST",
419
+ redirect: "manual",
420
+ signal,
421
+ });
422
+ const location = response.headers.get("location");
423
+ if (![301, 302].includes(response.status) || !location) {
424
+ break;
425
+ }
426
+ await response.body?.cancel();
427
+ if (redirectCount >= SLACK_EVENT_MAX_REDIRECTS) {
428
+ response = undefined;
429
+ retryReason = "too_many_redirects";
430
+ break;
431
+ }
432
+ requestUrl = new URL(location, requestUrl).toString();
433
+ }
434
+ if (response) {
435
+ const noRetry = response.headers.get("x-slack-no-retry") === "1";
436
+ await response.body?.cancel();
437
+ if (response.ok || noRetry) {
438
+ return;
439
+ }
440
+ retryReason = "http_error";
441
+ }
442
+ }
443
+ catch (error) {
444
+ if (lifecycleSignal.aborted) {
445
+ return;
446
+ }
447
+ retryReason = classifySlackRetryReason(error);
448
+ }
449
+ const retryDelay = SLACK_EVENT_RETRY_DELAYS_MS[attempt];
450
+ if (retryDelay !== undefined && !(await waitForSlackRetry(retryDelay, lifecycleSignal))) {
451
+ return;
452
+ }
453
+ }
454
+ }
455
+ function scheduleSlackEventDelivery(state, event) {
456
+ if (!state.eventsRequestUrl || state.closing) {
457
+ return;
458
+ }
459
+ const delivery = deliverSlackEvent(state, event, state.deliveryAbortController.signal).finally(() => {
460
+ state.activeEventDeliveries.delete(delivery);
461
+ });
462
+ state.activeEventDeliveries.add(delivery);
463
+ }
464
+ async function handleSlackApi(params) {
179
465
  switch (params.method) {
180
466
  case "auth.test":
181
467
  return slackOk({
@@ -196,17 +482,30 @@ async function handleSlackApi(params) {
196
482
  user_id: params.state.botUserId,
197
483
  });
198
484
  case "chat.postMessage": {
199
- if (readBoolean(params.body.simulate_rate_limit) === true) {
200
- return slackRateLimited(readInteger(params.body.retry_after) ?? 1);
485
+ const rateLimit = params.state.chatPostMessageRateLimit;
486
+ if (rateLimit && rateLimit.remaining <= 0) {
487
+ return slackRateLimited(rateLimit.retryAfterSeconds);
488
+ }
489
+ if (rateLimit) {
490
+ rateLimit.remaining -= 1;
201
491
  }
202
492
  const channel = requireSlackSendTargetId(params.body.channel);
203
493
  if (channel instanceof Response) {
204
494
  return channel;
205
495
  }
206
- const text = readTrimmedString(params.body.text) ?? "";
207
- const attachments = readStructuredValue(params.body.attachments);
208
- const blocks = readStructuredValue(params.body.blocks);
209
- const metadata = readStructuredValue(params.body.metadata);
496
+ const text = readSlackText(params.body.text) ?? "";
497
+ const attachments = readStructuredArray(params.body.attachments, "invalid_attachments");
498
+ if (attachments instanceof Response) {
499
+ return attachments;
500
+ }
501
+ const blocks = readStructuredArray(params.body.blocks, "invalid_blocks");
502
+ if (blocks instanceof Response) {
503
+ return blocks;
504
+ }
505
+ const metadata = readSlackMetadata(params.body.metadata);
506
+ if (metadata instanceof Response) {
507
+ return metadata;
508
+ }
210
509
  if (!text &&
211
510
  !hasStructuredMessageContent(blocks) &&
212
511
  !hasStructuredMessageContent(attachments)) {
@@ -237,12 +536,23 @@ async function handleSlackApi(params) {
237
536
  return slackOk({ channel, message, ts: message.ts });
238
537
  }
239
538
  case "conversations.open": {
240
- const users = readTrimmedString(params.body.users);
241
- const firstUser = users?.split(",")[0]?.trim();
242
- const user = requireSlackUserId(firstUser);
243
- if (user instanceof Response) {
244
- return user;
539
+ const users = requireSlackUsers(params.body.users, params.state.botUserId);
540
+ if (users instanceof Response) {
541
+ return users;
542
+ }
543
+ if (users.length > 1) {
544
+ const channel = mpimChannelForUsers(params.state, users);
545
+ return slackOk({
546
+ channel: {
547
+ id: channel.id,
548
+ is_group: false,
549
+ is_mpim: true,
550
+ is_private: true,
551
+ members: [params.state.botUserId, ...channel.users],
552
+ },
553
+ });
245
554
  }
555
+ const user = users[0];
246
556
  return slackOk({
247
557
  channel: {
248
558
  id: dmChannelForUser(params.state, user),
@@ -256,12 +566,20 @@ async function handleSlackApi(params) {
256
566
  if (channel instanceof Response) {
257
567
  return channel;
258
568
  }
569
+ const mpim = [...params.state.userMpimChannels.values()].find((candidate) => candidate.id === channel);
259
570
  return slackOk({
260
571
  channel: {
261
572
  id: channel,
262
573
  is_channel: channel.startsWith("C"),
263
- is_group: channel.startsWith("G"),
574
+ is_group: mpim ? false : channel.startsWith("G"),
264
575
  is_im: channel.startsWith("D"),
576
+ ...(mpim
577
+ ? {
578
+ is_mpim: true,
579
+ is_private: true,
580
+ members: [params.state.botUserId, ...mpim.users],
581
+ }
582
+ : {}),
265
583
  name: "crabline",
266
584
  },
267
585
  });
@@ -279,8 +597,18 @@ async function handleSlackApi(params) {
279
597
  if (latest instanceof Response) {
280
598
  return latest;
281
599
  }
282
- const limit = readInteger(params.body.limit) ?? 100;
600
+ const limit = requireSlackLimit(params.body.limit);
601
+ if (limit instanceof Response) {
602
+ return limit;
603
+ }
604
+ const boundary = decodeSlackCursor(params.body.cursor, "history");
605
+ if (boundary instanceof Response) {
606
+ return boundary;
607
+ }
283
608
  const messages = messagesForChannel(params.state, channel).filter((message) => {
609
+ if (message.thread_ts !== undefined && message.reply_broadcast !== true) {
610
+ return false;
611
+ }
284
612
  if (oldest && message.ts <= oldest) {
285
613
  return false;
286
614
  }
@@ -289,9 +617,18 @@ async function handleSlackApi(params) {
289
617
  }
290
618
  return true;
291
619
  });
620
+ const ordered = [...messages]
621
+ .reverse()
622
+ .filter((message) => boundary === undefined || message.ts < boundary);
623
+ const page = ordered.slice(0, limit);
624
+ const hasMore = page.length < ordered.length;
625
+ const nextBoundary = page.at(-1)?.ts;
292
626
  return slackOk({
293
- has_more: messages.length > limit,
294
- messages: [...messages].reverse().slice(0, limit),
627
+ has_more: hasMore,
628
+ messages: page,
629
+ response_metadata: {
630
+ next_cursor: hasMore && nextBoundary ? encodeSlackCursor("history", nextBoundary) : "",
631
+ },
295
632
  });
296
633
  }
297
634
  case "conversations.replies": {
@@ -306,12 +643,30 @@ async function handleSlackApi(params) {
306
643
  if (!ts) {
307
644
  return slackError("message_not_found");
308
645
  }
309
- const limit = readInteger(params.body.limit) ?? 100;
310
- const messages = messagesForChannel(params.state, channel).filter((message) => message.ts === ts || message.thread_ts === ts);
311
- if (messages.length === 0) {
646
+ const threadTs = resolveThreadTs(params.state, { channel, ts });
647
+ if (!threadTs) {
312
648
  return slackError("thread_not_found");
313
649
  }
314
- return slackOk({ has_more: messages.length > limit, messages: messages.slice(0, limit) });
650
+ const limit = requireSlackLimit(params.body.limit);
651
+ if (limit instanceof Response) {
652
+ return limit;
653
+ }
654
+ const boundary = decodeSlackCursor(params.body.cursor, "replies");
655
+ if (boundary instanceof Response) {
656
+ return boundary;
657
+ }
658
+ const messages = messagesForChannel(params.state, channel).filter((message) => (message.ts === threadTs || message.thread_ts === threadTs) &&
659
+ (boundary === undefined || message.ts > boundary));
660
+ const page = messages.slice(0, limit);
661
+ const hasMore = page.length < messages.length;
662
+ const nextBoundary = page.at(-1)?.ts;
663
+ return slackOk({
664
+ has_more: hasMore,
665
+ messages: page,
666
+ response_metadata: {
667
+ next_cursor: hasMore && nextBoundary ? encodeSlackCursor("replies", nextBoundary) : "",
668
+ },
669
+ });
315
670
  }
316
671
  default:
317
672
  return slackError("unknown_method", 404);
@@ -322,9 +677,17 @@ async function handleAdminInbound(params) {
322
677
  if (channel instanceof Response) {
323
678
  return channel;
324
679
  }
325
- const text = readTrimmedString(params.body.text);
326
- if (!text) {
327
- return slackError("text is required", 400);
680
+ if (params.body.text !== undefined && typeof params.body.text !== "string") {
681
+ return slackError("invalid_text", 400);
682
+ }
683
+ const text = readSlackText(params.body.text) ?? "";
684
+ const attachments = readStructuredArray(params.body.attachments, "invalid_attachments");
685
+ if (attachments instanceof Response) {
686
+ return attachments;
687
+ }
688
+ const blocks = readStructuredArray(params.body.blocks, "invalid_blocks");
689
+ if (blocks instanceof Response) {
690
+ return blocks;
328
691
  }
329
692
  const user = requireSlackUserId(params.body.user ?? "UCRABUSER");
330
693
  if (user instanceof Response) {
@@ -334,18 +697,25 @@ async function handleAdminInbound(params) {
334
697
  if (threadTs instanceof Response) {
335
698
  return threadTs;
336
699
  }
700
+ if (threadTs && !hasThreadParent(params.state, { channel, threadTs })) {
701
+ return slackError("thread_not_found");
702
+ }
337
703
  const ts = requireSlackThreadTs(params.body.ts);
338
704
  if (ts instanceof Response) {
339
705
  return ts;
340
706
  }
341
707
  const message = appendMessage(params.state, createSlackMessage(params.state, {
708
+ attachments,
709
+ blocks,
342
710
  channel,
343
711
  text,
344
712
  threadTs,
345
713
  ts,
346
714
  user,
347
715
  }));
348
- return slackOk({ event: asSlackEventCallback(message), message });
716
+ const event = asSlackEventCallback(message);
717
+ scheduleSlackEventDelivery(params.state, event);
718
+ return slackOk({ event, message });
349
719
  }
350
720
  async function handleRequest(params) {
351
721
  const url = new URL(params.request.url ?? "/", "http://127.0.0.1");
@@ -354,9 +724,13 @@ async function handleRequest(params) {
354
724
  return new Response("not found", { status: 404 });
355
725
  }
356
726
  if (!hasAdminToken(params.request, params.state.adminToken)) {
727
+ params.request.resume();
357
728
  return adminAuthError();
358
729
  }
359
- const body = await parseRequestBody(params.request);
730
+ const body = await parseUnknownRequestBody(params.request);
731
+ if (!isJsonObject(body)) {
732
+ return slackError("invalid_json", 400);
733
+ }
360
734
  await appendEvent(params.state, {
361
735
  at: new Date().toISOString(),
362
736
  body,
@@ -368,49 +742,138 @@ async function handleRequest(params) {
368
742
  return await handleAdminInbound({ body, state: params.state });
369
743
  }
370
744
  const query = queryRecord(url);
371
- const body = params.request.method === "GET" ? query : await parseRequestBody(params.request);
372
- await appendEvent(params.state, {
373
- at: new Date().toISOString(),
374
- body: redactSlackAuthFields(body),
375
- method: params.request.method ?? "GET",
376
- path: url.pathname,
377
- query: redactSlackAuthQuery(query),
378
- type: "api",
379
- });
380
745
  if (url.pathname === "/slack/events") {
746
+ if (params.request.method !== "POST") {
747
+ params.request.resume();
748
+ return new Response("method not allowed", {
749
+ headers: { allow: "POST" },
750
+ status: 405,
751
+ });
752
+ }
753
+ if (!params.request.headers["x-slack-request-timestamp"] ||
754
+ !params.request.headers["x-slack-signature"]) {
755
+ params.request.resume();
756
+ return new Response("unauthorized", { status: 401 });
757
+ }
758
+ const rawBody = (await readBody(params.request)).toString("utf8");
759
+ const authError = authenticateSlackEventsRequest(params.request, params.state, rawBody);
760
+ if (authError) {
761
+ return authError;
762
+ }
763
+ let body;
764
+ try {
765
+ body = rawBody ? JSON.parse(rawBody) : {};
766
+ }
767
+ catch (error) {
768
+ throw new InvalidJsonBodyError(error);
769
+ }
770
+ if (!isJsonObject(body)) {
771
+ return slackError("invalid_json", 400);
772
+ }
381
773
  if (body.type === "url_verification") {
382
774
  return jsonResponse({ challenge: readTrimmedString(body.challenge) ?? "" });
383
775
  }
384
- return slackOk();
776
+ if (!readTrimmedString(body.type)) {
777
+ return slackError("invalid_payload", 400);
778
+ }
779
+ return new Response(null, { status: 200 });
385
780
  }
386
781
  const methodMatch = /^\/api\/([a-z]+(?:\.[a-zA-Z]+)*)$/u.exec(url.pathname);
387
782
  if (!methodMatch?.[1]) {
388
783
  return new Response("not found", { status: 404 });
389
784
  }
390
- return await handleSlackApi({
785
+ const requestMethod = params.request.method ?? "GET";
786
+ if (requestMethod !== "GET" && requestMethod !== "POST") {
787
+ params.request.resume();
788
+ return new Response("method not allowed", {
789
+ headers: { allow: "GET, POST" },
790
+ status: 405,
791
+ });
792
+ }
793
+ if (params.request.headers.authorization) {
794
+ const headerAuthError = requireSlackToken(params.request, {}, params.state);
795
+ if (headerAuthError) {
796
+ params.request.resume();
797
+ return headerAuthError;
798
+ }
799
+ }
800
+ const body = requestMethod === "GET" ? query : await parseUnknownRequestBody(params.request);
801
+ if (!isJsonObject(body)) {
802
+ return slackError("json_not_object", 400);
803
+ }
804
+ const authError = requireSlackToken(params.request, body, params.state);
805
+ if (authError) {
806
+ return authError;
807
+ }
808
+ const event = {
809
+ at: new Date().toISOString(),
810
+ body: redactSlackAuthFields(body),
811
+ method: params.request.method ?? "GET",
812
+ path: url.pathname,
813
+ query: redactSlackAuthQuery(query),
814
+ type: "api",
815
+ };
816
+ const response = await handleSlackApi({
391
817
  body,
392
818
  method: methodMatch[1],
393
- request: params.request,
394
819
  state: params.state,
395
820
  });
821
+ const mutation = ["chat.postMessage", "conversations.open"].includes(methodMatch[1]);
822
+ const payload = mutation
823
+ ? (await response
824
+ .clone()
825
+ .json()
826
+ .catch(() => undefined))
827
+ : undefined;
828
+ const committed = isJsonObject(payload) && payload.ok === true;
829
+ if (methodMatch[1] === "chat.postMessage") {
830
+ event.accepted = committed;
831
+ }
832
+ await appendEvent(params.state, event, committed);
833
+ return response;
396
834
  }
397
835
  export async function startSlackServer(params = {}) {
836
+ const host = params.host ?? "127.0.0.1";
837
+ const externallyBound = !isLoopbackHost(host);
398
838
  const state = {
839
+ activeEventDeliveries: new Set(),
399
840
  adminToken: params.adminToken ?? randomBytes(24).toString("base64url"),
400
841
  botId: params.botId ?? "BCRABLINE",
401
842
  botToken: params.botToken ?? "xoxb-crabline-slack-token",
402
843
  botUserId: params.botUserId ?? "UCRABBOT",
844
+ chatPostMessageRateLimit: resolveChatPostMessageRateLimit(params.chatPostMessageRateLimit),
845
+ closing: false,
846
+ deliveryAbortController: new AbortController(),
847
+ eventsRequestUrl: params.eventsRequestUrl,
403
848
  nextDmIndex: 1,
849
+ nextMpimIndex: 1,
404
850
  nextTsIndex: 100,
405
851
  onEvent: params.onEvent,
406
852
  recorderPath: params.recorderPath ?? path.resolve(".crabline", "servers", "slack.jsonl"),
407
853
  signingSecret: params.signingSecret ?? "crabline-slack-signing-secret",
408
854
  userDmChannels: new Map(),
855
+ userMpimChannels: new Map(),
409
856
  messagesByChannel: new Map(),
410
857
  };
411
- const host = params.host ?? "127.0.0.1";
858
+ if (externallyBound && !params.botToken) {
859
+ const generatedBotValue = `xoxb-${randomDecimalDigits(12)}-${randomDecimalDigits(12)}-${randomBytes(18).toString("base64url")}`;
860
+ state.botToken = generatedBotValue;
861
+ }
862
+ if (externallyBound && !params.signingSecret) {
863
+ const generatedSigningValue = randomBytes(16).toString("hex");
864
+ state.signingSecret = generatedSigningValue;
865
+ }
412
866
  const httpServer = await startHttpJsonServer({
413
867
  handle: (request) => handleRequest({ request, state }),
868
+ handleError: (error) => {
869
+ if (error instanceof InvalidJsonBodyError) {
870
+ return slackError("invalid_json", 400);
871
+ }
872
+ if (error instanceof RequestBodyTooLargeError) {
873
+ return slackError("request_too_large", 413);
874
+ }
875
+ return undefined;
876
+ },
414
877
  host,
415
878
  port: params.port ?? 0,
416
879
  serverName: "Slack",
@@ -419,6 +882,9 @@ export async function startSlackServer(params = {}) {
419
882
  const apiRoot = `${baseUrl}/api/`;
420
883
  return {
421
884
  async close() {
885
+ state.closing = true;
886
+ state.deliveryAbortController.abort();
887
+ await Promise.allSettled(state.activeEventDeliveries);
422
888
  await httpServer.close();
423
889
  },
424
890
  manifest: {