@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
@@ -29,6 +29,10 @@ import {
29
29
  validateStoredUploadMetadata,
30
30
  validateStoredUploadSignature,
31
31
  } from "../../lib/upload.js";
32
+ import {
33
+ IMAGE_DIMENSION_PEEK_BYTES,
34
+ parseImageDimensions,
35
+ } from "../../lib/image-dimensions.js";
32
36
  import { supportsMultipart } from "../../lib/storage.js";
33
37
  import {
34
38
  MediaQuotaExceededError,
@@ -230,7 +234,16 @@ multipartUploadApiRoutes.post("/:id/complete", async (c) => {
230
234
  data.parts,
231
235
  );
232
236
 
233
- const peekLength = getStoredUploadSignaturePeekLength(data.contentType);
237
+ const signaturePeekLength = getStoredUploadSignaturePeekLength(
238
+ data.contentType,
239
+ );
240
+ let width = data.width && data.width > 0 ? data.width : undefined;
241
+ let height = data.height && data.height > 0 ? data.height : undefined;
242
+ const needsDimensionSniff =
243
+ (!width || !height) && data.contentType.startsWith("image/");
244
+ const peekLength = needsDimensionSniff
245
+ ? Math.max(signaturePeekLength, IMAGE_DIMENSION_PEEK_BYTES)
246
+ : signaturePeekLength;
234
247
  if (peekLength > 0) {
235
248
  const object = await storage.get(data.storageKey, {
236
249
  range: { offset: 0, length: peekLength },
@@ -239,16 +252,25 @@ multipartUploadApiRoutes.post("/:id/complete", async (c) => {
239
252
  throw new ValidationError("The uploaded file could not be found.");
240
253
  }
241
254
  const bytes = new Uint8Array(await new Response(object.body).arrayBuffer());
242
- const signatureError = validateStoredUploadSignature(
243
- data.contentType,
244
- bytes,
245
- );
246
- if (signatureError) {
247
- await storage.delete(data.storageKey).catch(() => {});
248
- if (data.posterKey) {
249
- await storage.delete(data.posterKey).catch(() => {});
255
+ if (signaturePeekLength > 0) {
256
+ const signatureError = validateStoredUploadSignature(
257
+ data.contentType,
258
+ bytes.subarray(0, signaturePeekLength),
259
+ );
260
+ if (signatureError) {
261
+ await storage.delete(data.storageKey).catch(() => {});
262
+ if (data.posterKey) {
263
+ await storage.delete(data.posterKey).catch(() => {});
264
+ }
265
+ throw new ValidationError(signatureError);
266
+ }
267
+ }
268
+ if (needsDimensionSniff) {
269
+ const dimensions = parseImageDimensions(data.contentType, bytes);
270
+ if (dimensions) {
271
+ width ??= dimensions.width;
272
+ height ??= dimensions.height;
250
273
  }
251
- throw new ValidationError(signatureError);
252
274
  }
253
275
  }
254
276
 
@@ -261,8 +283,8 @@ multipartUploadApiRoutes.post("/:id/complete", async (c) => {
261
283
  size: data.size,
262
284
  storageKey: data.storageKey,
263
285
  provider: c.var.appConfig.storageDriver,
264
- width: data.width && data.width > 0 ? data.width : undefined,
265
- height: data.height && data.height > 0 ? data.height : undefined,
286
+ width,
287
+ height,
266
288
  durationSeconds:
267
289
  data.durationSeconds && data.durationSeconds > 0
268
290
  ? data.durationSeconds
@@ -28,6 +28,10 @@ import {
28
28
  validateStoredUploadMetadata,
29
29
  validateStoredUploadSignature,
30
30
  } from "../../lib/upload.js";
31
+ import {
32
+ IMAGE_DIMENSION_PEEK_BYTES,
33
+ parseImageDimensions,
34
+ } from "../../lib/image-dimensions.js";
31
35
  import {
32
36
  assertFound,
33
37
  MediaQuotaExceededError,
@@ -284,6 +288,23 @@ uploadApiRoutes.post("/", async (c) => {
284
288
  const blurhashRaw = (formData.get("blurhash") as string) || undefined;
285
289
  const waveformRaw = (formData.get("waveform") as string) || undefined;
286
290
 
291
+ let width = widthRaw && widthRaw > 0 ? widthRaw : undefined;
292
+ let height = heightRaw && heightRaw > 0 ? heightRaw : undefined;
293
+ if ((!width || !height) && file.type.startsWith("image/")) {
294
+ try {
295
+ const headerBytes = new Uint8Array(
296
+ await file.slice(0, IMAGE_DIMENSION_PEEK_BYTES).arrayBuffer(),
297
+ );
298
+ const dimensions = parseImageDimensions(file.type, headerBytes);
299
+ if (dimensions) {
300
+ width ??= dimensions.width;
301
+ height ??= dimensions.height;
302
+ }
303
+ } catch {
304
+ // Dimensions are optional — fall through with whatever the client sent.
305
+ }
306
+ }
307
+
287
308
  // Upload poster frame for videos (if provided by client)
288
309
  let posterKey: string | undefined;
289
310
  const posterFile = formData.get("poster") as File | null;
@@ -308,8 +329,8 @@ uploadApiRoutes.post("/", async (c) => {
308
329
  size: file.size,
309
330
  storageKey,
310
331
  provider: c.var.appConfig.storageDriver,
311
- width: widthRaw && widthRaw > 0 ? widthRaw : undefined,
312
- height: heightRaw && heightRaw > 0 ? heightRaw : undefined,
332
+ width,
333
+ height,
313
334
  durationSeconds:
314
335
  durationSecondsRaw && durationSecondsRaw > 0
315
336
  ? durationSecondsRaw
@@ -44,6 +44,12 @@ import {
44
44
  GitHubSyncContent,
45
45
  type GitHubSyncStatus,
46
46
  } from "../../ui/dash/settings/GitHubSyncContent.js";
47
+ import {
48
+ readTelegramSettingsView,
49
+ renderTelegramContentHtml,
50
+ getTelegramStatusStreamUrl,
51
+ } from "../../lib/telegram-settings-status.js";
52
+ import { TelegramContent } from "../../ui/dash/settings/TelegramContent.js";
47
53
  import { toAbsoluteSiteUrl, toPublicPath } from "../../lib/url.js";
48
54
  import { parseValidated, UpdateSiteSettingsSchema } from "../../lib/schemas.js";
49
55
  import {
@@ -56,6 +62,7 @@ import { syncHostedControlPlaneSiteAvatar } from "../../lib/hosted-control-plane
56
62
  import {
57
63
  getGitHubAppConfig,
58
64
  getHostedControlPlaneSsoSecret,
65
+ getTelegramBotPool,
59
66
  } from "../../lib/env.js";
60
67
  import {
61
68
  buildInstallUrl,
@@ -131,7 +138,8 @@ function breadcrumbLabel(
131
138
  | "password"
132
139
  | "deleteAccount"
133
140
  | "apiTokens"
134
- | "githubSync",
141
+ | "githubSync"
142
+ | "telegram",
135
143
  ): string {
136
144
  const i18n = getI18n(c);
137
145
  switch (key) {
@@ -197,6 +205,10 @@ function breadcrumbLabel(
197
205
  return i18n._(
198
206
  msg({ message: "GitHub Sync", comment: "@context: Breadcrumb label" }),
199
207
  );
208
+ case "telegram":
209
+ return i18n._(
210
+ msg({ message: "Telegram", comment: "@context: Breadcrumb label" }),
211
+ );
200
212
  }
201
213
  }
202
214
 
@@ -2297,3 +2309,121 @@ settingsRoutes.get("/github-sync", async (c) => {
2297
2309
  ),
2298
2310
  });
2299
2311
  });
2312
+
2313
+ // ===========================================================================
2314
+ // Telegram
2315
+ // ===========================================================================
2316
+
2317
+ settingsRoutes.get("/telegram", async (c) => {
2318
+ const view = await readTelegramSettingsView(c);
2319
+ const streamUrl = getTelegramStatusStreamUrl(c);
2320
+ const navData = await getNavigationData(c);
2321
+ return renderPublicPage(c, {
2322
+ title: buildPageTitle("Telegram", navData.siteName),
2323
+ navData,
2324
+ content: (
2325
+ <>
2326
+ <AdminBreadcrumb
2327
+ parent={breadcrumbLabel(c, "settings")}
2328
+ parentHref={publicPath(c, "/settings")}
2329
+ current={breadcrumbLabel(c, "telegram")}
2330
+ />
2331
+ <TelegramContent
2332
+ view={view}
2333
+ sitePathPrefix={c.var.appConfig.sitePathPrefix}
2334
+ streamUrl={streamUrl}
2335
+ />
2336
+ </>
2337
+ ),
2338
+ });
2339
+ });
2340
+
2341
+ /**
2342
+ * Live status stream — swaps the connect view for the connected view the
2343
+ * moment a binding lands, so the user doesn't have to refresh after sending
2344
+ * the binding code to the bot. Same pattern as GitHub Sync's status stream:
2345
+ * subscribed via Datastar `data-init="@get(...)"`, each frame is a
2346
+ * `patchElements` with `mode: outer` on the stable `#telegram-status` id.
2347
+ *
2348
+ * The connected view ships without `data-init`, so the stream closes as
2349
+ * soon as we send the first "binding present" frame. A 5-minute cap bounds
2350
+ * an abandoned subscription.
2351
+ */
2352
+ settingsRoutes.get("/telegram/status/stream", async (c) => {
2353
+ const streamUrl = getTelegramStatusStreamUrl(c);
2354
+ // 5 minutes is well above the time a user reasonably spends sending a
2355
+ // code to a bot; longer windows just close and the page can be reloaded.
2356
+ const MAX_DURATION_MS = 5 * 60 * 1000;
2357
+ // 1.5s keeps the UI snappy without hammering the binding table.
2358
+ const POLL_INTERVAL_MS = 1500;
2359
+
2360
+ return sse(c, async (stream) => {
2361
+ const startedAt = Date.now();
2362
+ let lastHtml: string | null = null;
2363
+
2364
+ while (true) {
2365
+ const view = await readTelegramSettingsView(c);
2366
+ const html = renderTelegramContentHtml(c, view, streamUrl);
2367
+ if (html !== lastHtml) {
2368
+ stream.patchElements(html, {
2369
+ mode: "outer",
2370
+ selector: "#telegram-status",
2371
+ });
2372
+ lastHtml = html;
2373
+ }
2374
+
2375
+ // Binding landed: the patch above just shipped the connected view
2376
+ // (no `data-init`), so the client will close. A brief beat lets the
2377
+ // browser apply the patch before the server-side stream ends.
2378
+ if (view.binding) {
2379
+ await new Promise((resolve) => setTimeout(resolve, 100));
2380
+ break;
2381
+ }
2382
+
2383
+ if (Date.now() - startedAt >= MAX_DURATION_MS) break;
2384
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
2385
+ }
2386
+ });
2387
+ });
2388
+
2389
+ settingsRoutes.post("/telegram/connect", async (c) => {
2390
+ // Token entry is for bring-your-own deployments only. When a managed pool
2391
+ // is configured the bot is platform-owned and users connect via a code.
2392
+ if (getTelegramBotPool(c.env).length > 0) {
2393
+ return dsToast(
2394
+ "This deployment uses a managed Telegram bot. Connect with the binding code instead.",
2395
+ "error",
2396
+ );
2397
+ }
2398
+
2399
+ const body = await c.req.json<{ token?: string }>();
2400
+ const token = body.token?.trim();
2401
+ if (!token) {
2402
+ return dsToast("Paste your bot token to continue.", "error");
2403
+ }
2404
+
2405
+ const siteUrl = c.var.appConfig.siteUrl.replace(/\/+$/, "");
2406
+ try {
2407
+ await c.var.services.telegram.connectUserBot(token, siteUrl);
2408
+ } catch (err) {
2409
+ const detail = err instanceof Error ? err.message : String(err);
2410
+ return dsToast(`Could not set up the bot: ${detail}`, "error");
2411
+ }
2412
+
2413
+ return dsRedirect(publicPath(c, "/settings/telegram"));
2414
+ });
2415
+
2416
+ settingsRoutes.post("/telegram/remove-bot", async (c) => {
2417
+ await c.var.services.telegram.removeUserBot();
2418
+ return dsRedirect(publicPath(c, "/settings/telegram"));
2419
+ });
2420
+
2421
+ settingsRoutes.post("/telegram/regenerate-code", async (c) => {
2422
+ await c.var.services.telegram.generateCode();
2423
+ return dsRedirect(publicPath(c, "/settings/telegram"));
2424
+ });
2425
+
2426
+ settingsRoutes.post("/telegram/disconnect", async (c) => {
2427
+ await c.var.services.telegram.disconnect();
2428
+ return dsRedirect(publicPath(c, "/settings/telegram"));
2429
+ });
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Tests for the `<title>` tag on post pages.
3
+ *
4
+ * Post pages compose `<title>` as "Post Title - Site Name" so the site
5
+ * name is visible in browser tabs, bookmarks, and SEO snippets, matching
6
+ * the convention used by Settings, Search, Archive, and Collection pages.
7
+ */
8
+
9
+ import { describe, expect, it } from "vitest";
10
+ import { createTestApp } from "../../../__tests__/helpers/app.js";
11
+ import { pageRoutes } from "../page.js";
12
+
13
+ function createPageTestApp() {
14
+ const testApp = createTestApp();
15
+ const { app } = testApp;
16
+
17
+ app.use("*", async (c, next) => {
18
+ c.set("publicPath", c.req.path);
19
+ c.set("publicRequestUrl", c.req.url);
20
+ await next();
21
+ });
22
+
23
+ app.route("/", pageRoutes);
24
+
25
+ return testApp;
26
+ }
27
+
28
+ function extractTitle(html: string): string | null {
29
+ const match = html.match(/<title>([^<]*)<\/title>/i);
30
+ return match?.[1] ?? null;
31
+ }
32
+
33
+ describe("Post page <title>", () => {
34
+ it("appends the site name to the post title", async () => {
35
+ const { app, services } = createPageTestApp();
36
+ await services.settings.set("SITE_NAME", "Owen");
37
+
38
+ const post = await services.posts.create({
39
+ format: "note",
40
+ title: "Hello world",
41
+ bodyMarkdown: "Body",
42
+ status: "published",
43
+ });
44
+
45
+ const res = await app.request(`/${post.slug}`);
46
+ expect(res.status).toBe(200);
47
+
48
+ const html = await res.text();
49
+ expect(extractTitle(html)).toBe("Hello world - Owen");
50
+ });
51
+
52
+ it("falls back to derived meta title when post has no title", async () => {
53
+ const { app, services } = createPageTestApp();
54
+ await services.settings.set("SITE_NAME", "Owen");
55
+
56
+ const post = await services.posts.create({
57
+ format: "note",
58
+ bodyMarkdown: "First sentence of the body.",
59
+ status: "published",
60
+ });
61
+
62
+ const res = await app.request(`/${post.slug}`);
63
+ expect(res.status).toBe(200);
64
+
65
+ const html = await res.text();
66
+ const title = extractTitle(html);
67
+ expect(title).toMatch(/- Owen$/);
68
+ expect(title).toContain("First sentence");
69
+ });
70
+ });
@@ -11,6 +11,7 @@ import type { AppVariables } from "../../types/app-context.js";
11
11
  import { PostPage } from "../../ui/pages/PostPage.js";
12
12
  import { getNavigationData } from "../../lib/navigation.js";
13
13
  import { renderPublicPage } from "../../lib/render.js";
14
+ import { buildPageTitle } from "../../lib/page-title.js";
14
15
  import { buildPostMeta } from "../../lib/post-meta.js";
15
16
  import { assemblePostPageDisplay } from "../../lib/post-display.js";
16
17
  import { toPublicHref, toPublicPath } from "../../lib/url.js";
@@ -93,7 +94,7 @@ async function renderPostWithTextPreview(
93
94
  .replace(/>/g, "\\u003e");
94
95
 
95
96
  return renderPublicPage(c, {
96
- title: pageTitle,
97
+ title: buildPageTitle(pageTitle, navData.siteName),
97
98
  description: meta.description,
98
99
  canonicalHref,
99
100
  navData,
@@ -162,7 +163,7 @@ async function renderPost(c: Context<Env>, post: Post) {
162
163
  );
163
164
 
164
165
  return renderPublicPage(c, {
165
- title: meta.title,
166
+ title: buildPageTitle(meta.title, navData.siteName),
166
167
  description: meta.description,
167
168
  canonicalHref,
168
169
  navData,
@@ -34,6 +34,12 @@ export interface CloudflareRequestRuntime {
34
34
  hostedHandoff: HostedHandoffService;
35
35
  rateLimiter: RateLimiter;
36
36
  services: Services;
37
+ /**
38
+ * Builds a `Services` object scoped to an arbitrary site. Used by
39
+ * host-agnostic handlers (e.g. the Telegram webhook) that resolve the
40
+ * target site from request data rather than the hostname.
41
+ */
42
+ servicesForSite: (siteId: string) => Services;
37
43
  storage: StorageDriver | null;
38
44
  }
39
45
 
@@ -81,6 +87,18 @@ export async function createCloudflareRequestRuntime(
81
87
  useSecureCookies: shouldUseSecureCookies(env, publicRequestUrl),
82
88
  });
83
89
 
90
+ const servicesConfig = {
91
+ databaseDialect: "sqlite" as const,
92
+ bootstrapSite: getSingleSiteBootstrapOptions(env),
93
+ enforceHostedMediaQuota: getSiteResolutionMode(env) === "host-based",
94
+ hostedControlPlane: createHostedControlPlaneClient(env),
95
+ siteResolutionMode: getSiteResolutionMode(env),
96
+ slugIdLength,
97
+ schema: sqliteSchemaBundle,
98
+ };
99
+ const servicesForSite = (siteId: string): Services =>
100
+ createServices(db, session, siteId, servicesConfig);
101
+
84
102
  return {
85
103
  auth,
86
104
  currentSite: siteLookup.site,
@@ -92,15 +110,8 @@ export async function createCloudflareRequestRuntime(
92
110
  secret: hostedControlPlaneSsoSecret,
93
111
  }),
94
112
  rateLimiter: createD1RateLimiter(db, sqliteSchemaBundle),
95
- services: createServices(db, session, siteLookup.site.id, {
96
- databaseDialect: "sqlite",
97
- bootstrapSite: getSingleSiteBootstrapOptions(env),
98
- enforceHostedMediaQuota: getSiteResolutionMode(env) === "host-based",
99
- hostedControlPlane: createHostedControlPlaneClient(env),
100
- siteResolutionMode: getSiteResolutionMode(env),
101
- slugIdLength,
102
- schema: sqliteSchemaBundle,
103
- }),
113
+ services: servicesForSite(siteLookup.site.id),
114
+ servicesForSite,
104
115
  storage: createStorageDriver(env),
105
116
  };
106
117
  }
@@ -37,6 +37,12 @@ export interface NodeRequestRuntime {
37
37
  hostedHandoff: HostedHandoffService;
38
38
  rateLimiter: RateLimiter;
39
39
  services: Services;
40
+ /**
41
+ * Builds a `Services` object scoped to an arbitrary site. Used by
42
+ * host-agnostic handlers (e.g. the Telegram webhook) that resolve the
43
+ * target site from request data rather than the hostname.
44
+ */
45
+ servicesForSite: (siteId: string) => Services;
40
46
  storage: StorageDriver | null;
41
47
  }
42
48
 
@@ -136,6 +142,18 @@ export async function createNodeRequestRuntime(
136
142
  useSecureCookies: shouldUseSecureCookies(env, publicRequestUrl),
137
143
  });
138
144
 
145
+ const servicesConfig = {
146
+ databaseDialect,
147
+ bootstrapSite: getSingleSiteBootstrapOptions(env),
148
+ enforceHostedMediaQuota: getSiteResolutionMode(env) === "host-based",
149
+ hostedControlPlane: createHostedControlPlaneClient(env),
150
+ siteResolutionMode: getSiteResolutionMode(env),
151
+ slugIdLength,
152
+ schema: databaseSchema,
153
+ };
154
+ const servicesForSite = (siteId: string): Services =>
155
+ createServices(db, rawQuery, siteId, servicesConfig);
156
+
139
157
  return {
140
158
  auth,
141
159
  currentSite: siteLookup.site,
@@ -147,15 +165,8 @@ export async function createNodeRequestRuntime(
147
165
  secret: hostedControlPlaneSsoSecret,
148
166
  }),
149
167
  rateLimiter: getNodeRateLimiter(),
150
- services: createServices(db, rawQuery, siteLookup.site.id, {
151
- databaseDialect,
152
- bootstrapSite: getSingleSiteBootstrapOptions(env),
153
- enforceHostedMediaQuota: getSiteResolutionMode(env) === "host-based",
154
- hostedControlPlane: createHostedControlPlaneClient(env),
155
- siteResolutionMode: getSiteResolutionMode(env),
156
- slugIdLength,
157
- schema: databaseSchema,
158
- }),
168
+ services: servicesForSite(siteLookup.site.id),
169
+ servicesForSite,
159
170
  storage: createStorageDriver(env),
160
171
  };
161
172
  }
@@ -91,7 +91,8 @@ export async function resolveRequestSite(
91
91
  // payload's installation id) instead of relying on `currentSite`.
92
92
  if (
93
93
  requestUrl.pathname.startsWith("/api/internal/") ||
94
- requestUrl.pathname === "/api/github-sync/app-webhook"
94
+ requestUrl.pathname === "/api/github-sync/app-webhook" ||
95
+ requestUrl.pathname.startsWith("/api/telegram/webhook/")
95
96
  ) {
96
97
  return {
97
98
  site: createTransientSite("internal"),
@@ -0,0 +1,148 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import {
3
+ createTestDatabase,
4
+ DEFAULT_TEST_SITE_ID,
5
+ } from "../../__tests__/helpers/db.js";
6
+ import type { Database } from "../../db/index.js";
7
+ import { createTelegramService, type TelegramService } from "../telegram.js";
8
+
9
+ const SECOND_SITE_ID = "sit_second00000000000000000000000";
10
+ const BOT_ID = "111111";
11
+ const USER_ID = "999999";
12
+
13
+ function insertSecondSite(
14
+ sqlite: ReturnType<typeof createTestDatabase>["sqlite"],
15
+ ): void {
16
+ const timestamp = Math.floor(Date.now() / 1000);
17
+ sqlite
18
+ .prepare(
19
+ `INSERT INTO site (id, key, status, created_at, updated_at)
20
+ VALUES (?, ?, 'active', ?, ?)`,
21
+ )
22
+ .run(SECOND_SITE_ID, "second", timestamp, timestamp);
23
+ }
24
+
25
+ describe("TelegramService", () => {
26
+ let db: Database;
27
+ let sqlite: ReturnType<typeof createTestDatabase>["sqlite"];
28
+ let service: TelegramService;
29
+
30
+ beforeEach(() => {
31
+ const testDb = createTestDatabase();
32
+ db = testDb.db as unknown as Database;
33
+ sqlite = testDb.sqlite;
34
+ insertSecondSite(sqlite);
35
+ service = createTelegramService(db, DEFAULT_TEST_SITE_ID);
36
+ });
37
+
38
+ it("reports an empty status for a fresh site", async () => {
39
+ const status = await service.getStatus();
40
+ expect(status.binding).toBeNull();
41
+ expect(status.userBot).toBeNull();
42
+ });
43
+
44
+ it("reuses an existing code via getOrCreateCode", async () => {
45
+ const first = await service.getOrCreateCode();
46
+ const second = await service.getOrCreateCode();
47
+ expect(second).toBe(first);
48
+ });
49
+
50
+ it("replaces the code on generateCode", async () => {
51
+ const first = await service.getOrCreateCode();
52
+ const next = await service.generateCode();
53
+ expect(next).not.toBe(first);
54
+ expect(await service.resolvePendingCode(first)).toBeNull();
55
+ expect(await service.resolvePendingCode(next)).toEqual({
56
+ siteId: DEFAULT_TEST_SITE_ID,
57
+ });
58
+ });
59
+
60
+ it("resolves a pending code to its site", async () => {
61
+ const code = await service.getOrCreateCode();
62
+ expect(await service.resolvePendingCode(code)).toEqual({
63
+ siteId: DEFAULT_TEST_SITE_ID,
64
+ });
65
+ });
66
+
67
+ it("returns null for an unknown code", async () => {
68
+ expect(await service.resolvePendingCode("nope")).toBeNull();
69
+ });
70
+
71
+ it("treats an expired code as unknown", async () => {
72
+ const code = await service.getOrCreateCode();
73
+ sqlite
74
+ .prepare(
75
+ `UPDATE telegram_pending_binding SET expires_at = ? WHERE code = ?`,
76
+ )
77
+ .run(1, code);
78
+ expect(await service.resolvePendingCode(code)).toBeNull();
79
+ });
80
+
81
+ it("binds an account and surfaces it in status", async () => {
82
+ await service.getOrCreateCode();
83
+ const binding = await service.bindAccount({
84
+ siteId: DEFAULT_TEST_SITE_ID,
85
+ botId: BOT_ID,
86
+ telegramUserId: USER_ID,
87
+ telegramUsername: "alice",
88
+ });
89
+ expect(binding.siteId).toBe(DEFAULT_TEST_SITE_ID);
90
+
91
+ const status = await service.getStatus();
92
+ expect(status.binding).toMatchObject({
93
+ botId: BOT_ID,
94
+ telegramUserId: USER_ID,
95
+ telegramUsername: "alice",
96
+ });
97
+
98
+ // The pending code is consumed on bind.
99
+ const found = await service.findBindingByUser(BOT_ID, USER_ID);
100
+ expect(found?.siteId).toBe(DEFAULT_TEST_SITE_ID);
101
+ });
102
+
103
+ it("moves a binding to a new site on rebind (last-write-wins)", async () => {
104
+ await service.bindAccount({
105
+ siteId: DEFAULT_TEST_SITE_ID,
106
+ botId: BOT_ID,
107
+ telegramUserId: USER_ID,
108
+ telegramUsername: "alice",
109
+ });
110
+ await service.bindAccount({
111
+ siteId: SECOND_SITE_ID,
112
+ botId: BOT_ID,
113
+ telegramUserId: USER_ID,
114
+ telegramUsername: "alice",
115
+ });
116
+
117
+ const found = await service.findBindingByUser(BOT_ID, USER_ID);
118
+ expect(found?.siteId).toBe(SECOND_SITE_ID);
119
+
120
+ // The first site no longer has a binding.
121
+ const firstSiteService = createTelegramService(db, DEFAULT_TEST_SITE_ID);
122
+ expect((await firstSiteService.getStatus()).binding).toBeNull();
123
+ });
124
+
125
+ it("records the last processed update id", async () => {
126
+ const binding = await service.bindAccount({
127
+ siteId: DEFAULT_TEST_SITE_ID,
128
+ botId: BOT_ID,
129
+ telegramUserId: USER_ID,
130
+ telegramUsername: null,
131
+ });
132
+ await service.markUpdateProcessed(binding.id, 42);
133
+ const found = await service.findBindingByUser(BOT_ID, USER_ID);
134
+ expect(found?.lastUpdateId).toBe(42);
135
+ });
136
+
137
+ it("disconnects the active binding", async () => {
138
+ await service.bindAccount({
139
+ siteId: DEFAULT_TEST_SITE_ID,
140
+ botId: BOT_ID,
141
+ telegramUserId: USER_ID,
142
+ telegramUsername: null,
143
+ });
144
+ await service.disconnect();
145
+ expect((await service.getStatus()).binding).toBeNull();
146
+ expect(await service.findBindingByUser(BOT_ID, USER_ID)).toBeNull();
147
+ });
148
+ });
@@ -43,6 +43,7 @@ import {
43
43
  createGitHubAppInstallationsService,
44
44
  type GitHubAppInstallationsService,
45
45
  } from "./github-app-installations.js";
46
+ import { createTelegramService, type TelegramService } from "./telegram.js";
46
47
  import type { HostedControlPlaneClient } from "../lib/hosted-control-plane.js";
47
48
  import type { EnsureSingleSiteOptions } from "./site.js";
48
49
 
@@ -64,6 +65,7 @@ export interface Services {
64
65
  siteMembers: SiteMemberService;
65
66
  siteProfile: SiteProfileService;
66
67
  githubAppInstallations: GitHubAppInstallationsService;
68
+ telegram: TelegramService;
67
69
  }
68
70
 
69
71
  export function createServices(
@@ -150,6 +152,7 @@ export function createServices(
150
152
  db,
151
153
  databaseSchema,
152
154
  ),
155
+ telegram: createTelegramService(db, siteId, databaseSchema),
153
156
  };
154
157
  }
155
158
 
@@ -182,3 +185,9 @@ export type {
182
185
  GitHubAccountType,
183
186
  StoredGitHubAppInstallation,
184
187
  } from "./github-app-installations.js";
188
+ export type {
189
+ TelegramService,
190
+ TelegramBinding,
191
+ TelegramStatus,
192
+ TelegramUserBot,
193
+ } from "./telegram.js";