@openclaw/crabline 0.1.11 → 0.1.13

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 (214) hide show
  1. package/README.md +137 -23
  2. package/dist/src/bin/crabline.js +3 -1
  3. package/dist/src/bin/crabline.js.map +1 -1
  4. package/dist/src/cli/program.d.ts +5 -1
  5. package/dist/src/cli/program.js +281 -52
  6. package/dist/src/cli/program.js.map +1 -1
  7. package/dist/src/config/load.js +39 -3
  8. package/dist/src/config/load.js.map +1 -1
  9. package/dist/src/config/schema.d.ts +20 -0
  10. package/dist/src/config/schema.js +163 -35
  11. package/dist/src/config/schema.js.map +1 -1
  12. package/dist/src/core/errors.d.ts +1 -0
  13. package/dist/src/core/errors.js +6 -3
  14. package/dist/src/core/errors.js.map +1 -1
  15. package/dist/src/core/http-path.d.ts +1 -0
  16. package/dist/src/core/http-path.js +14 -0
  17. package/dist/src/core/http-path.js.map +1 -0
  18. package/dist/src/core/matcher.d.ts +3 -1
  19. package/dist/src/core/matcher.js +58 -2
  20. package/dist/src/core/matcher.js.map +1 -1
  21. package/dist/src/core/nonces.d.ts +2 -0
  22. package/dist/src/core/nonces.js +11 -1
  23. package/dist/src/core/nonces.js.map +1 -1
  24. package/dist/src/core/reporters.d.ts +5 -0
  25. package/dist/src/core/reporters.js +26 -2
  26. package/dist/src/core/reporters.js.map +1 -1
  27. package/dist/src/core/run.d.ts +3 -1
  28. package/dist/src/core/run.js +178 -66
  29. package/dist/src/core/run.js.map +1 -1
  30. package/dist/src/index.d.ts +12 -10
  31. package/dist/src/index.js +6 -5
  32. package/dist/src/index.js.map +1 -1
  33. package/dist/src/matrix-ids.d.ts +5 -0
  34. package/dist/src/matrix-ids.js +83 -0
  35. package/dist/src/matrix-ids.js.map +1 -0
  36. package/dist/src/openclaw/artifact-generation.d.ts +7 -6
  37. package/dist/src/openclaw/artifact-generation.js +385 -41
  38. package/dist/src/openclaw/artifact-generation.js.map +1 -1
  39. package/dist/src/openclaw/bridges/discord.d.ts +1 -0
  40. package/dist/src/openclaw/bridges/discord.js +149 -0
  41. package/dist/src/openclaw/bridges/discord.js.map +1 -0
  42. package/dist/src/openclaw/bridges/matrix.js +22 -5
  43. package/dist/src/openclaw/bridges/matrix.js.map +1 -1
  44. package/dist/src/openclaw/bridges/mattermost.js +14 -9
  45. package/dist/src/openclaw/bridges/mattermost.js.map +1 -1
  46. package/dist/src/openclaw/bridges/probe-response.d.ts +1 -0
  47. package/dist/src/openclaw/bridges/probe-response.js +5 -0
  48. package/dist/src/openclaw/bridges/probe-response.js.map +1 -0
  49. package/dist/src/openclaw/bridges/signal.js +16 -7
  50. package/dist/src/openclaw/bridges/signal.js.map +1 -1
  51. package/dist/src/openclaw/bridges/slack.js +117 -24
  52. package/dist/src/openclaw/bridges/slack.js.map +1 -1
  53. package/dist/src/openclaw/bridges/telegram.js +46 -20
  54. package/dist/src/openclaw/bridges/telegram.js.map +1 -1
  55. package/dist/src/openclaw/bridges/whatsapp.js +33 -17
  56. package/dist/src/openclaw/bridges/whatsapp.js.map +1 -1
  57. package/dist/src/openclaw/bridges/zalo.js +9 -4
  58. package/dist/src/openclaw/bridges/zalo.js.map +1 -1
  59. package/dist/src/openclaw/outbound-contract.js +3 -0
  60. package/dist/src/openclaw/outbound-contract.js.map +1 -1
  61. package/dist/src/openclaw/private-file.d.ts +38 -3
  62. package/dist/src/openclaw/private-file.js +2008 -87
  63. package/dist/src/openclaw/private-file.js.map +1 -1
  64. package/dist/src/openclaw/{smoke-lock.d.ts → provider-readiness-lock.d.ts} +14 -5
  65. package/dist/src/openclaw/provider-readiness-lock.js +1320 -0
  66. package/dist/src/openclaw/provider-readiness-lock.js.map +1 -0
  67. package/dist/src/openclaw/shared.d.ts +10 -17
  68. package/dist/src/openclaw/shared.js +53 -19
  69. package/dist/src/openclaw/shared.js.map +1 -1
  70. package/dist/src/openclaw.d.ts +3 -5
  71. package/dist/src/openclaw.js +245 -60
  72. package/dist/src/openclaw.js.map +1 -1
  73. package/dist/src/platform/process-owned-lock.d.ts +11 -0
  74. package/dist/src/platform/process-owned-lock.js +1698 -0
  75. package/dist/src/platform/process-owned-lock.js.map +1 -0
  76. package/dist/src/platform/recorder-directory.d.ts +1 -0
  77. package/dist/src/platform/recorder-directory.js +14 -0
  78. package/dist/src/platform/recorder-directory.js.map +1 -0
  79. package/dist/src/platform/windows-acl.d.ts +18 -0
  80. package/dist/src/platform/windows-acl.js +1367 -0
  81. package/dist/src/platform/windows-acl.js.map +1 -0
  82. package/dist/src/platform/windows-lock-root.d.ts +13 -0
  83. package/dist/src/platform/windows-lock-root.js +98 -0
  84. package/dist/src/platform/windows-lock-root.js.map +1 -0
  85. package/dist/src/providers/builtin/discord.d.ts +4 -0
  86. package/dist/src/providers/builtin/discord.js +52 -9
  87. package/dist/src/providers/builtin/discord.js.map +1 -1
  88. package/dist/src/providers/builtin/external-webhook-auth.d.ts +1 -0
  89. package/dist/src/providers/builtin/external-webhook-auth.js +38 -1
  90. package/dist/src/providers/builtin/external-webhook-auth.js.map +1 -1
  91. package/dist/src/providers/builtin/feishu.d.ts +1 -0
  92. package/dist/src/providers/builtin/feishu.js +96 -26
  93. package/dist/src/providers/builtin/feishu.js.map +1 -1
  94. package/dist/src/providers/builtin/googlechat.d.ts +1 -0
  95. package/dist/src/providers/builtin/googlechat.js +95 -26
  96. package/dist/src/providers/builtin/googlechat.js.map +1 -1
  97. package/dist/src/providers/builtin/imessage.js +60 -17
  98. package/dist/src/providers/builtin/imessage.js.map +1 -1
  99. package/dist/src/providers/builtin/loopback.js +88 -35
  100. package/dist/src/providers/builtin/loopback.js.map +1 -1
  101. package/dist/src/providers/builtin/matrix.d.ts +2 -2
  102. package/dist/src/providers/builtin/matrix.js +78 -21
  103. package/dist/src/providers/builtin/matrix.js.map +1 -1
  104. package/dist/src/providers/builtin/mattermost.d.ts +11 -7
  105. package/dist/src/providers/builtin/mattermost.js +138 -24
  106. package/dist/src/providers/builtin/mattermost.js.map +1 -1
  107. package/dist/src/providers/builtin/msteams.d.ts +1 -0
  108. package/dist/src/providers/builtin/msteams.js +118 -38
  109. package/dist/src/providers/builtin/msteams.js.map +1 -1
  110. package/dist/src/providers/builtin/native-local-mock.js +4 -4
  111. package/dist/src/providers/builtin/native-local-mock.js.map +1 -1
  112. package/dist/src/providers/builtin/script.js +1057 -146
  113. package/dist/src/providers/builtin/script.js.map +1 -1
  114. package/dist/src/providers/builtin/slack.d.ts +8 -1
  115. package/dist/src/providers/builtin/slack.js +327 -24
  116. package/dist/src/providers/builtin/slack.js.map +1 -1
  117. package/dist/src/providers/builtin/telegram.d.ts +1 -1
  118. package/dist/src/providers/builtin/telegram.js +55 -14
  119. package/dist/src/providers/builtin/telegram.js.map +1 -1
  120. package/dist/src/providers/builtin/whatsapp.d.ts +4 -3
  121. package/dist/src/providers/builtin/whatsapp.js +157 -29
  122. package/dist/src/providers/builtin/whatsapp.js.map +1 -1
  123. package/dist/src/providers/builtin/zalo.d.ts +5 -2
  124. package/dist/src/providers/builtin/zalo.js +31 -7
  125. package/dist/src/providers/builtin/zalo.js.map +1 -1
  126. package/dist/src/providers/catalog.d.ts +1 -1
  127. package/dist/src/providers/catalog.js +1 -1
  128. package/dist/src/providers/catalog.js.map +1 -1
  129. package/dist/src/providers/local-mock.d.ts +4 -0
  130. package/dist/src/providers/local-mock.js +162 -35
  131. package/dist/src/providers/local-mock.js.map +1 -1
  132. package/dist/src/providers/native-ids.d.ts +2 -0
  133. package/dist/src/providers/native-ids.js +3 -0
  134. package/dist/src/providers/native-ids.js.map +1 -1
  135. package/dist/src/providers/recorder.d.ts +14 -3
  136. package/dist/src/providers/recorder.js +521 -41
  137. package/dist/src/providers/recorder.js.map +1 -1
  138. package/dist/src/providers/registry.d.ts +1 -0
  139. package/dist/src/providers/registry.js +65 -20
  140. package/dist/src/providers/registry.js.map +1 -1
  141. package/dist/src/providers/signed-jwt.d.ts +6 -1
  142. package/dist/src/providers/signed-jwt.js +241 -50
  143. package/dist/src/providers/signed-jwt.js.map +1 -1
  144. package/dist/src/providers/slack-ids.d.ts +1 -0
  145. package/dist/src/providers/slack-ids.js +5 -0
  146. package/dist/src/providers/slack-ids.js.map +1 -1
  147. package/dist/src/providers/target-normalizers.d.ts +9 -1
  148. package/dist/src/providers/target-normalizers.js +177 -58
  149. package/dist/src/providers/target-normalizers.js.map +1 -1
  150. package/dist/src/providers/webhook-server.d.ts +3 -0
  151. package/dist/src/providers/webhook-server.js +268 -47
  152. package/dist/src/providers/webhook-server.js.map +1 -1
  153. package/dist/src/servers/discord.d.ts +40 -0
  154. package/dist/src/servers/discord.js +842 -0
  155. package/dist/src/servers/discord.js.map +1 -0
  156. package/dist/src/servers/http.d.ts +14 -2
  157. package/dist/src/servers/http.js +229 -58
  158. package/dist/src/servers/http.js.map +1 -1
  159. package/dist/src/servers/index.d.ts +10 -4
  160. package/dist/src/servers/index.js +4 -0
  161. package/dist/src/servers/index.js.map +1 -1
  162. package/dist/src/servers/matrix.d.ts +3 -0
  163. package/dist/src/servers/matrix.js +268 -110
  164. package/dist/src/servers/matrix.js.map +1 -1
  165. package/dist/src/servers/mattermost.d.ts +5 -0
  166. package/dist/src/servers/mattermost.js +389 -91
  167. package/dist/src/servers/mattermost.js.map +1 -1
  168. package/dist/src/servers/recorder.d.ts +11 -0
  169. package/dist/src/servers/recorder.js +1072 -69
  170. package/dist/src/servers/recorder.js.map +1 -1
  171. package/dist/src/servers/signal.d.ts +1 -0
  172. package/dist/src/servers/signal.js +279 -44
  173. package/dist/src/servers/signal.js.map +1 -1
  174. package/dist/src/servers/slack.d.ts +15 -0
  175. package/dist/src/servers/slack.js +270 -54
  176. package/dist/src/servers/slack.js.map +1 -1
  177. package/dist/src/servers/telegram-identity.d.ts +6 -0
  178. package/dist/src/servers/telegram-identity.js +27 -0
  179. package/dist/src/servers/telegram-identity.js.map +1 -0
  180. package/dist/src/servers/telegram.d.ts +17 -6
  181. package/dist/src/servers/telegram.js +1218 -107
  182. package/dist/src/servers/telegram.js.map +1 -1
  183. package/dist/src/servers/webhook-target.d.ts +12 -4
  184. package/dist/src/servers/webhook-target.js +226 -28
  185. package/dist/src/servers/webhook-target.js.map +1 -1
  186. package/dist/src/servers/whatsapp-baileys-websocket.d.ts +60 -1
  187. package/dist/src/servers/whatsapp-baileys-websocket.js +782 -112
  188. package/dist/src/servers/whatsapp-baileys-websocket.js.map +1 -1
  189. package/dist/src/servers/whatsapp-jid.d.ts +1 -0
  190. package/dist/src/servers/whatsapp-jid.js +9 -5
  191. package/dist/src/servers/whatsapp-jid.js.map +1 -1
  192. package/dist/src/servers/whatsapp-wire/binary-node.d.ts +1 -0
  193. package/dist/src/servers/whatsapp-wire/binary-node.js +37 -13
  194. package/dist/src/servers/whatsapp-wire/binary-node.js.map +1 -1
  195. package/dist/src/servers/whatsapp-wire/crypto.d.ts +3 -0
  196. package/dist/src/servers/whatsapp-wire/crypto.js +23 -8
  197. package/dist/src/servers/whatsapp-wire/crypto.js.map +1 -1
  198. package/dist/src/servers/whatsapp-wire/handshake.d.ts +9 -3
  199. package/dist/src/servers/whatsapp-wire/handshake.js +173 -12
  200. package/dist/src/servers/whatsapp-wire/handshake.js.map +1 -1
  201. package/dist/src/servers/whatsapp-wire/signal.js +3 -3
  202. package/dist/src/servers/whatsapp-wire/signal.js.map +1 -1
  203. package/dist/src/servers/whatsapp.d.ts +7 -1
  204. package/dist/src/servers/whatsapp.js +149 -25
  205. package/dist/src/servers/whatsapp.js.map +1 -1
  206. package/dist/src/servers/zalo.d.ts +1 -0
  207. package/dist/src/servers/zalo.js +193 -82
  208. package/dist/src/servers/zalo.js.map +1 -1
  209. package/docs/channel-setup.md +251 -64
  210. package/fixtures/examples/crabline.example.yaml +3 -2
  211. package/package.json +18 -10
  212. package/dist/src/openclaw/smoke-lock.js +0 -1004
  213. package/dist/src/openclaw/smoke-lock.js.map +0 -1
  214. package/fixtures/examples/openclaw-bridge.yaml +0 -125
@@ -1,17 +1,26 @@
1
1
  import { createServer } from "node:http";
2
- import { randomBytes } from "node:crypto";
2
+ import { createHash, randomBytes } from "node:crypto";
3
+ import { Buffer } from "node:buffer";
3
4
  import path from "node:path";
4
5
  import { CrablineError } from "../core/errors.js";
5
- import { adminAuthError, closeServer, drainRequestBody, formatUrlHost, hasAdminToken, InvalidJsonBodyError, isJsonObject, isLoopbackHost, jsonResponse, queryRecord, readBody, RequestBodyTooLargeError, writeResponse, } from "./http.js";
6
+ import { adminAuthError, closeServer, drainRequestBody, formatUrlHost, hasAdminToken, InvalidJsonBodyError, isJsonObject, isJsonMediaType, isLoopbackHost, jsonResponse, queryRecord, readBody, RequestBodyTooLargeError, writeResponse, } from "./http.js";
6
7
  import { recordServerEvent } from "./recorder.js";
7
8
  import { resolveMaxPendingInboundEvents } from "./pending-events.js";
8
9
  import { postWebhookRequest, validateWebhookTarget, } from "./webhook-target.js";
10
+ import { canonicalizeTelegramUsername, isTelegramUsernameChatId, TELEGRAM_BOT_USERNAME_PATTERN, TELEGRAM_NATIVE_CHAT_ID_MAX, telegramUsernameChatId, } from "./telegram-identity.js";
9
11
  const TELEGRAM_MAX_REQUEST_BODY_BYTES = 50 * 1024 * 1024;
12
+ const TELEGRAM_MAX_MULTIPART_HEADER_BYTES = 16 * 1024;
13
+ const TELEGRAM_MAX_MULTIPART_PARTS = 128;
14
+ const TELEGRAM_MAX_MULTIPART_TEXT_BYTES = 1024 * 1024;
10
15
  const TELEGRAM_WEBHOOK_MAX_BACKOFF_EXPONENT = 5;
11
16
  const TELEGRAM_WEBHOOK_RETRY_BASE_MS = 100;
12
17
  const TELEGRAM_WEBHOOK_DELIVERY_TIMEOUT_MS = 3_000;
13
18
  const MAX_ACTIVE_TELEGRAM_WEBHOOK_VALIDATIONS = 8;
14
- const TELEGRAM_CHAT_USERNAME_PATTERN = /^@[A-Za-z][A-Za-z0-9_]{3,31}$/u;
19
+ const TELEGRAM_WEBHOOK_SECRET_PATTERN = /^[A-Za-z0-9_-]{1,256}(?![\s\S])/u;
20
+ const TELEGRAM_MAX_TEXT_LENGTH = 4096;
21
+ const TELEGRAM_MAX_CAPTION_LENGTH = 1024;
22
+ const TELEGRAM_HTML_TAG_PATTERN = /<\/?(?:a|b|blockquote|code|del|em|i|ins|pre|s|span|strike|strong|tg-emoji|tg-spoiler|tg-time|u)(?:\s+[^<>]*)?>/giu;
23
+ const TELEGRAM_HTML_ENTITY_PATTERN = /&(?:#\d+|#x[\da-f]+|amp|gt|lt|quot);/giu;
15
24
  const TELEGRAM_SEND_METHODS = new Set([
16
25
  "sendanimation",
17
26
  "sendaudio",
@@ -58,6 +67,27 @@ const TELEGRAM_UNSUPPORTED_UPDATE_FIELDS = [
58
67
  "removed_chat_boost",
59
68
  "shipping_query",
60
69
  ];
70
+ const TELEGRAM_NEW_MESSAGE_UPDATE_FIELDS = ["business_message", "channel_post", "message"];
71
+ const TELEGRAM_MESSAGE_REFERENCE_FIELDS = [
72
+ "edited_business_message",
73
+ "edited_channel_post",
74
+ "edited_message",
75
+ ];
76
+ const TELEGRAM_MESSAGE_UPDATE_FIELDS = [
77
+ ...TELEGRAM_NEW_MESSAGE_UPDATE_FIELDS,
78
+ ...TELEGRAM_MESSAGE_REFERENCE_FIELDS,
79
+ ];
80
+ const TELEGRAM_CHAT_UPDATE_FIELDS = [
81
+ "chat_boost",
82
+ "chat_join_request",
83
+ "chat_member",
84
+ "deleted_business_messages",
85
+ "message_reaction",
86
+ "message_reaction_count",
87
+ "my_chat_member",
88
+ "removed_chat_boost",
89
+ ];
90
+ const TELEGRAM_MULTIPART_FILE = Symbol("telegramMultipartFile");
61
91
  function telegramOk(result) {
62
92
  return jsonResponse({ ok: true, result });
63
93
  }
@@ -65,13 +95,17 @@ function telegramError(description, status = 400) {
65
95
  return jsonResponse({ description, error_code: status, ok: false }, status);
66
96
  }
67
97
  async function parseRequestBody(request) {
98
+ const contentType = request.headers["content-type"] ?? "";
99
+ const contentTypes = Array.isArray(contentType) ? contentType : [contentType];
100
+ const multipartType = contentTypes.find((entry) => entry.toLowerCase().includes("multipart/form-data"));
101
+ if (multipartType) {
102
+ return await parseMultipartFormDataRequest(request, multipartType);
103
+ }
68
104
  const body = await readBody(request, TELEGRAM_MAX_REQUEST_BODY_BYTES);
69
105
  if (body.length === 0) {
70
106
  return {};
71
107
  }
72
- const contentType = request.headers["content-type"] ?? "";
73
- const contentTypes = Array.isArray(contentType) ? contentType : [contentType];
74
- if (contentTypes.some((entry) => entry.toLowerCase().includes("json"))) {
108
+ if (contentTypes.some(isJsonMediaType)) {
75
109
  try {
76
110
  return JSON.parse(body.toString("utf8"));
77
111
  }
@@ -79,30 +113,435 @@ async function parseRequestBody(request) {
79
113
  throw new InvalidJsonBodyError(error);
80
114
  }
81
115
  }
82
- const multipartType = contentTypes.find((entry) => entry.toLowerCase().includes("multipart/form-data"));
83
- if (multipartType) {
84
- return await parseMultipartFormDataBody(body, multipartType);
85
- }
86
116
  const params = new URLSearchParams(body.toString("utf8"));
87
117
  return Object.fromEntries(params.entries());
88
118
  }
89
- async function parseMultipartFormDataBody(body, contentType) {
90
- let form;
119
+ function invalidTelegramMultipartBody(message) {
120
+ return new InvalidJsonBodyError(new Error(message));
121
+ }
122
+ function parseTelegramMimeParameters(value, options = {}) {
123
+ const separator = value.indexOf(";");
124
+ const type = value
125
+ .slice(0, separator < 0 ? value.length : separator)
126
+ .trim()
127
+ .toLowerCase();
128
+ const parameters = new Map();
129
+ let index = separator < 0 ? value.length : separator + 1;
130
+ while (index < value.length) {
131
+ while (index < value.length && /[\t ;]/u.test(value[index] ?? "")) {
132
+ index += 1;
133
+ }
134
+ if (index >= value.length) {
135
+ break;
136
+ }
137
+ const nameStart = index;
138
+ while (index < value.length && /[!#$%&'*+\-.^_`|~0-9A-Za-z]/u.test(value[index] ?? "")) {
139
+ index += 1;
140
+ }
141
+ const name = value.slice(nameStart, index).toLowerCase();
142
+ while (index < value.length && /[\t ]/u.test(value[index] ?? "")) {
143
+ index += 1;
144
+ }
145
+ if (!name || value[index] !== "=") {
146
+ throw invalidTelegramMultipartBody("Multipart header contains a malformed parameter.");
147
+ }
148
+ index += 1;
149
+ while (index < value.length && /[\t ]/u.test(value[index] ?? "")) {
150
+ index += 1;
151
+ }
152
+ let parameterValue = "";
153
+ let quoted = false;
154
+ if (value[index] === '"') {
155
+ quoted = true;
156
+ index += 1;
157
+ let closed = false;
158
+ while (index < value.length) {
159
+ const character = value[index];
160
+ index += 1;
161
+ if (character === "\\" && options.decodeQuotedPairs) {
162
+ if (index >= value.length) {
163
+ break;
164
+ }
165
+ const quotedCharacter = value[index];
166
+ if (quotedCharacter === "\r" || quotedCharacter === "\n") {
167
+ break;
168
+ }
169
+ parameterValue += quotedCharacter;
170
+ index += 1;
171
+ }
172
+ else if (character === '"') {
173
+ closed = true;
174
+ break;
175
+ }
176
+ else if (character === "\r" || character === "\n") {
177
+ break;
178
+ }
179
+ else {
180
+ parameterValue += character;
181
+ }
182
+ }
183
+ if (!closed) {
184
+ throw invalidTelegramMultipartBody("Multipart header contains an unterminated quote.");
185
+ }
186
+ while (index < value.length && /[\t ]/u.test(value[index] ?? "")) {
187
+ index += 1;
188
+ }
189
+ if (index < value.length && value[index] !== ";") {
190
+ throw invalidTelegramMultipartBody("Multipart header contains trailing parameter data.");
191
+ }
192
+ }
193
+ else {
194
+ const parameterStart = index;
195
+ while (index < value.length && value[index] !== ";") {
196
+ if (value[index] === "\r" || value[index] === "\n") {
197
+ throw invalidTelegramMultipartBody("Multipart header contains a line break.");
198
+ }
199
+ index += 1;
200
+ }
201
+ parameterValue = value.slice(parameterStart, index).trim();
202
+ }
203
+ parameters.set(name, { quoted, value: parameterValue });
204
+ if (value[index] === ";") {
205
+ index += 1;
206
+ }
207
+ }
208
+ return { parameters, type };
209
+ }
210
+ function parseTelegramMultipartBoundary(contentType) {
211
+ const parsed = parseTelegramMimeParameters(contentType, { decodeQuotedPairs: true });
212
+ const boundary = parsed.parameters.get("boundary")?.value;
213
+ if (parsed.type !== "multipart/form-data" ||
214
+ !boundary ||
215
+ boundary.length > 70 ||
216
+ boundary.endsWith(" ") ||
217
+ !/^[0-9A-Za-z'()+_,./:=? -]+$/u.test(boundary)) {
218
+ throw invalidTelegramMultipartBody("Multipart boundary is missing or invalid.");
219
+ }
220
+ return Buffer.from(`--${boundary}`, "ascii");
221
+ }
222
+ function decodeTelegramMultipartParameter(parameter) {
223
+ return parameter.quoted
224
+ ? parameter.value.replace(/%0A/giu, "\n").replace(/%0D/giu, "\r").replace(/%22/giu, '"')
225
+ : parameter.value;
226
+ }
227
+ function decodeTelegramMultipartExtendedParameter(parameter) {
228
+ const extended = /^([^']*)'[^']*'(.*)$/u.exec(parameter.value);
229
+ if (!extended) {
230
+ return decodeTelegramMultipartParameter(parameter);
231
+ }
232
+ const charset = extended[1]?.toLowerCase();
233
+ if (charset !== "utf-8" && charset !== "us-ascii") {
234
+ return decodeTelegramMultipartParameter(parameter);
235
+ }
91
236
  try {
92
- form = await new Request("http://localhost", {
93
- body,
94
- headers: { "content-type": contentType },
95
- method: "POST",
96
- }).formData();
237
+ return decodeURIComponent(extended[2] ?? "");
97
238
  }
98
- catch (error) {
99
- throw new InvalidJsonBodyError(error);
239
+ catch {
240
+ return extended[2] ?? "";
241
+ }
242
+ }
243
+ function parseTelegramMultipartDisposition(headerBlock) {
244
+ let contentDisposition;
245
+ for (const line of headerBlock.toString("utf8").split("\r\n")) {
246
+ const separator = line.indexOf(":");
247
+ if (separator <= 0) {
248
+ throw invalidTelegramMultipartBody("Multipart part contains a malformed header.");
249
+ }
250
+ if (line.slice(0, separator).trim().toLowerCase() === "content-disposition") {
251
+ contentDisposition = line.slice(separator + 1).trim();
252
+ }
253
+ }
254
+ if (!contentDisposition) {
255
+ throw invalidTelegramMultipartBody("Multipart part is missing a form-data disposition.");
256
+ }
257
+ const parsed = parseTelegramMimeParameters(contentDisposition, { decodeQuotedPairs: true });
258
+ if (parsed.type !== "form-data") {
259
+ throw invalidTelegramMultipartBody("Multipart part is missing a form-data disposition.");
260
+ }
261
+ const rawName = parsed.parameters.get("name");
262
+ if (rawName === undefined) {
263
+ throw invalidTelegramMultipartBody("Multipart part is missing a field name.");
264
+ }
265
+ const rawFileName = parsed.parameters.get("filename");
266
+ const rawExtendedFileName = parsed.parameters.get("filename*");
267
+ return {
268
+ ...(rawExtendedFileName !== undefined
269
+ ? { fileName: decodeTelegramMultipartExtendedParameter(rawExtendedFileName) }
270
+ : rawFileName !== undefined
271
+ ? { fileName: decodeTelegramMultipartParameter(rawFileName) }
272
+ : {}),
273
+ name: decodeTelegramMultipartParameter(rawName),
274
+ };
275
+ }
276
+ class TelegramMultipartReader {
277
+ request;
278
+ maxBytes;
279
+ #iterator;
280
+ #buffer = Buffer.alloc(0);
281
+ #ended = false;
282
+ #totalBytes = 0;
283
+ constructor(request, maxBytes) {
284
+ this.request = request;
285
+ this.maxBytes = maxBytes;
286
+ this.#iterator = request[Symbol.asyncIterator]();
287
+ }
288
+ async readUntil(delimiter, maxBytes) {
289
+ const chunks = [];
290
+ let length = 0;
291
+ while (true) {
292
+ const delimiterIndex = this.#buffer.indexOf(delimiter);
293
+ if (delimiterIndex >= 0) {
294
+ length += delimiterIndex;
295
+ if (length > maxBytes) {
296
+ throw new RequestBodyTooLargeError(maxBytes);
297
+ }
298
+ chunks.push(this.#buffer.subarray(0, delimiterIndex));
299
+ this.#consume(delimiterIndex + delimiter.length);
300
+ return Buffer.concat(chunks, length);
301
+ }
302
+ const emitLength = Math.max(0, this.#buffer.length - delimiter.length + 1);
303
+ if (emitLength > 0) {
304
+ length += emitLength;
305
+ if (length > maxBytes) {
306
+ throw new RequestBodyTooLargeError(maxBytes);
307
+ }
308
+ chunks.push(this.#buffer.subarray(0, emitLength));
309
+ this.#consume(emitLength);
310
+ }
311
+ if (!(await this.#readMore())) {
312
+ throw invalidTelegramMultipartBody("Multipart delimiter is incomplete.");
313
+ }
314
+ }
315
+ }
316
+ async readInitialBoundary(boundary, maxPreambleBytes) {
317
+ const lineBoundary = Buffer.concat([Buffer.from("\r\n"), boundary]);
318
+ let preambleBytes = 0;
319
+ let checkBodyStart = true;
320
+ while (true) {
321
+ if (checkBodyStart) {
322
+ await this.#ensure(boundary.length + 2);
323
+ checkBodyStart = false;
324
+ if (this.#buffer.subarray(0, boundary.length).equals(boundary)) {
325
+ const suffix = await this.#readBoundarySuffix(boundary.length);
326
+ if (suffix) {
327
+ this.#consume(suffix.consumedBytes);
328
+ return suffix.kind;
329
+ }
330
+ }
331
+ }
332
+ const boundaryIndex = this.#buffer.indexOf(lineBoundary);
333
+ if (boundaryIndex >= 0) {
334
+ const suffixOffset = boundaryIndex + lineBoundary.length;
335
+ if (this.#buffer.length < suffixOffset + 2) {
336
+ if (boundaryIndex > 0) {
337
+ preambleBytes += boundaryIndex;
338
+ if (preambleBytes > maxPreambleBytes) {
339
+ throw new RequestBodyTooLargeError(maxPreambleBytes);
340
+ }
341
+ this.#consume(boundaryIndex);
342
+ }
343
+ if (!(await this.#readMore())) {
344
+ throw invalidTelegramMultipartBody("Multipart boundary is incomplete.");
345
+ }
346
+ continue;
347
+ }
348
+ const suffix = await this.#readBoundarySuffix(suffixOffset);
349
+ if (suffix) {
350
+ preambleBytes += boundaryIndex;
351
+ if (preambleBytes > maxPreambleBytes) {
352
+ throw new RequestBodyTooLargeError(maxPreambleBytes);
353
+ }
354
+ this.#consume(suffix.consumedBytes);
355
+ return suffix.kind;
356
+ }
357
+ const emitLength = suffixOffset;
358
+ preambleBytes += emitLength;
359
+ if (preambleBytes > maxPreambleBytes) {
360
+ throw new RequestBodyTooLargeError(maxPreambleBytes);
361
+ }
362
+ this.#consume(emitLength);
363
+ continue;
364
+ }
365
+ const emitLength = Math.max(0, this.#buffer.length - lineBoundary.length + 1);
366
+ if (emitLength > 0) {
367
+ preambleBytes += emitLength;
368
+ if (preambleBytes > maxPreambleBytes) {
369
+ throw new RequestBodyTooLargeError(maxPreambleBytes);
370
+ }
371
+ this.#consume(emitLength);
372
+ }
373
+ if (!(await this.#readMore())) {
374
+ throw invalidTelegramMultipartBody("Multipart body is missing its initial boundary.");
375
+ }
376
+ }
377
+ }
378
+ async readPartBody(boundaryMarker, onChunk) {
379
+ while (true) {
380
+ const boundaryIndex = this.#buffer.indexOf(boundaryMarker);
381
+ if (boundaryIndex >= 0) {
382
+ const suffixOffset = boundaryIndex + boundaryMarker.length;
383
+ if (this.#buffer.length < suffixOffset + 2) {
384
+ if (boundaryIndex > 0) {
385
+ onChunk(this.#buffer.subarray(0, boundaryIndex));
386
+ this.#consume(boundaryIndex);
387
+ }
388
+ if (!(await this.#readMore())) {
389
+ throw invalidTelegramMultipartBody("Multipart boundary is incomplete.");
390
+ }
391
+ continue;
392
+ }
393
+ const suffix = await this.#readBoundarySuffix(suffixOffset);
394
+ if (suffix) {
395
+ if (boundaryIndex > 0) {
396
+ onChunk(this.#buffer.subarray(0, boundaryIndex));
397
+ }
398
+ this.#consume(suffix.consumedBytes);
399
+ return suffix.kind;
400
+ }
401
+ const emitLength = suffixOffset;
402
+ onChunk(this.#buffer.subarray(0, emitLength));
403
+ this.#consume(emitLength);
404
+ continue;
405
+ }
406
+ const emitLength = Math.max(0, this.#buffer.length - boundaryMarker.length + 1);
407
+ if (emitLength > 0) {
408
+ onChunk(this.#buffer.subarray(0, emitLength));
409
+ this.#consume(emitLength);
410
+ }
411
+ if (!(await this.#readMore())) {
412
+ throw invalidTelegramMultipartBody("Multipart body is missing its closing boundary.");
413
+ }
414
+ }
415
+ }
416
+ async consumeRemainder() {
417
+ while (await this.#readMore()) {
418
+ this.#buffer = Buffer.alloc(0);
419
+ }
420
+ }
421
+ #consume(length) {
422
+ this.#buffer = this.#buffer.subarray(length);
423
+ }
424
+ async #ensure(length) {
425
+ while (this.#buffer.length < length) {
426
+ if (!(await this.#readMore())) {
427
+ throw invalidTelegramMultipartBody("Multipart body ended unexpectedly.");
428
+ }
429
+ }
430
+ }
431
+ async #readBoundarySuffix(suffixOffset) {
432
+ await this.#ensure(suffixOffset + 2);
433
+ const suffix = this.#buffer.subarray(suffixOffset, suffixOffset + 2);
434
+ const final = suffix.equals(Buffer.from("--"));
435
+ let lineEnd = suffixOffset + (final ? 2 : 0);
436
+ while (true) {
437
+ while (lineEnd < this.#buffer.length &&
438
+ (this.#buffer[lineEnd] === 0x20 || this.#buffer[lineEnd] === 0x09)) {
439
+ lineEnd += 1;
440
+ if (lineEnd - suffixOffset > TELEGRAM_MAX_MULTIPART_HEADER_BYTES) {
441
+ return undefined;
442
+ }
443
+ }
444
+ if (lineEnd >= this.#buffer.length) {
445
+ if (await this.#readMore()) {
446
+ continue;
447
+ }
448
+ return final ? { consumedBytes: lineEnd, kind: "final" } : undefined;
449
+ }
450
+ if (this.#buffer[lineEnd] !== 0x0d) {
451
+ return undefined;
452
+ }
453
+ if (lineEnd + 1 >= this.#buffer.length) {
454
+ if (await this.#readMore()) {
455
+ continue;
456
+ }
457
+ return undefined;
458
+ }
459
+ return this.#buffer[lineEnd + 1] === 0x0a
460
+ ? { consumedBytes: lineEnd + 2, kind: final ? "final" : "next" }
461
+ : undefined;
462
+ }
463
+ }
464
+ async #readMore() {
465
+ if (this.#ended) {
466
+ return false;
467
+ }
468
+ const next = await this.#iterator.next();
469
+ if (next.done) {
470
+ this.#ended = true;
471
+ return false;
472
+ }
473
+ const chunk = Buffer.isBuffer(next.value) ? next.value : Buffer.from(next.value);
474
+ this.#totalBytes += chunk.length;
475
+ if (this.#totalBytes > this.maxBytes) {
476
+ throw new RequestBodyTooLargeError(this.maxBytes);
477
+ }
478
+ this.#buffer =
479
+ this.#buffer.length === 0
480
+ ? chunk
481
+ : Buffer.concat([this.#buffer, chunk], this.#buffer.length + chunk.length);
482
+ return true;
483
+ }
484
+ }
485
+ function enforceTelegramMultipartContentLength(request) {
486
+ const contentLengthHeader = request.headers["content-length"];
487
+ const contentLength = Array.isArray(contentLengthHeader)
488
+ ? contentLengthHeader[0]
489
+ : contentLengthHeader;
490
+ if (contentLength && /^\d+$/u.test(contentLength)) {
491
+ const bytes = Number(contentLength);
492
+ if (!Number.isSafeInteger(bytes) || bytes > TELEGRAM_MAX_REQUEST_BODY_BYTES) {
493
+ drainRequestBody(request);
494
+ throw new RequestBodyTooLargeError(TELEGRAM_MAX_REQUEST_BODY_BYTES);
495
+ }
496
+ }
497
+ }
498
+ async function parseMultipartFormDataRequest(request, contentType) {
499
+ enforceTelegramMultipartContentLength(request);
500
+ try {
501
+ const boundary = parseTelegramMultipartBoundary(contentType);
502
+ const bodyBoundary = Buffer.concat([Buffer.from("\r\n"), boundary]);
503
+ const reader = new TelegramMultipartReader(request, TELEGRAM_MAX_REQUEST_BODY_BYTES);
504
+ const fields = Object.create(null);
505
+ let retainedTextBytes = 0;
506
+ let partCount = 0;
507
+ let boundaryKind = await reader.readInitialBoundary(boundary, TELEGRAM_MAX_MULTIPART_HEADER_BYTES);
508
+ while (boundaryKind !== "final") {
509
+ partCount += 1;
510
+ if (partCount > TELEGRAM_MAX_MULTIPART_PARTS) {
511
+ throw new RequestBodyTooLargeError(TELEGRAM_MAX_MULTIPART_PARTS);
512
+ }
513
+ const disposition = parseTelegramMultipartDisposition(await reader.readUntil(Buffer.from("\r\n\r\n"), TELEGRAM_MAX_MULTIPART_HEADER_BYTES));
514
+ const digest = disposition.fileName === undefined ? undefined : createHash("sha256");
515
+ const textChunks = [];
516
+ let textLength = 0;
517
+ boundaryKind = await reader.readPartBody(bodyBoundary, (chunk) => {
518
+ if (digest) {
519
+ digest.update(chunk);
520
+ return;
521
+ }
522
+ retainedTextBytes += chunk.length;
523
+ textLength += chunk.length;
524
+ if (retainedTextBytes > TELEGRAM_MAX_MULTIPART_TEXT_BYTES) {
525
+ throw new RequestBodyTooLargeError(TELEGRAM_MAX_MULTIPART_TEXT_BYTES);
526
+ }
527
+ textChunks.push(Buffer.from(chunk));
528
+ });
529
+ fields[disposition.name] =
530
+ digest && disposition.fileName !== undefined
531
+ ? {
532
+ [TELEGRAM_MULTIPART_FILE]: true,
533
+ contentDigest: digest.digest("base64url"),
534
+ fileName: disposition.fileName,
535
+ }
536
+ : Buffer.concat(textChunks, textLength).toString("utf8");
537
+ }
538
+ await reader.consumeRemainder();
539
+ return fields;
100
540
  }
101
- const fields = {};
102
- for (const [name, value] of form) {
103
- fields[name] = typeof value === "string" ? value : value.name;
541
+ catch (error) {
542
+ drainRequestBody(request);
543
+ throw error;
104
544
  }
105
- return fields;
106
545
  }
107
546
  function requireTelegramBotPath(pathname) {
108
547
  const match = /^\/bot([^/]+)\/([A-Za-z][A-Za-z0-9_]*)$/u.exec(pathname);
@@ -135,18 +574,48 @@ function telegramChatId(value) {
135
574
  return undefined;
136
575
  }
137
576
  if (/^-?\d+$/u.test(stringValue)) {
138
- const integerValue = toIntegerValue(stringValue);
139
- return integerValue === 0 ? undefined : integerValue;
577
+ return telegramNumericChatId(stringValue);
578
+ }
579
+ return telegramUsernameChatId(stringValue);
580
+ }
581
+ function telegramNumericChatId(value) {
582
+ const stringValue = toStringValue(value);
583
+ if (!stringValue || !/^-?\d+$/u.test(stringValue)) {
584
+ return undefined;
585
+ }
586
+ const integerValue = BigInt(stringValue);
587
+ if (integerValue === 0n ||
588
+ integerValue < -TELEGRAM_NATIVE_CHAT_ID_MAX ||
589
+ integerValue > TELEGRAM_NATIVE_CHAT_ID_MAX) {
590
+ return undefined;
140
591
  }
141
- return TELEGRAM_CHAT_USERNAME_PATTERN.test(stringValue) ? stringValue : undefined;
592
+ return Number(integerValue);
593
+ }
594
+ function telegramStoredChatId(value) {
595
+ const nativeChatId = telegramNumericChatId(value);
596
+ if (nativeChatId !== undefined) {
597
+ return nativeChatId;
598
+ }
599
+ const chatId = toIntegerValue(value);
600
+ return chatId !== undefined && isTelegramUsernameChatId(chatId) ? chatId : undefined;
142
601
  }
143
602
  async function appendEvent(state, event) {
144
603
  await recordServerEvent({ event, onEvent: state.onEvent, recorderPath: state.recorderPath });
145
604
  }
605
+ function isTelegramMultipartFile(value) {
606
+ return (typeof value === "object" &&
607
+ value !== null &&
608
+ TELEGRAM_MULTIPART_FILE in value &&
609
+ value[TELEGRAM_MULTIPART_FILE] === true);
610
+ }
146
611
  function redactTelegramSecrets(body) {
147
612
  return Object.fromEntries(Object.entries(body).map(([key, value]) => [
148
613
  key,
149
- key === "secret_token" ? "<redacted>" : value,
614
+ key === "secret_token"
615
+ ? "<redacted>"
616
+ : isTelegramMultipartFile(value)
617
+ ? value.fileName
618
+ : value,
150
619
  ]));
151
620
  }
152
621
  function createBotUser(state) {
@@ -154,73 +623,242 @@ function createBotUser(state) {
154
623
  can_join_groups: true,
155
624
  can_read_all_group_messages: true,
156
625
  first_name: "Crabline",
157
- has_topics_enabled: true,
626
+ ...(state.botHasTopicsEnabled ? { has_topics_enabled: true } : {}),
158
627
  id: state.botId,
159
628
  is_bot: true,
160
629
  supports_inline_queries: false,
161
630
  username: state.botUsername,
162
631
  };
163
632
  }
633
+ function inferTelegramChatType(chatId) {
634
+ return chatId >= 0
635
+ ? "private"
636
+ : String(chatId).startsWith("-100") || isTelegramUsernameChatId(chatId)
637
+ ? "supergroup"
638
+ : "group";
639
+ }
164
640
  function createChat(chatId) {
165
- const type = typeof chatId === "string"
166
- ? "supergroup"
167
- : chatId >= 0
168
- ? "private"
169
- : String(chatId).startsWith("-100")
170
- ? "supergroup"
171
- : "group";
172
641
  return {
173
642
  id: chatId,
643
+ type: inferTelegramChatType(chatId),
644
+ };
645
+ }
646
+ function telegramDestinationUsername(value) {
647
+ const username = toStringValue(value);
648
+ return username ? canonicalizeTelegramUsername(username) : undefined;
649
+ }
650
+ function telegramStoredUsername(value) {
651
+ const username = toStringValue(value)?.trim();
652
+ if (!username) {
653
+ return undefined;
654
+ }
655
+ return canonicalizeTelegramUsername(username.startsWith("@") ? username : `@${username}`)?.slice(1);
656
+ }
657
+ function telegramChatType(value) {
658
+ return value === "channel" || value === "group" || value === "private" || value === "supergroup"
659
+ ? value
660
+ : undefined;
661
+ }
662
+ function registerTelegramChat(state, chat) {
663
+ const previous = state.chatsById.get(chat.id);
664
+ const previousUsername = telegramStoredUsername(previous?.username);
665
+ if (previousUsername && state.chatsByUsername.get(previousUsername)?.id === chat.id) {
666
+ state.chatsByUsername.delete(previousUsername);
667
+ }
668
+ const username = telegramStoredUsername(chat.username);
669
+ const stored = {
670
+ id: chat.id,
671
+ ...(chat.type === "supergroup" && chat.is_forum === true ? { is_forum: true } : {}),
672
+ ...(chat.title ? { title: chat.title } : {}),
673
+ type: chat.type,
674
+ ...(username ? { username } : {}),
675
+ };
676
+ state.chatsById.set(chat.id, stored);
677
+ if (username) {
678
+ state.chatsByUsername.set(username, stored);
679
+ }
680
+ }
681
+ function resolveTelegramChat(state, value) {
682
+ const stringValue = toStringValue(value);
683
+ if (!stringValue) {
684
+ return undefined;
685
+ }
686
+ if (/^-?\d+$/u.test(stringValue)) {
687
+ const chatId = telegramChatId(stringValue);
688
+ if (chatId === undefined) {
689
+ return undefined;
690
+ }
691
+ return state.chatsById.get(chatId) ?? createChat(chatId);
692
+ }
693
+ const username = telegramDestinationUsername(stringValue);
694
+ if (!username) {
695
+ return undefined;
696
+ }
697
+ const storedUsername = username.slice(1);
698
+ const registered = state.chatsByUsername.get(storedUsername);
699
+ if (registered) {
700
+ return registered;
701
+ }
702
+ const chatId = telegramUsernameChatId(username);
703
+ return chatId === undefined
704
+ ? undefined
705
+ : {
706
+ id: chatId,
707
+ type: inferTelegramChatType(chatId),
708
+ username: storedUsername,
709
+ };
710
+ }
711
+ function telegramChatFromRecord(value) {
712
+ if (!isJsonObject(value)) {
713
+ return undefined;
714
+ }
715
+ const id = telegramStoredChatId(value.id);
716
+ const type = telegramChatType(value.type);
717
+ if (id === undefined || !type) {
718
+ return undefined;
719
+ }
720
+ const username = telegramStoredUsername(value.username);
721
+ const title = toStringValue(value.title);
722
+ const isForum = value.is_forum === true;
723
+ if (isForum && type !== "supergroup") {
724
+ return undefined;
725
+ }
726
+ return {
727
+ id,
728
+ ...(isForum ? { is_forum: true } : {}),
729
+ ...(title ? { title } : {}),
174
730
  type,
731
+ ...(username ? { username } : {}),
175
732
  };
176
733
  }
177
- function createOutboundMessage(state, body) {
178
- const chatId = telegramChatId(body.chat_id);
179
- const text = toStringValue(body.text);
180
- if (chatId === undefined || !text) {
734
+ function registerTelegramMessageChat(state, message) {
735
+ const chat = telegramChatFromRecord(message.chat);
736
+ if (!chat) {
737
+ return;
738
+ }
739
+ registerTelegramChat(state, chat);
740
+ }
741
+ function registerTelegramUpdateChats(state, body) {
742
+ for (const field of TELEGRAM_MESSAGE_UPDATE_FIELDS) {
743
+ const message = body[field];
744
+ if (!isJsonObject(message)) {
745
+ continue;
746
+ }
747
+ registerTelegramMessageChat(state, message);
748
+ }
749
+ const callbackQuery = isJsonObject(body.callback_query) ? body.callback_query : undefined;
750
+ const callbackMessage = callbackQuery && isJsonObject(callbackQuery.message) ? callbackQuery.message : undefined;
751
+ if (callbackMessage) {
752
+ registerTelegramMessageChat(state, callbackMessage);
753
+ }
754
+ for (const field of TELEGRAM_CHAT_UPDATE_FIELDS) {
755
+ const update = body[field];
756
+ if (!isJsonObject(update)) {
757
+ continue;
758
+ }
759
+ const chat = telegramChatFromRecord(update.chat);
760
+ if (chat) {
761
+ registerTelegramChat(state, chat);
762
+ }
763
+ }
764
+ }
765
+ function telegramChatKey(chatId) {
766
+ return `number:${chatId}`;
767
+ }
768
+ function nextTelegramMessageId(state, chatId) {
769
+ return state.nextMessageIds.get(telegramChatKey(chatId)) ?? 1;
770
+ }
771
+ function takeNextTelegramMessageId(state, chatId) {
772
+ const messageId = nextTelegramMessageId(state, chatId);
773
+ if (messageId >= Number.MAX_SAFE_INTEGER) {
774
+ return undefined;
775
+ }
776
+ state.nextMessageIds.set(telegramChatKey(chatId), messageId + 1);
777
+ return messageId;
778
+ }
779
+ function createOutboundMessage(state, body, text) {
780
+ const chat = resolveTelegramChat(state, body.chat_id);
781
+ if (!chat) {
181
782
  return undefined;
182
783
  }
183
- const parsedThreadId = toIntegerValue(body.message_thread_id);
784
+ const parsedThreadId = telegramMessageThreadId(body.message_thread_id);
184
785
  if (body.message_thread_id !== undefined && parsedThreadId === undefined) {
185
786
  return undefined;
186
787
  }
187
- const threadId = parsedThreadId !== undefined && parsedThreadId > 0 ? parsedThreadId : undefined;
788
+ if (parsedThreadId !== undefined && !canTargetTelegramTopic(state, chat)) {
789
+ return undefined;
790
+ }
791
+ const messageId = takeNextTelegramMessageId(state, chat.id);
792
+ if (messageId === undefined) {
793
+ return undefined;
794
+ }
188
795
  return {
189
- chat: createChat(chatId),
796
+ chat,
190
797
  date: Math.floor(Date.now() / 1000),
191
798
  from: createBotUser(state),
192
- message_id: state.nextMessageId++,
193
- ...(threadId !== undefined ? { message_thread_id: threadId } : {}),
799
+ message_id: messageId,
800
+ ...(parsedThreadId !== undefined ? { message_thread_id: parsedThreadId } : {}),
194
801
  text,
195
802
  };
196
803
  }
197
- function createOutboundMediaMessage(state, body, mediaKind) {
198
- const chatId = telegramChatId(body.chat_id);
199
- const fileName = toStringValue(body[mediaKind]);
200
- if (chatId === undefined || !fileName) {
804
+ function createOutboundMediaMessage(state, body, mediaKind, caption) {
805
+ const chat = resolveTelegramChat(state, body.chat_id);
806
+ const mediaInput = body[mediaKind];
807
+ const multipartFile = isTelegramMultipartFile(mediaInput) ? mediaInput : undefined;
808
+ const fileName = multipartFile?.fileName ?? toStringValue(mediaInput);
809
+ if (!chat || !fileName) {
201
810
  return undefined;
202
811
  }
203
- const parsedThreadId = toIntegerValue(body.message_thread_id);
812
+ const parsedThreadId = telegramMessageThreadId(body.message_thread_id);
204
813
  if (body.message_thread_id !== undefined && parsedThreadId === undefined) {
205
814
  return undefined;
206
815
  }
207
- const threadId = parsedThreadId !== undefined && parsedThreadId > 0 ? parsedThreadId : undefined;
208
- const caption = toStringValue(body.caption);
816
+ if (parsedThreadId !== undefined && !canTargetTelegramTopic(state, chat)) {
817
+ return undefined;
818
+ }
819
+ const messageId = takeNextTelegramMessageId(state, chat.id);
820
+ if (messageId === undefined) {
821
+ return undefined;
822
+ }
209
823
  const duration = Math.max(0, toIntegerValue(body.duration) ?? 0);
210
824
  const height = Math.max(1, toIntegerValue(body.height) ?? 1);
211
825
  const width = Math.max(1, toIntegerValue(body.width) ?? 1);
826
+ const chatIdentity = Buffer.from(telegramChatKey(chat.id)).toString("base64url");
827
+ let fileIdentity;
828
+ if (multipartFile) {
829
+ fileIdentity = multipartFile.contentDigest.slice(0, 32);
830
+ }
831
+ else {
832
+ fileIdentity =
833
+ new RegExp(`^crabline-${mediaKind}-([A-Za-z0-9_-]{32})-`, "u").exec(fileName)?.[1] ??
834
+ createHash("sha256")
835
+ .update(mediaKind)
836
+ .update("\0")
837
+ .update(fileName)
838
+ .digest("base64url")
839
+ .slice(0, 32);
840
+ }
841
+ const fileId = `crabline-${mediaKind}-${fileIdentity}-${chatIdentity}-${messageId}`;
842
+ const fileUniqueId = `crabline-${mediaKind}-unique-${fileIdentity}`;
212
843
  const media = {
213
- file_id: `crabline-${mediaKind}-${state.nextMessageId}`,
844
+ file_id: fileId,
214
845
  file_name: fileName,
215
- file_unique_id: `crabline-${mediaKind}-unique-${state.nextMessageId}`,
846
+ file_unique_id: fileUniqueId,
216
847
  };
217
848
  return {
218
- chat: createChat(chatId),
849
+ chat,
219
850
  date: Math.floor(Date.now() / 1000),
220
851
  from: createBotUser(state),
221
852
  ...(caption ? { caption } : {}),
222
853
  [mediaKind]: mediaKind === "photo"
223
- ? [{ ...media, height: 1, width: 1 }]
854
+ ? [
855
+ {
856
+ file_id: media.file_id,
857
+ file_unique_id: media.file_unique_id,
858
+ height: 1,
859
+ width: 1,
860
+ },
861
+ ]
224
862
  : {
225
863
  ...media,
226
864
  ...(mediaKind === "audio"
@@ -230,35 +868,39 @@ function createOutboundMediaMessage(state, body, mediaKind) {
230
868
  : {}),
231
869
  mime_type: "application/octet-stream",
232
870
  },
233
- message_id: state.nextMessageId++,
234
- ...(threadId !== undefined ? { message_thread_id: threadId } : {}),
871
+ message_id: messageId,
872
+ ...(parsedThreadId !== undefined ? { message_thread_id: parsedThreadId } : {}),
235
873
  };
236
874
  }
237
- function createEditedMessage(state, body) {
238
- const chatId = telegramChatId(body.chat_id);
239
- const text = toStringValue(body.text);
875
+ function createEditedMessage(state, body, text) {
876
+ const chat = resolveTelegramChat(state, body.chat_id);
240
877
  const messageId = toIntegerValue(body.message_id);
241
- if (chatId === undefined || !text || messageId === undefined) {
878
+ if (!chat || messageId === undefined || messageId < 1) {
242
879
  return undefined;
243
880
  }
244
- const parsedThreadId = toIntegerValue(body.message_thread_id);
881
+ const parsedThreadId = telegramMessageThreadId(body.message_thread_id);
245
882
  if (body.message_thread_id !== undefined && parsedThreadId === undefined) {
246
883
  return undefined;
247
884
  }
248
- const threadId = parsedThreadId !== undefined && parsedThreadId > 0 ? parsedThreadId : undefined;
885
+ if (parsedThreadId !== undefined && !canTargetTelegramTopic(state, chat)) {
886
+ return undefined;
887
+ }
249
888
  return {
250
- chat: createChat(chatId),
889
+ chat,
251
890
  date: Math.floor(Date.now() / 1000),
252
891
  from: createBotUser(state),
253
892
  message_id: messageId,
254
- ...(threadId !== undefined ? { message_thread_id: threadId } : {}),
893
+ ...(parsedThreadId !== undefined ? { message_thread_id: parsedThreadId } : {}),
255
894
  text,
256
895
  };
257
896
  }
258
897
  function createInboundUpdate(state, body) {
259
- const chatId = telegramChatId(body.chatId ?? body.chat_id);
260
- const text = toStringValue(body.text);
261
- if (chatId === undefined || !text) {
898
+ const chat = resolveTelegramChat(state, body.chatId ?? body.chat_id);
899
+ const text = body.text;
900
+ if (!chat ||
901
+ typeof text !== "string" ||
902
+ text.length < 1 ||
903
+ text.length > TELEGRAM_MAX_TEXT_LENGTH) {
262
904
  return undefined;
263
905
  }
264
906
  const fromIdValue = body.fromId ?? body.from_id;
@@ -266,24 +908,43 @@ function createInboundUpdate(state, body) {
266
908
  const messageIdValue = body.messageId ?? body.message_id;
267
909
  const updateIdValue = body.updateId ?? body.update_id;
268
910
  const fromId = toIntegerValue(fromIdValue);
269
- const parsedThreadId = toIntegerValue(threadIdValue);
270
- const threadId = parsedThreadId !== undefined && parsedThreadId > 0 ? parsedThreadId : undefined;
271
- const fromUsername = toStringValue(body.fromUsername ?? body.from_username);
911
+ const threadId = telegramMessageThreadId(threadIdValue);
912
+ const fromUsernameValue = body.fromUsername ?? body.from_username;
913
+ const fromUsername = telegramStoredUsername(fromUsernameValue);
272
914
  const messageId = toIntegerValue(messageIdValue);
273
915
  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)) {
916
+ const explicitChatType = telegramChatType(body.chatType ?? body.chat_type);
917
+ const isForumValue = body.isForum ?? body.is_forum;
918
+ if (((body.chatType !== undefined || body.chat_type !== undefined) && !explicitChatType) ||
919
+ (isForumValue !== undefined && typeof isForumValue !== "boolean") ||
920
+ (fromIdValue !== undefined && (fromId === undefined || fromId < 1)) ||
921
+ (fromUsernameValue !== undefined && !fromUsername) ||
922
+ (threadIdValue !== undefined && threadId === undefined) ||
923
+ (messageIdValue !== undefined && (messageId === undefined || messageId < 1)) ||
924
+ (updateIdValue !== undefined && (updateId === undefined || updateId < 1))) {
278
925
  return undefined;
279
926
  }
280
927
  const entities = parseTelegramEntities(body.entities, text);
281
928
  if (body.entities !== undefined && !entities) {
282
929
  return undefined;
283
930
  }
931
+ const inboundChat = {
932
+ ...chat,
933
+ ...(explicitChatType ? { type: explicitChatType } : {}),
934
+ ...(isForumValue === true ? { is_forum: true } : {}),
935
+ };
936
+ if (isForumValue === false) {
937
+ delete inboundChat.is_forum;
938
+ }
939
+ if (inboundChat.is_forum === true && inboundChat.type !== "supergroup") {
940
+ return undefined;
941
+ }
942
+ if (threadId !== undefined && !canTargetTelegramTopic(state, inboundChat)) {
943
+ return undefined;
944
+ }
284
945
  return {
285
946
  message: {
286
- chat: createChat(chatId),
947
+ chat: inboundChat,
287
948
  date: Math.floor(Date.now() / 1000),
288
949
  from: {
289
950
  first_name: toStringValue(body.fromName ?? body.from_name) ?? "QA User",
@@ -291,7 +952,7 @@ function createInboundUpdate(state, body) {
291
952
  is_bot: false,
292
953
  ...(fromUsername ? { username: fromUsername } : {}),
293
954
  },
294
- message_id: messageId ?? state.nextMessageId,
955
+ message_id: messageId ?? nextTelegramMessageId(state, chat.id),
295
956
  ...(entities && entities.length > 0 ? { entities } : {}),
296
957
  ...(threadId !== undefined ? { message_thread_id: threadId } : {}),
297
958
  text,
@@ -313,22 +974,346 @@ function parseTelegramEntities(value, text) {
313
974
  }
314
975
  const length = toIntegerValue(entry.length);
315
976
  const offset = toIntegerValue(entry.offset);
316
- const type = toStringValue(entry.type);
977
+ const type = typeof entry.type === "string" && entry.type.length > 0 ? entry.type : undefined;
978
+ const customEmojiId = entry.custom_emoji_id;
979
+ const language = entry.language;
980
+ const url = entry.url;
981
+ const user = entry.user;
317
982
  if (length === undefined ||
318
983
  length < 1 ||
319
984
  offset === undefined ||
320
985
  offset < 0 ||
321
986
  offset + length > text.length ||
322
- !type) {
987
+ !isTelegramUtf16Boundary(text, offset) ||
988
+ !isTelegramUtf16Boundary(text, offset + length) ||
989
+ !type ||
990
+ (customEmojiId !== undefined &&
991
+ (typeof customEmojiId !== "string" || customEmojiId.length === 0)) ||
992
+ (language !== undefined && (typeof language !== "string" || language.length === 0)) ||
993
+ (url !== undefined && (typeof url !== "string" || url.length === 0)) ||
994
+ (user !== undefined && !isJsonObject(user))) {
323
995
  return undefined;
324
996
  }
325
- entities.push({ length, offset, type });
997
+ entities.push({
998
+ ...(customEmojiId !== undefined ? { custom_emoji_id: customEmojiId } : {}),
999
+ ...(language !== undefined ? { language } : {}),
1000
+ length,
1001
+ offset,
1002
+ type,
1003
+ ...(url !== undefined ? { url } : {}),
1004
+ ...(user !== undefined ? { user } : {}),
1005
+ });
326
1006
  }
327
1007
  return entities;
328
1008
  }
1009
+ function isTelegramUtf16Boundary(text, index) {
1010
+ if (index <= 0 || index >= text.length) {
1011
+ return true;
1012
+ }
1013
+ const previous = text.charCodeAt(index - 1);
1014
+ const next = text.charCodeAt(index);
1015
+ return !(previous >= 0xd800 && previous <= 0xdbff && next >= 0xdc00 && next <= 0xdfff);
1016
+ }
329
1017
  function hasTelegramMedia(value) {
330
1018
  return TELEGRAM_MEDIA_FIELDS.some((field) => value[field] !== undefined);
331
1019
  }
1020
+ function telegramMessageThreadId(value) {
1021
+ const threadId = toIntegerValue(value);
1022
+ return threadId !== undefined && threadId > 0 ? threadId : undefined;
1023
+ }
1024
+ function canTargetTelegramTopic(state, chat) {
1025
+ if (chat.type === "supergroup") {
1026
+ return chat.is_forum === true;
1027
+ }
1028
+ if (chat.type === "private") {
1029
+ return state.botHasTopicsEnabled;
1030
+ }
1031
+ return false;
1032
+ }
1033
+ function telegramMessageTargetsValidTopic(state, message) {
1034
+ if (message.message_thread_id === undefined) {
1035
+ return true;
1036
+ }
1037
+ const chat = telegramChatFromRecord(message.chat);
1038
+ if (!chat) {
1039
+ return false;
1040
+ }
1041
+ return canTargetTelegramTopic(state, chat);
1042
+ }
1043
+ function hasValidTelegramTopicDestinations(state, body) {
1044
+ const topLevelThreadId = body.messageThreadId ?? body.message_thread_id;
1045
+ if (topLevelThreadId !== undefined) {
1046
+ const chat = resolveTelegramChat(state, body.chatId ?? body.chat_id);
1047
+ const explicitType = telegramChatType(body.chatType ?? body.chat_type);
1048
+ if (!chat || ((body.chatType !== undefined || body.chat_type !== undefined) && !explicitType)) {
1049
+ return false;
1050
+ }
1051
+ const topicChat = {
1052
+ ...chat,
1053
+ ...(explicitType ? { type: explicitType } : {}),
1054
+ ...((body.isForum ?? body.is_forum) === true ? { is_forum: true } : {}),
1055
+ };
1056
+ if ((body.isForum ?? body.is_forum) === false) {
1057
+ delete topicChat.is_forum;
1058
+ }
1059
+ if ((body.isForum !== undefined || body.is_forum !== undefined) &&
1060
+ typeof (body.isForum ?? body.is_forum) !== "boolean") {
1061
+ return false;
1062
+ }
1063
+ if (!canTargetTelegramTopic(state, topicChat)) {
1064
+ return false;
1065
+ }
1066
+ }
1067
+ for (const field of TELEGRAM_MESSAGE_UPDATE_FIELDS) {
1068
+ const message = body[field];
1069
+ if (isJsonObject(message) && !telegramMessageTargetsValidTopic(state, message)) {
1070
+ return false;
1071
+ }
1072
+ }
1073
+ const callbackQuery = isJsonObject(body.callback_query) ? body.callback_query : undefined;
1074
+ const callbackMessage = callbackQuery && isJsonObject(callbackQuery.message) ? callbackQuery.message : undefined;
1075
+ return !callbackMessage || telegramMessageTargetsValidTopic(state, callbackMessage);
1076
+ }
1077
+ function hasValidTelegramMessageThreadIds(body) {
1078
+ const values = [
1079
+ body.messageThreadId,
1080
+ body.message_thread_id,
1081
+ ...TELEGRAM_MESSAGE_UPDATE_FIELDS.flatMap((field) => {
1082
+ const message = body[field];
1083
+ return isJsonObject(message) ? [message.message_thread_id] : [];
1084
+ }),
1085
+ ];
1086
+ const callbackQuery = isJsonObject(body.callback_query) ? body.callback_query : undefined;
1087
+ const callbackMessage = callbackQuery && isJsonObject(callbackQuery.message) ? callbackQuery.message : undefined;
1088
+ if (callbackMessage) {
1089
+ values.push(callbackMessage.message_thread_id);
1090
+ }
1091
+ return values.every((value) => value === undefined || telegramMessageThreadId(value) !== undefined);
1092
+ }
1093
+ function explicitTelegramId(body, names, additionalValues = [], options = {}) {
1094
+ const values = [
1095
+ ...names.flatMap((name) => (body[name] === undefined ? [] : [body[name]])),
1096
+ ...additionalValues.filter((value) => value !== undefined),
1097
+ ];
1098
+ if (values.length === 0) {
1099
+ return { present: false };
1100
+ }
1101
+ const parsed = values.map(toIntegerValue);
1102
+ const minimum = options.allowZero ? 0 : 1;
1103
+ if (parsed.some((value) => value === undefined || value < minimum || value >= Number.MAX_SAFE_INTEGER) ||
1104
+ new Set(parsed).size !== 1) {
1105
+ return undefined;
1106
+ }
1107
+ return { present: true, value: parsed[0] };
1108
+ }
1109
+ function explicitTelegramMessageIdValues(body) {
1110
+ return TELEGRAM_NEW_MESSAGE_UPDATE_FIELDS.flatMap((field) => {
1111
+ const message = body[field];
1112
+ return isJsonObject(message) ? [message.message_id] : [];
1113
+ });
1114
+ }
1115
+ function referencedTelegramMessageIdValues(body) {
1116
+ const values = TELEGRAM_MESSAGE_REFERENCE_FIELDS.flatMap((field) => {
1117
+ const message = body[field];
1118
+ return isJsonObject(message) ? [message.message_id] : [];
1119
+ });
1120
+ const callbackQuery = isJsonObject(body.callback_query) ? body.callback_query : undefined;
1121
+ const callbackMessage = callbackQuery && isJsonObject(callbackQuery.message) ? callbackQuery.message : undefined;
1122
+ return callbackMessage ? [...values, callbackMessage.message_id] : values;
1123
+ }
1124
+ function explicitTelegramMessageChatId(body) {
1125
+ const chatIds = [];
1126
+ if (body.messageId !== undefined || body.message_id !== undefined) {
1127
+ const chatId = telegramChatId(body.chatId ?? body.chat_id);
1128
+ if (chatId === undefined) {
1129
+ return false;
1130
+ }
1131
+ chatIds.push(chatId);
1132
+ }
1133
+ for (const field of TELEGRAM_MESSAGE_UPDATE_FIELDS) {
1134
+ const message = body[field];
1135
+ if (!isJsonObject(message) || message.message_id === undefined) {
1136
+ continue;
1137
+ }
1138
+ const chat = isJsonObject(message.chat) ? message.chat : undefined;
1139
+ const chatId = telegramStoredChatId(chat?.id);
1140
+ if (chatId === undefined) {
1141
+ return false;
1142
+ }
1143
+ chatIds.push(chatId);
1144
+ }
1145
+ const callbackQuery = isJsonObject(body.callback_query) ? body.callback_query : undefined;
1146
+ const callbackMessage = callbackQuery && isJsonObject(callbackQuery.message) ? callbackQuery.message : undefined;
1147
+ if (callbackMessage?.message_id !== undefined) {
1148
+ const chat = isJsonObject(callbackMessage.chat) ? callbackMessage.chat : undefined;
1149
+ const chatId = telegramStoredChatId(chat?.id);
1150
+ if (chatId === undefined) {
1151
+ return false;
1152
+ }
1153
+ chatIds.push(chatId);
1154
+ }
1155
+ if (chatIds.length === 0) {
1156
+ return undefined;
1157
+ }
1158
+ const firstChatId = chatIds[0];
1159
+ return chatIds.every((chatId) => telegramChatKey(chatId) === telegramChatKey(firstChatId))
1160
+ ? firstChatId
1161
+ : false;
1162
+ }
1163
+ function readTelegramTextField(value, field, options) {
1164
+ if (value === undefined) {
1165
+ return options.required ? telegramError(`Bad Request: ${field} is required`) : undefined;
1166
+ }
1167
+ if (typeof value !== "string") {
1168
+ return telegramError(`Bad Request: ${field} must be a string`);
1169
+ }
1170
+ const parsedLength = telegramTextLength(value, options.parseMode);
1171
+ if (options.required && parsedLength === 0) {
1172
+ return telegramError(`Bad Request: ${field} must not be empty`);
1173
+ }
1174
+ const maxLength = field === "text" ? TELEGRAM_MAX_TEXT_LENGTH : TELEGRAM_MAX_CAPTION_LENGTH;
1175
+ if (parsedLength > maxLength) {
1176
+ return telegramError(`Bad Request: ${field} is too long`);
1177
+ }
1178
+ return value;
1179
+ }
1180
+ function telegramTextLength(value, parseMode) {
1181
+ const normalizedParseMode = typeof parseMode === "string" ? parseMode.toLowerCase() : undefined;
1182
+ if (normalizedParseMode === "html") {
1183
+ return telegramHtmlText(value).length;
1184
+ }
1185
+ if (normalizedParseMode === "markdown" || normalizedParseMode === "markdownv2") {
1186
+ return telegramMarkdownText(value, normalizedParseMode === "markdownv2").length;
1187
+ }
1188
+ return value.length;
1189
+ }
1190
+ function telegramHtmlText(value) {
1191
+ return value
1192
+ .replace(TELEGRAM_HTML_TAG_PATTERN, "")
1193
+ .replace(TELEGRAM_HTML_ENTITY_PATTERN, (entity) => {
1194
+ const normalized = entity.toLowerCase();
1195
+ if (normalized === "&amp;") {
1196
+ return "&";
1197
+ }
1198
+ if (normalized === "&gt;") {
1199
+ return ">";
1200
+ }
1201
+ if (normalized === "&lt;") {
1202
+ return "<";
1203
+ }
1204
+ if (normalized === "&quot;") {
1205
+ return '"';
1206
+ }
1207
+ const radix = normalized.startsWith("&#x") ? 16 : 10;
1208
+ const digits = normalized.slice(radix === 16 ? 3 : 2, -1);
1209
+ const codePoint = Number.parseInt(digits, radix);
1210
+ return Number.isSafeInteger(codePoint) && codePoint <= 0x10ffff
1211
+ ? String.fromCodePoint(codePoint)
1212
+ : entity;
1213
+ });
1214
+ }
1215
+ function protectTelegramMarkdownEscapes(value, versionTwo) {
1216
+ let protectedText = "";
1217
+ for (let index = 0; index < value.length; index += 1) {
1218
+ const character = value[index];
1219
+ const escapedCharacter = value[index + 1];
1220
+ if (character === "\\" &&
1221
+ escapedCharacter !== undefined &&
1222
+ (versionTwo
1223
+ ? escapedCharacter.charCodeAt(0) >= 1 && escapedCharacter.charCodeAt(0) <= 0x7e
1224
+ : "*_[]`".includes(escapedCharacter))) {
1225
+ protectedText += "\0";
1226
+ index += 1;
1227
+ continue;
1228
+ }
1229
+ protectedText += character;
1230
+ }
1231
+ return protectedText;
1232
+ }
1233
+ function telegramMarkdownLiteralMask(value) {
1234
+ return "\0".repeat(value.length);
1235
+ }
1236
+ function stripTelegramMarkdownExpandableMarker(line) {
1237
+ if (!line.endsWith("||")) {
1238
+ return line;
1239
+ }
1240
+ let markers = 0;
1241
+ for (let index = line.indexOf("||"); index >= 0; index = line.indexOf("||", index + 2)) {
1242
+ markers += 1;
1243
+ }
1244
+ return markers % 2 === 1 ? line.slice(0, -2) : line;
1245
+ }
1246
+ // Keep this scan strictly forward-only: this request path accepts client-controlled
1247
+ // Markdown, and backtracking over malformed link destinations amplifies CPU use.
1248
+ function stripTelegramMarkdownLinks(value) {
1249
+ const output = [];
1250
+ let copyStart = 0;
1251
+ let searchStart = 0;
1252
+ while (searchStart < value.length) {
1253
+ const labelStart = value.indexOf("[", searchStart);
1254
+ if (labelStart < 0) {
1255
+ break;
1256
+ }
1257
+ let labelEnd = labelStart + 1;
1258
+ while (labelEnd < value.length && value[labelEnd] !== "]" && value[labelEnd] !== "\n") {
1259
+ labelEnd += 1;
1260
+ }
1261
+ if (labelEnd === labelStart + 1 || value[labelEnd] !== "]" || value[labelEnd + 1] !== "(") {
1262
+ searchStart = labelEnd < value.length ? labelEnd + 1 : value.length;
1263
+ continue;
1264
+ }
1265
+ let destinationEnd = labelEnd + 2;
1266
+ let matched = false;
1267
+ while (destinationEnd < value.length) {
1268
+ const character = value[destinationEnd];
1269
+ if (character === "\n") {
1270
+ break;
1271
+ }
1272
+ if (character === ")") {
1273
+ output.push(value.slice(copyStart, labelStart), value.slice(labelStart + 1, labelEnd));
1274
+ copyStart = destinationEnd + 1;
1275
+ searchStart = copyStart;
1276
+ matched = true;
1277
+ break;
1278
+ }
1279
+ if (character === "\\" &&
1280
+ destinationEnd + 1 < value.length &&
1281
+ value[destinationEnd + 1] !== "\n") {
1282
+ destinationEnd += 2;
1283
+ continue;
1284
+ }
1285
+ destinationEnd += 1;
1286
+ }
1287
+ if (matched) {
1288
+ continue;
1289
+ }
1290
+ searchStart = destinationEnd < value.length ? destinationEnd + 1 : value.length;
1291
+ }
1292
+ output.push(value.slice(copyStart));
1293
+ return output.join("");
1294
+ }
1295
+ function telegramMarkdownText(value, versionTwo) {
1296
+ let parsed = protectTelegramMarkdownEscapes(value, versionTwo)
1297
+ .replace(/```(?:[^\n]*\n)?([\s\S]*?)```/gu, (_match, content) => telegramMarkdownLiteralMask(content))
1298
+ .replace(/`([^`\n]*)`/gu, (_match, content) => telegramMarkdownLiteralMask(content))
1299
+ .replace(/!\[([^\]\n]+)\]\(tg:\/\/(?:emoji\?id=\d+|time\?unix=-?\d+(?:&format=[rwdDtT]+)?)\)/gu, "$1");
1300
+ parsed = stripTelegramMarkdownLinks(parsed);
1301
+ if (versionTwo) {
1302
+ parsed = parsed
1303
+ .replace(/^(?:\*\*)?>[^\n]*$/gmu, stripTelegramMarkdownExpandableMarker)
1304
+ .replace(/^\*\*(?=>)/gmu, "")
1305
+ .replace(/^>/gmu, "")
1306
+ .replace(/\|\|([\s\S]*?)\|\|/gu, "$1")
1307
+ .replace(/__([\s\S]*?)__/gu, "$1")
1308
+ .replace(/~([\s\S]*?)~/gu, "$1");
1309
+ }
1310
+ let previous;
1311
+ do {
1312
+ previous = parsed;
1313
+ parsed = parsed.replace(/\*([\s\S]*?)\*/gu, "$1").replace(/_([\s\S]*?)_/gu, "$1");
1314
+ } while (parsed !== previous);
1315
+ return parsed;
1316
+ }
332
1317
  function hasValidExplicitTelegramIdentities(body) {
333
1318
  return ([body.chatId, body.chat_id].every((value) => value === undefined || telegramChatId(value) !== undefined) &&
334
1319
  [
@@ -336,8 +1321,6 @@ function hasValidExplicitTelegramIdentities(body) {
336
1321
  body.from_id,
337
1322
  body.messageId,
338
1323
  body.message_id,
339
- body.messageThreadId,
340
- body.message_thread_id,
341
1324
  body.updateId,
342
1325
  body.update_id,
343
1326
  ].every((value) => value === undefined || toIntegerValue(value) !== undefined));
@@ -348,14 +1331,17 @@ function isValidIgnoredTelegramUpdate(body) {
348
1331
  if (isJsonObject(body.message)) {
349
1332
  const message = body.message;
350
1333
  const chat = isJsonObject(message.chat) ? message.chat : undefined;
1334
+ const messageId = toIntegerValue(message.message_id);
351
1335
  if (updateId === undefined ||
1336
+ updateId < 1 ||
352
1337
  !chat ||
353
- telegramChatId(chat.id) === undefined ||
354
- toIntegerValue(message.message_id) === undefined ||
1338
+ telegramStoredChatId(chat.id) === undefined ||
1339
+ messageId === undefined ||
1340
+ messageId < 0 ||
355
1341
  (message.from !== undefined &&
356
- (!isJsonObject(message.from) || toIntegerValue(message.from.id) === undefined)) ||
1342
+ (!isJsonObject(message.from) || (toIntegerValue(message.from.id) ?? 0) < 1)) ||
357
1343
  (message.message_thread_id !== undefined &&
358
- toIntegerValue(message.message_thread_id) === undefined)) {
1344
+ telegramMessageThreadId(message.message_thread_id) === undefined)) {
359
1345
  return false;
360
1346
  }
361
1347
  return toStringValue(message.text) === undefined;
@@ -378,13 +1364,55 @@ async function handleTelegramAdminInbound(params) {
378
1364
  });
379
1365
  await previousAdmission;
380
1366
  try {
381
- const nextMessageId = params.state.nextMessageId;
382
1367
  const nextUpdateId = params.state.nextUpdateId;
1368
+ const explicitMessageId = explicitTelegramId(params.body, ["messageId", "message_id"], explicitTelegramMessageIdValues(params.body), { allowZero: true });
1369
+ const explicitMessageChatId = explicitTelegramMessageChatId(params.body);
1370
+ const explicitUpdateId = explicitTelegramId(params.body, ["updateId", "update_id"]);
1371
+ const referencedMessageId = explicitTelegramId({}, [], referencedTelegramMessageIdValues(params.body));
1372
+ const topLevelChatId = resolveTelegramChat(params.state, params.body.chatId ?? params.body.chat_id)?.id;
1373
+ const messageChatId = explicitMessageChatId === false ? undefined : (explicitMessageChatId ?? topLevelChatId);
1374
+ const messageChatKey = messageChatId === undefined ? undefined : telegramChatKey(messageChatId);
1375
+ const previousNextMessageId = messageChatKey === undefined ? undefined : params.state.nextMessageIds.get(messageChatKey);
1376
+ const nextMessageId = messageChatId === undefined ? undefined : nextTelegramMessageId(params.state, messageChatId);
1377
+ if (!hasValidTelegramMessageThreadIds(params.body) ||
1378
+ !hasValidTelegramTopicDestinations(params.state, params.body) ||
1379
+ explicitMessageId === undefined ||
1380
+ explicitMessageChatId === false ||
1381
+ explicitUpdateId === undefined ||
1382
+ referencedMessageId === undefined ||
1383
+ (explicitMessageId.present &&
1384
+ explicitMessageId.value > 0 &&
1385
+ (nextMessageId === undefined || explicitMessageId.value < nextMessageId)) ||
1386
+ (explicitUpdateId.present && explicitUpdateId.value < nextUpdateId)) {
1387
+ return telegramError("Bad Request: explicit IDs must be valid and monotonic");
1388
+ }
383
1389
  const update = createInboundUpdate(params.state, params.body);
384
- params.state.nextMessageId = nextMessageId;
385
1390
  params.state.nextUpdateId = nextUpdateId;
1391
+ if (update &&
1392
+ ((!explicitMessageId.present &&
1393
+ (nextMessageId ?? Number.MAX_SAFE_INTEGER) >= Number.MAX_SAFE_INTEGER) ||
1394
+ (!explicitUpdateId.present && nextUpdateId >= Number.MAX_SAFE_INTEGER))) {
1395
+ return telegramError("Bad Request: generated ID space is exhausted");
1396
+ }
386
1397
  if (!update) {
387
1398
  if (isValidIgnoredTelegramUpdate(params.body)) {
1399
+ if (messageChatKey !== undefined) {
1400
+ const observedNextMessageIds = [
1401
+ ...(explicitMessageId.present && explicitMessageId.value > 0
1402
+ ? [explicitMessageId.value + 1]
1403
+ : []),
1404
+ ...(referencedMessageId.present && referencedMessageId.value > 0
1405
+ ? [referencedMessageId.value + 1]
1406
+ : []),
1407
+ ];
1408
+ if (observedNextMessageIds.length > 0) {
1409
+ params.state.nextMessageIds.set(messageChatKey, Math.max(params.state.nextMessageIds.get(messageChatKey) ?? 1, ...observedNextMessageIds));
1410
+ }
1411
+ }
1412
+ if (explicitUpdateId.present) {
1413
+ params.state.nextUpdateId = explicitUpdateId.value + 1;
1414
+ }
1415
+ registerTelegramUpdateChats(params.state, params.body);
388
1416
  return jsonResponse({ ok: true });
389
1417
  }
390
1418
  return telegramError("Bad Request: chatId and text are required");
@@ -392,9 +1420,12 @@ async function handleTelegramAdminInbound(params) {
392
1420
  if (params.state.updates.length >= params.state.maxPendingInboundEvents) {
393
1421
  return telegramError(`Too Many Requests: pending inbound queue is full (${params.state.maxPendingInboundEvents} updates)`, 429);
394
1422
  }
395
- params.state.nextMessageId = Math.max(params.state.nextMessageId, update.message.message_id + 1);
1423
+ const updateChatKey = telegramChatKey(update.message.chat.id);
1424
+ params.state.nextMessageIds.set(updateChatKey, Math.max(params.state.nextMessageIds.get(updateChatKey) ?? 1, update.message.message_id + 1, ...(referencedMessageId.present && referencedMessageId.value > 0
1425
+ ? [referencedMessageId.value + 1]
1426
+ : [])));
396
1427
  params.state.nextUpdateId = Math.max(params.state.nextUpdateId, update.update_id + 1);
397
- const reservedNextMessageId = params.state.nextMessageId;
1428
+ const reservedNextMessageId = params.state.nextMessageIds.get(updateChatKey);
398
1429
  const reservedNextUpdateId = params.state.nextUpdateId;
399
1430
  try {
400
1431
  await appendEvent(params.state, {
@@ -407,14 +1438,20 @@ async function handleTelegramAdminInbound(params) {
407
1438
  });
408
1439
  }
409
1440
  catch (error) {
410
- if (params.state.nextMessageId === reservedNextMessageId) {
411
- params.state.nextMessageId = nextMessageId;
1441
+ if (params.state.nextMessageIds.get(updateChatKey) === reservedNextMessageId) {
1442
+ if (previousNextMessageId === undefined) {
1443
+ params.state.nextMessageIds.delete(updateChatKey);
1444
+ }
1445
+ else {
1446
+ params.state.nextMessageIds.set(updateChatKey, previousNextMessageId);
1447
+ }
412
1448
  }
413
1449
  if (params.state.nextUpdateId === reservedNextUpdateId) {
414
1450
  params.state.nextUpdateId = nextUpdateId;
415
1451
  }
416
1452
  throw error;
417
1453
  }
1454
+ registerTelegramChat(params.state, update.message.chat);
418
1455
  params.state.updates.push(update);
419
1456
  params.state.updates.sort((left, right) => left.update_id - right.update_id);
420
1457
  if (params.state.webhook) {
@@ -690,14 +1727,29 @@ async function handleTelegramApi(params) {
690
1727
  return telegramOk(createBotUser(params.state));
691
1728
  case "setmycommands":
692
1729
  case "deletemycommands":
693
- case "sendchataction":
694
1730
  case "answercallbackquery":
695
1731
  case "deletemessage":
696
1732
  case "pinchatmessage":
697
1733
  case "unpinchatmessage":
698
1734
  case "setmessagereaction":
699
- case "editforumtopic":
700
1735
  return telegramOk(true);
1736
+ case "sendchataction": {
1737
+ if (params.body.message_thread_id === undefined) {
1738
+ return telegramOk(true);
1739
+ }
1740
+ const chat = resolveTelegramChat(params.state, params.body.chat_id);
1741
+ const messageThreadId = telegramMessageThreadId(params.body.message_thread_id);
1742
+ return chat && messageThreadId !== undefined && canTargetTelegramTopic(params.state, chat)
1743
+ ? telegramOk(true)
1744
+ : telegramError("Bad Request: chat is not a forum or message_thread_id is invalid");
1745
+ }
1746
+ case "editforumtopic": {
1747
+ const chat = resolveTelegramChat(params.state, params.body.chat_id);
1748
+ const messageThreadId = telegramMessageThreadId(params.body.message_thread_id);
1749
+ return chat && messageThreadId !== undefined && canTargetTelegramTopic(params.state, chat)
1750
+ ? telegramOk(true)
1751
+ : telegramError("Bad Request: chat is not a forum or message_thread_id is invalid");
1752
+ }
701
1753
  case "setwebhook": {
702
1754
  if (params.body.url === "") {
703
1755
  await replaceTelegramWebhook(params.state, undefined, toBooleanValue(params.body.drop_pending_updates));
@@ -717,6 +1769,15 @@ async function handleTelegramApi(params) {
717
1769
  if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
718
1770
  return telegramError("Bad Request: bad webhook URL");
719
1771
  }
1772
+ const secretToken = params.body.secret_token === undefined
1773
+ ? undefined
1774
+ : typeof params.body.secret_token === "string"
1775
+ ? params.body.secret_token
1776
+ : null;
1777
+ if (secretToken === null ||
1778
+ (secretToken !== undefined && !TELEGRAM_WEBHOOK_SECRET_PATTERN.test(secretToken))) {
1779
+ return telegramError("Bad Request: invalid secret token");
1780
+ }
720
1781
  if (params.state.activeWebhookValidations.size >= MAX_ACTIVE_TELEGRAM_WEBHOOK_VALIDATIONS) {
721
1782
  return telegramError("Too Many Requests: too many webhook validations", 429);
722
1783
  }
@@ -728,10 +1789,6 @@ async function handleTelegramApi(params) {
728
1789
  if (target instanceof Response) {
729
1790
  return target;
730
1791
  }
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
1792
  await replaceTelegramWebhook(params.state, {
736
1793
  ...(secretToken ? { secretToken } : {}),
737
1794
  url: parsedUrl.href,
@@ -753,20 +1810,50 @@ async function handleTelegramApi(params) {
753
1810
  pending_update_count: params.state.updates.length,
754
1811
  url: params.state.webhook?.url ?? "",
755
1812
  });
756
- case "createforumtopic":
1813
+ case "createforumtopic": {
1814
+ const chat = resolveTelegramChat(params.state, params.body.chat_id);
1815
+ if (!chat) {
1816
+ return telegramError("Bad Request: chat_id is required");
1817
+ }
1818
+ if (!canTargetTelegramTopic(params.state, chat)) {
1819
+ return telegramError("Bad Request: chat is not a forum");
1820
+ }
1821
+ const messageThreadId = takeNextTelegramMessageId(params.state, chat.id);
1822
+ if (messageThreadId === undefined) {
1823
+ return telegramError("Bad Request: generated ID space is exhausted");
1824
+ }
757
1825
  return telegramOk({
758
1826
  icon_color: 0x6fb9f0,
759
- message_thread_id: params.state.nextMessageId++,
1827
+ message_thread_id: messageThreadId,
760
1828
  name: toStringValue(params.body.name) ?? "Crabline Topic",
761
1829
  });
1830
+ }
762
1831
  case "editmessagetext": {
763
- const message = createEditedMessage(params.state, params.body);
1832
+ const text = readTelegramTextField(params.body.text, "text", {
1833
+ parseMode: params.body.parse_mode,
1834
+ required: true,
1835
+ });
1836
+ if (text instanceof Response) {
1837
+ return text;
1838
+ }
1839
+ const message = createEditedMessage(params.state, params.body, text);
764
1840
  return message
765
1841
  ? telegramOk(message)
766
1842
  : telegramError("Bad Request: chat_id, message_id, and text are required");
767
1843
  }
768
1844
  case "sendmessage": {
769
- const message = createOutboundMessage(params.state, params.body);
1845
+ const text = readTelegramTextField(params.body.text, "text", {
1846
+ parseMode: params.body.parse_mode,
1847
+ required: true,
1848
+ });
1849
+ if (text instanceof Response) {
1850
+ return text;
1851
+ }
1852
+ const chat = resolveTelegramChat(params.state, params.body.chat_id);
1853
+ if (chat && nextTelegramMessageId(params.state, chat.id) >= Number.MAX_SAFE_INTEGER) {
1854
+ return telegramError("Bad Request: generated ID space is exhausted");
1855
+ }
1856
+ const message = createOutboundMessage(params.state, params.body, text);
770
1857
  return message
771
1858
  ? telegramOk(message)
772
1859
  : telegramError("Bad Request: chat_id and text are required");
@@ -777,7 +1864,18 @@ async function handleTelegramApi(params) {
777
1864
  case "sendphoto":
778
1865
  case "sendvideo": {
779
1866
  const mediaKind = method.slice("send".length);
780
- const message = createOutboundMediaMessage(params.state, params.body, mediaKind);
1867
+ const caption = readTelegramTextField(params.body.caption, "caption", {
1868
+ parseMode: params.body.parse_mode,
1869
+ required: false,
1870
+ });
1871
+ if (caption instanceof Response) {
1872
+ return caption;
1873
+ }
1874
+ const chat = resolveTelegramChat(params.state, params.body.chat_id);
1875
+ if (chat && nextTelegramMessageId(params.state, chat.id) >= Number.MAX_SAFE_INTEGER) {
1876
+ return telegramError("Bad Request: generated ID space is exhausted");
1877
+ }
1878
+ const message = createOutboundMediaMessage(params.state, params.body, mediaKind, caption);
781
1879
  return message
782
1880
  ? telegramOk(message)
783
1881
  : telegramError(`Bad Request: chat_id and ${mediaKind} are required`);
@@ -958,13 +2056,15 @@ async function handleRequest(params) {
958
2056
  }
959
2057
  return response;
960
2058
  }
961
- await appendEvent(params.state, event);
962
- return await handleTelegramApi({
2059
+ const response = await handleTelegramApi({
963
2060
  body,
964
2061
  method: botPath.method,
965
2062
  request: params.request,
966
2063
  state: params.state,
967
2064
  });
2065
+ event.accepted = response.ok;
2066
+ await appendEvent(params.state, event);
2067
+ return response;
968
2068
  }
969
2069
  async function serveRequest(params) {
970
2070
  let fetchResponse;
@@ -990,25 +2090,36 @@ async function serveRequest(params) {
990
2090
  }
991
2091
  export async function startTelegramServer(params = {}) {
992
2092
  const host = params.host ?? "127.0.0.1";
2093
+ if (params.botHasTopicsEnabled !== undefined && typeof params.botHasTopicsEnabled !== "boolean") {
2094
+ throw new Error("botHasTopicsEnabled must be a boolean.");
2095
+ }
993
2096
  const botId = params.botId ?? 424242;
994
2097
  if (!Number.isSafeInteger(botId) || botId < 1) {
995
2098
  throw new Error("botId must be a positive safe integer.");
996
2099
  }
2100
+ const botUsernameValue = params.botUsername ?? "crabline_bot";
2101
+ const botUsername = botUsernameValue.trim().replace(/^@/u, "").toLowerCase();
2102
+ if (!TELEGRAM_BOT_USERNAME_PATTERN.test(`@${botUsername}`)) {
2103
+ throw new Error("botUsername must be a valid 5-32 character Telegram username.");
2104
+ }
997
2105
  const state = {
998
2106
  activeUpdatePoll: undefined,
999
2107
  activeWebhookDeliveries: new Set(),
1000
2108
  activeWebhookValidations: new Set(),
1001
2109
  adminToken: params.adminToken ?? randomBytes(24).toString("hex"),
1002
2110
  allowLoopbackHttpWebhook: isLoopbackHost(host),
2111
+ botHasTopicsEnabled: params.botHasTopicsEnabled ?? true,
1003
2112
  botId,
1004
2113
  botToken: params.botToken ??
1005
2114
  (isLoopbackHost(host)
1006
2115
  ? "424242:crabline-telegram-token"
1007
2116
  : `${botId}:${randomBytes(26).toString("base64url")}`),
1008
- botUsername: params.botUsername ?? "crabline_bot",
2117
+ botUsername,
2118
+ chatsById: new Map(),
2119
+ chatsByUsername: new Map(),
1009
2120
  inboundAdmission: Promise.resolve(),
1010
2121
  maxPendingInboundEvents: resolveMaxPendingInboundEvents(params.maxPendingInboundEvents),
1011
- nextMessageId: 1,
2122
+ nextMessageIds: new Map(),
1012
2123
  nextUpdateId: 1,
1013
2124
  onEvent: params.onEvent,
1014
2125
  recorderPath: params.recorderPath ?? path.resolve(".crabline", "servers", "telegram.jsonl"),