@jant/core 0.5.4 → 0.6.1

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 (90) hide show
  1. package/bin/commands/telegram/register-webhooks.js +93 -0
  2. package/dist/app-CMSW_AYG.js +6 -0
  3. package/dist/{app-BtNdUAqz.js → app-DYQdDMs8.js} +2249 -387
  4. package/dist/client/.vite/manifest.json +3 -3
  5. package/dist/client/_assets/client-BRTh1ii1.js +274 -0
  6. package/dist/client/_assets/client-CO4b-RKd.css +2 -0
  7. package/dist/client/_assets/{client-auth-DJ_5wx9N.js → client-auth-CSNcTJwP.js} +81 -81
  8. package/dist/{env-CgaH9Mut.js → env-C7e2Nlnt.js} +30 -1
  9. package/dist/{export-CR9Megtb.js → export-Bbn86HmS.js} +1 -1
  10. package/dist/{github-sync-DYZq9rQp.js → github-sync-CBQPRZ8H.js} +1 -1
  11. package/dist/{github-sync-8Vv06aCr.js → github-sync-dXsiZa_e.js} +2 -2
  12. package/dist/index.js +4 -4
  13. package/dist/node.js +61 -5
  14. package/package.json +2 -1
  15. package/src/__tests__/helpers/app.ts +15 -2
  16. package/src/app.tsx +3 -0
  17. package/src/client/thread-context.ts +146 -2
  18. package/src/client/tiptap/__tests__/link-toolbar.test.ts +1 -1
  19. package/src/client/tiptap/bubble-menu.ts +1 -16
  20. package/src/client/tiptap/extensions.ts +2 -6
  21. package/src/client/tiptap/link-toolbar.ts +0 -21
  22. package/src/client/tiptap/toolbar-mode.ts +0 -43
  23. package/src/db/migrations/0022_old_gressill.sql +24 -0
  24. package/src/db/migrations/0023_broad_terror.sql +20 -0
  25. package/src/db/migrations/0024_red_the_twelve.sql +3 -0
  26. package/src/db/migrations/0025_exotic_wendell_rand.sql +1 -0
  27. package/src/db/migrations/meta/0022_snapshot.json +2267 -0
  28. package/src/db/migrations/meta/0023_snapshot.json +2396 -0
  29. package/src/db/migrations/meta/0024_snapshot.json +2417 -0
  30. package/src/db/migrations/meta/0025_snapshot.json +2424 -0
  31. package/src/db/migrations/meta/_journal.json +28 -0
  32. package/src/db/migrations/pg/0020_bizarre_smasher.sql +24 -0
  33. package/src/db/migrations/pg/0021_sharp_puppet_master.sql +20 -0
  34. package/src/db/migrations/pg/0022_blushing_blue_shield.sql +3 -0
  35. package/src/db/migrations/pg/0023_organic_zemo.sql +1 -0
  36. package/src/db/migrations/pg/meta/0020_snapshot.json +2904 -0
  37. package/src/db/migrations/pg/meta/0021_snapshot.json +3060 -0
  38. package/src/db/migrations/pg/meta/0022_snapshot.json +3078 -0
  39. package/src/db/migrations/pg/meta/0023_snapshot.json +3084 -0
  40. package/src/db/migrations/pg/meta/_journal.json +28 -0
  41. package/src/db/pg/schema.ts +82 -0
  42. package/src/db/schema.ts +90 -0
  43. package/src/i18n/coverage.generated.ts +2 -2
  44. package/src/i18n/locales/public/en.po +8 -0
  45. package/src/i18n/locales/public/zh-Hans.po +8 -0
  46. package/src/i18n/locales/public/zh-Hant.po +8 -0
  47. package/src/i18n/locales/settings/en.po +135 -0
  48. package/src/i18n/locales/settings/en.ts +1 -1
  49. package/src/i18n/locales/settings/zh-Hans.po +136 -1
  50. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  51. package/src/i18n/locales/settings/zh-Hant.po +136 -1
  52. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  53. package/src/lib/__tests__/image-dimensions.test.ts +314 -0
  54. package/src/lib/__tests__/telegram-entities.test.ts +180 -0
  55. package/src/lib/__tests__/telegram-pool-webhooks.test.ts +127 -0
  56. package/src/lib/env.ts +45 -0
  57. package/src/lib/ids.ts +3 -0
  58. package/src/lib/image-dimensions.ts +258 -0
  59. package/src/lib/telegram-entities.ts +240 -0
  60. package/src/lib/telegram-pool-webhooks.ts +86 -0
  61. package/src/lib/telegram-settings-status.tsx +109 -0
  62. package/src/lib/telegram.ts +363 -0
  63. package/src/node/runtime.ts +6 -0
  64. package/src/routes/api/__tests__/telegram.test.ts +612 -0
  65. package/src/routes/api/telegram.ts +782 -0
  66. package/src/routes/api/upload-multipart.ts +34 -12
  67. package/src/routes/api/upload.ts +23 -2
  68. package/src/routes/dash/settings.tsx +131 -1
  69. package/src/routes/pages/__tests__/post-page-title.test.ts +70 -0
  70. package/src/routes/pages/page.tsx +3 -2
  71. package/src/runtime/cloudflare.ts +20 -9
  72. package/src/runtime/node.ts +20 -9
  73. package/src/runtime/site.ts +2 -1
  74. package/src/services/__tests__/telegram.test.ts +148 -0
  75. package/src/services/index.ts +9 -0
  76. package/src/services/telegram.ts +613 -0
  77. package/src/services/upload-session.ts +39 -12
  78. package/src/styles/tokens.css +1 -0
  79. package/src/styles/ui.css +117 -38
  80. package/src/types/app-context.ts +6 -0
  81. package/src/types/bindings.ts +3 -0
  82. package/src/types/config.ts +40 -0
  83. package/src/ui/dash/settings/SettingsRootContent.tsx +48 -17
  84. package/src/ui/dash/settings/TelegramContent.tsx +549 -0
  85. package/src/ui/feed/ThreadPreview.tsx +90 -38
  86. package/src/ui/feed/__tests__/thread-preview.test.ts +66 -5
  87. package/src/ui/pages/PostPage.tsx +77 -15
  88. package/dist/app-DLINgGBd.js +0 -6
  89. package/dist/client/_assets/client-BErXNT6k.css +0 -2
  90. package/dist/client/_assets/client-CtAgWT8i.js +0 -274
@@ -0,0 +1,314 @@
1
+ /**
2
+ * Image dimensions parser tests.
3
+ *
4
+ * Each test constructs the minimal valid header bytes for a format and
5
+ * verifies that {@link parseImageDimensions} extracts width/height.
6
+ */
7
+
8
+ import { describe, expect, it } from "vitest";
9
+ import { parseImageDimensions } from "../image-dimensions.js";
10
+
11
+ describe("parseImageDimensions", () => {
12
+ describe("PNG", () => {
13
+ it("parses width and height from the IHDR chunk", () => {
14
+ const bytes = buildPng(1280, 720);
15
+ expect(parseImageDimensions("image/png", bytes)).toEqual({
16
+ width: 1280,
17
+ height: 720,
18
+ });
19
+ });
20
+
21
+ it("returns null when the PNG signature is wrong", () => {
22
+ const bytes = buildPng(10, 10);
23
+ bytes[0] = 0;
24
+ expect(parseImageDimensions("image/png", bytes)).toBeNull();
25
+ });
26
+
27
+ it("returns null when the buffer is too short", () => {
28
+ expect(parseImageDimensions("image/png", new Uint8Array(10))).toBeNull();
29
+ });
30
+ });
31
+
32
+ describe("JPEG", () => {
33
+ it("parses dimensions from the SOF0 marker", () => {
34
+ const bytes = buildJpeg(800, 600);
35
+ expect(parseImageDimensions("image/jpeg", bytes)).toEqual({
36
+ width: 800,
37
+ height: 600,
38
+ });
39
+ });
40
+
41
+ it("skips APP segments before reaching SOF", () => {
42
+ const bytes = buildJpegWithApp(1920, 1080);
43
+ expect(parseImageDimensions("image/jpeg", bytes)).toEqual({
44
+ width: 1920,
45
+ height: 1080,
46
+ });
47
+ });
48
+
49
+ it("returns null when SOI marker is missing", () => {
50
+ const bytes = new Uint8Array([0x00, 0x00, 0xff, 0xc0]);
51
+ expect(parseImageDimensions("image/jpeg", bytes)).toBeNull();
52
+ });
53
+ });
54
+
55
+ describe("GIF", () => {
56
+ it("parses width and height from the logical screen descriptor", () => {
57
+ const bytes = buildGif(640, 480);
58
+ expect(parseImageDimensions("image/gif", bytes)).toEqual({
59
+ width: 640,
60
+ height: 480,
61
+ });
62
+ });
63
+ });
64
+
65
+ describe("WebP", () => {
66
+ it("parses VP8 (lossy) dimensions", () => {
67
+ const bytes = buildWebpVp8(1024, 768);
68
+ expect(parseImageDimensions("image/webp", bytes)).toEqual({
69
+ width: 1024,
70
+ height: 768,
71
+ });
72
+ });
73
+
74
+ it("parses VP8L (lossless) dimensions", () => {
75
+ const bytes = buildWebpVp8L(300, 200);
76
+ expect(parseImageDimensions("image/webp", bytes)).toEqual({
77
+ width: 300,
78
+ height: 200,
79
+ });
80
+ });
81
+
82
+ it("parses VP8X (extended) dimensions", () => {
83
+ const bytes = buildWebpVp8X(4096, 4096);
84
+ expect(parseImageDimensions("image/webp", bytes)).toEqual({
85
+ width: 4096,
86
+ height: 4096,
87
+ });
88
+ });
89
+
90
+ it("returns null when RIFF header is missing", () => {
91
+ const bytes = buildWebpVp8(10, 10);
92
+ bytes[0] = 0;
93
+ expect(parseImageDimensions("image/webp", bytes)).toBeNull();
94
+ });
95
+ });
96
+
97
+ describe("AVIF", () => {
98
+ it("parses dimensions from the ispe box", () => {
99
+ const bytes = buildAvif(2000, 1500);
100
+ expect(parseImageDimensions("image/avif", bytes)).toEqual({
101
+ width: 2000,
102
+ height: 1500,
103
+ });
104
+ });
105
+
106
+ it("accepts AVIF declared via compatible_brands", () => {
107
+ const bytes = buildAvif(500, 500, { majorBrand: "mif1" });
108
+ expect(parseImageDimensions("image/avif", bytes)).toEqual({
109
+ width: 500,
110
+ height: 500,
111
+ });
112
+ });
113
+ });
114
+
115
+ describe("unsupported types", () => {
116
+ it("returns null for SVG", () => {
117
+ const bytes = new TextEncoder().encode(
118
+ '<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"/>',
119
+ );
120
+ expect(parseImageDimensions("image/svg+xml", bytes)).toBeNull();
121
+ });
122
+
123
+ it("returns null for unknown mime types", () => {
124
+ expect(parseImageDimensions("image/bmp", new Uint8Array(64))).toBeNull();
125
+ expect(parseImageDimensions("text/plain", new Uint8Array(64))).toBeNull();
126
+ });
127
+ });
128
+ });
129
+
130
+ // ── Fixture builders ──────────────────────────────────────────────────
131
+
132
+ function buildPng(width: number, height: number): Uint8Array {
133
+ // Signature + IHDR chunk header. Only the first 24 bytes are inspected.
134
+ const bytes = new Uint8Array(24);
135
+ bytes.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
136
+ const view = new DataView(bytes.buffer);
137
+ view.setUint32(8, 13, false); // IHDR chunk length
138
+ bytes.set([0x49, 0x48, 0x44, 0x52], 12); // "IHDR"
139
+ view.setUint32(16, width, false);
140
+ view.setUint32(20, height, false);
141
+ return bytes;
142
+ }
143
+
144
+ function buildJpeg(width: number, height: number): Uint8Array {
145
+ // SOI + SOF0(payload: 8 bytes precision/height/width/components)
146
+ const bytes = new Uint8Array(2 + 2 + 8);
147
+ bytes[0] = 0xff;
148
+ bytes[1] = 0xd8;
149
+ bytes[2] = 0xff;
150
+ bytes[3] = 0xc0; // SOF0
151
+ bytes[4] = 0x00;
152
+ bytes[5] = 0x08; // segment length
153
+ bytes[6] = 0x08; // precision (8-bit)
154
+ bytes[7] = (height >> 8) & 0xff;
155
+ bytes[8] = height & 0xff;
156
+ bytes[9] = (width >> 8) & 0xff;
157
+ bytes[10] = width & 0xff;
158
+ bytes[11] = 0x03; // components
159
+ return bytes;
160
+ }
161
+
162
+ function buildJpegWithApp(width: number, height: number): Uint8Array {
163
+ // SOI + APP0 (length 16) + SOF0
164
+ const appPayload = 14; // 16-byte segment - 2 length bytes
165
+ const total = 2 + 2 + 2 + appPayload + 2 + 8;
166
+ const bytes = new Uint8Array(total);
167
+ let i = 0;
168
+ bytes[i++] = 0xff;
169
+ bytes[i++] = 0xd8;
170
+ bytes[i++] = 0xff;
171
+ bytes[i++] = 0xe0; // APP0
172
+ bytes[i++] = 0x00;
173
+ bytes[i++] = 0x10; // length 16
174
+ i += appPayload;
175
+ bytes[i++] = 0xff;
176
+ bytes[i++] = 0xc2; // SOF2 (progressive — also recognized)
177
+ bytes[i++] = 0x00;
178
+ bytes[i++] = 0x08;
179
+ bytes[i++] = 0x08;
180
+ bytes[i++] = (height >> 8) & 0xff;
181
+ bytes[i++] = height & 0xff;
182
+ bytes[i++] = (width >> 8) & 0xff;
183
+ bytes[i++] = width & 0xff;
184
+ bytes[i] = 0x03;
185
+ return bytes;
186
+ }
187
+
188
+ function buildGif(width: number, height: number): Uint8Array {
189
+ const bytes = new Uint8Array(10);
190
+ bytes.set([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]); // GIF89a
191
+ bytes[6] = width & 0xff;
192
+ bytes[7] = (width >> 8) & 0xff;
193
+ bytes[8] = height & 0xff;
194
+ bytes[9] = (height >> 8) & 0xff;
195
+ return bytes;
196
+ }
197
+
198
+ function buildWebpHeader(chunk: string, payloadLen: number): Uint8Array {
199
+ const totalChunk = 8 + payloadLen;
200
+ const bytes = new Uint8Array(12 + totalChunk);
201
+ bytes.set([0x52, 0x49, 0x46, 0x46]); // RIFF
202
+ const view = new DataView(bytes.buffer);
203
+ view.setUint32(4, 4 + totalChunk, true); // file size minus 8
204
+ bytes.set([0x57, 0x45, 0x42, 0x50], 8); // WEBP
205
+ for (let i = 0; i < 4; i += 1) {
206
+ bytes[12 + i] = chunk.charCodeAt(i);
207
+ }
208
+ view.setUint32(16, payloadLen, true);
209
+ return bytes;
210
+ }
211
+
212
+ function buildWebpVp8(width: number, height: number): Uint8Array {
213
+ // VP8 chunk: 10-byte frame header + signature 0x9d 0x01 0x2a + width/height
214
+ const payloadLen = 16;
215
+ const bytes = buildWebpHeader("VP8 ", payloadLen);
216
+ bytes[23] = 0x9d;
217
+ bytes[24] = 0x01;
218
+ bytes[25] = 0x2a;
219
+ bytes[26] = width & 0xff;
220
+ bytes[27] = (width >> 8) & 0x3f;
221
+ bytes[28] = height & 0xff;
222
+ bytes[29] = (height >> 8) & 0x3f;
223
+ return bytes;
224
+ }
225
+
226
+ function buildWebpVp8L(width: number, height: number): Uint8Array {
227
+ const payloadLen = 5;
228
+ const bytes = buildWebpHeader("VP8L", payloadLen);
229
+ bytes[20] = 0x2f; // signature
230
+ const w = width - 1;
231
+ const h = height - 1;
232
+ bytes[21] = w & 0xff;
233
+ bytes[22] = ((w >> 8) & 0x3f) | ((h & 0x03) << 6);
234
+ bytes[23] = (h >> 2) & 0xff;
235
+ bytes[24] = (h >> 10) & 0x0f;
236
+ return bytes;
237
+ }
238
+
239
+ function buildWebpVp8X(width: number, height: number): Uint8Array {
240
+ const payloadLen = 10;
241
+ const bytes = buildWebpHeader("VP8X", payloadLen);
242
+ // bytes[20..23] flags + reserved
243
+ const w = width - 1;
244
+ const h = height - 1;
245
+ bytes[24] = w & 0xff;
246
+ bytes[25] = (w >> 8) & 0xff;
247
+ bytes[26] = (w >> 16) & 0xff;
248
+ bytes[27] = h & 0xff;
249
+ bytes[28] = (h >> 8) & 0xff;
250
+ bytes[29] = (h >> 16) & 0xff;
251
+ return bytes;
252
+ }
253
+
254
+ interface AvifFixtureOptions {
255
+ majorBrand?: string;
256
+ }
257
+
258
+ function buildAvif(
259
+ width: number,
260
+ height: number,
261
+ opts: AvifFixtureOptions = {},
262
+ ): Uint8Array {
263
+ const majorBrand = opts.majorBrand ?? "avif";
264
+ const ispe = makeBox(
265
+ "ispe",
266
+ concat([new Uint8Array(4), u32BE(width), u32BE(height)]),
267
+ );
268
+ const ipco = makeBox("ipco", ispe);
269
+ const iprp = makeBox("iprp", ipco);
270
+ const meta = makeBox(
271
+ "meta",
272
+ concat([new Uint8Array(4), iprp]), // FullBox version/flags + iprp
273
+ );
274
+ // ftyp: major_brand + minor_version + compatible_brands ("avif")
275
+ const ftypPayload = concat([
276
+ asciiBytes(majorBrand),
277
+ u32BE(0),
278
+ asciiBytes("avif"),
279
+ ]);
280
+ const ftyp = makeBox("ftyp", ftypPayload);
281
+ return concat([ftyp, meta]);
282
+ }
283
+
284
+ function makeBox(type: string, payload: Uint8Array): Uint8Array {
285
+ const size = 8 + payload.length;
286
+ const bytes = new Uint8Array(size);
287
+ new DataView(bytes.buffer).setUint32(0, size, false);
288
+ bytes.set(asciiBytes(type), 4);
289
+ bytes.set(payload, 8);
290
+ return bytes;
291
+ }
292
+
293
+ function asciiBytes(s: string): Uint8Array {
294
+ const bytes = new Uint8Array(s.length);
295
+ for (let i = 0; i < s.length; i += 1) bytes[i] = s.charCodeAt(i);
296
+ return bytes;
297
+ }
298
+
299
+ function u32BE(value: number): Uint8Array {
300
+ const bytes = new Uint8Array(4);
301
+ new DataView(bytes.buffer).setUint32(0, value, false);
302
+ return bytes;
303
+ }
304
+
305
+ function concat(chunks: Uint8Array[]): Uint8Array {
306
+ const total = chunks.reduce((sum, c) => sum + c.length, 0);
307
+ const out = new Uint8Array(total);
308
+ let offset = 0;
309
+ for (const chunk of chunks) {
310
+ out.set(chunk, offset);
311
+ offset += chunk.length;
312
+ }
313
+ return out;
314
+ }
@@ -0,0 +1,180 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { entitiesToMarkdown } from "../telegram-entities.js";
3
+ import type { TelegramMessageEntity } from "../telegram.js";
4
+
5
+ function ents(
6
+ ...entries: Array<
7
+ Partial<TelegramMessageEntity> & {
8
+ type: string;
9
+ offset: number;
10
+ length: number;
11
+ }
12
+ >
13
+ ): TelegramMessageEntity[] {
14
+ return entries as TelegramMessageEntity[];
15
+ }
16
+
17
+ describe("entitiesToMarkdown", () => {
18
+ it("returns text unchanged when entities is empty or undefined", () => {
19
+ expect(entitiesToMarkdown("hello", [])).toBe("hello");
20
+ expect(entitiesToMarkdown("hello", undefined)).toBe("hello");
21
+ });
22
+
23
+ it("preserves user-typed markdown that sits outside any entity", () => {
24
+ // No entities → byte-for-byte passthrough. This is the case where the
25
+ // user typed literal markdown into Telegram instead of using its
26
+ // formatting menu.
27
+ expect(entitiesToMarkdown("**already bold** _italic_", [])).toBe(
28
+ "**already bold** _italic_",
29
+ );
30
+ });
31
+
32
+ it("wraps bold, italic, and strikethrough spans", () => {
33
+ expect(
34
+ entitiesToMarkdown(
35
+ "bold italic strike",
36
+ ents(
37
+ { type: "bold", offset: 0, length: 4 },
38
+ { type: "italic", offset: 5, length: 6 },
39
+ { type: "strikethrough", offset: 12, length: 6 },
40
+ ),
41
+ ),
42
+ ).toBe("**bold** *italic* ~~strike~~");
43
+ });
44
+
45
+ it("wraps text_link entities with the url", () => {
46
+ expect(
47
+ entitiesToMarkdown(
48
+ "see Jant for details",
49
+ ents({
50
+ type: "text_link",
51
+ offset: 4,
52
+ length: 4,
53
+ url: "https://jant.example/",
54
+ }),
55
+ ),
56
+ ).toBe("see [Jant](https://jant.example/) for details");
57
+ });
58
+
59
+ it("escapes parentheses in link URLs", () => {
60
+ expect(
61
+ entitiesToMarkdown(
62
+ "ref",
63
+ ents({
64
+ type: "text_link",
65
+ offset: 0,
66
+ length: 3,
67
+ url: "https://en.wikipedia.org/wiki/Foo_(bar)",
68
+ }),
69
+ ),
70
+ ).toBe("[ref](https://en.wikipedia.org/wiki/Foo_\\(bar\\))");
71
+ });
72
+
73
+ it("uses the shortest non-colliding fence for inline code with backticks", () => {
74
+ // Content has a single backtick, so a double-backtick fence is required.
75
+ expect(
76
+ entitiesToMarkdown(
77
+ "use `code` here",
78
+ ents({ type: "code", offset: 4, length: 6 }),
79
+ ),
80
+ ).toBe("use `` `code` `` here");
81
+ });
82
+
83
+ it("emits fenced code blocks with the entity language", () => {
84
+ expect(
85
+ entitiesToMarkdown(
86
+ "print(1)",
87
+ ents({ type: "pre", offset: 0, length: 8, language: "python" }),
88
+ ),
89
+ ).toBe("```python\nprint(1)\n```");
90
+ });
91
+
92
+ it("escapes markdown delimiters that appear inside a styled span", () => {
93
+ // Without escaping, the inner `_underscore_` would render as italic
94
+ // when the post body is parsed as markdown, even though the user only
95
+ // asked for bold.
96
+ expect(
97
+ entitiesToMarkdown(
98
+ "a _b_ c",
99
+ ents({ type: "bold", offset: 0, length: 7 }),
100
+ ),
101
+ ).toBe("**a \\_b\\_ c**");
102
+ });
103
+
104
+ it("handles nested entities (italic inside bold)", () => {
105
+ // "bold and italic" — bold covers the whole span, italic covers "italic"
106
+ expect(
107
+ entitiesToMarkdown(
108
+ "bold and italic",
109
+ ents(
110
+ { type: "bold", offset: 0, length: 15 },
111
+ { type: "italic", offset: 9, length: 6 },
112
+ ),
113
+ ),
114
+ ).toBe("**bold and *italic***");
115
+ });
116
+
117
+ it("renders a bold link as a bold-wrapped markdown link", () => {
118
+ expect(
119
+ entitiesToMarkdown(
120
+ "click here",
121
+ ents(
122
+ { type: "bold", offset: 0, length: 10 },
123
+ {
124
+ type: "text_link",
125
+ offset: 6,
126
+ length: 4,
127
+ url: "https://example.com/",
128
+ },
129
+ ),
130
+ ),
131
+ ).toBe("**click [here](https://example.com/)**");
132
+ });
133
+
134
+ it("leaves auto-detected url/mention/hashtag entities as plain text", () => {
135
+ // Telegram tags these automatically; markdown will auto-link the URL.
136
+ expect(
137
+ entitiesToMarkdown(
138
+ "visit https://jant.example/ @alice #news",
139
+ ents(
140
+ { type: "url", offset: 6, length: 21 },
141
+ { type: "mention", offset: 28, length: 6 },
142
+ { type: "hashtag", offset: 35, length: 5 },
143
+ ),
144
+ ),
145
+ ).toBe("visit https://jant.example/ @alice #news");
146
+ });
147
+
148
+ it("renders blockquote entities with `> ` per line", () => {
149
+ expect(
150
+ entitiesToMarkdown(
151
+ "line one\nline two",
152
+ ents({ type: "blockquote", offset: 0, length: 17 }),
153
+ ),
154
+ ).toBe("> line one\n> line two");
155
+ });
156
+
157
+ it("uses UTF-16 offsets so surrogate-pair emoji align correctly", () => {
158
+ // The emoji is one Telegram "character" but two JS code units. Telegram
159
+ // sends offset 3 / length 4 for "bold" — that lands on indices 3..7 in
160
+ // the JS string, which is exactly what slice expects.
161
+ const text = "👋 hi bold tail";
162
+ // wave emoji is two UTF-16 units, so the string is 15 long, not 14.
163
+ expect(text.length).toBe(15);
164
+ expect(text.slice(6, 10)).toBe("bold");
165
+ expect(
166
+ entitiesToMarkdown(text, ents({ type: "bold", offset: 6, length: 4 })),
167
+ ).toBe("👋 hi **bold** tail");
168
+ });
169
+
170
+ it("does not escape markdown chars between top-level entities", () => {
171
+ // Bold span at the end; the **literal asterisks** the user typed before
172
+ // it must pass through unchanged.
173
+ expect(
174
+ entitiesToMarkdown(
175
+ "**typed** styled",
176
+ ents({ type: "bold", offset: 10, length: 6 }),
177
+ ),
178
+ ).toBe("**typed** **styled**");
179
+ });
180
+ });
@@ -0,0 +1,127 @@
1
+ import { describe, it, expect, afterEach, vi } from "vitest";
2
+ import { registerTelegramPoolWebhooks } from "../telegram-pool-webhooks.js";
3
+
4
+ const BOT_ID = "111111";
5
+ const TOKEN = `${BOT_ID}:AA-test-token`;
6
+ const BASE_URL = "https://cloud.example";
7
+ const SECRET = "shared-webhook-secret";
8
+ const EXPECTED_URL = `${BASE_URL}/api/telegram/webhook/${BOT_ID}`;
9
+
10
+ interface Call {
11
+ method: string;
12
+ body: Record<string, unknown>;
13
+ }
14
+
15
+ /** Mocks the Telegram API; `currentWebhookUrl` is what getWebhookInfo reports. */
16
+ function mockFetch(currentWebhookUrl: string): Call[] {
17
+ const calls: Call[] = [];
18
+ vi.stubGlobal(
19
+ "fetch",
20
+ vi.fn(async (url: string, init?: { body?: unknown }) => {
21
+ const method = String(url).split("/").pop() ?? "";
22
+ const body = init?.body ? JSON.parse(String(init.body)) : {};
23
+ calls.push({ method, body });
24
+ const result =
25
+ method === "getWebhookInfo" ? { url: currentWebhookUrl } : true;
26
+ return new Response(JSON.stringify({ ok: true, result }), {
27
+ headers: { "content-type": "application/json" },
28
+ });
29
+ }),
30
+ );
31
+ return calls;
32
+ }
33
+
34
+ describe("registerTelegramPoolWebhooks", () => {
35
+ afterEach(() => {
36
+ vi.unstubAllGlobals();
37
+ vi.restoreAllMocks();
38
+ });
39
+
40
+ it("does nothing when no pool is configured", async () => {
41
+ const calls = mockFetch("");
42
+ await registerTelegramPoolWebhooks({});
43
+ expect(calls).toHaveLength(0);
44
+ });
45
+
46
+ it("does nothing when not a hosted deployment", async () => {
47
+ const calls = mockFetch("");
48
+ await registerTelegramPoolWebhooks({
49
+ TELEGRAM_BOT_TOKENS: TOKEN,
50
+ TELEGRAM_WEBHOOK_SECRET: SECRET,
51
+ });
52
+ expect(calls).toHaveLength(0);
53
+ });
54
+
55
+ it("skips registration when the shared secret is missing", async () => {
56
+ const calls = mockFetch("");
57
+ vi.spyOn(console, "error").mockImplementation(() => {});
58
+ await registerTelegramPoolWebhooks({
59
+ TELEGRAM_BOT_TOKENS: TOKEN,
60
+ HOSTED_CONTROL_PLANE_BASE_URL: BASE_URL,
61
+ });
62
+ expect(calls).toHaveLength(0);
63
+ });
64
+
65
+ it("registers a webhook that is not yet set", async () => {
66
+ const calls = mockFetch("");
67
+ vi.spyOn(console, "log").mockImplementation(() => {});
68
+ await registerTelegramPoolWebhooks({
69
+ TELEGRAM_BOT_TOKENS: TOKEN,
70
+ TELEGRAM_WEBHOOK_SECRET: SECRET,
71
+ HOSTED_CONTROL_PLANE_BASE_URL: BASE_URL,
72
+ });
73
+ expect(calls.map((c) => c.method)).toEqual([
74
+ "getWebhookInfo",
75
+ "setWebhook",
76
+ "setMyCommands",
77
+ ]);
78
+ expect(calls[1]?.body).toMatchObject({
79
+ url: EXPECTED_URL,
80
+ secret_token: SECRET,
81
+ });
82
+ });
83
+
84
+ it("skips setWebhook when already pointed at the right URL but still syncs commands", async () => {
85
+ const calls = mockFetch(EXPECTED_URL);
86
+ await registerTelegramPoolWebhooks({
87
+ TELEGRAM_BOT_TOKENS: TOKEN,
88
+ TELEGRAM_WEBHOOK_SECRET: SECRET,
89
+ HOSTED_CONTROL_PLANE_BASE_URL: BASE_URL,
90
+ });
91
+ expect(calls.map((c) => c.method)).toEqual([
92
+ "getWebhookInfo",
93
+ "setMyCommands",
94
+ ]);
95
+ });
96
+
97
+ it("re-registers when the webhook points elsewhere", async () => {
98
+ const calls = mockFetch(
99
+ "https://stale.example/api/telegram/webhook/111111",
100
+ );
101
+ vi.spyOn(console, "log").mockImplementation(() => {});
102
+ await registerTelegramPoolWebhooks({
103
+ TELEGRAM_BOT_TOKENS: TOKEN,
104
+ TELEGRAM_WEBHOOK_SECRET: SECRET,
105
+ HOSTED_CONTROL_PLANE_BASE_URL: BASE_URL,
106
+ });
107
+ expect(calls.map((c) => c.method)).toEqual([
108
+ "getWebhookInfo",
109
+ "setWebhook",
110
+ "setMyCommands",
111
+ ]);
112
+ });
113
+
114
+ it("registers /start in the command list so Telegram autocomplete works", async () => {
115
+ const calls = mockFetch("");
116
+ vi.spyOn(console, "log").mockImplementation(() => {});
117
+ await registerTelegramPoolWebhooks({
118
+ TELEGRAM_BOT_TOKENS: TOKEN,
119
+ TELEGRAM_WEBHOOK_SECRET: SECRET,
120
+ HOSTED_CONTROL_PLANE_BASE_URL: BASE_URL,
121
+ });
122
+ const setCommands = calls.find((c) => c.method === "setMyCommands");
123
+ expect(setCommands?.body).toMatchObject({
124
+ commands: [{ command: "start" }],
125
+ });
126
+ });
127
+ });
package/src/lib/env.ts CHANGED
@@ -301,6 +301,51 @@ export function shouldTrustProxy(env: EnvSource): boolean {
301
301
  return getEnvString(env, "TRUST_PROXY") === "true";
302
302
  }
303
303
 
304
+ /** A single platform-managed Telegram bot from `TELEGRAM_BOT_TOKENS`. */
305
+ export interface TelegramPoolBot {
306
+ /** Numeric bot id — the part before `:` in the token. */
307
+ botId: string;
308
+ /** Full `<bot_id>:<secret>` bot token. */
309
+ token: string;
310
+ }
311
+
312
+ /**
313
+ * Parses the platform-managed Telegram bot pool from `TELEGRAM_BOT_TOKENS`.
314
+ *
315
+ * The env value is a comma-separated list of `<bot_id>:<secret>` tokens. The
316
+ * first entry is the public-facing bot (`bot1`); the rest are surfaced only
317
+ * contextually when a binding code reaches an already-bound bot slot.
318
+ *
319
+ * @param env - Runtime environment bindings
320
+ * @returns Parsed pool bots in declared order; empty when unset/invalid
321
+ * @example
322
+ * getTelegramBotPool({ TELEGRAM_BOT_TOKENS: "111:aaa,222:bbb" });
323
+ */
324
+ export function getTelegramBotPool(env: EnvSource): TelegramPoolBot[] {
325
+ const raw = getEnvString(env, "TELEGRAM_BOT_TOKENS");
326
+ if (!raw) {
327
+ return [];
328
+ }
329
+
330
+ return raw
331
+ .split(",")
332
+ .map((entry) => entry.trim())
333
+ .filter((entry) => entry.length > 0)
334
+ .map((token) => {
335
+ const botId = token.split(":")[0]?.trim() ?? "";
336
+ return { botId, token };
337
+ })
338
+ .filter((bot) => bot.botId.length > 0);
339
+ }
340
+
341
+ /**
342
+ * Returns the shared `secret_token` used when registering every pool bot's
343
+ * webhook. Only meaningful alongside `TELEGRAM_BOT_TOKENS`.
344
+ */
345
+ export function getTelegramWebhookSecret(env: EnvSource): string | undefined {
346
+ return getEnvString(env, "TELEGRAM_WEBHOOK_SECRET");
347
+ }
348
+
304
349
  export function shouldUseSecureCookies(
305
350
  env: EnvSource,
306
351
  publicRequestUrl: string,
package/src/lib/ids.ts CHANGED
@@ -16,6 +16,9 @@ export const ID_PREFIX = {
16
16
  session: "ses",
17
17
  account: "acc",
18
18
  verification: "vrf",
19
+ telegramBinding: "tgb",
20
+ telegramBindingCode: "tgc",
21
+ telegramMediaGroupItem: "tmg",
19
22
  } as const;
20
23
 
21
24
  export type IdPrefix = (typeof ID_PREFIX)[keyof typeof ID_PREFIX];