@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,613 @@
1
+ /**
2
+ * Telegram Service
3
+ *
4
+ * Owns the two Telegram binding tables:
5
+ *
6
+ * - `telegram_pending_binding` — short-lived, single-use binding codes. One
7
+ * per site; regenerating replaces the previous code.
8
+ * - `telegram_binding` — active links between a Telegram account and a site.
9
+ * One per site, and unique per `(bot_id, telegram_user_id)` so a Telegram
10
+ * account uses a distinct pool bot for each site it posts to.
11
+ *
12
+ * Code-lookup and binding-upsert methods are deliberately cross-site: the
13
+ * webhook handler runs without a host-resolved site (hosted mode forwards the
14
+ * webhook through the control plane), so it resolves the target site from the
15
+ * binding tables instead. Site-scoped methods (`getStatus`, `generateCode`,
16
+ * `disconnect`, and the bring-your-own-bot token management) operate on the
17
+ * site this service instance was created for.
18
+ */
19
+
20
+ import { and, eq } from "drizzle-orm";
21
+ import type { Database } from "../db/index.js";
22
+ import {
23
+ sqliteSchemaBundle,
24
+ type DatabaseSchema,
25
+ } from "../db/schema-bundle.js";
26
+ import { createEntityId } from "../lib/ids.js";
27
+ import { generateRandomId } from "../lib/nanoid.js";
28
+ import { now } from "../lib/time.js";
29
+ import {
30
+ deleteWebhook,
31
+ downloadFile,
32
+ getFile,
33
+ getMe,
34
+ parseBotId,
35
+ setMyCommands,
36
+ setWebhook,
37
+ } from "../lib/telegram.js";
38
+ import { generateStorageKey, getPosterStorageKey } from "../lib/upload.js";
39
+ import type { StorageDriver } from "../lib/storage.js";
40
+ import type { Media, MediaKind } from "../types.js";
41
+ import { ValidationError } from "../lib/errors.js";
42
+ import type { MediaService } from "./media.js";
43
+
44
+ /** How long a freshly generated binding code stays valid (seconds). */
45
+ const BINDING_CODE_TTL = 30 * 60;
46
+ const BINDING_CODE_LENGTH = 12;
47
+
48
+ export interface TelegramBinding {
49
+ id: string;
50
+ siteId: string;
51
+ botId: string;
52
+ telegramUserId: string;
53
+ telegramUsername: string | null;
54
+ lastUpdateId: number | null;
55
+ boundAt: number;
56
+ }
57
+
58
+ /** Kind of media held in a buffered album item. */
59
+ export type TelegramMediaGroupKind = "image" | "video" | "document";
60
+
61
+ /** A single buffered album item awaiting the rest of its group. */
62
+ export interface TelegramMediaGroupItem {
63
+ id: string;
64
+ siteId: string;
65
+ botId: string;
66
+ telegramUserId: string;
67
+ mediaGroupId: string;
68
+ chatId: number;
69
+ messageId: number;
70
+ updateId: number;
71
+ fileId: string;
72
+ mediaKind: TelegramMediaGroupKind;
73
+ mimeType: string | null;
74
+ originalName: string | null;
75
+ captionMarkdown: string | null;
76
+ /** Photo/video pixel dimensions from the original Telegram message. */
77
+ width: number | null;
78
+ height: number | null;
79
+ /** Video duration in seconds, when applicable. */
80
+ durationSeconds: number | null;
81
+ /** Telegram thumbnail `file_id` for poster extraction at flush time. */
82
+ posterFileId: string | null;
83
+ createdAt: number;
84
+ }
85
+
86
+ export interface IngestTelegramMediaInput {
87
+ /** Bot token used to fetch the file. */
88
+ botToken: string;
89
+ /** Identifier returned by Telegram for the file to download. */
90
+ fileId: string;
91
+ /** Human-readable name to record on the media row. */
92
+ originalName: string;
93
+ /** Effective MIME type; `image/jpeg` for compressed photos, falls back to `application/octet-stream`. */
94
+ mimeType: string;
95
+ /** Coarse classification for filtering and rendering. */
96
+ mediaKind: MediaKind;
97
+ /**
98
+ * Optional pixel dimensions. Telegram always supplies these for photos and
99
+ * videos, so passing them through lets timeline thumbnails reserve the
100
+ * right aspect ratio instead of falling back to a default box.
101
+ */
102
+ width?: number;
103
+ height?: number;
104
+ /** Optional video duration in seconds. */
105
+ durationSeconds?: number;
106
+ /**
107
+ * Optional Telegram `thumbnail.file_id` to download alongside the main file
108
+ * and store as the media row's `posterKey`. Used for videos and previewable
109
+ * documents so timeline thumbnails have a static frame to render before the
110
+ * full asset loads.
111
+ */
112
+ posterFileId?: string;
113
+ }
114
+
115
+ export interface IngestTelegramMediaDeps {
116
+ storage: StorageDriver;
117
+ storageDriver: string;
118
+ maxFileSizeMB: number;
119
+ media: MediaService;
120
+ }
121
+
122
+ export interface BufferAlbumItemInput {
123
+ siteId: string;
124
+ botId: string;
125
+ telegramUserId: string;
126
+ mediaGroupId: string;
127
+ chatId: number;
128
+ messageId: number;
129
+ updateId: number;
130
+ fileId: string;
131
+ mediaKind: TelegramMediaGroupKind;
132
+ mimeType: string | null;
133
+ originalName: string | null;
134
+ captionMarkdown: string | null;
135
+ width: number | null;
136
+ height: number | null;
137
+ durationSeconds: number | null;
138
+ posterFileId: string | null;
139
+ }
140
+
141
+ /** Bring-your-own-bot config stored per site (single-site, no env pool). */
142
+ export interface TelegramUserBot {
143
+ botId: string;
144
+ username: string;
145
+ }
146
+
147
+ export interface TelegramStatus {
148
+ /** Active binding for this site, or null when nothing is connected. */
149
+ binding: TelegramBinding | null;
150
+ /**
151
+ * The site's own bot, when the operator pasted a token in the settings
152
+ * page. Null in env-managed-pool deployments (the route hides the token
153
+ * field there) and before a token has been saved.
154
+ */
155
+ userBot: TelegramUserBot | null;
156
+ }
157
+
158
+ export interface TelegramService {
159
+ /** Status for the current site — drives the settings page. */
160
+ getStatus(): Promise<TelegramStatus>;
161
+ /**
162
+ * Return the current site's pending binding code, creating one when none
163
+ * exists or the existing one has expired. Stable across page loads.
164
+ */
165
+ getOrCreateCode(): Promise<string>;
166
+ /**
167
+ * Replace the current site's pending binding code with a fresh one.
168
+ *
169
+ * @returns The new code; valid for 30 minutes and single-use.
170
+ */
171
+ generateCode(): Promise<string>;
172
+ /** Remove the current site's active binding. */
173
+ disconnect(): Promise<void>;
174
+ /**
175
+ * Resolve a pending binding code to its site.
176
+ *
177
+ * @param code - Code sent to the bot via `/start <code>`
178
+ * @returns The owning site id, or null when unknown or expired.
179
+ */
180
+ resolvePendingCode(code: string): Promise<{ siteId: string } | null>;
181
+ /**
182
+ * Find the active binding for a `(botId, telegramUserId)` pair, across all
183
+ * sites.
184
+ */
185
+ findBindingByUser(
186
+ botId: string,
187
+ telegramUserId: string,
188
+ ): Promise<TelegramBinding | null>;
189
+ /**
190
+ * Bind a Telegram account to a site (or move an existing binding to it).
191
+ *
192
+ * Last-write-wins: any prior binding for the same `(botId, user)` and any
193
+ * prior binding for the target site are dropped first, then a fresh row is
194
+ * inserted. The site's pending code is consumed.
195
+ */
196
+ bindAccount(input: {
197
+ siteId: string;
198
+ botId: string;
199
+ telegramUserId: string;
200
+ telegramUsername: string | null;
201
+ }): Promise<TelegramBinding>;
202
+ /** Record the latest processed `update_id` for retry de-duplication. */
203
+ markUpdateProcessed(bindingId: string, updateId: number): Promise<void>;
204
+ /**
205
+ * Bring-your-own-bot: validate a pasted token, register its webhook, and
206
+ * persist it for this site. Used only when no env bot pool is configured.
207
+ *
208
+ * @param token - Full `<bot_id>:<secret>` bot token
209
+ * @param webhookBaseUrl - Public origin (+ path prefix) of this site
210
+ */
211
+ connectUserBot(token: string, webhookBaseUrl: string): Promise<void>;
212
+ /** Bring-your-own-bot: delete the webhook and clear the stored token. */
213
+ removeUserBot(): Promise<void>;
214
+ /**
215
+ * Buffer one album item so the rest of its `media_group_id` can join it
216
+ * before the post is published. Idempotent on `(botId, mediaGroupId,
217
+ * messageId)` — duplicate Telegram retries are ignored.
218
+ */
219
+ bufferAlbumItem(input: BufferAlbumItemInput): Promise<void>;
220
+ /**
221
+ * Atomically remove every buffered row for `(botId, mediaGroupId)`. The
222
+ * caller that wins the `DELETE ... RETURNING` race owns the group and
223
+ * publishes the post; concurrent waking handlers see an empty result and
224
+ * exit silently. Returns rows ordered by `messageId` so attachment order
225
+ * matches the order the user typed in Telegram.
226
+ */
227
+ claimAlbumGroup(
228
+ botId: string,
229
+ mediaGroupId: string,
230
+ ): Promise<TelegramMediaGroupItem[]>;
231
+ /**
232
+ * Download a Telegram-hosted file into Jant's media storage and create the
233
+ * corresponding `media` row. The caller is responsible for attaching the
234
+ * returned media to a post.
235
+ *
236
+ * Throws `ValidationError` when the file exceeds `deps.maxFileSizeMB`, and
237
+ * propagates any `TelegramApiError` from `getFile`/`downloadFile`.
238
+ */
239
+ ingestMediaFile(
240
+ input: IngestTelegramMediaInput,
241
+ deps: IngestTelegramMediaDeps,
242
+ ): Promise<Media>;
243
+ }
244
+
245
+ export function createTelegramService(
246
+ db: Database,
247
+ siteId: string,
248
+ databaseSchema: DatabaseSchema = sqliteSchemaBundle,
249
+ ): TelegramService {
250
+ const {
251
+ telegramBindings,
252
+ telegramPendingBindings,
253
+ telegramMediaGroupItems,
254
+ settings,
255
+ } = databaseSchema;
256
+
257
+ async function readSetting(key: string): Promise<string | null> {
258
+ const rows = await db
259
+ .select({ value: settings.value })
260
+ .from(settings)
261
+ .where(and(eq(settings.siteId, siteId), eq(settings.key, key)))
262
+ .limit(1);
263
+ return rows[0]?.value ?? null;
264
+ }
265
+
266
+ async function writeSetting(key: string, value: string): Promise<void> {
267
+ const timestamp = now();
268
+ await db
269
+ .insert(settings)
270
+ .values({ siteId, key, value, updatedAt: timestamp })
271
+ .onConflictDoUpdate({
272
+ target: [settings.siteId, settings.key],
273
+ set: { value, updatedAt: timestamp },
274
+ });
275
+ }
276
+
277
+ async function findBindingForSite(
278
+ targetSiteId: string,
279
+ ): Promise<TelegramBinding | null> {
280
+ const rows = await db
281
+ .select()
282
+ .from(telegramBindings)
283
+ .where(eq(telegramBindings.siteId, targetSiteId))
284
+ .limit(1);
285
+ return rows[0] ?? null;
286
+ }
287
+
288
+ async function generateCode(): Promise<string> {
289
+ const code = generateRandomId(BINDING_CODE_LENGTH);
290
+ const timestamp = now();
291
+ await db
292
+ .insert(telegramPendingBindings)
293
+ .values({
294
+ id: createEntityId("telegramBindingCode"),
295
+ siteId,
296
+ code,
297
+ createdAt: timestamp,
298
+ expiresAt: timestamp + BINDING_CODE_TTL,
299
+ })
300
+ .onConflictDoUpdate({
301
+ target: telegramPendingBindings.siteId,
302
+ set: {
303
+ code,
304
+ createdAt: timestamp,
305
+ expiresAt: timestamp + BINDING_CODE_TTL,
306
+ },
307
+ });
308
+ return code;
309
+ }
310
+
311
+ return {
312
+ async getStatus() {
313
+ const binding = await findBindingForSite(siteId);
314
+ const botId = await readSetting("TELEGRAM_BOT_ID");
315
+ const username = await readSetting("TELEGRAM_BOT_USERNAME");
316
+ return {
317
+ binding,
318
+ userBot: botId ? { botId, username: username ?? "" } : null,
319
+ };
320
+ },
321
+
322
+ async getOrCreateCode() {
323
+ const rows = await db
324
+ .select()
325
+ .from(telegramPendingBindings)
326
+ .where(eq(telegramPendingBindings.siteId, siteId))
327
+ .limit(1);
328
+ const existing = rows[0];
329
+ if (existing && existing.expiresAt > now()) {
330
+ return existing.code;
331
+ }
332
+ return generateCode();
333
+ },
334
+
335
+ generateCode,
336
+
337
+ async disconnect() {
338
+ await db
339
+ .delete(telegramBindings)
340
+ .where(eq(telegramBindings.siteId, siteId));
341
+ },
342
+
343
+ async resolvePendingCode(code) {
344
+ const rows = await db
345
+ .select()
346
+ .from(telegramPendingBindings)
347
+ .where(eq(telegramPendingBindings.code, code))
348
+ .limit(1);
349
+ const row = rows[0];
350
+ if (!row) return null;
351
+ if (row.expiresAt < now()) {
352
+ await db
353
+ .delete(telegramPendingBindings)
354
+ .where(eq(telegramPendingBindings.id, row.id));
355
+ return null;
356
+ }
357
+ return { siteId: row.siteId };
358
+ },
359
+
360
+ async findBindingByUser(botId, telegramUserId) {
361
+ const rows = await db
362
+ .select()
363
+ .from(telegramBindings)
364
+ .where(
365
+ and(
366
+ eq(telegramBindings.botId, botId),
367
+ eq(telegramBindings.telegramUserId, telegramUserId),
368
+ ),
369
+ )
370
+ .limit(1);
371
+ return rows[0] ?? null;
372
+ },
373
+
374
+ async bindAccount(input) {
375
+ // Last-write-wins: clear any prior binding for this Telegram account
376
+ // and any prior binding for the target site, then insert fresh. This
377
+ // covers a first-time bind, a rebind to a different site, and
378
+ // re-binding a site to a different account in one path.
379
+ await db
380
+ .delete(telegramBindings)
381
+ .where(
382
+ and(
383
+ eq(telegramBindings.botId, input.botId),
384
+ eq(telegramBindings.telegramUserId, input.telegramUserId),
385
+ ),
386
+ );
387
+ await db
388
+ .delete(telegramBindings)
389
+ .where(eq(telegramBindings.siteId, input.siteId));
390
+
391
+ const binding: TelegramBinding = {
392
+ id: createEntityId("telegramBinding"),
393
+ siteId: input.siteId,
394
+ botId: input.botId,
395
+ telegramUserId: input.telegramUserId,
396
+ telegramUsername: input.telegramUsername,
397
+ lastUpdateId: null,
398
+ boundAt: now(),
399
+ };
400
+ await db.insert(telegramBindings).values(binding);
401
+ await db
402
+ .delete(telegramPendingBindings)
403
+ .where(eq(telegramPendingBindings.siteId, input.siteId));
404
+ return binding;
405
+ },
406
+
407
+ async markUpdateProcessed(bindingId, updateId) {
408
+ await db
409
+ .update(telegramBindings)
410
+ .set({ lastUpdateId: updateId })
411
+ .where(eq(telegramBindings.id, bindingId));
412
+ },
413
+
414
+ async connectUserBot(token, webhookBaseUrl) {
415
+ const botId = parseBotId(token);
416
+ if (!botId) {
417
+ throw new Error("That doesn't look like a bot token.");
418
+ }
419
+ // Validates the token and surfaces a clear error for a bad one.
420
+ const identity = await getMe(token);
421
+ const secret = generateRandomId(32);
422
+ const webhookUrl = `${webhookBaseUrl.replace(/\/+$/, "")}/api/telegram/webhook/${botId}`;
423
+ await setWebhook(token, webhookUrl, secret);
424
+ try {
425
+ await setMyCommands(token);
426
+ } catch {
427
+ // `/` autocomplete is a polish feature — never fail the connect
428
+ // flow over it. The webhook is set, the bot works.
429
+ }
430
+ await writeSetting("TELEGRAM_BOT_TOKEN", token);
431
+ await writeSetting("TELEGRAM_BOT_ID", botId);
432
+ await writeSetting("TELEGRAM_BOT_USERNAME", identity.username);
433
+ await writeSetting("TELEGRAM_BOT_WEBHOOK_SECRET", secret);
434
+ },
435
+
436
+ async bufferAlbumItem(input) {
437
+ // ON CONFLICT DO NOTHING keeps Telegram's retry semantics safe: the
438
+ // same `(botId, mediaGroupId, messageId)` can arrive twice if the
439
+ // first webhook response was lost, and we want the second one to be a
440
+ // no-op rather than a duplicate row.
441
+ await db
442
+ .insert(telegramMediaGroupItems)
443
+ .values({
444
+ id: createEntityId("telegramMediaGroupItem"),
445
+ siteId: input.siteId,
446
+ botId: input.botId,
447
+ telegramUserId: input.telegramUserId,
448
+ mediaGroupId: input.mediaGroupId,
449
+ chatId: input.chatId,
450
+ messageId: input.messageId,
451
+ updateId: input.updateId,
452
+ fileId: input.fileId,
453
+ mediaKind: input.mediaKind,
454
+ mimeType: input.mimeType,
455
+ originalName: input.originalName,
456
+ captionMarkdown: input.captionMarkdown,
457
+ width: input.width,
458
+ height: input.height,
459
+ durationSeconds: input.durationSeconds,
460
+ posterFileId: input.posterFileId,
461
+ createdAt: now(),
462
+ })
463
+ .onConflictDoNothing({
464
+ target: [
465
+ telegramMediaGroupItems.botId,
466
+ telegramMediaGroupItems.mediaGroupId,
467
+ telegramMediaGroupItems.messageId,
468
+ ],
469
+ });
470
+ },
471
+
472
+ async claimAlbumGroup(botId, mediaGroupId) {
473
+ // `DELETE ... RETURNING` is atomic on both SQLite and Postgres, so two
474
+ // handlers waking up at the same instant cannot both win the group —
475
+ // whoever runs second gets an empty result and exits.
476
+ const rows = await db
477
+ .delete(telegramMediaGroupItems)
478
+ .where(
479
+ and(
480
+ eq(telegramMediaGroupItems.botId, botId),
481
+ eq(telegramMediaGroupItems.mediaGroupId, mediaGroupId),
482
+ ),
483
+ )
484
+ .returning();
485
+ return rows
486
+ .map(
487
+ (row): TelegramMediaGroupItem => ({
488
+ id: row.id,
489
+ siteId: row.siteId,
490
+ botId: row.botId,
491
+ telegramUserId: row.telegramUserId,
492
+ mediaGroupId: row.mediaGroupId,
493
+ chatId: row.chatId,
494
+ messageId: row.messageId,
495
+ updateId: row.updateId,
496
+ fileId: row.fileId,
497
+ mediaKind: row.mediaKind as TelegramMediaGroupKind,
498
+ mimeType: row.mimeType,
499
+ originalName: row.originalName,
500
+ captionMarkdown: row.captionMarkdown,
501
+ width: row.width,
502
+ height: row.height,
503
+ durationSeconds: row.durationSeconds,
504
+ posterFileId: row.posterFileId,
505
+ createdAt: row.createdAt,
506
+ }),
507
+ )
508
+ .sort((a, b) => a.messageId - b.messageId);
509
+ },
510
+
511
+ async ingestMediaFile(input, deps) {
512
+ const fileInfo = await getFile(input.botToken, input.fileId);
513
+ if (!fileInfo.file_path) {
514
+ throw new ValidationError(
515
+ "Telegram returned no file path for the attachment.",
516
+ );
517
+ }
518
+ const maxBytes = deps.maxFileSizeMB * 1024 * 1024;
519
+ if (fileInfo.file_size !== undefined && fileInfo.file_size > maxBytes) {
520
+ throw new ValidationError(
521
+ `Attachment exceeds the ${deps.maxFileSizeMB} MB upload limit.`,
522
+ );
523
+ }
524
+
525
+ const response = await downloadFile(input.botToken, fileInfo.file_path);
526
+ const bytes = new Uint8Array(await response.arrayBuffer());
527
+ if (bytes.byteLength > maxBytes) {
528
+ throw new ValidationError(
529
+ `Attachment exceeds the ${deps.maxFileSizeMB} MB upload limit.`,
530
+ );
531
+ }
532
+
533
+ await deps.media.assertCanWriteBytes(bytes.byteLength);
534
+
535
+ const { id, filename, storageKey } = generateStorageKey(
536
+ siteId,
537
+ input.originalName,
538
+ );
539
+
540
+ // Telegram's CDN serves trusted bytes already validated on their side,
541
+ // so we skip the signature peek that browser uploads need and just
542
+ // pass the bytes straight through to storage.
543
+ await deps.storage.put(storageKey, bytes, {
544
+ contentType: input.mimeType,
545
+ contentDisposition:
546
+ input.mediaKind === "document" ? "attachment" : "inline",
547
+ cacheControl: "public, max-age=31536000, immutable",
548
+ });
549
+
550
+ // Telegram videos and previewable documents carry a `thumbnail`
551
+ // PhotoSize alongside the main file. It's already a small JPEG, so we
552
+ // can store it directly as the media poster without any server-side
553
+ // image decoding.
554
+ let posterKey: string | undefined;
555
+ if (input.posterFileId) {
556
+ try {
557
+ const posterFile = await getFile(input.botToken, input.posterFileId);
558
+ if (posterFile.file_path) {
559
+ const posterResp = await downloadFile(
560
+ input.botToken,
561
+ posterFile.file_path,
562
+ );
563
+ const posterBytes = new Uint8Array(await posterResp.arrayBuffer());
564
+ posterKey = getPosterStorageKey(siteId, id, "jpg");
565
+ await deps.storage.put(posterKey, posterBytes, {
566
+ contentType: "image/jpeg",
567
+ cacheControl: "public, max-age=31536000, immutable",
568
+ });
569
+ }
570
+ } catch {
571
+ // Posters are a nice-to-have; never fail the whole ingest if the
572
+ // thumbnail download stumbles. The media row still publishes with
573
+ // no posterKey and the timeline falls back to its default.
574
+ posterKey = undefined;
575
+ }
576
+ }
577
+
578
+ return deps.media.create({
579
+ id,
580
+ filename,
581
+ originalName: input.originalName,
582
+ mimeType: input.mimeType,
583
+ size: bytes.byteLength,
584
+ storageKey,
585
+ provider: deps.storageDriver,
586
+ mediaKind: input.mediaKind,
587
+ width: input.width,
588
+ height: input.height,
589
+ durationSeconds: input.durationSeconds,
590
+ posterKey,
591
+ });
592
+ },
593
+
594
+ async removeUserBot() {
595
+ const token = await readSetting("TELEGRAM_BOT_TOKEN");
596
+ if (token) {
597
+ try {
598
+ await deleteWebhook(token);
599
+ } catch {
600
+ // The bot or webhook may already be gone — clearing local state
601
+ // is what matters, so don't block on Telegram's response.
602
+ }
603
+ }
604
+ await writeSetting("TELEGRAM_BOT_TOKEN", "");
605
+ await writeSetting("TELEGRAM_BOT_ID", "");
606
+ await writeSetting("TELEGRAM_BOT_USERNAME", "");
607
+ await writeSetting("TELEGRAM_BOT_WEBHOOK_SECRET", "");
608
+ await db
609
+ .delete(telegramBindings)
610
+ .where(eq(telegramBindings.siteId, siteId));
611
+ },
612
+ };
613
+ }
@@ -31,6 +31,10 @@ import {
31
31
  validateStoredUploadSignature,
32
32
  generateStorageKeyForId,
33
33
  } from "../lib/upload.js";
34
+ import {
35
+ IMAGE_DIMENSION_PEEK_BYTES,
36
+ parseImageDimensions,
37
+ } from "../lib/image-dimensions.js";
34
38
  import {
35
39
  supportsCopy,
36
40
  supportsMultipart,
@@ -294,7 +298,8 @@ export function createUploadSessionService(
294
298
  async function validateStoredObject(
295
299
  storage: StorageDriver,
296
300
  session: UploadSessionRow,
297
- ): Promise<void> {
301
+ sniffDimensions: boolean,
302
+ ): Promise<{ width: number; height: number } | null> {
298
303
  const head = await storage.head(session.tempStorageKey);
299
304
  if (!head) {
300
305
  throw new ValidationError("The uploaded file could not be found.");
@@ -308,23 +313,32 @@ export function createUploadSessionService(
308
313
  throw new ValidationError("The uploaded file type does not match.");
309
314
  }
310
315
 
311
- const peekLength = getStoredUploadSignaturePeekLength(
316
+ const signaturePeekLength = getStoredUploadSignaturePeekLength(
312
317
  session.expectedContentType,
313
318
  );
314
- if (peekLength > 0) {
315
- const bytes = await readBytes(
316
- storage,
317
- session.tempStorageKey,
318
- peekLength,
319
- );
319
+ const peekLength = sniffDimensions
320
+ ? Math.max(signaturePeekLength, IMAGE_DIMENSION_PEEK_BYTES)
321
+ : signaturePeekLength;
322
+
323
+ if (peekLength === 0) return null;
324
+
325
+ const bytes = await readBytes(storage, session.tempStorageKey, peekLength);
326
+
327
+ if (signaturePeekLength > 0) {
320
328
  const signatureError = validateStoredUploadSignature(
321
329
  session.expectedContentType,
322
- bytes,
330
+ bytes.subarray(0, signaturePeekLength),
323
331
  );
324
332
  if (signatureError) {
325
333
  throw new ValidationError(signatureError);
326
334
  }
327
335
  }
336
+
337
+ if (sniffDimensions) {
338
+ return parseImageDimensions(session.expectedContentType, bytes);
339
+ }
340
+
341
+ return null;
328
342
  }
329
343
 
330
344
  async function validatePoster(
@@ -585,7 +599,20 @@ export function createUploadSessionService(
585
599
  if (session.multipartUploadId) {
586
600
  await validateStoredChecksum(deps.storage, session);
587
601
  }
588
- await validateStoredObject(deps.storage, session);
602
+ let width = data.width;
603
+ let height = data.height;
604
+ const needsDimensionSniff =
605
+ (!width || !height) &&
606
+ session.expectedContentType.startsWith("image/");
607
+ const sniffed = await validateStoredObject(
608
+ deps.storage,
609
+ session,
610
+ needsDimensionSniff,
611
+ );
612
+ if (sniffed) {
613
+ width ??= sniffed.width;
614
+ height ??= sniffed.height;
615
+ }
589
616
  const posterInfo = await validatePoster(deps.storage, id);
590
617
 
591
618
  const objectOptions = getObjectOptions(session);
@@ -622,8 +649,8 @@ export function createUploadSessionService(
622
649
  size: session.expectedSize,
623
650
  storageKey: session.finalStorageKey,
624
651
  provider: deps.storageDriver,
625
- width: data.width,
626
- height: data.height,
652
+ width,
653
+ height,
627
654
  durationSeconds: data.durationSeconds,
628
655
  blurhash: data.blurhash,
629
656
  waveform: data.waveform,