@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,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
|
-
|
|
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
|
|
316
|
+
const signaturePeekLength = getStoredUploadSignaturePeekLength(
|
|
312
317
|
session.expectedContentType,
|
|
313
318
|
);
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
|
|
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
|
|
626
|
-
height
|
|
652
|
+
width,
|
|
653
|
+
height,
|
|
627
654
|
durationSeconds: data.durationSeconds,
|
|
628
655
|
blurhash: data.blurhash,
|
|
629
656
|
waveform: data.waveform,
|