@openclaw/crabline 0.1.8 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (190) hide show
  1. package/README.md +52 -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 +4 -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 +38 -0
  34. package/dist/src/openclaw/artifact-generation.js +314 -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 +26 -0
  54. package/dist/src/openclaw/private-file.js +411 -0
  55. package/dist/src/openclaw/private-file.js.map +1 -0
  56. package/dist/src/openclaw/shared.d.ts +23 -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 +949 -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 +195 -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 +539 -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.d.ts +2 -0
  141. package/dist/src/servers/matrix.js +466 -52
  142. package/dist/src/servers/matrix.js.map +1 -1
  143. package/dist/src/servers/mattermost.d.ts +7 -0
  144. package/dist/src/servers/mattermost.js +307 -59
  145. package/dist/src/servers/mattermost.js.map +1 -1
  146. package/dist/src/servers/pending-events.d.ts +2 -0
  147. package/dist/src/servers/pending-events.js +11 -0
  148. package/dist/src/servers/pending-events.js.map +1 -0
  149. package/dist/src/servers/recorder.d.ts +12 -0
  150. package/dist/src/servers/recorder.js +51 -0
  151. package/dist/src/servers/recorder.js.map +1 -0
  152. package/dist/src/servers/signal.d.ts +4 -0
  153. package/dist/src/servers/signal.js +445 -57
  154. package/dist/src/servers/signal.js.map +1 -1
  155. package/dist/src/servers/slack.d.ts +7 -0
  156. package/dist/src/servers/slack.js +516 -50
  157. package/dist/src/servers/slack.js.map +1 -1
  158. package/dist/src/servers/telegram.d.ts +113 -0
  159. package/dist/src/servers/telegram.js +814 -155
  160. package/dist/src/servers/telegram.js.map +1 -1
  161. package/dist/src/servers/webhook-target.d.ts +40 -0
  162. package/dist/src/servers/webhook-target.js +349 -0
  163. package/dist/src/servers/webhook-target.js.map +1 -0
  164. package/dist/src/servers/websocket.d.ts +2 -0
  165. package/dist/src/servers/websocket.js +29 -0
  166. package/dist/src/servers/websocket.js.map +1 -0
  167. package/dist/src/servers/whatsapp-baileys-websocket.d.ts +65 -1
  168. package/dist/src/servers/whatsapp-baileys-websocket.js +723 -99
  169. package/dist/src/servers/whatsapp-baileys-websocket.js.map +1 -1
  170. package/dist/src/servers/whatsapp-jid.d.ts +5 -0
  171. package/dist/src/servers/whatsapp-jid.js +32 -0
  172. package/dist/src/servers/whatsapp-jid.js.map +1 -0
  173. package/dist/src/servers/whatsapp-wire/binary-node.d.ts +5 -0
  174. package/dist/src/servers/whatsapp-wire/binary-node.js +87 -20
  175. package/dist/src/servers/whatsapp-wire/binary-node.js.map +1 -1
  176. package/dist/src/servers/whatsapp-wire/crypto.d.ts +6 -1
  177. package/dist/src/servers/whatsapp-wire/crypto.js +99 -45
  178. package/dist/src/servers/whatsapp-wire/crypto.js.map +1 -1
  179. package/dist/src/servers/whatsapp-wire/handshake.js +39 -4
  180. package/dist/src/servers/whatsapp-wire/handshake.js.map +1 -1
  181. package/dist/src/servers/whatsapp-wire/signal.js.map +1 -1
  182. package/dist/src/servers/whatsapp.d.ts +14 -7
  183. package/dist/src/servers/whatsapp.js +272 -110
  184. package/dist/src/servers/whatsapp.js.map +1 -1
  185. package/dist/src/servers/zalo.d.ts +16 -0
  186. package/dist/src/servers/zalo.js +524 -91
  187. package/dist/src/servers/zalo.js.map +1 -1
  188. package/docs/channel-setup.md +197 -19
  189. package/fixtures/examples/crabline.example.yaml +10 -0
  190. package/package.json +20 -15
@@ -1,8 +1,12 @@
1
- import { randomBytes } from "node:crypto";
2
- import fs from "node:fs/promises";
1
+ import { createHmac, randomBytes, randomInt, timingSafeEqual } from "node:crypto";
3
2
  import path from "node:path";
4
- 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";
5
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 { 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,15 +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 fs.mkdir(path.dirname(state.recorderPath), { recursive: true });
131
- await fs.appendFile(state.recorderPath, `${JSON.stringify(event)}\n`, "utf8");
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));
132
253
  }
133
254
  function redactSlackAuthFields(value) {
134
255
  return Object.fromEntries(Object.entries(value).map(([key, entry]) => [
@@ -172,11 +293,175 @@ function asSlackEventCallback(message) {
172
293
  type: "event_callback",
173
294
  };
174
295
  }
175
- async function handleSlackApi(params) {
176
- const authError = requireSlackToken(params.request, params.body, params.state);
177
- if (authError) {
178
- 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";
179
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) {
180
465
  switch (params.method) {
181
466
  case "auth.test":
182
467
  return slackOk({
@@ -197,17 +482,30 @@ async function handleSlackApi(params) {
197
482
  user_id: params.state.botUserId,
198
483
  });
199
484
  case "chat.postMessage": {
200
- if (readBoolean(params.body.simulate_rate_limit) === true) {
201
- 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;
202
491
  }
203
492
  const channel = requireSlackSendTargetId(params.body.channel);
204
493
  if (channel instanceof Response) {
205
494
  return channel;
206
495
  }
207
- const text = readTrimmedString(params.body.text) ?? "";
208
- const attachments = readStructuredValue(params.body.attachments);
209
- const blocks = readStructuredValue(params.body.blocks);
210
- 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
+ }
211
509
  if (!text &&
212
510
  !hasStructuredMessageContent(blocks) &&
213
511
  !hasStructuredMessageContent(attachments)) {
@@ -238,12 +536,23 @@ async function handleSlackApi(params) {
238
536
  return slackOk({ channel, message, ts: message.ts });
239
537
  }
240
538
  case "conversations.open": {
241
- const users = readTrimmedString(params.body.users);
242
- const firstUser = users?.split(",")[0]?.trim();
243
- const user = requireSlackUserId(firstUser);
244
- if (user instanceof Response) {
245
- 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
+ });
246
554
  }
555
+ const user = users[0];
247
556
  return slackOk({
248
557
  channel: {
249
558
  id: dmChannelForUser(params.state, user),
@@ -257,12 +566,20 @@ async function handleSlackApi(params) {
257
566
  if (channel instanceof Response) {
258
567
  return channel;
259
568
  }
569
+ const mpim = [...params.state.userMpimChannels.values()].find((candidate) => candidate.id === channel);
260
570
  return slackOk({
261
571
  channel: {
262
572
  id: channel,
263
573
  is_channel: channel.startsWith("C"),
264
- is_group: channel.startsWith("G"),
574
+ is_group: mpim ? false : channel.startsWith("G"),
265
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
+ : {}),
266
583
  name: "crabline",
267
584
  },
268
585
  });
@@ -280,8 +597,18 @@ async function handleSlackApi(params) {
280
597
  if (latest instanceof Response) {
281
598
  return latest;
282
599
  }
283
- 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
+ }
284
608
  const messages = messagesForChannel(params.state, channel).filter((message) => {
609
+ if (message.thread_ts !== undefined && message.reply_broadcast !== true) {
610
+ return false;
611
+ }
285
612
  if (oldest && message.ts <= oldest) {
286
613
  return false;
287
614
  }
@@ -290,9 +617,18 @@ async function handleSlackApi(params) {
290
617
  }
291
618
  return true;
292
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;
293
626
  return slackOk({
294
- has_more: messages.length > limit,
295
- 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
+ },
296
632
  });
297
633
  }
298
634
  case "conversations.replies": {
@@ -307,12 +643,30 @@ async function handleSlackApi(params) {
307
643
  if (!ts) {
308
644
  return slackError("message_not_found");
309
645
  }
310
- const limit = readInteger(params.body.limit) ?? 100;
311
- const messages = messagesForChannel(params.state, channel).filter((message) => message.ts === ts || message.thread_ts === ts);
312
- if (messages.length === 0) {
646
+ const threadTs = resolveThreadTs(params.state, { channel, ts });
647
+ if (!threadTs) {
313
648
  return slackError("thread_not_found");
314
649
  }
315
- 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
+ });
316
670
  }
317
671
  default:
318
672
  return slackError("unknown_method", 404);
@@ -323,9 +677,17 @@ async function handleAdminInbound(params) {
323
677
  if (channel instanceof Response) {
324
678
  return channel;
325
679
  }
326
- const text = readTrimmedString(params.body.text);
327
- if (!text) {
328
- 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;
329
691
  }
330
692
  const user = requireSlackUserId(params.body.user ?? "UCRABUSER");
331
693
  if (user instanceof Response) {
@@ -335,18 +697,25 @@ async function handleAdminInbound(params) {
335
697
  if (threadTs instanceof Response) {
336
698
  return threadTs;
337
699
  }
700
+ if (threadTs && !hasThreadParent(params.state, { channel, threadTs })) {
701
+ return slackError("thread_not_found");
702
+ }
338
703
  const ts = requireSlackThreadTs(params.body.ts);
339
704
  if (ts instanceof Response) {
340
705
  return ts;
341
706
  }
342
707
  const message = appendMessage(params.state, createSlackMessage(params.state, {
708
+ attachments,
709
+ blocks,
343
710
  channel,
344
711
  text,
345
712
  threadTs,
346
713
  ts,
347
714
  user,
348
715
  }));
349
- return slackOk({ event: asSlackEventCallback(message), message });
716
+ const event = asSlackEventCallback(message);
717
+ scheduleSlackEventDelivery(params.state, event);
718
+ return slackOk({ event, message });
350
719
  }
351
720
  async function handleRequest(params) {
352
721
  const url = new URL(params.request.url ?? "/", "http://127.0.0.1");
@@ -355,9 +724,13 @@ async function handleRequest(params) {
355
724
  return new Response("not found", { status: 404 });
356
725
  }
357
726
  if (!hasAdminToken(params.request, params.state.adminToken)) {
727
+ params.request.resume();
358
728
  return adminAuthError();
359
729
  }
360
- const body = await parseRequestBody(params.request);
730
+ const body = await parseUnknownRequestBody(params.request);
731
+ if (!isJsonObject(body)) {
732
+ return slackError("invalid_json", 400);
733
+ }
361
734
  await appendEvent(params.state, {
362
735
  at: new Date().toISOString(),
363
736
  body,
@@ -369,48 +742,138 @@ async function handleRequest(params) {
369
742
  return await handleAdminInbound({ body, state: params.state });
370
743
  }
371
744
  const query = queryRecord(url);
372
- const body = params.request.method === "GET" ? query : await parseRequestBody(params.request);
373
- await appendEvent(params.state, {
374
- at: new Date().toISOString(),
375
- body: redactSlackAuthFields(body),
376
- method: params.request.method ?? "GET",
377
- path: url.pathname,
378
- query: redactSlackAuthQuery(query),
379
- type: "api",
380
- });
381
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
+ }
382
773
  if (body.type === "url_verification") {
383
774
  return jsonResponse({ challenge: readTrimmedString(body.challenge) ?? "" });
384
775
  }
385
- return slackOk();
776
+ if (!readTrimmedString(body.type)) {
777
+ return slackError("invalid_payload", 400);
778
+ }
779
+ return new Response(null, { status: 200 });
386
780
  }
387
781
  const methodMatch = /^\/api\/([a-z]+(?:\.[a-zA-Z]+)*)$/u.exec(url.pathname);
388
782
  if (!methodMatch?.[1]) {
389
783
  return new Response("not found", { status: 404 });
390
784
  }
391
- 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({
392
817
  body,
393
818
  method: methodMatch[1],
394
- request: params.request,
395
819
  state: params.state,
396
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;
397
834
  }
398
835
  export async function startSlackServer(params = {}) {
836
+ const host = params.host ?? "127.0.0.1";
837
+ const externallyBound = !isLoopbackHost(host);
399
838
  const state = {
839
+ activeEventDeliveries: new Set(),
400
840
  adminToken: params.adminToken ?? randomBytes(24).toString("base64url"),
401
841
  botId: params.botId ?? "BCRABLINE",
402
842
  botToken: params.botToken ?? "xoxb-crabline-slack-token",
403
843
  botUserId: params.botUserId ?? "UCRABBOT",
844
+ chatPostMessageRateLimit: resolveChatPostMessageRateLimit(params.chatPostMessageRateLimit),
845
+ closing: false,
846
+ deliveryAbortController: new AbortController(),
847
+ eventsRequestUrl: params.eventsRequestUrl,
404
848
  nextDmIndex: 1,
849
+ nextMpimIndex: 1,
405
850
  nextTsIndex: 100,
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: {