@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.
- package/bin/commands/telegram/register-webhooks.js +93 -0
- package/dist/app-CMSW_AYG.js +6 -0
- package/dist/{app-BtNdUAqz.js → app-DYQdDMs8.js} +2249 -387
- package/dist/client/.vite/manifest.json +3 -3
- package/dist/client/_assets/client-BRTh1ii1.js +274 -0
- package/dist/client/_assets/client-CO4b-RKd.css +2 -0
- package/dist/client/_assets/{client-auth-DJ_5wx9N.js → client-auth-CSNcTJwP.js} +81 -81
- package/dist/{env-CgaH9Mut.js → env-C7e2Nlnt.js} +30 -1
- package/dist/{export-CR9Megtb.js → export-Bbn86HmS.js} +1 -1
- package/dist/{github-sync-DYZq9rQp.js → github-sync-CBQPRZ8H.js} +1 -1
- package/dist/{github-sync-8Vv06aCr.js → github-sync-dXsiZa_e.js} +2 -2
- package/dist/index.js +4 -4
- package/dist/node.js +61 -5
- package/package.json +2 -1
- package/src/__tests__/helpers/app.ts +15 -2
- package/src/app.tsx +3 -0
- package/src/client/thread-context.ts +146 -2
- package/src/client/tiptap/__tests__/link-toolbar.test.ts +1 -1
- package/src/client/tiptap/bubble-menu.ts +1 -16
- package/src/client/tiptap/extensions.ts +2 -6
- package/src/client/tiptap/link-toolbar.ts +0 -21
- package/src/client/tiptap/toolbar-mode.ts +0 -43
- package/src/db/migrations/0022_old_gressill.sql +24 -0
- package/src/db/migrations/0023_broad_terror.sql +20 -0
- package/src/db/migrations/0024_red_the_twelve.sql +3 -0
- package/src/db/migrations/0025_exotic_wendell_rand.sql +1 -0
- package/src/db/migrations/meta/0022_snapshot.json +2267 -0
- package/src/db/migrations/meta/0023_snapshot.json +2396 -0
- package/src/db/migrations/meta/0024_snapshot.json +2417 -0
- package/src/db/migrations/meta/0025_snapshot.json +2424 -0
- package/src/db/migrations/meta/_journal.json +28 -0
- package/src/db/migrations/pg/0020_bizarre_smasher.sql +24 -0
- package/src/db/migrations/pg/0021_sharp_puppet_master.sql +20 -0
- package/src/db/migrations/pg/0022_blushing_blue_shield.sql +3 -0
- package/src/db/migrations/pg/0023_organic_zemo.sql +1 -0
- package/src/db/migrations/pg/meta/0020_snapshot.json +2904 -0
- package/src/db/migrations/pg/meta/0021_snapshot.json +3060 -0
- package/src/db/migrations/pg/meta/0022_snapshot.json +3078 -0
- package/src/db/migrations/pg/meta/0023_snapshot.json +3084 -0
- package/src/db/migrations/pg/meta/_journal.json +28 -0
- package/src/db/pg/schema.ts +82 -0
- package/src/db/schema.ts +90 -0
- package/src/i18n/coverage.generated.ts +2 -2
- package/src/i18n/locales/public/en.po +8 -0
- package/src/i18n/locales/public/zh-Hans.po +8 -0
- package/src/i18n/locales/public/zh-Hant.po +8 -0
- package/src/i18n/locales/settings/en.po +135 -0
- package/src/i18n/locales/settings/en.ts +1 -1
- package/src/i18n/locales/settings/zh-Hans.po +136 -1
- package/src/i18n/locales/settings/zh-Hans.ts +1 -1
- package/src/i18n/locales/settings/zh-Hant.po +136 -1
- package/src/i18n/locales/settings/zh-Hant.ts +1 -1
- package/src/lib/__tests__/image-dimensions.test.ts +314 -0
- package/src/lib/__tests__/telegram-entities.test.ts +180 -0
- package/src/lib/__tests__/telegram-pool-webhooks.test.ts +127 -0
- package/src/lib/env.ts +45 -0
- package/src/lib/ids.ts +3 -0
- package/src/lib/image-dimensions.ts +258 -0
- package/src/lib/telegram-entities.ts +240 -0
- package/src/lib/telegram-pool-webhooks.ts +86 -0
- package/src/lib/telegram-settings-status.tsx +109 -0
- package/src/lib/telegram.ts +363 -0
- package/src/node/runtime.ts +6 -0
- package/src/routes/api/__tests__/telegram.test.ts +612 -0
- package/src/routes/api/telegram.ts +782 -0
- package/src/routes/api/upload-multipart.ts +34 -12
- package/src/routes/api/upload.ts +23 -2
- package/src/routes/dash/settings.tsx +131 -1
- package/src/routes/pages/__tests__/post-page-title.test.ts +70 -0
- package/src/routes/pages/page.tsx +3 -2
- package/src/runtime/cloudflare.ts +20 -9
- package/src/runtime/node.ts +20 -9
- package/src/runtime/site.ts +2 -1
- package/src/services/__tests__/telegram.test.ts +148 -0
- package/src/services/index.ts +9 -0
- package/src/services/telegram.ts +613 -0
- package/src/services/upload-session.ts +39 -12
- package/src/styles/tokens.css +1 -0
- package/src/styles/ui.css +117 -38
- package/src/types/app-context.ts +6 -0
- package/src/types/bindings.ts +3 -0
- package/src/types/config.ts +40 -0
- package/src/ui/dash/settings/SettingsRootContent.tsx +48 -17
- package/src/ui/dash/settings/TelegramContent.tsx +549 -0
- package/src/ui/feed/ThreadPreview.tsx +90 -38
- package/src/ui/feed/__tests__/thread-preview.test.ts +66 -5
- package/src/ui/pages/PostPage.tsx +77 -15
- package/dist/app-DLINgGBd.js +0 -6
- package/dist/client/_assets/client-BErXNT6k.css +0 -2
- 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];
|