@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
@@ -2,90 +2,108 @@ import { createServer } from "node:http";
2
2
  import { randomBytes } from "node:crypto";
3
3
  import path from "node:path";
4
4
  import { CrablineError } from "../core/errors.js";
5
- import { adminAuthError, hasAdminToken } from "./http.js";
5
+ import { adminAuthError, closeServer, drainRequestBody, formatUrlHost, hasAdminToken, InvalidJsonBodyError, isJsonObject, isLoopbackHost, jsonResponse, queryRecord, readBody, RequestBodyTooLargeError, writeResponse, } from "./http.js";
6
6
  import { recordServerEvent } from "./recorder.js";
7
- function jsonResponse(value, status = 200) {
8
- return Response.json(value, { status });
9
- }
7
+ import { resolveMaxPendingInboundEvents } from "./pending-events.js";
8
+ import { postWebhookRequest, validateWebhookTarget, } from "./webhook-target.js";
9
+ const TELEGRAM_MAX_REQUEST_BODY_BYTES = 50 * 1024 * 1024;
10
+ const TELEGRAM_WEBHOOK_MAX_BACKOFF_EXPONENT = 5;
11
+ const TELEGRAM_WEBHOOK_RETRY_BASE_MS = 100;
12
+ const TELEGRAM_WEBHOOK_DELIVERY_TIMEOUT_MS = 3_000;
13
+ const MAX_ACTIVE_TELEGRAM_WEBHOOK_VALIDATIONS = 8;
14
+ const TELEGRAM_CHAT_USERNAME_PATTERN = /^@[A-Za-z][A-Za-z0-9_]{3,31}$/u;
15
+ const TELEGRAM_SEND_METHODS = new Set([
16
+ "sendanimation",
17
+ "sendaudio",
18
+ "senddocument",
19
+ "sendmessage",
20
+ "sendphoto",
21
+ "sendvideo",
22
+ ]);
23
+ const TELEGRAM_MEDIA_FIELDS = [
24
+ "animation",
25
+ "audio",
26
+ "document",
27
+ "paid_media",
28
+ "photo",
29
+ "sticker",
30
+ "story",
31
+ "video",
32
+ "video_note",
33
+ "voice",
34
+ ];
35
+ const TELEGRAM_UNSUPPORTED_UPDATE_FIELDS = [
36
+ "business_connection",
37
+ "business_message",
38
+ "callback_query",
39
+ "channel_post",
40
+ "chat_boost",
41
+ "chat_join_request",
42
+ "chat_member",
43
+ "chosen_inline_result",
44
+ "deleted_business_messages",
45
+ "edited_business_message",
46
+ "edited_channel_post",
47
+ "edited_message",
48
+ "guest_message",
49
+ "inline_query",
50
+ "managed_bot",
51
+ "message_reaction",
52
+ "message_reaction_count",
53
+ "my_chat_member",
54
+ "poll",
55
+ "poll_answer",
56
+ "pre_checkout_query",
57
+ "purchased_paid_media",
58
+ "removed_chat_boost",
59
+ "shipping_query",
60
+ ];
10
61
  function telegramOk(result) {
11
62
  return jsonResponse({ ok: true, result });
12
63
  }
13
64
  function telegramError(description, status = 400) {
14
65
  return jsonResponse({ description, error_code: status, ok: false }, status);
15
66
  }
16
- async function readBody(request) {
17
- const chunks = [];
18
- for await (const chunk of request) {
19
- chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
20
- }
21
- return Buffer.concat(chunks);
22
- }
23
67
  async function parseRequestBody(request) {
24
- const body = await readBody(request);
68
+ const body = await readBody(request, TELEGRAM_MAX_REQUEST_BODY_BYTES);
25
69
  if (body.length === 0) {
26
70
  return {};
27
71
  }
28
72
  const contentType = request.headers["content-type"] ?? "";
29
73
  const contentTypes = Array.isArray(contentType) ? contentType : [contentType];
30
- if (contentTypes.some((entry) => entry.includes("json"))) {
31
- return JSON.parse(body.toString("utf8"));
74
+ if (contentTypes.some((entry) => entry.toLowerCase().includes("json"))) {
75
+ try {
76
+ return JSON.parse(body.toString("utf8"));
77
+ }
78
+ catch (error) {
79
+ throw new InvalidJsonBodyError(error);
80
+ }
32
81
  }
33
- const multipartType = contentTypes.find((entry) => entry.includes("multipart/form-data"));
82
+ const multipartType = contentTypes.find((entry) => entry.toLowerCase().includes("multipart/form-data"));
34
83
  if (multipartType) {
35
- return parseMultipartFormDataBody(body, multipartType);
84
+ return await parseMultipartFormDataBody(body, multipartType);
36
85
  }
37
86
  const params = new URLSearchParams(body.toString("utf8"));
38
87
  return Object.fromEntries(params.entries());
39
88
  }
40
- function parseMultipartFormDataBody(body, contentType) {
41
- const boundary = /(?:^|;\s*)boundary=(?:"([^"]+)"|([^;]+))/iu.exec(contentType);
42
- const boundaryValue = boundary?.[1] ?? boundary?.[2];
43
- if (!boundaryValue) {
44
- return {};
89
+ async function parseMultipartFormDataBody(body, contentType) {
90
+ let form;
91
+ try {
92
+ form = await new Request("http://localhost", {
93
+ body,
94
+ headers: { "content-type": contentType },
95
+ method: "POST",
96
+ }).formData();
97
+ }
98
+ catch (error) {
99
+ throw new InvalidJsonBodyError(error);
45
100
  }
46
101
  const fields = {};
47
- const delimiter = `--${boundaryValue}`;
48
- for (const rawPart of body.toString("binary").split(delimiter)) {
49
- const part = rawPart.replace(/^\r?\n/u, "").replace(/\r?\n$/u, "");
50
- if (!part || part === "--") {
51
- continue;
52
- }
53
- const separatorIndex = part.indexOf("\r\n\r\n");
54
- if (separatorIndex < 0) {
55
- continue;
56
- }
57
- const rawHeaders = part.slice(0, separatorIndex);
58
- const rawContent = part.slice(separatorIndex + 4).replace(/\r?\n--$/u, "");
59
- const disposition = rawHeaders
60
- .split(/\r?\n/u)
61
- .find((header) => header.toLowerCase().startsWith("content-disposition:"));
62
- const name = /(?:^|;\s*)name="([^"]+)"/iu.exec(disposition ?? "")?.[1];
63
- if (!name) {
64
- continue;
65
- }
66
- const filename = /(?:^|;\s*)filename="([^"]*)"/iu.exec(disposition ?? "")?.[1];
67
- fields[name] = filename && filename.length > 0 ? filename : rawContent;
102
+ for (const [name, value] of form) {
103
+ fields[name] = typeof value === "string" ? value : value.name;
68
104
  }
69
105
  return fields;
70
106
  }
71
- function closeServer(server) {
72
- return new Promise((resolve, reject) => {
73
- server.close((error) => {
74
- if (error) {
75
- reject(error);
76
- return;
77
- }
78
- resolve();
79
- });
80
- });
81
- }
82
- async function writeResponse(response, fetchResponse) {
83
- response.statusCode = fetchResponse.status;
84
- for (const [name, value] of fetchResponse.headers) {
85
- response.setHeader(name, value);
86
- }
87
- response.end(Buffer.from(await fetchResponse.arrayBuffer()));
88
- }
89
107
  function requireTelegramBotPath(pathname) {
90
108
  const match = /^\/bot([^/]+)\/([A-Za-z][A-Za-z0-9_]*)$/u.exec(pathname);
91
109
  if (!match?.[1] || !match[2]) {
@@ -105,18 +123,32 @@ function toIntegerValue(value) {
105
123
  if (!stringValue || !/^-?\d+$/u.test(stringValue)) {
106
124
  return undefined;
107
125
  }
108
- return Number(stringValue);
126
+ const integerValue = Number(stringValue);
127
+ return Number.isSafeInteger(integerValue) ? integerValue : undefined;
128
+ }
129
+ function toBooleanValue(value) {
130
+ return value === true || (typeof value === "string" && value.toLowerCase() === "true");
109
131
  }
110
132
  function telegramChatId(value) {
111
133
  const stringValue = toStringValue(value);
112
134
  if (!stringValue) {
113
135
  return undefined;
114
136
  }
115
- return /^-?\d+$/u.test(stringValue) ? Number(stringValue) : stringValue;
137
+ if (/^-?\d+$/u.test(stringValue)) {
138
+ const integerValue = toIntegerValue(stringValue);
139
+ return integerValue === 0 ? undefined : integerValue;
140
+ }
141
+ return TELEGRAM_CHAT_USERNAME_PATTERN.test(stringValue) ? stringValue : undefined;
116
142
  }
117
143
  async function appendEvent(state, event) {
118
144
  await recordServerEvent({ event, onEvent: state.onEvent, recorderPath: state.recorderPath });
119
145
  }
146
+ function redactTelegramSecrets(body) {
147
+ return Object.fromEntries(Object.entries(body).map(([key, value]) => [
148
+ key,
149
+ key === "secret_token" ? "<redacted>" : value,
150
+ ]));
151
+ }
120
152
  function createBotUser(state) {
121
153
  return {
122
154
  can_join_groups: true,
@@ -130,11 +162,13 @@ function createBotUser(state) {
130
162
  };
131
163
  }
132
164
  function createChat(chatId) {
133
- const type = typeof chatId !== "number" || chatId >= 0
134
- ? "private"
135
- : String(chatId).startsWith("-100")
136
- ? "supergroup"
137
- : "group";
165
+ const type = typeof chatId === "string"
166
+ ? "supergroup"
167
+ : chatId >= 0
168
+ ? "private"
169
+ : String(chatId).startsWith("-100")
170
+ ? "supergroup"
171
+ : "group";
138
172
  return {
139
173
  id: chatId,
140
174
  type,
@@ -146,7 +180,11 @@ function createOutboundMessage(state, body) {
146
180
  if (chatId === undefined || !text) {
147
181
  return undefined;
148
182
  }
149
- const threadId = toIntegerValue(body.message_thread_id);
183
+ const parsedThreadId = toIntegerValue(body.message_thread_id);
184
+ if (body.message_thread_id !== undefined && parsedThreadId === undefined) {
185
+ return undefined;
186
+ }
187
+ const threadId = parsedThreadId !== undefined && parsedThreadId > 0 ? parsedThreadId : undefined;
150
188
  return {
151
189
  chat: createChat(chatId),
152
190
  date: Math.floor(Date.now() / 1000),
@@ -158,15 +196,22 @@ function createOutboundMessage(state, body) {
158
196
  }
159
197
  function createOutboundMediaMessage(state, body, mediaKind) {
160
198
  const chatId = telegramChatId(body.chat_id);
161
- if (chatId === undefined) {
199
+ const fileName = toStringValue(body[mediaKind]);
200
+ if (chatId === undefined || !fileName) {
201
+ return undefined;
202
+ }
203
+ const parsedThreadId = toIntegerValue(body.message_thread_id);
204
+ if (body.message_thread_id !== undefined && parsedThreadId === undefined) {
162
205
  return undefined;
163
206
  }
164
- const threadId = toIntegerValue(body.message_thread_id);
207
+ const threadId = parsedThreadId !== undefined && parsedThreadId > 0 ? parsedThreadId : undefined;
165
208
  const caption = toStringValue(body.caption);
166
- const fileName = toStringValue(body[mediaKind]);
209
+ const duration = Math.max(0, toIntegerValue(body.duration) ?? 0);
210
+ const height = Math.max(1, toIntegerValue(body.height) ?? 1);
211
+ const width = Math.max(1, toIntegerValue(body.width) ?? 1);
167
212
  const media = {
168
213
  file_id: `crabline-${mediaKind}-${state.nextMessageId}`,
169
- ...(fileName ? { file_name: fileName } : {}),
214
+ file_name: fileName,
170
215
  file_unique_id: `crabline-${mediaKind}-unique-${state.nextMessageId}`,
171
216
  };
172
217
  return {
@@ -176,7 +221,15 @@ function createOutboundMediaMessage(state, body, mediaKind) {
176
221
  ...(caption ? { caption } : {}),
177
222
  [mediaKind]: mediaKind === "photo"
178
223
  ? [{ ...media, height: 1, width: 1 }]
179
- : { ...media, mime_type: "application/octet-stream" },
224
+ : {
225
+ ...media,
226
+ ...(mediaKind === "audio"
227
+ ? { duration }
228
+ : mediaKind === "animation" || mediaKind === "video"
229
+ ? { duration, height, width }
230
+ : {}),
231
+ mime_type: "application/octet-stream",
232
+ },
180
233
  message_id: state.nextMessageId++,
181
234
  ...(threadId !== undefined ? { message_thread_id: threadId } : {}),
182
235
  };
@@ -188,7 +241,11 @@ function createEditedMessage(state, body) {
188
241
  if (chatId === undefined || !text || messageId === undefined) {
189
242
  return undefined;
190
243
  }
191
- const threadId = toIntegerValue(body.message_thread_id);
244
+ const parsedThreadId = toIntegerValue(body.message_thread_id);
245
+ if (body.message_thread_id !== undefined && parsedThreadId === undefined) {
246
+ return undefined;
247
+ }
248
+ const threadId = parsedThreadId !== undefined && parsedThreadId > 0 ? parsedThreadId : undefined;
192
249
  return {
193
250
  chat: createChat(chatId),
194
251
  date: Math.floor(Date.now() / 1000),
@@ -204,23 +261,25 @@ function createInboundUpdate(state, body) {
204
261
  if (chatId === undefined || !text) {
205
262
  return undefined;
206
263
  }
207
- const fromId = toIntegerValue(body.fromId ?? body.from_id) ?? 100001;
208
- const threadId = toIntegerValue(body.messageThreadId ?? body.message_thread_id);
264
+ const fromIdValue = body.fromId ?? body.from_id;
265
+ const threadIdValue = body.messageThreadId ?? body.message_thread_id;
266
+ const messageIdValue = body.messageId ?? body.message_id;
267
+ const updateIdValue = body.updateId ?? body.update_id;
268
+ const fromId = toIntegerValue(fromIdValue);
269
+ const parsedThreadId = toIntegerValue(threadIdValue);
270
+ const threadId = parsedThreadId !== undefined && parsedThreadId > 0 ? parsedThreadId : undefined;
209
271
  const fromUsername = toStringValue(body.fromUsername ?? body.from_username);
210
- const messageId = toIntegerValue(body.messageId ?? body.message_id);
211
- const updateId = toIntegerValue(body.updateId ?? body.update_id);
212
- const entities = Array.isArray(body.entities)
213
- ? body.entities.filter((entry) => typeof entry === "object" &&
214
- entry !== null &&
215
- typeof entry.length === "number" &&
216
- typeof entry.offset === "number" &&
217
- typeof entry.type === "string")
218
- : undefined;
219
- if (messageId !== undefined) {
220
- state.nextMessageId = Math.max(state.nextMessageId, messageId + 1);
221
- }
222
- if (updateId !== undefined) {
223
- state.nextUpdateId = Math.max(state.nextUpdateId, updateId + 1);
272
+ const messageId = toIntegerValue(messageIdValue);
273
+ const updateId = toIntegerValue(updateIdValue);
274
+ if ((fromIdValue !== undefined && fromId === undefined) ||
275
+ (threadIdValue !== undefined && parsedThreadId === undefined) ||
276
+ (messageIdValue !== undefined && messageId === undefined) ||
277
+ (updateIdValue !== undefined && updateId === undefined)) {
278
+ return undefined;
279
+ }
280
+ const entities = parseTelegramEntities(body.entities, text);
281
+ if (body.entities !== undefined && !entities) {
282
+ return undefined;
224
283
  }
225
284
  return {
226
285
  message: {
@@ -228,75 +287,616 @@ function createInboundUpdate(state, body) {
228
287
  date: Math.floor(Date.now() / 1000),
229
288
  from: {
230
289
  first_name: toStringValue(body.fromName ?? body.from_name) ?? "QA User",
231
- id: fromId,
290
+ id: fromId ?? 100001,
232
291
  is_bot: false,
233
292
  ...(fromUsername ? { username: fromUsername } : {}),
234
293
  },
235
- message_id: messageId ?? state.nextMessageId++,
294
+ message_id: messageId ?? state.nextMessageId,
236
295
  ...(entities && entities.length > 0 ? { entities } : {}),
237
296
  ...(threadId !== undefined ? { message_thread_id: threadId } : {}),
238
297
  text,
239
298
  },
240
- update_id: updateId ?? state.nextUpdateId++,
299
+ update_id: updateId ?? state.nextUpdateId,
241
300
  };
242
301
  }
243
- function queryRecord(url) {
244
- return Object.fromEntries(url.searchParams.entries());
302
+ function parseTelegramEntities(value, text) {
303
+ if (value === undefined) {
304
+ return undefined;
305
+ }
306
+ if (!Array.isArray(value)) {
307
+ return undefined;
308
+ }
309
+ const entities = [];
310
+ for (const entry of value) {
311
+ if (!isJsonObject(entry)) {
312
+ return undefined;
313
+ }
314
+ const length = toIntegerValue(entry.length);
315
+ const offset = toIntegerValue(entry.offset);
316
+ const type = toStringValue(entry.type);
317
+ if (length === undefined ||
318
+ length < 1 ||
319
+ offset === undefined ||
320
+ offset < 0 ||
321
+ offset + length > text.length ||
322
+ !type) {
323
+ return undefined;
324
+ }
325
+ entities.push({ length, offset, type });
326
+ }
327
+ return entities;
328
+ }
329
+ function hasTelegramMedia(value) {
330
+ return TELEGRAM_MEDIA_FIELDS.some((field) => value[field] !== undefined);
331
+ }
332
+ function hasValidExplicitTelegramIdentities(body) {
333
+ return ([body.chatId, body.chat_id].every((value) => value === undefined || telegramChatId(value) !== undefined) &&
334
+ [
335
+ body.fromId,
336
+ body.from_id,
337
+ body.messageId,
338
+ body.message_id,
339
+ body.messageThreadId,
340
+ body.message_thread_id,
341
+ body.updateId,
342
+ body.update_id,
343
+ ].every((value) => value === undefined || toIntegerValue(value) !== undefined));
344
+ }
345
+ function isValidIgnoredTelegramUpdate(body) {
346
+ const updateIdValue = body.updateId ?? body.update_id;
347
+ const updateId = toIntegerValue(updateIdValue);
348
+ if (isJsonObject(body.message)) {
349
+ const message = body.message;
350
+ const chat = isJsonObject(message.chat) ? message.chat : undefined;
351
+ if (updateId === undefined ||
352
+ !chat ||
353
+ telegramChatId(chat.id) === undefined ||
354
+ toIntegerValue(message.message_id) === undefined ||
355
+ (message.from !== undefined &&
356
+ (!isJsonObject(message.from) || toIntegerValue(message.from.id) === undefined)) ||
357
+ (message.message_thread_id !== undefined &&
358
+ toIntegerValue(message.message_thread_id) === undefined)) {
359
+ return false;
360
+ }
361
+ return toStringValue(message.text) === undefined;
362
+ }
363
+ const chatId = telegramChatId(body.chatId ?? body.chat_id);
364
+ if (chatId !== undefined &&
365
+ hasValidExplicitTelegramIdentities(body) &&
366
+ hasTelegramMedia(body) &&
367
+ toStringValue(body.text) === undefined) {
368
+ return true;
369
+ }
370
+ return (updateId !== undefined &&
371
+ TELEGRAM_UNSUPPORTED_UPDATE_FIELDS.some((field) => isJsonObject(body[field])));
372
+ }
373
+ async function handleTelegramAdminInbound(params) {
374
+ let releaseAdmission;
375
+ const previousAdmission = params.state.inboundAdmission;
376
+ params.state.inboundAdmission = new Promise((resolve) => {
377
+ releaseAdmission = resolve;
378
+ });
379
+ await previousAdmission;
380
+ try {
381
+ const nextMessageId = params.state.nextMessageId;
382
+ const nextUpdateId = params.state.nextUpdateId;
383
+ const update = createInboundUpdate(params.state, params.body);
384
+ params.state.nextMessageId = nextMessageId;
385
+ params.state.nextUpdateId = nextUpdateId;
386
+ if (!update) {
387
+ if (isValidIgnoredTelegramUpdate(params.body)) {
388
+ return jsonResponse({ ok: true });
389
+ }
390
+ return telegramError("Bad Request: chatId and text are required");
391
+ }
392
+ if (params.state.updates.length >= params.state.maxPendingInboundEvents) {
393
+ return telegramError(`Too Many Requests: pending inbound queue is full (${params.state.maxPendingInboundEvents} updates)`, 429);
394
+ }
395
+ params.state.nextMessageId = Math.max(params.state.nextMessageId, update.message.message_id + 1);
396
+ params.state.nextUpdateId = Math.max(params.state.nextUpdateId, update.update_id + 1);
397
+ const reservedNextMessageId = params.state.nextMessageId;
398
+ const reservedNextUpdateId = params.state.nextUpdateId;
399
+ try {
400
+ await appendEvent(params.state, {
401
+ at: new Date().toISOString(),
402
+ body: params.body,
403
+ method: params.request.method ?? "POST",
404
+ path: params.url.pathname,
405
+ query: queryRecord(params.url),
406
+ type: "admin",
407
+ });
408
+ }
409
+ catch (error) {
410
+ if (params.state.nextMessageId === reservedNextMessageId) {
411
+ params.state.nextMessageId = nextMessageId;
412
+ }
413
+ if (params.state.nextUpdateId === reservedNextUpdateId) {
414
+ params.state.nextUpdateId = nextUpdateId;
415
+ }
416
+ throw error;
417
+ }
418
+ params.state.updates.push(update);
419
+ params.state.updates.sort((left, right) => left.update_id - right.update_id);
420
+ if (params.state.webhook) {
421
+ scheduleTelegramWebhookDelivery(params.state, 0);
422
+ return jsonResponse({ ok: true, update });
423
+ }
424
+ finishTelegramUpdatePoll(params.state, "update");
425
+ return jsonResponse({ ok: true, update });
426
+ }
427
+ finally {
428
+ releaseAdmission();
429
+ }
430
+ }
431
+ async function flushTelegramWebhookUpdates(state, onAttempt) {
432
+ if (!state.webhook) {
433
+ return undefined;
434
+ }
435
+ while (state.updates.length > 0 && state.webhook) {
436
+ syncTelegramWebhookRetryHead(state);
437
+ const webhook = state.webhook;
438
+ const update = state.updates[0];
439
+ onAttempt(update.update_id);
440
+ try {
441
+ const delivery = await postTelegramWebhook(state, webhook, update);
442
+ if ("error" in delivery) {
443
+ if (state.webhook === webhook) {
444
+ webhook.lastErrorDate = Math.floor(Date.now() / 1_000);
445
+ webhook.lastErrorMessage = delivery.error;
446
+ }
447
+ return telegramError("Bad Gateway: webhook delivery failed", 502);
448
+ }
449
+ if (delivery.status < 200 || delivery.status >= 300) {
450
+ if (state.webhook === webhook) {
451
+ webhook.lastErrorDate = Math.floor(Date.now() / 1_000);
452
+ webhook.lastErrorMessage = `Wrong response from the webhook: ${delivery.status}`;
453
+ }
454
+ return telegramError("Bad Gateway: webhook delivery failed", 502);
455
+ }
456
+ if (state.webhook !== webhook) {
457
+ return undefined;
458
+ }
459
+ const deliveredIndex = state.updates.indexOf(update);
460
+ if (deliveredIndex >= 0) {
461
+ state.updates.splice(deliveredIndex, 1);
462
+ }
463
+ if (state.webhook === webhook) {
464
+ state.webhookRetryAttempts = 0;
465
+ }
466
+ }
467
+ catch {
468
+ if (state.webhook === webhook) {
469
+ webhook.lastErrorDate = Math.floor(Date.now() / 1_000);
470
+ webhook.lastErrorMessage = "Webhook delivery failed";
471
+ }
472
+ return telegramError("Bad Gateway: webhook delivery failed", 502);
473
+ }
474
+ }
475
+ return undefined;
476
+ }
477
+ async function validateTelegramWebhookUrl(state, url, deadlineAt, signal) {
478
+ const remainingMs = deadlineAt - Date.now();
479
+ if (remainingMs <= 0) {
480
+ return telegramError("Bad Request: webhook host could not be resolved");
481
+ }
482
+ const deadline = new AbortController();
483
+ const timer = setTimeout(() => deadline.abort(new DOMException("Webhook delivery timed out", "TimeoutError")), remainingMs);
484
+ timer.unref();
485
+ let target;
486
+ try {
487
+ target = await validateWebhookTarget({
488
+ allowLoopbackHttp: state.allowLoopbackHttpWebhook,
489
+ restrictPrivateAddresses: state.restrictWebhookTargets,
490
+ signal: AbortSignal.any([signal, deadline.signal]),
491
+ url,
492
+ });
493
+ }
494
+ catch {
495
+ return telegramError("Bad Request: webhook host could not be resolved");
496
+ }
497
+ finally {
498
+ clearTimeout(timer);
499
+ }
500
+ if (!("error" in target)) {
501
+ return target;
502
+ }
503
+ return target.error === "unresolvable"
504
+ ? telegramError("Bad Request: webhook host could not be resolved")
505
+ : target.error === "private-address"
506
+ ? telegramError("Bad Request: webhook URL must not target a private or link-local address")
507
+ : telegramError("Bad Request: webhook URL must use HTTPS");
508
+ }
509
+ /** @internal */
510
+ export async function withTelegramWebhookDeadline(promise, deadlineAt, signal) {
511
+ const remainingMs = deadlineAt - Date.now();
512
+ if (remainingMs <= 0) {
513
+ throw new DOMException("Webhook delivery timed out", "TimeoutError");
514
+ }
515
+ return await new Promise((resolve, reject) => {
516
+ let settled = false;
517
+ const finish = (callback) => {
518
+ if (settled) {
519
+ return;
520
+ }
521
+ settled = true;
522
+ clearTimeout(timer);
523
+ signal.removeEventListener("abort", onAbort);
524
+ callback();
525
+ };
526
+ const onAbort = () => finish(() => reject(signal.reason instanceof Error
527
+ ? signal.reason
528
+ : new DOMException("Webhook delivery aborted", "AbortError")));
529
+ const timer = setTimeout(() => finish(() => reject(new DOMException("Webhook delivery timed out", "TimeoutError"))), remainingMs);
530
+ timer.unref();
531
+ signal.addEventListener("abort", onAbort, { once: true });
532
+ if (signal.aborted) {
533
+ onAbort();
534
+ return;
535
+ }
536
+ promise.then((value) => finish(() => resolve(value)), (error) => finish(() => reject(error)));
537
+ });
538
+ }
539
+ async function postTelegramWebhook(state, webhook, update) {
540
+ const url = new URL(webhook.url);
541
+ const deadlineAt = Date.now() + TELEGRAM_WEBHOOK_DELIVERY_TIMEOUT_MS;
542
+ const controller = new AbortController();
543
+ state.activeWebhookDeliveries.add(controller);
544
+ try {
545
+ const target = await validateTelegramWebhookUrl(state, url, deadlineAt, controller.signal);
546
+ if (target instanceof Response) {
547
+ return { error: "Webhook target is no longer allowed" };
548
+ }
549
+ const addresses = target.addresses && target.addresses.length > 0 ? target.addresses : [undefined];
550
+ let lastError;
551
+ for (const [index, address] of addresses.entries()) {
552
+ if (state.closing) {
553
+ throw new Error("Telegram server is shutting down.");
554
+ }
555
+ const remainingMs = deadlineAt - Date.now();
556
+ if (remainingMs <= 0) {
557
+ throw new DOMException("Webhook delivery timed out", "TimeoutError");
558
+ }
559
+ try {
560
+ const attemptsRemaining = addresses.length - index;
561
+ const status = await postWebhookRequest({
562
+ address,
563
+ body: JSON.stringify(update),
564
+ headerEntries: webhook.secretToken
565
+ ? [["x-telegram-bot-api-secret-token", webhook.secretToken]]
566
+ : undefined,
567
+ signal: controller.signal,
568
+ timeoutMs: Math.max(1, Math.floor(remainingMs / attemptsRemaining)),
569
+ url,
570
+ });
571
+ return { status };
572
+ }
573
+ catch (error) {
574
+ lastError = error;
575
+ if (state.closing || controller.signal.aborted) {
576
+ throw error;
577
+ }
578
+ }
579
+ }
580
+ throw lastError;
581
+ }
582
+ finally {
583
+ state.activeWebhookDeliveries.delete(controller);
584
+ }
585
+ }
586
+ async function replaceTelegramWebhook(state, webhook, dropPendingUpdates) {
587
+ let releaseAdmission;
588
+ const previousAdmission = state.webhookAdmission;
589
+ state.webhookAdmission = new Promise((resolve) => {
590
+ releaseAdmission = resolve;
591
+ });
592
+ await previousAdmission;
593
+ try {
594
+ state.webhook = undefined;
595
+ clearTelegramWebhookRetry(state, true);
596
+ for (const controller of state.activeWebhookDeliveries) {
597
+ controller.abort();
598
+ }
599
+ await state.webhookDelivery;
600
+ if (dropPendingUpdates) {
601
+ state.updates.length = 0;
602
+ }
603
+ state.webhook = webhook;
604
+ if (webhook) {
605
+ finishTelegramUpdatePoll(state, "conflict");
606
+ scheduleTelegramWebhookDelivery(state, 0);
607
+ }
608
+ }
609
+ finally {
610
+ releaseAdmission();
611
+ }
612
+ }
613
+ function clearTelegramWebhookRetry(state, resetAttempts = false) {
614
+ if (state.webhookRetryTimer) {
615
+ clearTimeout(state.webhookRetryTimer);
616
+ state.webhookRetryTimer = undefined;
617
+ }
618
+ if (resetAttempts) {
619
+ state.webhookRetryAttempts = 0;
620
+ state.webhookRetryUpdateId = undefined;
621
+ }
622
+ }
623
+ function syncTelegramWebhookRetryHead(state) {
624
+ const updateId = state.updates[0]?.update_id;
625
+ if (updateId === state.webhookRetryUpdateId) {
626
+ return;
627
+ }
628
+ clearTelegramWebhookRetry(state);
629
+ state.webhookRetryAttempts = 0;
630
+ state.webhookRetryUpdateId = updateId;
631
+ }
632
+ function scheduleTelegramWebhookDelivery(state, delayMs) {
633
+ if (state.closing ||
634
+ !state.webhook ||
635
+ state.updates.length === 0 ||
636
+ state.webhookDelivery ||
637
+ state.webhookRetryTimer) {
638
+ return;
639
+ }
640
+ state.webhookRetryTimer = setTimeout(() => {
641
+ state.webhookRetryTimer = undefined;
642
+ void deliverTelegramWebhookUpdates(state, true);
643
+ }, delayMs);
644
+ state.webhookRetryTimer.unref();
645
+ }
646
+ async function deliverTelegramWebhookUpdates(state, scheduledRetry = false) {
647
+ if (state.webhookDelivery) {
648
+ return await state.webhookDelivery;
649
+ }
650
+ syncTelegramWebhookRetryHead(state);
651
+ if (!scheduledRetry && state.webhookRetryTimer) {
652
+ return telegramError("Bad Gateway: webhook delivery failed", 502);
653
+ }
654
+ let attemptedUpdateId;
655
+ const delivery = flushTelegramWebhookUpdates(state, (updateId) => {
656
+ attemptedUpdateId = updateId;
657
+ });
658
+ state.webhookDelivery = delivery;
659
+ let result;
660
+ try {
661
+ result = await delivery;
662
+ return result;
663
+ }
664
+ finally {
665
+ if (state.webhookDelivery === delivery) {
666
+ state.webhookDelivery = undefined;
667
+ }
668
+ if (state.webhook && state.updates.length > 0) {
669
+ if (state.updates[0]?.update_id !== attemptedUpdateId) {
670
+ syncTelegramWebhookRetryHead(state);
671
+ scheduleTelegramWebhookDelivery(state, 0);
672
+ }
673
+ else if (result) {
674
+ const delayMs = TELEGRAM_WEBHOOK_RETRY_BASE_MS *
675
+ 2 ** Math.min(state.webhookRetryAttempts, TELEGRAM_WEBHOOK_MAX_BACKOFF_EXPONENT);
676
+ state.webhookRetryAttempts = Math.min(state.webhookRetryAttempts + 1, TELEGRAM_WEBHOOK_MAX_BACKOFF_EXPONENT);
677
+ scheduleTelegramWebhookDelivery(state, delayMs);
678
+ }
679
+ else {
680
+ state.webhookRetryAttempts = 0;
681
+ scheduleTelegramWebhookDelivery(state, 0);
682
+ }
683
+ }
684
+ }
245
685
  }
246
686
  async function handleTelegramApi(params) {
247
- switch (params.method) {
248
- case "getMe":
687
+ const method = params.method.toLowerCase();
688
+ switch (method) {
689
+ case "getme":
249
690
  return telegramOk(createBotUser(params.state));
250
- case "deleteWebhook":
251
- case "setWebhook":
252
- case "setMyCommands":
253
- case "deleteMyCommands":
254
- case "sendChatAction":
255
- case "answerCallbackQuery":
256
- case "deleteMessage":
257
- case "pinChatMessage":
258
- case "unpinChatMessage":
259
- case "setMessageReaction":
260
- case "editForumTopic":
691
+ case "setmycommands":
692
+ case "deletemycommands":
693
+ case "sendchataction":
694
+ case "answercallbackquery":
695
+ case "deletemessage":
696
+ case "pinchatmessage":
697
+ case "unpinchatmessage":
698
+ case "setmessagereaction":
699
+ case "editforumtopic":
261
700
  return telegramOk(true);
262
- case "createForumTopic":
701
+ case "setwebhook": {
702
+ if (params.body.url === "") {
703
+ await replaceTelegramWebhook(params.state, undefined, toBooleanValue(params.body.drop_pending_updates));
704
+ return telegramOk(true);
705
+ }
706
+ const webhookUrl = toStringValue(params.body.url);
707
+ if (!webhookUrl) {
708
+ return telegramError("Bad Request: url is required");
709
+ }
710
+ let parsedUrl;
711
+ try {
712
+ parsedUrl = new URL(webhookUrl);
713
+ }
714
+ catch {
715
+ return telegramError("Bad Request: bad webhook URL");
716
+ }
717
+ if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
718
+ return telegramError("Bad Request: bad webhook URL");
719
+ }
720
+ if (params.state.activeWebhookValidations.size >= MAX_ACTIVE_TELEGRAM_WEBHOOK_VALIDATIONS) {
721
+ return telegramError("Too Many Requests: too many webhook validations", 429);
722
+ }
723
+ const validation = new AbortController();
724
+ params.state.activeWebhookValidations.add(validation);
725
+ const target = await validateTelegramWebhookUrl(params.state, parsedUrl, Date.now() + TELEGRAM_WEBHOOK_DELIVERY_TIMEOUT_MS, validation.signal).finally(() => {
726
+ params.state.activeWebhookValidations.delete(validation);
727
+ });
728
+ if (target instanceof Response) {
729
+ return target;
730
+ }
731
+ const secretToken = toStringValue(params.body.secret_token);
732
+ if (secretToken && !/^[A-Za-z0-9_-]{1,256}$/u.test(secretToken)) {
733
+ return telegramError("Bad Request: invalid secret token");
734
+ }
735
+ await replaceTelegramWebhook(params.state, {
736
+ ...(secretToken ? { secretToken } : {}),
737
+ url: parsedUrl.href,
738
+ }, toBooleanValue(params.body.drop_pending_updates));
739
+ return telegramOk(true);
740
+ }
741
+ case "deletewebhook":
742
+ await replaceTelegramWebhook(params.state, undefined, toBooleanValue(params.body.drop_pending_updates));
743
+ return telegramOk(true);
744
+ case "getwebhookinfo":
745
+ return telegramOk({
746
+ has_custom_certificate: false,
747
+ ...(params.state.webhook?.lastErrorDate
748
+ ? { last_error_date: params.state.webhook.lastErrorDate }
749
+ : {}),
750
+ ...(params.state.webhook?.lastErrorMessage
751
+ ? { last_error_message: params.state.webhook.lastErrorMessage }
752
+ : {}),
753
+ pending_update_count: params.state.updates.length,
754
+ url: params.state.webhook?.url ?? "",
755
+ });
756
+ case "createforumtopic":
263
757
  return telegramOk({
264
758
  icon_color: 0x6fb9f0,
265
759
  message_thread_id: params.state.nextMessageId++,
266
760
  name: toStringValue(params.body.name) ?? "Crabline Topic",
267
761
  });
268
- case "editMessageText": {
762
+ case "editmessagetext": {
269
763
  const message = createEditedMessage(params.state, params.body);
270
764
  return message
271
765
  ? telegramOk(message)
272
766
  : telegramError("Bad Request: chat_id, message_id, and text are required");
273
767
  }
274
- case "sendMessage": {
768
+ case "sendmessage": {
275
769
  const message = createOutboundMessage(params.state, params.body);
276
770
  return message
277
771
  ? telegramOk(message)
278
772
  : telegramError("Bad Request: chat_id and text are required");
279
773
  }
280
- case "sendAnimation":
281
- case "sendDocument":
282
- case "sendPhoto":
283
- case "sendVideo": {
284
- const mediaKind = params.method.slice("send".length).toLowerCase();
774
+ case "sendanimation":
775
+ case "sendaudio":
776
+ case "senddocument":
777
+ case "sendphoto":
778
+ case "sendvideo": {
779
+ const mediaKind = method.slice("send".length);
285
780
  const message = createOutboundMediaMessage(params.state, params.body, mediaKind);
286
- return message ? telegramOk(message) : telegramError("Bad Request: chat_id is required");
287
- }
288
- case "getUpdates": {
289
- const offset = toIntegerValue(params.body.offset);
290
- const limit = toIntegerValue(params.body.limit) ?? 100;
291
- const updates = offset === undefined
292
- ? params.state.updates
293
- : params.state.updates.filter((update) => update.update_id >= offset);
294
- return telegramOk(updates.slice(0, limit));
781
+ return message
782
+ ? telegramOk(message)
783
+ : telegramError(`Bad Request: chat_id and ${mediaKind} are required`);
295
784
  }
785
+ case "getupdates":
786
+ if (params.state.webhook) {
787
+ return telegramError("Conflict: can't use getUpdates method while webhook is active", 409);
788
+ }
789
+ return await handleTelegramGetUpdates(params);
296
790
  default:
297
791
  return telegramError(`Not Found: unsupported method ${params.method}`, 404);
298
792
  }
299
793
  }
794
+ /** @internal */
795
+ export async function handleTelegramGetUpdates(params) {
796
+ const offset = toIntegerValue(params.body.offset);
797
+ const limit = toIntegerValue(params.body.limit) ?? 100;
798
+ const timeout = toIntegerValue(params.body.timeout) ?? 0;
799
+ if (params.body.offset !== undefined && offset === undefined) {
800
+ return telegramError("Bad Request: offset must be a safe integer");
801
+ }
802
+ if (params.body.limit !== undefined && toIntegerValue(params.body.limit) === undefined) {
803
+ return telegramError("Bad Request: limit must be a safe integer");
804
+ }
805
+ if (params.body.timeout !== undefined && toIntegerValue(params.body.timeout) === undefined) {
806
+ return telegramError("Bad Request: timeout must be a safe integer");
807
+ }
808
+ if (limit < 1 || limit > 100) {
809
+ return telegramError("Bad Request: limit must be between 1 and 100");
810
+ }
811
+ if (timeout < 0) {
812
+ return telegramError("Bad Request: timeout must be non-negative");
813
+ }
814
+ finishTelegramUpdatePoll(params.state, "conflict");
815
+ let updates = takeTelegramUpdates(params.state, offset, limit);
816
+ const deadline = Date.now() + Math.min(timeout * 1_000, 2_147_483_647);
817
+ if (updates.length === 0 && timeout > 0) {
818
+ while (updates.length === 0) {
819
+ const remainingMs = deadline - Date.now();
820
+ if (remainingMs <= 0) {
821
+ break;
822
+ }
823
+ const pollResult = await waitForTelegramUpdate(params.state, params.request, remainingMs, () => hasTelegramUpdates(params.state, offset));
824
+ if (pollResult === "conflict") {
825
+ return telegramError("Conflict: terminated by other getUpdates request; make sure that only one bot instance is running", 409);
826
+ }
827
+ if (pollResult !== "update") {
828
+ break;
829
+ }
830
+ updates = takeTelegramUpdates(params.state, offset, limit);
831
+ }
832
+ }
833
+ return telegramOk(updates);
834
+ }
835
+ function takeTelegramUpdates(state, offset, limit) {
836
+ if (offset !== undefined && offset < 0) {
837
+ const retainedCount = Math.min(Math.abs(offset), state.updates.length);
838
+ state.updates.splice(0, state.updates.length - retainedCount);
839
+ }
840
+ else if (offset !== undefined) {
841
+ const firstUnconfirmed = state.updates.findIndex((update) => update.update_id >= offset);
842
+ if (firstUnconfirmed === -1) {
843
+ state.updates.length = 0;
844
+ }
845
+ else if (firstUnconfirmed > 0) {
846
+ state.updates.splice(0, firstUnconfirmed);
847
+ }
848
+ }
849
+ return state.updates.slice(0, limit);
850
+ }
851
+ function hasTelegramUpdates(state, offset) {
852
+ if (offset === undefined || offset < 0) {
853
+ return state.updates.length > 0;
854
+ }
855
+ return state.updates.some((update) => update.update_id >= offset);
856
+ }
857
+ function finishTelegramUpdatePoll(state, result) {
858
+ state.activeUpdatePoll?.finish(result);
859
+ }
860
+ async function waitForTelegramUpdate(state, request, timeoutMs, hasUpdate) {
861
+ if (state.closing || request.socket.destroyed) {
862
+ return "shutdown";
863
+ }
864
+ return await new Promise((resolve) => {
865
+ let settled = false;
866
+ const poll = {
867
+ finish(result) {
868
+ if (settled) {
869
+ return;
870
+ }
871
+ settled = true;
872
+ clearTimeout(timer);
873
+ request.socket.off("close", onSocketClose);
874
+ if (state.activeUpdatePoll === poll) {
875
+ state.activeUpdatePoll = undefined;
876
+ }
877
+ resolve(result);
878
+ },
879
+ };
880
+ const onSocketClose = () => poll.finish("shutdown");
881
+ const timer = setTimeout(() => poll.finish("timeout"), timeoutMs);
882
+ timer.unref();
883
+ const previousPoll = state.activeUpdatePoll;
884
+ state.activeUpdatePoll = poll;
885
+ previousPoll?.finish("conflict");
886
+ request.socket.once("close", onSocketClose);
887
+ if (state.closing || request.socket.destroyed) {
888
+ poll.finish("shutdown");
889
+ }
890
+ else if (hasUpdate()) {
891
+ poll.finish("update");
892
+ }
893
+ });
894
+ }
895
+ function telegramMethodNotAllowed() {
896
+ const response = telegramError("Method Not Allowed", 405);
897
+ response.headers.set("allow", "GET, POST");
898
+ return response;
899
+ }
300
900
  async function handleRequest(params) {
301
901
  const url = new URL(params.request.url ?? "/", "http://127.0.0.1");
302
902
  if (url.pathname === "/crabline/telegram/inbound") {
@@ -304,67 +904,127 @@ async function handleRequest(params) {
304
904
  return new Response("not found", { status: 404 });
305
905
  }
306
906
  if (!hasAdminToken(params.request, params.state.adminToken)) {
907
+ drainRequestBody(params.request);
307
908
  return adminAuthError();
308
909
  }
309
910
  const body = await parseRequestBody(params.request);
310
- const update = createInboundUpdate(params.state, body);
311
- await appendEvent(params.state, {
312
- at: new Date().toISOString(),
911
+ if (!isJsonObject(body)) {
912
+ return telegramError("Bad Request: request body must be a JSON object");
913
+ }
914
+ return await handleTelegramAdminInbound({
313
915
  body,
314
- method: params.request.method ?? "POST",
315
- path: url.pathname,
316
- query: queryRecord(url),
317
- type: "admin",
916
+ request: params.request,
917
+ state: params.state,
918
+ url,
318
919
  });
319
- if (!update) {
320
- return telegramError("Bad Request: chatId and text are required");
321
- }
322
- params.state.updates.push(update);
323
- return jsonResponse({ ok: true, update });
324
920
  }
325
921
  const botPath = requireTelegramBotPath(url.pathname);
326
922
  if (!botPath || botPath.token !== params.state.botToken) {
923
+ drainRequestBody(params.request);
327
924
  return new Response("not found", { status: 404 });
328
925
  }
329
- const body = params.request.method === "GET" ? queryRecord(url) : await parseRequestBody(params.request);
330
- await appendEvent(params.state, {
926
+ const requestMethod = params.request.method ?? "GET";
927
+ if (requestMethod !== "GET" && requestMethod !== "POST") {
928
+ drainRequestBody(params.request);
929
+ return telegramMethodNotAllowed();
930
+ }
931
+ const body = requestMethod === "GET" ? queryRecord(url) : await parseRequestBody(params.request);
932
+ if (!isJsonObject(body)) {
933
+ return telegramError("Bad Request: can't parse JSON object");
934
+ }
935
+ const event = {
331
936
  at: new Date().toISOString(),
332
- body,
333
- method: params.request.method ?? "GET",
937
+ body: redactTelegramSecrets(body),
938
+ method: requestMethod,
334
939
  path: `/bot<redacted>/${botPath.method}`,
335
- query: queryRecord(url),
940
+ query: redactTelegramSecrets(queryRecord(url)),
336
941
  type: "api",
337
- });
338
- return handleTelegramApi({
942
+ };
943
+ if (TELEGRAM_SEND_METHODS.has(botPath.method.toLowerCase())) {
944
+ const response = await handleTelegramApi({
945
+ body,
946
+ method: botPath.method,
947
+ request: params.request,
948
+ state: params.state,
949
+ });
950
+ event.accepted = response.ok;
951
+ try {
952
+ await appendEvent(params.state, event);
953
+ }
954
+ catch (error) {
955
+ if (!event.accepted) {
956
+ throw error;
957
+ }
958
+ }
959
+ return response;
960
+ }
961
+ await appendEvent(params.state, event);
962
+ return await handleTelegramApi({
339
963
  body,
340
964
  method: botPath.method,
965
+ request: params.request,
341
966
  state: params.state,
342
967
  });
343
968
  }
969
+ async function serveRequest(params) {
970
+ let fetchResponse;
971
+ try {
972
+ fetchResponse = await handleRequest({ request: params.request, state: params.state });
973
+ }
974
+ catch (error) {
975
+ fetchResponse =
976
+ error instanceof InvalidJsonBodyError
977
+ ? telegramError("Bad Request: can't parse JSON object")
978
+ : error instanceof RequestBodyTooLargeError
979
+ ? telegramError("Request Entity Too Large", 413)
980
+ : jsonResponse({ error: "internal server error", ok: false }, 500);
981
+ }
982
+ try {
983
+ await writeResponse(params.response, fetchResponse);
984
+ }
985
+ catch {
986
+ // A disconnected client cannot receive an error fallback. End delivery here
987
+ // so the Node request callback never leaks an unhandled rejection.
988
+ params.response.destroy();
989
+ }
990
+ }
344
991
  export async function startTelegramServer(params = {}) {
992
+ const host = params.host ?? "127.0.0.1";
993
+ const botId = params.botId ?? 424242;
994
+ if (!Number.isSafeInteger(botId) || botId < 1) {
995
+ throw new Error("botId must be a positive safe integer.");
996
+ }
345
997
  const state = {
998
+ activeUpdatePoll: undefined,
999
+ activeWebhookDeliveries: new Set(),
1000
+ activeWebhookValidations: new Set(),
346
1001
  adminToken: params.adminToken ?? randomBytes(24).toString("hex"),
347
- botId: params.botId ?? 424242,
348
- botToken: params.botToken ?? "424242:crabline-telegram-token",
1002
+ allowLoopbackHttpWebhook: isLoopbackHost(host),
1003
+ botId,
1004
+ botToken: params.botToken ??
1005
+ (isLoopbackHost(host)
1006
+ ? "424242:crabline-telegram-token"
1007
+ : `${botId}:${randomBytes(26).toString("base64url")}`),
349
1008
  botUsername: params.botUsername ?? "crabline_bot",
1009
+ inboundAdmission: Promise.resolve(),
1010
+ maxPendingInboundEvents: resolveMaxPendingInboundEvents(params.maxPendingInboundEvents),
350
1011
  nextMessageId: 1,
351
1012
  nextUpdateId: 1,
352
1013
  onEvent: params.onEvent,
353
1014
  recorderPath: params.recorderPath ?? path.resolve(".crabline", "servers", "telegram.jsonl"),
1015
+ restrictWebhookTargets: true,
1016
+ closing: false,
354
1017
  updates: [],
1018
+ webhook: undefined,
1019
+ webhookAdmission: Promise.resolve(),
1020
+ webhookDelivery: undefined,
1021
+ webhookRetryAttempts: 0,
1022
+ webhookRetryTimer: undefined,
1023
+ webhookRetryUpdateId: undefined,
355
1024
  };
356
- const host = params.host ?? "127.0.0.1";
357
1025
  const port = params.port ?? 0;
358
- const server = createServer(async (request, response) => {
359
- try {
360
- await writeResponse(response, await handleRequest({ request, state }));
361
- }
362
- catch (error) {
363
- await writeResponse(response, jsonResponse({
364
- error: error instanceof Error ? error.message : String(error),
365
- ok: false,
366
- }, 500));
367
- }
1026
+ const server = createServer((request, response) => {
1027
+ void serveRequest({ request, response, state });
368
1028
  });
369
1029
  await new Promise((resolve, reject) => {
370
1030
  server.once("error", reject);
@@ -379,9 +1039,21 @@ export async function startTelegramServer(params = {}) {
379
1039
  kind: "connectivity",
380
1040
  });
381
1041
  }
382
- const baseUrl = `http://${host}:${address.port}`;
1042
+ const baseUrl = `http://${formatUrlHost(host)}:${address.port}`;
383
1043
  return {
384
1044
  async close() {
1045
+ state.closing = true;
1046
+ finishTelegramUpdatePoll(state, "shutdown");
1047
+ clearTelegramWebhookRetry(state);
1048
+ for (const controller of state.activeWebhookDeliveries) {
1049
+ controller.abort();
1050
+ }
1051
+ for (const controller of state.activeWebhookValidations) {
1052
+ controller.abort();
1053
+ }
1054
+ await state.webhookDelivery;
1055
+ await state.webhookAdmission;
1056
+ await state.inboundAdmission;
385
1057
  await closeServer(server);
386
1058
  },
387
1059
  manifest: {