From 977d7f0353b21844379a4ec1446c26d47a30eeb6 Mon Sep 17 00:00:00 2001 From: rzcoder Date: Wed, 3 Jun 2026 22:26:40 +0900 Subject: [PATCH 1/6] feat(web): mark the current Telegram-login admin in settings Highlight the Settings admin whose Telegram id matches the account viewing inside the Mini App (initDataUnsafe.user.id) with a "You" badge. Cosmetic only; the id comes from the unverified initDataUnsafe. --- .../components/settings/BotSettingsCard.tsx | 29 ++----- .../settings/bot/ConnectionSection.tsx | 75 ++++++++++--------- apps/web/src/lib/telegram.ts | 54 ++++--------- 3 files changed, 61 insertions(+), 97 deletions(-) diff --git a/apps/web/src/components/settings/BotSettingsCard.tsx b/apps/web/src/components/settings/BotSettingsCard.tsx index 12d7708..f56841b 100644 --- a/apps/web/src/components/settings/BotSettingsCard.tsx +++ b/apps/web/src/components/settings/BotSettingsCard.tsx @@ -1,27 +1,11 @@ -/** - * Settings → Bot card. - * - * One self-contained card holding every bot-related setting — connection - * params (token / admins / public URL, stored DB-over-env) and the stats - * digest schedule — behind a single status header and one Save / Reset. - * - * The two halves persist to different endpoints (bot config → /api/config/bot, - * digest → /api/settings); Save fires only the dirty halves and reports one - * outcome. "Reset all to .env" clears the DB bot-config row. The digest's time - * zone is captured from the browser on save (no picker), matching how the - * scheduler interprets it. - * - * Draft state lives in `useBotSettingsDraft`; the connection and digest fields - * render through the `bot/` sub-components. This card keeps the save - * orchestration (the two-half Promise.allSettled write + per-half reporting) - * and the DB-reset / key-mismatch affordances. - */ +// Bot config + digest persist to different endpoints; Save fires only the dirty halves and reports per-half. import { Check, KeyRound, Power } from 'lucide-react'; import type { UpdateBotConfigRequest, UpdateSettingsRequest } from '@tg-feed/shared'; import { Button } from '@/components/ui/button'; import { Spinner } from '@/components/ui/spinner'; import { useToast } from '@/components/ui/toast'; import { apiErrorMessage } from '@/api/client'; +import { getTelegramUserId } from '@/lib/telegram'; import { useBotConfig, useDeleteBotConfig, useUpdateBotConfig } from '@/hooks/useBotConfig'; import { useSettings, useUpdateSettings } from '@/hooks/useSettings'; import { CardFooter, CardHeader, SettingsCard, StatusPill } from './primitives'; @@ -96,8 +80,7 @@ export function BotSettingsCard() { const dirty = botDirty || digestDirty; const busy = updateBot.isPending || updateSettings.isPending || delMut.isPending; const keyMissingForToken = tokenDirty && !data.encryptionKeyConfigured; - // Each half gates on its own validity, so an invalid Public URL (a bot - // field) can't block saving an unrelated digest edit, and vice-versa. + // Each half gates on its own validity, so an invalid URL can't block an unrelated digest edit. const botSavable = botDirty && urlValid && !keyMissingForToken; const digestSavable = digestDirty; const canSave = !busy && (botSavable || digestSavable); @@ -127,8 +110,6 @@ export function BotSettingsCard() { ); const save = async () => { - // Run the two halves independently so a failure in one doesn't hide a - // success in the other, and report per-half so the user knows what landed. const jobs: Array<{ half: 'bot' | 'digest'; run: () => Promise }> = []; if (botSavable) { const body: UpdateBotConfigRequest = {}; @@ -150,8 +131,7 @@ export function BotSettingsCard() { const results = await Promise.allSettled(jobs.map((j) => j.run())); const botOk = results.every((r, i) => jobs[i]?.half !== 'bot' || r.status === 'fulfilled'); - // Only clear the (write-only) token field once the bot write actually - // landed, so a failed save doesn't silently drop the typed token. + // Clear the write-only token only once the write landed, so a failure doesn't drop it silently. if (botOk && tokenDirty) setTokenDraft(''); const failed = jobs.filter((_, i) => results[i]?.status === 'rejected').map((j) => j.half); @@ -212,6 +192,7 @@ export function BotSettingsCard() { admins={admins} onRemoveAdmin={(id) => setAdmins((xs) => xs.filter((x) => x.id !== id))} onAddAdmin={(admin) => setAdmins((xs) => [...xs, admin])} + currentTelegramUserId={getTelegramUserId()} urlDraft={urlDraft} onUrlChange={setUrlDraft} urlValid={urlValid} diff --git a/apps/web/src/components/settings/bot/ConnectionSection.tsx b/apps/web/src/components/settings/bot/ConnectionSection.tsx index 3dcabf1..e617322 100644 --- a/apps/web/src/components/settings/bot/ConnectionSection.tsx +++ b/apps/web/src/components/settings/bot/ConnectionSection.tsx @@ -1,14 +1,9 @@ -/** - * The "Connection" panel of the Bot settings card: bot token, admin allowlist - * and public URL. Presentational — every draft value, setter and the busy flag - * are owned by the card and passed in; this component only renders the fields - * and wires the admin add/remove callbacks. - */ import { useMemo, type ReactNode } from 'react'; import { User, X } from 'lucide-react'; import type { BotAdmin, BotConfigInfo } from '@tg-feed/shared'; import { Button } from '@/components/ui/button'; import { Hint, Input } from '@/components/ui/input'; +import { cn } from '@/lib/cn'; import { FieldHead, PanelSection } from '../primitives'; import { AdminLookup } from './AdminLookup'; import { adminLabel } from './utils'; @@ -21,6 +16,7 @@ export interface ConnectionSectionProps { admins: BotAdmin[]; onRemoveAdmin: (id: string) => void; onAddAdmin: (admin: BotAdmin) => void; + currentTelegramUserId: string | null; urlDraft: string; onUrlChange: (value: string) => void; urlValid: boolean; @@ -36,6 +32,7 @@ export function ConnectionSection({ admins, onRemoveAdmin, onAddAdmin, + currentTelegramUserId, urlDraft, onUrlChange, urlValid, @@ -45,7 +42,6 @@ export function ConnectionSection({ return ( - {/* Bot token */}
- {/* Admins */}
{admins.length === 0 ? (

No admins yet — look one up below.

) : (
- {admins.map((a) => ( -
- - - -
- - {adminLabel(a)} - - - {a.displayName && a.username ? `@${a.username} · ${a.id}` : a.id} + {admins.map((a) => { + const isYou = currentTelegramUserId !== null && a.id === currentTelegramUserId; + return ( +
+ + +
+ + + {adminLabel(a)} + + {isYou && ( + + You + + )} + + + {a.displayName && a.username ? `@${a.username} · ${a.id}` : a.id} + +
+
- -
- ))} + ); + })}
)} Look up a Telegram user to allow — @username, t.me link, or numeric id.
- {/* Public URL */}
void; - /** Expands the Mini App to full height. */ expand: () => void; colorScheme?: 'light' | 'dark'; } @@ -35,27 +27,23 @@ export function getTelegramWebApp(): TelegramWebApp | null { return window.Telegram?.WebApp ?? null; } -/** - * The signed initData string, or null when not running inside Telegram (the - * SDK leaves `initData` as an empty string outside a Telegram client). - */ export function getTelegramInitData(): string | null { const webApp = getTelegramWebApp(); if (!webApp || !webApp.initData) return null; return webApp.initData; } -/** True when the app is being rendered inside a Telegram client. */ export function isInsideTelegram(): boolean { return getTelegramInitData() !== null; } -/** - * Best-effort detection of a Telegram launch that works BEFORE the (async) - * SDK script has populated `window.Telegram.WebApp`. Native clients expose - * `TelegramWebviewProxy`; web clients put the launch params in the URL hash - * as `tgWebAppData`. Used to decide whether it's worth waiting for the SDK. - */ +// From unverified initDataUnsafe — cosmetic hints only, never authorization. +export function getTelegramUserId(): string | null { + const id = getTelegramWebApp()?.initDataUnsafe?.user?.id; + return typeof id === 'number' ? String(id) : null; +} + +// Detects a launch BEFORE the async SDK populates window.Telegram.WebApp (via TelegramWebviewProxy or the tgWebAppData hash). export function detectTelegramLaunch(): boolean { if (typeof window === 'undefined') return false; if (getTelegramInitData() !== null) return true; @@ -63,12 +51,7 @@ export function detectTelegramLaunch(): boolean { return window.location.hash.includes('tgWebAppData'); } -/** - * Resolve the signed initData. Returns it immediately when already present, - * null when this isn't a Telegram launch, or — when it is a launch but the - * async SDK hasn't populated it yet — polls every `intervalMs` up to - * `timeoutMs` before falling back to null. - */ +// Resolves initData now if present, null if not a launch, else polls up to timeoutMs while the async SDK populates it. export function waitForTelegramInitData(timeoutMs = 3000, intervalMs = 50): Promise { const immediate = getTelegramInitData(); if (immediate) return Promise.resolve(immediate); @@ -85,20 +68,13 @@ export function waitForTelegramInitData(timeoutMs = 3000, intervalMs = 50): Prom }); } -/** - * Telegram's current light/dark scheme, or null when not in Telegram / the - * SDK isn't loaded. Lets the app's `system` theme preference follow the - * surrounding Telegram chrome instead of the OS `prefers-color-scheme`. - */ +// Lets the `system` theme follow Telegram chrome instead of OS prefers-color-scheme. export function getTelegramColorScheme(): 'light' | 'dark' | null { const scheme = getTelegramWebApp()?.colorScheme; return scheme === 'light' || scheme === 'dark' ? scheme : null; } -/** - * Tell Telegram the Mini App is ready and expand it to full height. Safe to - * call unconditionally — a no-op outside Telegram. - */ +// No-op outside Telegram, safe to call unconditionally. export function initTelegramViewport(): void { const webApp = getTelegramWebApp(); if (!webApp) return; @@ -106,6 +82,6 @@ export function initTelegramViewport(): void { webApp.ready(); webApp.expand(); } catch { - // SDK present but a call failed (old client). Non-fatal. + // Non-fatal: SDK present but a call failed (old client). } } From c8ef68617141211c92c3ea487d53afc37bbd0ca3 Mon Sep 17 00:00:00 2001 From: rzcoder Date: Wed, 3 Jun 2026 22:27:08 +0900 Subject: [PATCH 2/6] fix: address code-review findings - forward fan-out: a source with multiple subscriptions now delivers to every matching destination (matchSubscriptions + per-subscription album batching), not just the first. - invites: legacy t.me/joinchat/ links resolve as invites, not a bogus handle. - filters: memoize compiled RE2 and reject uncompilable patterns at write time instead of failing open per message. - access monitor: hold each target's own status under the flap threshold so a shared chat keeps a divergent badge; clean up the failure-count map on threshold. - forwarding queue: dead-letter a job after repeated flood-waits so a perpetually flooded destination cannot wedge its FIFO. - web: stop the subscription sheet wiping in-progress input on a background destinations refetch. - retention: correct the misleading maxRows comment. --- .../src/api/routes/libraryFilters.test.ts | 14 ++ apps/server/src/api/routes/libraryFilters.ts | 19 +-- apps/server/src/api/routes/subscriptions.ts | 116 ++-------------- .../src/filters/rules/textRegex.test.ts | 8 +- apps/server/src/filters/rules/textRegex.ts | Bin 692 -> 1143 bytes .../server/src/filters/validateParams.test.ts | 21 +++ apps/server/src/filters/validateParams.ts | 17 +++ .../src/forwarding/albumDebouncer.test.ts | 25 +++- apps/server/src/forwarding/albumDebouncer.ts | 59 +-------- apps/server/src/forwarding/forwarder.test.ts | 26 +++- apps/server/src/forwarding/forwarder.ts | 111 +++++++--------- apps/server/src/forwarding/index.ts | 16 +-- apps/server/src/forwarding/queue.test.ts | 38 +++++- apps/server/src/forwarding/queue.ts | 42 +++--- apps/server/src/forwarding/retention.ts | 21 +-- apps/server/src/tg/accessMonitor.test.ts | 46 +++++++ apps/server/src/tg/accessMonitor.ts | 124 +++++++----------- apps/server/src/tg/entityResolver.test.ts | 21 +++ apps/server/src/tg/entityResolver.ts | 37 +++--- apps/server/src/tg/listener.test.ts | 30 +++++ apps/server/src/tg/listener.ts | 69 +++++----- apps/server/src/tg/messageMatcher.test.ts | 20 +-- apps/server/src/tg/messageMatcher.ts | 20 +-- apps/web/src/components/sheets/SubSheet.tsx | 65 ++++----- 24 files changed, 482 insertions(+), 483 deletions(-) create mode 100644 apps/server/src/filters/validateParams.test.ts create mode 100644 apps/server/src/filters/validateParams.ts diff --git a/apps/server/src/api/routes/libraryFilters.test.ts b/apps/server/src/api/routes/libraryFilters.test.ts index 09ff3e4..e214e2c 100644 --- a/apps/server/src/api/routes/libraryFilters.test.ts +++ b/apps/server/src/api/routes/libraryFilters.test.ts @@ -74,6 +74,20 @@ describe('library filter routes', () => { expect(res.statusCode).toBe(400); }); + it('POST /api/library-filters rejects an uncompilable text-regex pattern', async () => { + const res = await testApp.app.inject({ + method: 'POST', + url: '/api/library-filters', + headers: { cookie }, + payload: { + name: 'bad regex', + ruleType: 'text-regex', + params: { pattern: '(', flags: '' }, + }, + }); + expect(res.statusCode).toBe(400); + }); + it('GET /api/library-filters carries usageCount via JOIN', async () => { const [lib] = testApp.db .insert(libraryFilters) diff --git a/apps/server/src/api/routes/libraryFilters.ts b/apps/server/src/api/routes/libraryFilters.ts index b9d1fb7..a0841a4 100644 --- a/apps/server/src/api/routes/libraryFilters.ts +++ b/apps/server/src/api/routes/libraryFilters.ts @@ -1,16 +1,3 @@ -/** - * Library filter CRUD routes (Chapter 11). - * - * Reusable named filter rules attachable to many subscriptions via the - * `subscription_library_filters` join table. Delete is pre-checked for - * usage with a 409 `library_filter_in_use` error; the FK is `ON DELETE - * RESTRICT` as a belt-and-suspenders DB-level guard. - * - * `ruleType` is immutable post-creation (matches the per-sub filter - * convention from Ch 7); to change it, delete and re-add. PATCH bodies - * with `params` are revalidated against the existing row's `ruleType` - * via the matching `filterRuleParamsSchemas` entry. - */ import { asc, count, eq } from 'drizzle-orm'; import type { FastifyInstance } from 'fastify'; import { @@ -25,6 +12,7 @@ import type { Db } from '../../db/client.js'; import { libraryFilters, subscriptionLibraryFilters } from '../../db/schema.js'; import { countWhere } from '../../lib/dbHelpers.js'; import { ConflictError, NotFoundError, ValidationError } from '../../lib/errors.js'; +import { assertFilterParamsCompilable } from '../../filters/validateParams.js'; import { idParamsSchema } from './_params.js'; interface LibraryFilterRow { @@ -55,6 +43,7 @@ export function registerLibraryFilterRoutes( app.post('/library-filters', async (request, reply) => { const body = createLibraryFilterRequestSchema.parse(request.body); + assertFilterParamsCompilable(body.ruleType, body.params); const inserted = db .insert(libraryFilters) .values({ @@ -81,8 +70,7 @@ export function registerLibraryFilterRoutes( app.patch('/library-filters/:id', async (request) => { const { id } = idParamsSchema.parse(request.params); const body = updateLibraryFilterRequestSchema.parse(request.body); - // Need existing.ruleType to validate body.params against the matching - // params schema — ruleType is immutable post-creation. + // ruleType is immutable; needed to pick the params schema for validation. const existing = db.select().from(libraryFilters).where(eq(libraryFilters.id, id)).get(); if (!existing) throw new NotFoundError('library filter'); @@ -94,6 +82,7 @@ export function registerLibraryFilterRoutes( throw new ValidationError('invalid params for rule type', result.error.issues); } validatedParams = result.data; + assertFilterParamsCompilable(existing.ruleType, validatedParams); } const updated = db diff --git a/apps/server/src/api/routes/subscriptions.ts b/apps/server/src/api/routes/subscriptions.ts index 96319a6..f7a06c0 100644 --- a/apps/server/src/api/routes/subscriptions.ts +++ b/apps/server/src/api/routes/subscriptions.ts @@ -1,37 +1,3 @@ -/** - * Subscription CRUD + resolve routes. - * - * `sourceChatId` and `handle` are immutable post-creation (matches the - * shared schema's `updateSubscriptionRequestSchema`); to change the source - * channel, delete and recreate. - * - * DELETE returns 204 No Content (REST convention). Cascades down to - * `subscription_filters` and `subscription_library_filters` (Ch 11); - * `forward_log` rows survive with `subscriptionId` set to NULL via - * `ON DELETE SET NULL` (Ch 2). - * - * The resolve endpoint is preview-only — it never writes to the DB. The - * UI submits the resolved fields back to POST /subscriptions to commit. - * Routes that need the gramjs entity resolver gate on its presence and - * return 503 — `telegram_initializing` while the Telegram subsystem is - * still bootstrapping (background init in `apps/server/src/index.ts`), - * `telegram_unavailable` once it's settled in a disconnected state (test - * mode, missing Telegram env). The web UI keys on the code: the first is - * a transient retry; the second points the operator at configuration. - * - * Library filter attachments are exposed two ways: - * - bulk-replace via `libraryFilterIds` on POST/PATCH /subscriptions[/:id] - * (used by the SubSheet's checkbox group) - * - granular at POST /:id/library-filters and DELETE /:id/library-filters/:libId - * (used by the per-sub Filters view's `+` and `X` buttons) - * - * Inline (private) filters work the same way: bulk-replace via - * `inlineFilters` on POST/PATCH /subscriptions[/:id], plus the granular - * CRUD at /:id/filters[/:filterId] in `filters.ts`. Both library and - * inline writes happen inside a single `db.transaction(...)` so a partial - * failure can't leave the subscription with one set replaced and the other - * untouched. - */ import { and, asc, eq, inArray, sql } from 'drizzle-orm'; import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; @@ -56,6 +22,7 @@ import { subscriptions, } from '../../db/schema.js'; import type { EventBus } from '../../events/bus.js'; +import { assertFilterParamsCompilable } from '../../filters/validateParams.js'; import { InternalError, NotFoundError, @@ -69,9 +36,6 @@ import type { JoinChannelFn } from '../../tg/joinChannel.js'; import type { ProfilePhotoFetcher } from '../../tg/profilePhoto.js'; import { idParamsSchema } from './_params.js'; -// `db.transaction(cb)` passes `cb` a tx handle whose query interface matches -// `Db`'s, but its TS type is the more specific `SQLiteTransaction`. Helpers -// that need to run under either branch take this union. type DbOrTx = Db | Parameters[0]>[0]; const libraryFilterAttachmentParamsSchema = z.object({ @@ -82,38 +46,14 @@ const libraryFilterAttachmentParamsSchema = z.object({ export interface RegisterSubscriptionDeps { db: Db; bus: EventBus; - /** - * Live status getter used to distinguish "Telegram is starting up — try - * again in a moment" from "Telegram is not configured at all". Drives - * the error code returned when a tg-dep getter yields undefined. - */ + // Distinguishes "starting up" from "not configured" for the tg-dep error code. getTelegramStatus: () => TelegramStatus; - /** - * Lazy lookup for the universal "paste-anything" resolver — backs - * `POST /subscriptions/resolve`. Read per request because the boot path - * fills it asynchronously after `app.listen()`. Accepts any of: - * `@username`, `t.me/username`, `t.me/+HASH`, `+HASH`, or numeric chat id. - */ + // Lazy getters: boot fills them asynchronously after `app.listen()`, so read per request. getChatResolver?: () => ChatResolver | undefined; - /** - * Lazy lookup for the `messages.ImportChatInvite` wrapper invoked from - * `POST /subscriptions` when the body carries `inviteHash`. Same - * lifecycle as `getChatResolver`. - */ getImportInvite?: () => ImportInviteFn | undefined; - /** - * Lazy lookup for the auto-join helper invoked after a successful - * POST /subscriptions insert (chatId path). When the getter yields - * undefined the row keeps the default 'ok' status and the access - * monitor's first sweep corrects it. The `inviteHash` path bypasses - * this — `importInvite` already joined. - */ + // undefined => keep default status; access monitor's first sweep corrects it. getJoinChannel?: () => JoinChannelFn | undefined; - /** - * Lazy lookup for the best-effort profile-photo fetcher invoked after - * the row is inserted. When undefined the row's `iconDataUrl` stays - * null and the access monitor's lazy backfill catches it later. - */ + // undefined => iconDataUrl stays null; access monitor backfills later. getFetchProfilePhoto?: () => ProfilePhotoFetcher | undefined; } @@ -156,9 +96,7 @@ export function registerSubscriptionRoutes( app.post('/subscriptions', async (request, reply) => { const body = createSubscriptionRequestSchema.parse(request.body); - // `destinationId` is now optional/nullable — only validate when provided. - // FK SET NULL would coalesce a bad id silently, so we still 400 up-front - // for a missing one to surface a clear UI message. + // FK SET NULL would coalesce a bad id silently; 400 up-front for a clear UI message. if (body.destinationId !== undefined && body.destinationId !== null) { const dest = db .select() @@ -170,13 +108,7 @@ export function registerSubscriptionRoutes( if (body.libraryFilterIds && body.libraryFilterIds.length > 0) { assertLibraryFiltersExist(db, body.libraryFilterIds); } - // Source==destination guard. A subscription whose source chat id is also - // a destination chat id would forward into itself: the bot's own forwards - // would (via the history poller, which bypasses the `incoming:true` - // filter on the live listener) be re-ingested and forwarded again on the - // next sweep, ad infinitum. Reject up-front; safe to skip when the body - // uses an `inviteHash` since we don't know the chat id until after join, - // and the post-join shape is checked below. + // source==destination = forwarding loop (poller re-ingests own forwards); inviteHash re-checked post-join below. if (body.sourceChatId !== undefined) { const conflict = db .select({ id: destinations.id }) @@ -190,10 +122,7 @@ export function registerSubscriptionRoutes( } } - // Resolve the source chat id from either the resolved id (existing flow) - // or by joining via invite hash (new flow). The hash path is destructive - // and runs *before* the insert so a join failure doesn't leave a - // half-baked row behind. + // Invite-hash join is destructive; run it before the insert so a failure leaves no half-baked row. let sourceChatId: string; let preJoined = false; if (body.inviteHash) { @@ -210,11 +139,7 @@ export function registerSubscriptionRoutes( } else { sourceChatId = body.sourceChatId!; } - // Re-check post-resolve in case the invite path landed on a chat that is - // already a destination. (Same loop-prevention rationale as above.) We - // intentionally don't undo the join — at this point the bot has joined - // the channel; the operator just can't use it as a subscription with - // that destination configuration. + // Re-check post-resolve for the loop guard; the join is intentionally not undone. { const conflict = db .select({ id: destinations.id }) @@ -249,12 +174,8 @@ export function registerSubscriptionRoutes( } return row.id; }); - // For the chatId path: auto-join the source channel so the userbot - // starts receiving its events. Done outside the transaction (gramjs - // network I/O can't be atomic with SQLite) and before the SSE emit so - // the DTO read by the UI carries the post-join status. + // Outside the tx (gramjs I/O isn't atomic with SQLite) and before the SSE emit so the DTO carries post-join status. if (preJoined) { - // Already joined via importInvite; just stamp the access check. db.update(subscriptions) .set({ sourceAccessStatus: 'ok', sourceAccessCheckedAt: new Date() }) .where(eq(subscriptions.id, newId)) @@ -269,10 +190,7 @@ export function registerSubscriptionRoutes( .run(); } } - // Best-effort profile photo fetch. Same reasoning as on the - // destination create path: the fetcher swallows errors and returns - // null, so failure means the row stays icon-less until the access - // monitor's lazy backfill kicks in. + // Best-effort; fetcher swallows errors, so the row stays icon-less until backfill. const fetchProfilePhoto = getFetchProfilePhoto?.(); if (fetchProfilePhoto) { const iconDataUrl = await fetchProfilePhoto(sourceChatId); @@ -334,7 +252,6 @@ export function registerSubscriptionRoutes( return null; }); - /** Granular attach — backs the per-sub view's `+ library filter` action. */ app.post('/subscriptions/:id/library-filters', async (request, reply) => { const { id } = idParamsSchema.parse(request.params); const body = attachLibraryFilterRequestSchema.parse(request.body); @@ -361,7 +278,6 @@ export function registerSubscriptionRoutes( return dto; }); - /** Granular detach — the design's "X" button on attached library chips. */ app.delete('/subscriptions/:id/library-filters/:libId', async (request, reply) => { const { id, libId } = libraryFilterAttachmentParamsSchema.parse(request.params); const result = db @@ -429,10 +345,7 @@ export function replaceLibraryFilterAttachments( .run(); } -// Bulk-replace the private inline filter set for a subscription. The input -// array has already been zod-validated as a discriminated union, so each -// element's `params` is guaranteed to match its `ruleType`. Empty array = -// drop all (matches `replaceLibraryFilterAttachments` semantics). +// Empty array = drop all. Inputs are pre-validated as a discriminated union (params match ruleType). export function replaceInlineFilters( db: DbOrTx, subscriptionId: number, @@ -442,6 +355,7 @@ export function replaceInlineFilters( .where(eq(subscriptionFilters.subscriptionId, subscriptionId)) .run(); if (inputs.length === 0) return; + for (const f of inputs) assertFilterParamsCompilable(f.ruleType, f.params); db.insert(subscriptionFilters) .values( inputs.map((f) => ({ @@ -484,9 +398,7 @@ function loadLibraryFilterIdsBatch(db: Db, subscriptionIds: number[]): Map`( SELECT COUNT(*) FROM ${subscriptionFilters} WHERE ${subscriptionFilters.subscriptionId} = ${subscriptions.id} diff --git a/apps/server/src/filters/rules/textRegex.test.ts b/apps/server/src/filters/rules/textRegex.test.ts index 4adf22a..53dfabf 100644 --- a/apps/server/src/filters/rules/textRegex.test.ts +++ b/apps/server/src/filters/rules/textRegex.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import type { MessageContext } from '../types.js'; -import { textRegexRule } from './textRegex.js'; +import { getCompiledRegex, textRegexRule } from './textRegex.js'; const ctx = (overrides: Partial = {}): MessageContext => ({ text: '', @@ -34,6 +34,12 @@ describe('textRegexRule', () => { expect(() => textRegexRule.evaluate(ctx({ text: 'x' }), { pattern: '(', flags: '' })).toThrow(); }); + it('memoizes compiled RE2 instances by pattern+flags', () => { + const a = getCompiledRegex('foo', 'i'); + expect(getCompiledRegex('foo', 'i')).toBe(a); + expect(getCompiledRegex('foo', '')).not.toBe(a); + }); + it('does not catastrophically backtrack on pathological pattern (RE2 is linear-time)', () => { // (a+)+$ on a long run of `a` followed by `b` is the textbook ReDoS case // for native RegExp — exponential backtracking. RE2 finishes in linear time. diff --git a/apps/server/src/filters/rules/textRegex.ts b/apps/server/src/filters/rules/textRegex.ts index 16523559e8bc7419ec607fc8b8150f7255f66e05..447538c7ec5d5611f124a668abf184196eb6dc68 100644 GIT binary patch literal 1143 zcmZuw(Q4a35bU$QVhaIFAj^kw5AfMuDc77%;_ehXHG+Obzp!7@*^}hB zkQW>6&hE_4?&+p$U7*e5Emf{EXv@kigKjt@+Xk{K=ocn% zKc2dhde3g@`>r#Z57eoEzSh&e?ahJO@Wk$@J(Ca07{VtR?E_nO^r98*)c!d*{AgPj z!uilPoi^yCElTX3Jz)T9xM}q-gmmSEi~ziZAHSTxb>>H`=EiTIn0j4Wv7$D zB@7)U#kTWjRz?BmgfNN>^vtgqV|#Osu;Ihad1^3h*8mv~&<@0ZgBM^;QHeo`bVcb$ zhvB3tjcBn2R?K2@irphzh_5^>{Maf-VayNc;yCoq;%}V{$vpC?_j1*8n&s7*-Yq1x zbaL^Fdngt;a<3&_XL3Z1+Oa|S2MVZB0ju;NlR`;%SMQ~K{}~DFaxD-h)N*g~&}Jh> zz0tgPmL$kxvU;v8u3Zva9T$OpNGAq-oI-}<9PK%S(0eF!Jszp7;^N$#s+938)+*oH elC~2etz^yD5{9?PcnI+&kKP=%WA`CX!151ztcJ<} delta 179 zcmWm8u?@m76a-KSgh0$daV-sz*dYtBfpLBu3&*y;e@HqQ0V#|^2OXPGu>mbkbIs|U z?zeg@ZsP?^hK4PyPeQ6fsNt2^GvPp{93no~JE9UH_RyiFgqlN(E)lPbBD>fLl&^Bx zx$LDO?rkCnTVsf!m8c{fVq-OqN}q&tSZ^k^K@#O0vYOVM%`bdLE`+!nzq9w!Q;$Qr IAD{ErAGH)h@&Et; diff --git a/apps/server/src/filters/validateParams.test.ts b/apps/server/src/filters/validateParams.test.ts new file mode 100644 index 0000000..5d30270 --- /dev/null +++ b/apps/server/src/filters/validateParams.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { ValidationError } from '../lib/errors.js'; +import { assertFilterParamsCompilable } from './validateParams.js'; + +describe('assertFilterParamsCompilable', () => { + it('accepts a valid text-regex pattern', () => { + expect(() => + assertFilterParamsCompilable('text-regex', { pattern: 'foo', flags: 'i' }), + ).not.toThrow(); + }); + + it('rejects an uncompilable text-regex pattern with ValidationError', () => { + expect(() => assertFilterParamsCompilable('text-regex', { pattern: '(', flags: '' })).toThrow( + ValidationError, + ); + }); + + it('is a no-op for non-regex rule types', () => { + expect(() => assertFilterParamsCompilable('text-contains', { value: 'x' })).not.toThrow(); + }); +}); diff --git a/apps/server/src/filters/validateParams.ts b/apps/server/src/filters/validateParams.ts new file mode 100644 index 0000000..9eba4f5 --- /dev/null +++ b/apps/server/src/filters/validateParams.ts @@ -0,0 +1,17 @@ +import { ValidationError } from '../lib/errors.js'; +import { getCompiledRegex } from './rules/textRegex.js'; + +// Write-time validation beyond the zod schema: a text-regex pattern must actually compile, +// so an invalid one is rejected at save time instead of failing open per-message at eval. +// Assumes params already passed their zod schema (pattern/flags are strings). +export function assertFilterParamsCompilable(ruleType: string, params: unknown): void { + if (ruleType !== 'text-regex') return; + const { pattern, flags } = params as { pattern: string; flags?: string }; + try { + getCompiledRegex(pattern, flags ?? ''); + } catch (err) { + throw new ValidationError( + `invalid text-regex pattern: ${err instanceof Error ? err.message : String(err)}`, + ); + } +} diff --git a/apps/server/src/forwarding/albumDebouncer.test.ts b/apps/server/src/forwarding/albumDebouncer.test.ts index ea7ea47..9cacf43 100644 --- a/apps/server/src/forwarding/albumDebouncer.test.ts +++ b/apps/server/src/forwarding/albumDebouncer.test.ts @@ -120,19 +120,32 @@ describe('createAlbumDebouncer', () => { expect(downstream.jobs[0]?.sourceMessageIds).toEqual(['10', '11', '12']); }); - it('keys by sourceChatId so the same groupedId from two different chats does not conflate', async () => { + it('keys by subscriptionId so a fan-out source keeps each subscription album separate', async () => { const downstream = makeDownstream(); const debouncer = makeDebouncer({ downstream, filterEvaluator: passEvaluator }); - debouncer.enqueue(raw({ sourceChatId: '-100A', sourceMessageId: '1', groupedId: 'g' })); - debouncer.enqueue(raw({ sourceChatId: '-100B', sourceMessageId: '2', groupedId: 'g' })); + // Same source + same album (groupedId), two subscriptions → two destinations. + debouncer.enqueue( + raw({ subscriptionId: 1, destinationChatId: '-100D1', sourceMessageId: '1', groupedId: 'g' }), + ); + debouncer.enqueue( + raw({ subscriptionId: 1, destinationChatId: '-100D1', sourceMessageId: '2', groupedId: 'g' }), + ); + debouncer.enqueue( + raw({ subscriptionId: 2, destinationChatId: '-100D2', sourceMessageId: '1', groupedId: 'g' }), + ); + debouncer.enqueue( + raw({ subscriptionId: 2, destinationChatId: '-100D2', sourceMessageId: '2', groupedId: 'g' }), + ); await vi.advanceTimersByTimeAsync(TEST_WINDOW_MS); + // Two independent batches, one per subscription/destination — not conflated into one. expect(downstream.jobs).toHaveLength(2); - const bySource = new Map(downstream.jobs.map((j) => [j.sourceChatId, j.sourceMessageIds])); - expect(bySource.get('-100A')).toEqual(['1']); - expect(bySource.get('-100B')).toEqual(['2']); + const byDest = new Map(downstream.jobs.map((j) => [j.destinationChatId, j.sourceMessageIds])); + expect(byDest.get('-100D1')).toEqual(['1', '2']); + expect(byDest.get('-100D2')).toEqual(['1', '2']); + expect(new Set(downstream.jobs.map((j) => j.subscriptionId))).toEqual(new Set([1, 2])); }); it('treats a straggler arriving after the window as a new group', async () => { diff --git a/apps/server/src/forwarding/albumDebouncer.ts b/apps/server/src/forwarding/albumDebouncer.ts index 10eef5c..b246caf 100644 --- a/apps/server/src/forwarding/albumDebouncer.ts +++ b/apps/server/src/forwarding/albumDebouncer.ts @@ -1,31 +1,4 @@ -/** - * Album / grouped-media debouncer + filter gate. - * - * Telegram delivers album members (photos/videos sharing a `groupedId`) as N - * independent `NewMessage` events arriving milliseconds apart. Without - * debouncing they would be forwarded as N separate messages — fragmenting the - * album in the destination and burning N throttle slots. - * - * This module sits between the listener (which produces one `RawForwardJob` - * per event) and the per-destination pipeline (which consumes one - * `ForwardJob` carrying the full id list). For ungrouped messages the - * debouncer evaluates filters and either passes the job through or drops it. - * For grouped messages it buffers by `${sourceChatId}:${groupedId}` for - * `ALBUM_DEBOUNCE_MS`, picks the caption-bearing member at flush time - * (longest text; ties broken by lowest message id), and evaluates filters - * once against that member's content — so an album passes or fails as a - * unit. Source ids in the emitted `ForwardJob` are sorted ascending and - * de-duplicated. - * - * On filter rejection the evaluator writes one `forward_log` row per source - * id with `status='filtered'` and the joined reasons in `error`. The - * debouncer itself never touches `forward_log`. - * - * `stop()` cancels pending timers and drops their buffered jobs (matches the - * pipeline's "ignore enqueue after stop" behavior in `queue.ts`). The - * listener doesn't persist incoming messages anywhere, so a missed album - * member at shutdown is no different from any other miss while offline. - */ +// Albums arrive as N NewMessage events sharing a groupedId; buffer per `${subscriptionId}:${groupedId}` to forward+filter as one (per-sub so a fan-out source keeps each subscription's album separate). import type { FilterEvaluator } from '../filters/evaluate.js'; import type { MessageContext } from '../filters/types.js'; import type { Logger } from '../lib/logger.js'; @@ -40,12 +13,7 @@ export interface AlbumDebouncerDeps { downstream: ForwardingHandle; filterEvaluator: FilterEvaluator; logger: Logger; - /** - * Live-reads the debounce window from app_settings on each new album. - * Wired up to `getAlbumDebounceMs(db)` in production; tests pass a - * constant. Read per-album rather than per-debouncer so the Settings - * UI takes effect on the next album without a restart. - */ + // Read per-album so a Settings change to the window takes effect without a restart. getWindowMs: () => number; } @@ -66,11 +34,7 @@ export function createAlbumDebouncer(deps: AlbumDebouncerDeps): AlbumDebouncer { const first = group.jobs[0]!; const sourceMessageIds = dedupeAndSort(group.jobs.map((j) => j.sourceMessageId)); const captionMember = pickCaptionBearingMember(group.jobs); - // Build the per-album raw payload as an array aligned with - // `sourceMessageIds` (ascending numeric id, deduplicated). Both arrays - // are constructed from the same ordering so positions match — the - // forwarder stores this array verbatim onto every row of the album so - // any row can stand alone in the JSON viewer. + // Aligned 1:1 with sourceMessageIds; stored on every album row so any row can stand alone. const albumRawMessage = collectAlbumRawMessages(group.jobs, sourceMessageIds); const context: MessageContext = { ...toContext(captionMember), @@ -120,7 +84,7 @@ export function createAlbumDebouncer(deps: AlbumDebouncerDeps): AlbumDebouncer { return; } - const key = `${raw.sourceChatId}:${raw.groupedId}`; + const key = `${raw.subscriptionId}:${raw.groupedId}`; const existing = pending.get(key); if (existing) { existing.jobs.push(raw); @@ -155,14 +119,7 @@ function toContext(job: RawForwardJob): MessageContext { }; } -/** - * Map each entry in `sourceMessageIds` (sorted ascending) to the matching - * job's `rawMessage`. Duplicate ids — possible if the listener and history - * poller both observe the same album member before the debouncer flushes — - * collapse to the first occurrence so the array length matches - * `sourceMessageIds` exactly. Jobs whose `rawMessage` is missing fall back - * to `null`, keeping array positions stable. - */ +// Aligned 1:1 with sourceMessageIds; dup ids collapse to first, missing → null. function collectAlbumRawMessages( jobs: readonly RawForwardJob[], sourceMessageIds: readonly string[], @@ -176,11 +133,7 @@ function collectAlbumRawMessages( return sourceMessageIds.map((id) => bySourceId.get(id) ?? null); } -/** - * Telegram puts the caption on a single album member (typically the first by - * message id). Pick the longest-text member; ties go to the lowest message id - * for determinism. Falls back to the first job if all members have empty text. - */ +// Telegram puts the caption on one member: pick longest text, ties to lowest message id. function pickCaptionBearingMember(jobs: readonly RawForwardJob[]): RawForwardJob { let best = jobs[0]!; for (const candidate of jobs.slice(1)) { diff --git a/apps/server/src/forwarding/forwarder.test.ts b/apps/server/src/forwarding/forwarder.test.ts index 66f36b9..d3d0149 100644 --- a/apps/server/src/forwarding/forwarder.test.ts +++ b/apps/server/src/forwarding/forwarder.test.ts @@ -5,7 +5,7 @@ import { createTestDb, type TestDbHandle } from '../db/testing.js'; import { destinations, forwardLog, subscriptions, type Subscription } from '../db/schema.js'; import type { EventBus } from '../events/bus.js'; import { createLogger } from '../lib/logger.js'; -import { createForwarder, type ForwarderClient } from './forwarder.js'; +import { createForwarder, recordForwardFailure, type ForwarderClient } from './forwarder.js'; import type { ForwardJob } from './types.js'; const logger = createLogger({ silent: true }); @@ -292,6 +292,30 @@ describe('createForwarder', () => { }); }); + it('recordForwardFailure writes a failed row and emits forward.failed (dead-letter)', () => { + const sub = seedSubscription(handle); + const bus = makeStubBus(); + recordForwardFailure( + { db: handle.db, logger, bus }, + makeJob(sub, ['7']), + 'flood_wait_abandoned', + ); + + const rows = handle.db + .select() + .from(forwardLog) + .where(eq(forwardLog.subscriptionId, sub.id)) + .all(); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + sourceMessageId: '7', + status: 'failed', + error: 'flood_wait_abandoned', + destMessageId: null, + }); + expect(bus.emitted.filter((e) => e.type === 'forward.failed')).toHaveLength(1); + }); + it('classifies CHAT_FORWARDS_RESTRICTED as a permanent failure with a tagged error message', async () => { const sub = seedSubscription(handle); const forwardMessages = vi diff --git a/apps/server/src/forwarding/forwarder.ts b/apps/server/src/forwarding/forwarder.ts index d9d4dcc..70b3285 100644 --- a/apps/server/src/forwarding/forwarder.ts +++ b/apps/server/src/forwarding/forwarder.ts @@ -1,19 +1,3 @@ -/** - * Single-shot forward + log writer. - * - * One call = one `forwardMessages` attempt + N `forward_log` rows (one per - * source message id in `job.sourceMessageIds`). The worker in `queue.ts` - * drives looping/retry; this module only knows how to perform one attempt and - * record what happened. - * - * Returning a discriminated `ForwardOutcome` (instead of throwing) keeps the - * worker loop free of error classification — the worker just switches on - * `outcome.status`. Rate-limit errors (FLOOD_WAIT and SLOWMODE_WAIT) collapse - * into one `flood_wait` outcome with a `kind` discriminator: retry semantics - * are identical, but the diagnostic (log row + emitted event) preserves the - * distinction. Permanent errors get a `failureKind` tag so the activity feed - * can surface "this subscription will keep failing" vs "transient hiccup". - */ import { eq } from 'drizzle-orm'; import type { Db } from '../db/client.js'; import { forwardLog, subscriptions, type ForwardLogStatus } from '../db/schema.js'; @@ -22,15 +6,6 @@ import type { Logger } from '../lib/logger.js'; import { extractRateLimit, type RateLimitKind } from './floodwait.js'; import type { ForwardFailureKind, ForwardJob, ForwardOutcome } from './types.js'; -/** - * Minimal structural slice of `TelegramClient.forwardMessages` we depend on. - * Lets tests pass a `vi.fn()` instead of constructing a real client. - * - * `id` is intentionally optional and entries can be null/undefined: gramjs's - * helper occasionally surfaces non-`Message` Updates entries (e.g. MessageEmpty, - * service messages) that don't carry a numeric id. The forwarder filters those - * out before stringifying — see comment at the call site. - */ export interface ForwarderClient { forwardMessages( entity: string, @@ -47,9 +22,7 @@ export interface CreateForwarderDeps { export type Forwarder = (job: ForwardJob) => Promise; -// Map known-permanent Telegram RPC error codes to our taxonomy. The strings -// are matched against err.message / err.errorMessage (gramjs surfaces the -// upstream code in there). Anything not on this list stays `transient`. +// Matched against err.message/err.errorMessage; anything not listed stays `transient`. const PERMANENT_FAILURES: Array<{ code: string; kind: ForwardFailureKind }> = [ { code: 'CHAT_FORWARDS_RESTRICTED', kind: 'permanent_chat_forwards_restricted' }, { code: 'MESSAGE_ID_INVALID', kind: 'permanent_message_id_invalid' }, @@ -57,8 +30,7 @@ const PERMANENT_FAILURES: Array<{ code: string; kind: ForwardFailureKind }> = [ { code: 'CHANNEL_PRIVATE', kind: 'permanent_channel_private' }, { code: 'USER_BANNED_IN_CHANNEL', kind: 'permanent_user_banned_in_channel' }, { code: 'CHAT_WRITE_FORBIDDEN', kind: 'permanent_chat_write_forbidden' }, - // Forum topic gone or closed — the destination's `topicId` is stale; retrying - // won't help until the user re-points the destination at a live topic. + // Stale destination topicId; won't recover until re-pointed at a live topic. { code: 'TOPIC_CLOSED', kind: 'permanent_topic_unavailable' }, { code: 'TOPIC_DELETED', kind: 'permanent_topic_unavailable' }, { code: 'TOPIC_ID_INVALID', kind: 'permanent_topic_unavailable' }, @@ -95,19 +67,12 @@ export function createForwarder(deps: CreateForwarderDeps): Forwarder { fromPeer: job.sourceChatId, ...(job.destinationTopicId != null ? { topMsgId: Number(job.destinationTopicId) } : {}), }); - // gramjs's high-level helper sometimes surfaces non-`Message` entries - // in the result (MessageEmpty, service updates, occasional null slots - // when the server's Updates payload is sparse) — coerce defensively - // and drop anything without a numeric id. Without the filter, calling - // `.toString()` on `undefined.id` blows up the whole forward outcome - // and we log a successful redirect as `failed`. + // Drop non-`Message` result entries (MessageEmpty/service/null) with no numeric id, else `.toString()` throws and a success logs as `failed`. const destMessageIds = sent .filter((m): m is { id: number } => m != null && typeof m.id === 'number') .map((m) => m.id.toString()); if (destMessageIds.length !== job.sourceMessageIds.length) { - // Telegram normally returns one id per forwarded message; a mismatch - // means the upstream silently dropped some. The tail will be logged - // as `sent` with destMessageId=NULL — flag it so it's investigable. + // Mismatch = upstream silently dropped some; the tail logs `sent` with destMessageId=NULL. logger.warn( { subscriptionId: job.subscriptionId, @@ -128,10 +93,7 @@ export function createForwarder(deps: CreateForwarderDeps): Forwarder { null, rawMessage, ); - // A successful forward proves the channel isn't (currently) restricting - // forwards. Clear any sticky badge from a previous CHAT_FORWARDS_RESTRICTED. - // The UPDATE is unconditional but cheap — single PK lookup, NULL→NULL - // when nothing was set. + // Success proves forwards aren't restricted; clear any sticky CHAT_FORWARDS_RESTRICTED badge. db.update(subscriptions) .set({ forwardingRestrictedAt: null }) .where(eq(subscriptions.id, job.subscriptionId)) @@ -163,15 +125,11 @@ export function createForwarder(deps: CreateForwarderDeps): Forwarder { } const failureKind = classifyForwardError(err); const errorMessage = err instanceof Error ? err.message : String(err); - // Tag permanent failures so the activity feed / log can render them - // distinctly. Transient stays a bare message for backwards compatibility - // with existing log readers. + // Tag permanent failures; transient stays a bare message for existing log readers. const errorText = failureKind === 'transient' ? errorMessage : `${failureKind}: ${errorMessage}`; if (failureKind === 'permanent_chat_forwards_restricted') { - // Stamp the sticky badge so the UI can show "noforwards" until - // either the channel re-allows forwards (cleared on the next - // success) or the user disables / removes the subscription. + // Sticky "noforwards" badge, cleared on the next successful forward. db.update(subscriptions) .set({ forwardingRestrictedAt: new Date() }) .where(eq(subscriptions.id, job.subscriptionId)) @@ -214,6 +172,42 @@ export function createForwarder(deps: CreateForwarderDeps): Forwarder { }; } +// Record a terminal failure for a job (e.g. a dead-lettered flood_wait): a `failed` +// forward_log row + a `forward.failed` event, reusing the normal failure surfacing. +export function recordForwardFailure( + deps: Pick, + job: ForwardJob, + errorText: string, +): void { + const { db, logger, bus } = deps; + const forwardLogIds = writeLogs( + db, + job, + job.sourceMessageIds.map((sourceId) => ({ sourceMessageId: sourceId, destMessageId: null })), + 'failed', + errorText, + job.rawMessage ?? null, + ); + logger.warn( + { + subscriptionId: job.subscriptionId, + destinationChatId: job.destinationChatId, + sourceMessageIds: job.sourceMessageIds, + error: errorText, + }, + 'forward dead-lettered', + ); + bus.emit({ + type: 'forward.failed', + subscriptionId: job.subscriptionId, + sourceChatId: job.sourceChatId, + destinationChatId: job.destinationChatId, + sourceMessageIds: [...job.sourceMessageIds], + error: errorText, + forwardLogIds, + }); +} + function handleRateLimit( deps: CreateForwarderDeps, job: ForwardJob, @@ -255,17 +249,10 @@ function handleRateLimit( return { status: 'flood_wait', seconds, kind }; } -// Hard cap on the size of the `raw_message` JSON snapshot persisted per row. -// `toJsonSafe` already truncates at 64KB (string length, now byte length), -// but a malicious / pathological gramjs payload could still slip a large -// nested object through; clamp at the storage boundary as a second wall. +// Second wall behind `toJsonSafe`'s 64KB truncation in case a large nested payload slips through. const RAW_MESSAGE_MAX_BYTES = 128 * 1024; -/** - * If a snapshot would store more than `RAW_MESSAGE_MAX_BYTES` of JSON, swap - * it for a small truncation marker so the row still inserts cleanly and the - * UI surfaces the reason instead of streaming a huge JSON blob. - */ +// Over-limit snapshots become a truncation marker so the row still inserts. function clampRawMessage(value: unknown): unknown { if (value === null || value === undefined) return value; let encoded: string; @@ -280,13 +267,7 @@ function clampRawMessage(value: unknown): unknown { return { __truncated: true, size: byteLen }; } -/** - * Insert N `forward_log` rows in one batch and return the assigned ids in - * the same order so the caller can attach them to the emitted SSE event. - * The whole forward batch shares one `rawMessage` snapshot — single object - * for ordinary messages, array for albums — denormalized onto every row so - * any row is independently inspectable via `GET /forward-log/:id/raw`. - */ +// The batch's single rawMessage is denormalized onto every row so any row is inspectable via GET /forward-log/:id/raw. function writeLogs( db: Db, job: ForwardJob, diff --git a/apps/server/src/forwarding/index.ts b/apps/server/src/forwarding/index.ts index 1fad66c..f8112e5 100644 --- a/apps/server/src/forwarding/index.ts +++ b/apps/server/src/forwarding/index.ts @@ -1,14 +1,8 @@ -/** - * Public surface of the forwarding pipeline. - * - * `createForwardingPipeline` wires the default forwarder + throttle reader - * against the live gramjs client and DB. Tests build the pipeline directly - * via `new ForwardingPipeline(...)` with stubs. - */ +// `createForwardingPipeline` wires the live forwarder; tests build `ForwardingPipeline` with stubs. import type { Db } from '../db/client.js'; import type { EventBus } from '../events/bus.js'; import type { Logger } from '../lib/logger.js'; -import { createForwarder, type ForwarderClient } from './forwarder.js'; +import { createForwarder, recordForwardFailure, type ForwarderClient } from './forwarder.js'; import { ForwardingPipeline } from './queue.js'; import { getGlobalDelayMs } from './throttle.js'; @@ -47,5 +41,11 @@ export function createForwardingPipeline(deps: CreatePipelineDeps): ForwardingPi forwarder, getDelayMs: () => getGlobalDelayMs(deps.db), logger: deps.logger, + onDeadLetter: (job) => + recordForwardFailure( + { db: deps.db, logger: deps.logger, bus: deps.bus }, + job, + 'flood_wait_abandoned', + ), }); } diff --git a/apps/server/src/forwarding/queue.test.ts b/apps/server/src/forwarding/queue.test.ts index 90af084..3b33ba3 100644 --- a/apps/server/src/forwarding/queue.test.ts +++ b/apps/server/src/forwarding/queue.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { createLogger } from '../lib/logger.js'; import type { Forwarder } from './forwarder.js'; -import { ForwardingPipeline, type SleepFn } from './queue.js'; +import { ForwardingPipeline, MAX_FLOOD_WAIT_ATTEMPTS, type SleepFn } from './queue.js'; import type { ForwardJob, ForwardOutcome } from './types.js'; const logger = createLogger({ silent: true }); @@ -156,6 +156,42 @@ describe('ForwardingPipeline', () => { await pipeline.stop(); }); + it('dead-letters a job after MAX_FLOOD_WAIT_ATTEMPTS flood_waits and unblocks the FIFO', async () => { + const deadLettered: ForwardJob[] = []; + const forwarder: Forwarder = vi.fn(async (j): Promise => { + if (j.sourceMessageIds[0] === 'msg-1') { + return { status: 'flood_wait', seconds: 30, kind: 'flood_wait' }; + } + return sent(); + }); + + const pipeline = new ForwardingPipeline({ + forwarder, + getDelayMs: () => 1000, + logger, + sleep: fakeSleep, + onDeadLetter: (j) => deadLettered.push(j), + }); + + pipeline.enqueue(job('-100A', 'msg-1')); // perpetually flooded + pipeline.enqueue(job('-100A', 'msg-2')); // stuck behind it + + // First attempt fires immediately; each retry follows a 30s flood backoff. + await vi.advanceTimersByTimeAsync(0); + for (let i = 0; i <= MAX_FLOOD_WAIT_ATTEMPTS; i++) { + await vi.advanceTimersByTimeAsync(30_000); + } + + // msg-1 abandoned exactly once; msg-2 (behind it) then forwards. + expect(deadLettered).toHaveLength(1); + expect(deadLettered[0]).toMatchObject({ sourceMessageIds: ['msg-1'] }); + expect(forwarder).toHaveBeenCalledWith( + expect.objectContaining({ sourceMessageIds: ['msg-2'] }), + ); + + await pipeline.stop(); + }); + it('stop() aborts a pending throttle sleep and resolves promptly', async () => { const forwarder: Forwarder = vi.fn(async () => sent()); const pipeline = new ForwardingPipeline({ diff --git a/apps/server/src/forwarding/queue.ts b/apps/server/src/forwarding/queue.ts index b697a34..55d81ec 100644 --- a/apps/server/src/forwarding/queue.ts +++ b/apps/server/src/forwarding/queue.ts @@ -1,26 +1,12 @@ -/** - * Per-destination FIFO queues with one worker each. - * - * Telegram throttles per receiving chat, so the throttling domain == the - * destination chat. Workers are created lazily on first enqueue for a given - * destination and run for the lifetime of the pipeline. - * - * Worker loop: - * 1. Wait for a job (sleep on a wakeup promise). - * 2. Sleep until at least `delayMs` has elapsed since the last successful - * attempt for this destination. - * 3. Hand the job to the forwarder. - * 4. On `flood_wait`: sleep `seconds * 1000` and retry the SAME job. - * On `sent`/`failed`: pop the job and move on. - * - * `stop()` aborts in-flight sleeps and waits for the current send to finish. - */ +// Per-destination FIFO + worker: Telegram throttles per receiving chat, so that's the throttling domain. import { setTimeout as nodeSleep } from 'node:timers/promises'; import type { Logger } from '../lib/logger.js'; import type { Forwarder } from './forwarder.js'; import type { ForwardJob, ForwardingHandle } from './types.js'; export const MAX_FLOOD_WAIT_SECONDS = 300; +// Consecutive flood_waits on one head job before it's dead-lettered (and the FIFO unblocked). +export const MAX_FLOOD_WAIT_ATTEMPTS = 5; export type SleepFn = (ms: number, signal: AbortSignal) => Promise; @@ -32,6 +18,8 @@ export interface PipelineDeps { getDelayMs: () => number; logger: Logger; sleep?: SleepFn; + // Called when a job is abandoned after MAX_FLOOD_WAIT_ATTEMPTS; records the terminal failure. + onDeadLetter?: (job: ForwardJob) => void; } interface WorkerDeps { @@ -39,6 +27,7 @@ interface WorkerDeps { getDelayMs: () => number; logger: Logger; sleep: SleepFn; + onDeadLetter?: (job: ForwardJob) => void; } class DestinationWorker { @@ -46,6 +35,7 @@ class DestinationWorker { private wakeup: () => void = () => {}; private wakeupPromise: Promise; private lastSendAt = 0; + private floodWaitCount = 0; private loopPromise?: Promise; constructor( @@ -99,6 +89,22 @@ class DestinationWorker { const outcome = await this.deps.forwarder(job); if (outcome.status === 'flood_wait') { + this.floodWaitCount++; + if (this.floodWaitCount > MAX_FLOOD_WAIT_ATTEMPTS) { + // Perpetually flooded — abandon the head job so it can't wedge the FIFO forever. + this.deps.logger.error( + { + destinationChatId: this.destinationChatId, + sourceMessageIds: job.sourceMessageIds, + attempts: this.floodWaitCount, + }, + 'flood_wait exceeded max attempts — dead-lettering job', + ); + this.deps.onDeadLetter?.(job); + this.queue.shift(); + this.floodWaitCount = 0; + continue; + } const cappedSeconds = Math.min(outcome.seconds, MAX_FLOOD_WAIT_SECONDS); if (outcome.seconds > MAX_FLOOD_WAIT_SECONDS) { this.deps.logger.warn( @@ -116,6 +122,7 @@ class DestinationWorker { } this.queue.shift(); + this.floodWaitCount = 0; this.lastSendAt = Date.now(); } this.deps.logger.debug({ destinationChatId: this.destinationChatId }, 'worker stopped'); @@ -134,6 +141,7 @@ export class ForwardingPipeline implements ForwardingHandle { getDelayMs: deps.getDelayMs, logger: deps.logger, sleep: deps.sleep ?? cancellableSleep, + ...(deps.onDeadLetter ? { onDeadLetter: deps.onDeadLetter } : {}), }; } diff --git a/apps/server/src/forwarding/retention.ts b/apps/server/src/forwarding/retention.ts index 1f8bc46..b733d05 100644 --- a/apps/server/src/forwarding/retention.ts +++ b/apps/server/src/forwarding/retention.ts @@ -1,17 +1,4 @@ -/** - * Forward-log retention. - * - * Without periodic pruning, every forwarded message accumulates a row plus a - * (potentially large) `raw_message` JSON blob in `forward_log`. A subscribed - * channel can pump thousands of messages a day; over months the file balloons - * to GBs and read latency degrades. - * - * Policy: keep the most recent `maxRows` rows by `(createdAt DESC, id DESC)`, - * delete everything else. SQLite handles the DELETE…WHERE id NOT IN (SELECT…) - * pattern fine at this scale; we also add a hard cap on `raw_message` byte - * length at insert time so a single pathological payload can't dwarf the rest - * of the column. - */ +// Keep the most recent maxRows rows by (createdAt DESC, id DESC), delete the rest. import { sql } from 'drizzle-orm'; import type { Db } from '../db/client.js'; import { forwardLog } from '../db/schema.js'; @@ -22,14 +9,10 @@ export const DEFAULT_FORWARD_LOG_MAX_ROWS = 10_000; export interface PruneForwardLogDeps { db: Db; logger: Logger; - /** Hard cap on retained rows. Override via env on the boot path. */ + // Retained-row cap; defaults to DEFAULT_FORWARD_LOG_MAX_ROWS. Test-only override (prod uses the default). maxRows?: number; } -/** - * Returns the number of rows deleted. Idempotent — a fresh DB with fewer - * than `maxRows` rows is a no-op. - */ export function pruneForwardLog(deps: PruneForwardLogDeps): number { const { db, logger } = deps; const maxRows = deps.maxRows ?? DEFAULT_FORWARD_LOG_MAX_ROWS; diff --git a/apps/server/src/tg/accessMonitor.test.ts b/apps/server/src/tg/accessMonitor.test.ts index 39cf28f..8fe6c14 100644 --- a/apps/server/src/tg/accessMonitor.test.ts +++ b/apps/server/src/tg/accessMonitor.test.ts @@ -418,4 +418,50 @@ describe('createAccessMonitor', () => { expect(calls).toBeGreaterThanOrEqual(3); monitor.stop(); }); + + it('preserves each target status under threshold for a mixed-status shared chatId', async () => { + // Same chat is both the subscription source and a destination, with divergent + // persisted statuses — an under-threshold sweep must not clobber either badge. + s.dbHandle.db + .update(destinations) + .set({ chatId: '-1001111111111', accessStatus: 'no_access' }) + .where(eq(destinations.id, s.destId)) + .run(); + s.dbHandle.db + .update(subscriptions) + .set({ sourceAccessStatus: 'ok' }) + .where(eq(subscriptions.id, s.subId)) + .run(); + + const client = makeClient(() => Promise.reject(new Error('CHANNEL_PRIVATE'))); + const monitor = createAccessMonitor({ + client, + db: s.dbHandle.db, + bus: s.bus, + logger, + intervalMs: 100, + noAccessFailThreshold: 3, // single sweep stays under the threshold + }); + + await monitor.probe(); + + const subRow = s.dbHandle.db + .select() + .from(subscriptions) + .where(eq(subscriptions.id, s.subId)) + .get(); + const destRow = s.dbHandle.db + .select() + .from(destinations) + .where(eq(destinations.id, s.destId)) + .get(); + // Each keeps its own prior status; the destination's no_access is NOT cleared to ok. + expect(subRow?.sourceAccessStatus).toBe('ok'); + expect(destRow?.accessStatus).toBe('no_access'); + // Freshness still bumped on both, and no status change means no events. + expect(subRow?.sourceAccessCheckedAt).toBeInstanceOf(Date); + expect(destRow?.accessCheckedAt).toBeInstanceOf(Date); + expect(s.busEvents).toHaveLength(0); + monitor.stop(); + }); }); diff --git a/apps/server/src/tg/accessMonitor.ts b/apps/server/src/tg/accessMonitor.ts index cf58cc1..b3ae90f 100644 --- a/apps/server/src/tg/accessMonitor.ts +++ b/apps/server/src/tg/accessMonitor.ts @@ -1,26 +1,4 @@ -/** - * Periodic check that the userbot still has access to every chat the - * forwarder cares about — both source channels (subscribed to) and - * destination chats (forwarded to). - * - * The userbot can lose access without any error reaching the app: the - * channel admin kicks it, deletes the channel, or the account swaps. The - * symptom is that `NewMessage` stops arriving from that source and/or - * `forwardMessages` starts failing for that destination — both invisible - * until something tries to use them. This monitor surfaces the loss - * proactively by calling `getEntity` on every chat once a day and writing - * a binary status to the DB so the UI can render a "no access" badge. - * - * Probe granularity is per-chat (deduped across subscriptions and - * destinations) and sequential, mirroring `resolveSubscriptionsOnStartup`'s - * floodwait-conscious pattern. On a `FloodWaitError` we wait the requested - * duration once; further waits are deferred to the next 24h tick. - * - * Status writes are diffed against the current row value: only transitions - * emit `subscription.changed` / `destination.changed` events. The - * `*_checked_at` timestamp is always updated so future UIs can distinguish - * fresh from stale. - */ +// Daily getEntity probe of every source/destination chat, persisting a no-access status the UI badges; access loss is otherwise invisible until a forward fails. import { setTimeout as sleep } from 'node:timers/promises'; import { eq } from 'drizzle-orm'; import type { Api } from 'telegram'; @@ -34,14 +12,9 @@ import type { ProfilePhotoFetcher } from './profilePhoto.js'; export const ACCESS_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; -// Avoid flapping the "no access" badge on transient Telegram errors (a hostile -// admin briefly kicking the bot, a one-off CHANNEL_PRIVATE on the wire, etc). -// We only flip an existing `ok` status to `no_access` after this many -// consecutive failures; going back to `ok` is always one-shot because access -// being restored is unambiguous. +// Consecutive failures before flipping ok→no_access (anti-flap); recovery to ok is one-shot. const NO_ACCESS_CONSECUTIVE_FAILS = 3; -// Subset of `TelegramClient` we depend on so tests can pass a stub. export interface AccessProbeClient { getEntity(entity: string | Api.TypeInputPeer | Api.TypeInputUser): Promise; } @@ -51,28 +24,18 @@ export interface AccessMonitorDeps { db: Db; bus: EventBus; logger: Logger; - /** Default 24h. Overridable for tests. */ + // default 24h intervalMs?: number; - /** - * How many consecutive failed probes flip a chat's status from `ok` to - * `no_access`. Default 3 — a hostile admin briefly kicking the bot, or a - * one-off `CHANNEL_PRIVATE` on the wire, shouldn't stamp the badge for a - * full 24h. Tests that want single-probe semantics override to 1. - */ + // consecutive failed probes that flip ok→no_access (default 3) noAccessFailThreshold?: number; - /** - * Lazy backfill: when a row's `icon_data_url` is null and the access - * probe came back ok, the monitor calls this to fetch and stamp the - * channel/chat profile photo. Optional — when missing (Telegram-less - * boot, tests) the monitor only updates the access status columns. - */ + // when set, backfills a row's null icon_data_url on a successful probe fetchProfilePhoto?: ProfilePhotoFetcher; } export interface AccessMonitor { start(): void; stop(): void; - /** Run one full sweep immediately. Exposed for tests and shutdown coverage. */ + // run one full sweep immediately probe(): Promise; } @@ -100,16 +63,12 @@ export function createAccessMonitor(deps: AccessMonitorDeps): AccessMonitor { const failThreshold = deps.noAccessFailThreshold ?? NO_ACCESS_CONSECUTIVE_FAILS; let stopped = false; let inFlight: Promise | undefined; - // Per-chat consecutive-failure counter. Reset on success; flip the persisted - // status only after `failThreshold` sweeps in a row. Lives in memory — - // survives between sweeps but not restarts, which is fine because the - // operator can see the persisted status in the UI either way. + // Per-chat consecutive-failure counter; in-memory, resets on success and on restart. const failureCounts = new Map(); async function probe(): Promise { if (stopped) return; - // Serialize concurrent probes (interval tick mid-sweep is harmless but - // doubles the load). The second caller awaits the first. + // Serialize concurrent probes; the second caller awaits the first. if (inFlight) { await inFlight; return; @@ -129,8 +88,7 @@ export function createAccessMonitor(deps: AccessMonitorDeps): AccessMonitor { let okCount = 0; let noAccessCount = 0; - // Group by chatId so a single chat used as both source and destination - // (or by multiple subscriptions) is probed once per sweep. + // Group by chatId so a chat reused across rows is probed once per sweep. const byChatId = new Map(); for (const target of targets) { const existing = byChatId.get(target.chatId); @@ -147,41 +105,41 @@ export function createAccessMonitor(deps: AccessMonitorDeps): AccessMonitor { skippedCount++; continue; } - // Hysteresis: a single failed probe doesn't flip the badge. Only after - // N consecutive failures does the persisted status move ok → no_access. - // Recovery (no_access → ok) is one-shot — the moment we can read the - // channel again, clear the badge. - let status: 'ok' | 'no_access'; if (probed === 'no_access') { const next = (failureCounts.get(chatId) ?? 0) + 1; failureCounts.set(chatId, next); if (next < failThreshold) { - // Keep the previous persisted status; just bump the freshness ts. - // Recovery shortcut: if every target in the group was already - // `no_access`, treat as still `no_access` so we don't write `ok`. - const wasNoAccess = group.every((t) => t.prevStatus === 'no_access'); - status = wasNoAccess ? 'no_access' : 'ok'; + // Under threshold: hold each target's OWN prior status (anti-flap) and just bump + // its freshness ts. A single group-wide status would clobber a divergent badge + // when the same chatId is both a source and a destination. logger.debug( { chatId, consecutiveFailures: next }, 'access monitor: probe failed but still under flap threshold', ); - } else { - status = 'no_access'; + for (const target of group) { + touchCheckedAt(target); + if (target.prevStatus === 'no_access') noAccessCount++; + else okCount++; + } + continue; } - } else { + // Threshold reached: flip the whole group to no_access and reset the counter so the + // map doesn't grow unbounded for a chat that never recovers. failureCounts.delete(chatId); - status = 'ok'; + for (const target of group) { + applyStatus(target, 'no_access'); + } + noAccessCount += group.length; + continue; } - if (status === 'ok') okCount++; - else noAccessCount++; + + // Reachable: clear the failure counter, mark every row ok, backfill any missing icons. + failureCounts.delete(chatId); for (const target of group) { - applyStatus(target, status); + applyStatus(target, 'ok'); } - // Icon backfill: when this chat is reachable and at least one row in - // the group is still icon-less, fetch the profile photo once and - // stamp it on every matching row. Skipped on `no_access` because - // gramjs would just fail again. - if (status === 'ok' && fetchProfilePhoto) { + okCount += group.length; + if (fetchProfilePhoto) { const needsIcon = group.filter((t) => t.iconDataUrl === null); if (needsIcon.length > 0) { const iconDataUrl = await fetchProfilePhoto(chatId); @@ -208,9 +166,7 @@ export function createAccessMonitor(deps: AccessMonitorDeps): AccessMonitor { } catch (err) { const rl = extractRateLimit(err); if (rl === null) return 'no_access'; - // Bounded retry: wait the requested time once. If it floods again - // we leave the chat untouched until the next tick — better than - // burning CPU on a multi-hour cooldown. + // Wait the requested floodwait once; if it floods again, defer to the next tick. logger.warn({ chatId, seconds: rl.seconds }, 'access monitor: flood wait, retrying once'); await sleep(rl.seconds * 1000); if (stopped) return 'skip'; @@ -260,6 +216,22 @@ export function createAccessMonitor(deps: AccessMonitorDeps): AccessMonitor { } } + // Bump only the freshness timestamp, leaving the access status (and its event) untouched. + function touchCheckedAt(target: Target): void { + const now = new Date(); + if (target.kind === 'subscription') { + db.update(subscriptions) + .set({ sourceAccessCheckedAt: now }) + .where(eq(subscriptions.id, target.id)) + .run(); + } else { + db.update(destinations) + .set({ accessCheckedAt: now }) + .where(eq(destinations.id, target.id)) + .run(); + } + } + function stampIcon(target: Target, iconDataUrl: string): void { if (target.kind === 'subscription') { db.update(subscriptions).set({ iconDataUrl }).where(eq(subscriptions.id, target.id)).run(); diff --git a/apps/server/src/tg/entityResolver.test.ts b/apps/server/src/tg/entityResolver.test.ts index b4340d3..f1838e3 100644 --- a/apps/server/src/tg/entityResolver.test.ts +++ b/apps/server/src/tg/entityResolver.test.ts @@ -54,6 +54,27 @@ describe('parseInput', () => { }); }); + it('classifies legacy joinchat/ links as { kind: "invite" }', () => { + expect(parseInput('https://t.me/joinchat/AAAAAEHbZQ')).toEqual({ + kind: 'invite', + hash: 'AAAAAEHbZQ', + }); + expect(parseInput('t.me/joinchat/AAAAAEHbZQ')).toEqual({ + kind: 'invite', + hash: 'AAAAAEHbZQ', + }); + expect(parseInput('joinchat/AAAAAEHbZQ')).toEqual({ kind: 'invite', hash: 'AAAAAEHbZQ' }); + // Trailing path/query after the hash is stripped. + expect(parseInput('https://t.me/joinchat/AAAAAEHbZQ/extra?q=1')).toEqual({ + kind: 'invite', + hash: 'AAAAAEHbZQ', + }); + }); + + it('rejects a joinchat link with an invalid hash', () => { + expect(() => parseInput('t.me/joinchat/abc.def')).toThrow(ValidationError); + }); + it('classifies numeric ids as { kind: "chatId" }', () => { expect(parseInput('-1001234567890')).toEqual({ kind: 'chatId', value: '-1001234567890' }); expect(parseInput('1234567')).toEqual({ kind: 'chatId', value: '1234567' }); diff --git a/apps/server/src/tg/entityResolver.ts b/apps/server/src/tg/entityResolver.ts index 217b8f7..95779d4 100644 --- a/apps/server/src/tg/entityResolver.ts +++ b/apps/server/src/tg/entityResolver.ts @@ -1,11 +1,4 @@ -/** - * Input parsing + entity → DTO mapping shared by `chatResolver` and the - * invite resolver. - * - * This module no longer wraps gramjs directly — `chatResolver.ts` does that, - * dispatching on the `parseInput` discriminator. The two helpers exported - * here are pure: tests don't need a Telegram stub to exercise them. - */ +// Pure input-parsing + entity → DTO mapping; no gramjs dependency so tests need no Telegram stub. import { UpstreamError, ValidationError } from '../lib/errors.js'; export interface ResolvedEntity { @@ -14,12 +7,7 @@ export interface ResolvedEntity { handle: string | null; } -/** - * Discriminated classification of a paste-the-source-here input. The web UI - * accepts any of: `@username`, `t.me/username`, `t.me/+HASH` private invite, - * `+HASH` raw invite, or a numeric chat id (`-100…` channels/supergroups - * or bare positive ids for users / basic groups). - */ +// Accepts @username, t.me/username, t.me/+HASH or +HASH invite, or a numeric chat id (-100… channel/supergroup, bare positive for user/basic group). export type ParsedInput = | { kind: 'handle'; value: string } | { kind: 'invite'; hash: string } @@ -32,11 +20,19 @@ const CHAT_ID_RE = /^-?\d{6,}$/; export function parseInput(input: string): ParsedInput { let s = input.trim(); if (!s) throw new ValidationError('input is required'); - // Strip URL prefix variants. s = s.replace(/^https?:\/\//i, ''); s = s.replace(/^(?:www\.)?(?:t\.me|telegram\.me|telegram\.dog)\//i, ''); - // Strip trailing path / query (e.g. `t.me/foo/123`, `t.me/foo?x=1`). We do - // this before classifying so a paste like `t.me/+HASH/123` still resolves. + // Legacy invite form `joinchat/` — capture the hash before the trailing-path strip + // below collapses it to the bare word `joinchat`, which would mis-classify as a handle. + const joinchat = s.match(/^joinchat\/([^/?#]+)/i); + if (joinchat) { + const hash = joinchat[1]!; + if (!INVITE_HASH_RE.test(hash)) { + throw new ValidationError('expected a Telegram invite hash after `joinchat/`'); + } + return { kind: 'invite', hash }; + } + // Strip trailing path/query before classifying so `t.me/+HASH/123` still resolves. s = s.replace(/[/?#].*$/, ''); if (!s) throw new ValidationError('input is required'); @@ -71,9 +67,7 @@ export function entityToResolved(raw: unknown, handle: string): ResolvedEntity { const e = raw as MaybeEntity; if (!e?.id) throw new UpstreamError(`Telegram returned an unrecognised entity for @${handle}`); const idStr = typeof e.id === 'object' ? e.id.toString() : String(e.id); - // Channels arrive as positive ids from getEntity; the storage convention - // (matching what the listener sees) is the supergroup form prefixed -100. - // For users / bots the bare positive id is correct. + // getEntity returns positive channel ids; storage convention is the -100 supergroup form (users/bots keep the bare id). const sourceChatId = sourceChatIdFor(idStr, raw); const fullName = [e.firstName, e.lastName].filter(Boolean).join(' ').trim(); const sourceTitle = e.title ?? (fullName !== '' ? fullName : handle); @@ -82,8 +76,7 @@ export function entityToResolved(raw: unknown, handle: string): ResolvedEntity { } function sourceChatIdFor(idStr: string, raw: unknown): string { - // gramjs Channel/Chat carry a className; we can't import the class here - // without dragging telegram into the type surface, so do a structural check. + // Structural className check to avoid dragging telegram into the type surface. const className = (raw as { className?: string })?.className ?? ''; if (className === 'Channel' || className === 'Chat') { return idStr.startsWith('-') ? idStr : `-100${idStr}`; diff --git a/apps/server/src/tg/listener.test.ts b/apps/server/src/tg/listener.test.ts index a2f030f..cdc09e8 100644 --- a/apps/server/src/tg/listener.test.ts +++ b/apps/server/src/tg/listener.test.ts @@ -132,4 +132,34 @@ describe('attachNewMessageListener', () => { sourceMessageId: '42', }); }); + + it('fans out to every subscription that shares the same source', async () => { + const [dest2] = dbHandle.db + .insert(destinations) + .values({ name: 'd2', chatId: '-100888' }) + .returning() + .all(); + dbHandle.db + .insert(subscriptions) + .values({ sourceChatId: '-100123', sourceTitle: 's2', destinationId: dest2!.id }) + .run(); + + const captured: Partial = {}; + const enqueued: RawForwardJob[] = []; + const forwarding: RawForwardingHandle = { + enqueue: (job) => { + enqueued.push(job); + }, + }; + attachNewMessageListener( + fakeClient(captured as CapturedHandler), + dbHandle.db, + logger, + forwarding, + ); + await captured.handler!(makeEvent('-100123', 42)); + expect(enqueued).toHaveLength(2); + expect(enqueued.map((j) => j.destinationChatId).sort()).toEqual(['-100888', '-100999']); + expect(enqueued.every((j) => j.sourceMessageId === '42')).toBe(true); + }); }); diff --git a/apps/server/src/tg/listener.ts b/apps/server/src/tg/listener.ts index cfeb83f..4fc97f0 100644 --- a/apps/server/src/tg/listener.ts +++ b/apps/server/src/tg/listener.ts @@ -8,7 +8,7 @@ import { toJsonSafe } from '../lib/jsonSafe.js'; import type { Logger } from '../lib/logger.js'; import { extractMatchableEvent, - matchSubscription, + matchSubscriptions, type ResolvedSubscription, } from './messageMatcher.js'; @@ -22,9 +22,7 @@ export function attachNewMessageListener( const matchable = extractMatchableEvent(event); if (!matchable) return; - // Filter by source_chat_id in the WHERE clause (backed by - // idx_subscriptions_source_chat_id) so the DB returns only the rows - // that could possibly match — usually 0 or 1, never the full table. + // WHERE source_chat_id hits idx_subscriptions_source_chat_id, not a full scan. const activeSubs = db .select({ id: subscriptions.id, @@ -41,9 +39,9 @@ export function attachNewMessageListener( .innerJoin(destinations, eq(subscriptions.destinationId, destinations.id)) .where(and(eq(subscriptions.enabled, true), eq(subscriptions.sourceChatId, matchable.chatId))) .all() as ResolvedSubscription[]; - const matched = matchSubscription(matchable, activeSubs); + const matched = matchSubscriptions(matchable, activeSubs); - if (!matched) { + if (matched.length === 0) { logger.debug( { chatId: matchable.chatId, messageId: matchable.messageId }, 'message has no matching subscription', @@ -51,42 +49,41 @@ export function attachNewMessageListener( return; } - logger.info( - { - subscriptionId: matched.id, - sourceChatId: matched.sourceChatId, - messageId: matchable.messageId, - hasMedia: !!event.message.media, - }, - 'message matched subscription', - ); + // One snapshot of the source message, reused for every fan-out destination. + const rawMessage = toJsonSafe(event.message); + for (const sub of matched) { + logger.info( + { + subscriptionId: sub.id, + sourceChatId: sub.sourceChatId, + messageId: matchable.messageId, + hasMedia: !!event.message.media, + }, + 'message matched subscription', + ); - forwarding.enqueue({ - subscriptionId: matched.id, - sourceChatId: matched.sourceChatId, - destinationChatId: matched.destinationChatId, - destinationTopicId: matched.destinationTopicId, - sourceMessageId: matchable.messageId, - text: matchable.text, - hasMedia: matchable.hasMedia, - rawMessage: toJsonSafe(event.message), - ...(matchable.groupedId !== undefined ? { groupedId: matchable.groupedId } : {}), - ...(matchable.senderUsername !== undefined - ? { senderUsername: matchable.senderUsername } - : {}), - }); + forwarding.enqueue({ + subscriptionId: sub.id, + sourceChatId: sub.sourceChatId, + destinationChatId: sub.destinationChatId, + destinationTopicId: sub.destinationTopicId, + sourceMessageId: matchable.messageId, + text: matchable.text, + hasMedia: matchable.hasMedia, + rawMessage, + ...(matchable.groupedId !== undefined ? { groupedId: matchable.groupedId } : {}), + ...(matchable.senderUsername !== undefined + ? { senderUsername: matchable.senderUsername } + : {}), + }); + } }; - // Wrap the handler so a single bad event (DB hiccup, malformed gramjs - // payload, downstream throw from `enqueue`) gets logged and dropped - // instead of bubbling up into gramjs and potentially destabilising the - // whole TG session. + // Drop a single bad event rather than let it bubble into gramjs and destabilise the TG session. const safeHandler = async (event: NewMessageEvent): Promise => { try { await handler(event); } catch (err) { - // Include the error type so DB / gramjs / forwarding failures are - // distinguishable in the logs without re-deriving it from the stack. logger.error( { err, errorType: err instanceof Error ? err.constructor.name : typeof err }, 'listener handler threw; dropping event', @@ -94,6 +91,6 @@ export function attachNewMessageListener( } }; - // `incoming: true` filters out events the userbot itself produced. + // incoming: true filters out events the userbot itself produced. client.addEventHandler(safeHandler, new NewMessage({ incoming: true })); } diff --git a/apps/server/src/tg/messageMatcher.test.ts b/apps/server/src/tg/messageMatcher.test.ts index 3ab785c..3d612eb 100644 --- a/apps/server/src/tg/messageMatcher.test.ts +++ b/apps/server/src/tg/messageMatcher.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect } from 'vitest'; -import { extractMatchableEvent, matchSubscription, type MatchableEvent } from './messageMatcher.js'; +import { + extractMatchableEvent, + matchSubscriptions, + type MatchableEvent, +} from './messageMatcher.js'; import type { Subscription } from '../db/schema.js'; function makeSub(overrides: Partial): Subscription { @@ -26,25 +30,25 @@ const event: MatchableEvent = { hasMedia: false, }; -describe('matchSubscription', () => { +describe('matchSubscriptions', () => { it('returns the matching enabled subscription', () => { const subs = [makeSub({ id: 1 })]; - expect(matchSubscription(event, subs)).toEqual(subs[0]); + expect(matchSubscriptions(event, subs)).toEqual(subs); }); it('skips disabled subscriptions even on a chat-id match', () => { const subs = [makeSub({ id: 1, enabled: false })]; - expect(matchSubscription(event, subs)).toBeUndefined(); + expect(matchSubscriptions(event, subs)).toEqual([]); }); - it('returns undefined when no subscription matches', () => { + it('returns an empty array when no subscription matches', () => { const subs = [makeSub({ id: 1, sourceChatId: '-100888' })]; - expect(matchSubscription(event, subs)).toBeUndefined(); + expect(matchSubscriptions(event, subs)).toEqual([]); }); - it('returns the first enabled subscription when multiple match', () => { + it('returns all enabled subscriptions when multiple match (fan-out)', () => { const subs = [makeSub({ id: 1, enabled: false }), makeSub({ id: 2 }), makeSub({ id: 3 })]; - expect(matchSubscription(event, subs)?.id).toBe(2); + expect(matchSubscriptions(event, subs).map((s) => s.id)).toEqual([2, 3]); }); }); diff --git a/apps/server/src/tg/messageMatcher.ts b/apps/server/src/tg/messageMatcher.ts index c0257d7..e054687 100644 --- a/apps/server/src/tg/messageMatcher.ts +++ b/apps/server/src/tg/messageMatcher.ts @@ -1,12 +1,7 @@ import type { NewMessageEvent } from 'telegram/events/index.js'; import type { Subscription } from '../db/schema.js'; -/** - * Subscription with destination chat id (and optional forum topic) resolved - * via JOIN. The listener needs `destinationChatId` to enqueue forward jobs - * but the `subscriptions` row only carries a FK now (`destinationId`); - * `destinationTopicId` rides along so forum destinations route to a topic. - */ +// Subscription row plus the JOIN-resolved destination chat/topic. export type ResolvedSubscription = Subscription & { destinationChatId: string; destinationTopicId: string | null; @@ -16,11 +11,9 @@ export interface MatchableEvent { chatId: string; messageId: string; groupedId?: string; - /** Body text (or empty string for media-only messages without caption). */ - text: string; + text: string; // empty string for media-only messages hasMedia: boolean; - /** Lowercased sender username, no leading '@'. Undefined for anonymous channel posts. */ - senderUsername?: string; + senderUsername?: string; // lowercased, no '@'; undefined for anonymous channel posts } export function extractMatchableEvent(event: NewMessageEvent): MatchableEvent | null { @@ -56,9 +49,10 @@ function extractSenderUsername(message: { sender?: unknown }): string | undefine return undefined; } -export function matchSubscription( +// All enabled subscriptions for the event's source chat — a source may fan out to several. +export function matchSubscriptions( event: MatchableEvent, subscriptions: readonly T[], -): T | undefined { - return subscriptions.find((s) => s.enabled && s.sourceChatId === event.chatId); +): T[] { + return subscriptions.filter((s) => s.enabled && s.sourceChatId === event.chatId); } diff --git a/apps/web/src/components/sheets/SubSheet.tsx b/apps/web/src/components/sheets/SubSheet.tsx index c7af555..05f6180 100644 --- a/apps/web/src/components/sheets/SubSheet.tsx +++ b/apps/web/src/components/sheets/SubSheet.tsx @@ -26,7 +26,6 @@ import { RuleForm } from '@/components/domain/RuleForm'; import { RuleListItem } from '@/components/domain/RuleListItem'; export interface InlineFilterDraft { - /** Stable react key for the lifetime of an unsaved row. */ clientKey: string; ruleType: FilterRuleType; params: Record; @@ -35,28 +34,14 @@ export interface InlineFilterDraft { } export interface SubSheetSubmit { - /** - * Resolved chat id when known (public link / username / numeric id, or - * already-member private invite). `null` only for `t.me/+HASH` invites - * where the userbot isn't a member yet — in that case `inviteHash` is - * non-null and the server joins on create to derive the id. - */ + // null only for not-yet-joined t.me/+HASH invites; then inviteHash is set and the server joins on create. sourceChatId: string | null; inviteHash: string | null; sourceTitle: string; handle: string | null; - /** - * Null means "no destination" — subscription is created/saved in a - * detached state and won't forward until the user attaches a destination. - */ - destinationId: number | null; + destinationId: number | null; // null = detached, won't forward libraryFilterIds: number[]; - /** - * Bulk-replace set of private inline filters. Empty array = drop all on - * save. Each entry has been validated against - * `filterRuleParamsSchemas[ruleType]` so `params` matches the discriminator. - */ - inlineFilters: InlineFilterInput[]; + inlineFilters: InlineFilterInput[]; // bulk-replace set; [] drops all } export interface SubSheetProps { @@ -65,7 +50,6 @@ export interface SubSheetProps { initial?: SubscriptionDto | null; destinations: DestinationDto[]; library: LibraryFilterDto[]; - /** Catalog of supported rule types (from `useFilterCatalog`). */ availableTypes: readonly FilterRuleType[]; onClose: () => void; onSubmit: (data: SubSheetSubmit) => void; @@ -94,8 +78,7 @@ export function SubSheet({ const [libIds, setLibIds] = useState([]); const [inlineFilters, setInlineFilters] = useState([]); const [step, setStep] = useState({ kind: 'list' }); - // Step-local draft buffer (shared with FilterSheet via useFilterDraft); - // committed back into `inlineFilters` only on Save. + // Step-local draft buffer; committed into inlineFilters only on Save. const { ruleType: draftRule, params: draftParams, @@ -117,32 +100,43 @@ export function SubSheet({ error: resolveErrorRaw, } = useResolveSubscription(); - // Pull existing inline filters in edit mode so the user sees and can edit - // what they already have. Only enabled when the sheet is open + edit mode. const filtersQuery = useSubscriptionFilters(isEdit && open && initial ? initial.id : null); - // Reset on open / mode change. useEffect(() => { if (!open) return; if (isEdit && initial) { setLink(initial.handle ?? `@${initial.sourceTitle}`); setDestId(initial.destinationId); setLibIds([...initial.libraryFilterIds]); - // inlineFilters seeded from filtersQuery.data when it lands (effect below). resetResolve(); } else { setLink(''); - setDestId(destinations[0]?.id ?? null); + setDestId(null); setLibIds([]); setInlineFilters([]); resetResolve(); } setStep({ kind: 'list' }); resetDraft(); - }, [open, isEdit, initial, destinations, resetResolve, resetDraft]); + // `destinations` is intentionally NOT a dep — its ref changes on every background + // refetch, and re-running this would wipe the user's in-progress input. The default + // destination is seeded once by the effect below. + }, [open, isEdit, initial, resetResolve, resetDraft]); - // Seed inline filters from the server once they land. Ignored on subsequent - // refetches so the user's in-progress edits aren't clobbered. + // Default the destination to the first available one, once per add-sheet open. Guarded + // so a later background refetch of `destinations` can't clobber the user's pick. + const [destSeeded, setDestSeeded] = useState(false); + useEffect(() => { + if (!open) { + setDestSeeded(false); + return; + } + if (isEdit || destSeeded || destinations.length === 0) return; + setDestId((cur) => cur ?? destinations[0]!.id); + setDestSeeded(true); + }, [open, isEdit, destSeeded, destinations]); + + // Seed once; later refetches are ignored so in-progress edits aren't clobbered. const [seeded, setSeeded] = useState(false); useEffect(() => { if (!open) { @@ -164,7 +158,6 @@ export function SubSheet({ setSeeded(true); }, [open, isEdit, seeded, filtersQuery.data]); - // Debounce resolve in add mode. useDebouncedResolve({ value: link, enabled: !isEdit, @@ -184,10 +177,7 @@ export function SubSheet({ const handleSubmit = () => { if (!canSave) return; - // Validate each draft against the wire schema at the boundary rather than - // casting. Inline-edited drafts are already parsed on commit, but ones - // seeded from the server are re-checked here, so a schema drift fails - // loudly instead of writing a malformed filter. + // Re-validate at the wire boundary so server-seeded drafts fail loudly on schema drift. const wireInline: InlineFilterInput[] = inlineFilters.map((d) => inlineFilterInputSchema.parse({ ruleType: d.ruleType, @@ -208,9 +198,6 @@ export function SubSheet({ }); } else if (resolved) { onSubmit({ - // For not-yet-joined private invites the resolve preview can't - // know the chat id; the server joins via importInvite and derives - // the id during create. Otherwise we forward the resolved id. sourceChatId: resolved.sourceChatId ?? null, inviteHash: resolved.inviteHash, sourceTitle: resolved.sourceTitle, @@ -470,9 +457,7 @@ export function SubSheet({ let inlineKeyCounter = 0; -// Stable React key for an unsaved inline-filter row. Prefers a UUID but falls -// back to a counter on insecure contexts (http:// LAN host, where -// crypto.randomUUID is undefined) — the key only needs to be unique in-sheet. +// Counter fallback for insecure contexts where crypto.randomUUID is undefined. function newClientKey(): string { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { return crypto.randomUUID(); From a4afa0cf546209f965e371eb33a1abb601d0cece Mon Sep 17 00:00:00 2001 From: rzcoder Date: Wed, 3 Jun 2026 22:27:36 +0900 Subject: [PATCH 3/6] chore: remove chapter references and trim verbose comments Strip leftover "Chapter N"/"Ch N" plan markers and dead PLAN.md/AGENTS.md pointers, and condense over-verbose block comments across the codebase to terse one-liners while keeping load-bearing rationale. --- apps/server/drizzle/0001_destinations.sql | 2 +- apps/server/drizzle/0002_library_filters.sql | 2 +- apps/server/drizzle/0005_drop_tg_session.sql | 2 +- apps/server/drizzle/0007_filter_mode.sql | 7 +- apps/server/src/api/auth.ts | 40 +-- apps/server/src/api/errorHandler.ts | 20 +- apps/server/src/api/routes/_params.ts | 5 +- apps/server/src/api/routes/auth.ts | 43 +-- apps/server/src/api/routes/botConfig.ts | 40 +-- apps/server/src/api/routes/destinations.ts | 45 +-- apps/server/src/api/routes/filters.ts | 19 +- apps/server/src/api/routes/forwardLog.ts | 33 +-- apps/server/src/api/routes/settings.ts | 21 +- apps/server/src/api/routes/stream.ts | 59 +--- apps/server/src/api/routes/system.ts | 142 ++-------- apps/server/src/api/routes/telegramAccount.ts | 66 +---- apps/server/src/api/sessionStore.ts | 32 +-- apps/server/src/api/testing.ts | 51 +--- apps/server/src/bot/bot.ts | 42 +-- apps/server/src/bot/botConfig.ts | 22 +- apps/server/src/bot/initData.ts | 29 +- apps/server/src/bot/statsDigest.ts | 52 +--- apps/server/src/bot/testing.ts | 6 - apps/server/src/config.ts | 38 +-- apps/server/src/db/botConfigRepo.ts | 22 +- apps/server/src/db/client.ts | 10 +- apps/server/src/db/migrate.ts | 14 +- apps/server/src/db/schema.ts | 153 ++--------- apps/server/src/db/statsDigestStateRepo.ts | 19 +- apps/server/src/db/telegramAccountRepo.ts | 10 +- apps/server/src/db/testing.ts | 11 +- apps/server/src/events/bus.ts | 22 +- apps/server/src/filters/evaluate.ts | 56 +--- apps/server/src/filters/index.ts | 10 - apps/server/src/filters/registry.ts | 14 +- apps/server/src/filters/rules/index.ts | 6 +- apps/server/src/filters/types.ts | 24 +- apps/server/src/forwarding/floodwait.ts | 27 +- apps/server/src/forwarding/forwarderClient.ts | 25 +- apps/server/src/forwarding/historyPoller.ts | 50 +--- apps/server/src/forwarding/stats.ts | 15 +- .../src/forwarding/statsDigestConfig.ts | 13 +- apps/server/src/forwarding/throttle.ts | 16 +- apps/server/src/forwarding/types.ts | 45 +-- apps/server/src/index.ts | 124 +-------- apps/server/src/lib/dbHelpers.ts | 1 - apps/server/src/lib/errors.ts | 34 +-- apps/server/src/lib/jsonSafe.ts | 38 +-- apps/server/src/lib/loadEnv.ts | 15 +- apps/server/src/lib/logger.ts | 15 +- apps/server/src/lib/poller.ts | 13 +- apps/server/src/lib/sessionCrypto.ts | 49 +--- apps/server/src/tg/chatResolver.ts | 23 +- apps/server/src/tg/client.ts | 29 +- apps/server/src/tg/dialogsKeepalive.ts | 22 +- apps/server/src/tg/forumTopics.ts | 24 +- apps/server/src/tg/healthMonitor.ts | 59 +--- apps/server/src/tg/inviteResolver.ts | 38 +-- apps/server/src/tg/joinChannel.ts | 33 +-- apps/server/src/tg/loginSession.ts | 55 +--- apps/server/src/tg/profilePhoto.ts | 25 +- apps/server/src/tg/subscriptions.ts | 36 +-- apps/web/src/api/authApi.ts | 5 +- apps/web/src/api/client.ts | 15 +- .../web/src/components/domain/ActivityRow.tsx | 23 +- .../components/domain/DestinationOption.tsx | 6 - apps/web/src/components/domain/EntityIcon.tsx | 10 +- apps/web/src/components/domain/FilterRow.tsx | 4 +- .../src/components/domain/JsonViewSheet.tsx | 48 +--- apps/web/src/components/domain/Logo.tsx | 4 - apps/web/src/components/domain/RuleForm.tsx | 18 +- apps/web/src/components/domain/SubRow.tsx | 10 +- .../filters/FilterSheetController.tsx | 4 +- .../web/src/components/layout/RequireAuth.tsx | 4 +- .../components/settings/AppearanceSection.tsx | 5 +- .../src/components/settings/DataSection.tsx | 14 +- .../components/settings/ForwardingSection.tsx | 11 +- .../settings/TelegramAccountSection.tsx | 12 +- .../settings/TelegramLoginSheet.tsx | 18 +- .../components/settings/bot/AdminLookup.tsx | 6 - .../components/settings/bot/DigestSection.tsx | 8 - .../settings/bot/useBotSettingsDraft.ts | 27 +- apps/web/src/components/settings/bot/utils.ts | 4 - .../components/settings/data/ExportSheet.tsx | 5 - .../components/settings/data/ImportSheet.tsx | 7 - .../components/settings/data/WipeSheet.tsx | 5 - .../src/components/settings/data/shared.ts | 13 +- .../src/components/settings/primitives.tsx | 17 -- apps/web/src/components/sheets/DestSheet.tsx | 26 +- .../sheets/DestinationPickerSheet.tsx | 6 +- .../web/src/components/sheets/FilterSheet.tsx | 18 +- apps/web/src/components/ui/button.tsx | 3 - apps/web/src/components/ui/checkbox-card.tsx | 6 - apps/web/src/components/ui/fab.tsx | 6 +- apps/web/src/components/ui/list-state.tsx | 3 - apps/web/src/components/ui/sheet.tsx | 4 - apps/web/src/components/ui/toast.tsx | 11 - apps/web/src/hooks/useActivityStream.tsx | 57 +--- apps/web/src/hooks/useAuth.ts | 11 +- apps/web/src/hooks/useBotConfig.ts | 4 +- apps/web/src/hooks/useDebouncedResolve.ts | 13 +- apps/web/src/hooks/useDestinations.ts | 6 +- apps/web/src/hooks/useExportImport.ts | 2 - apps/web/src/hooks/useFilterDraft.ts | 18 +- apps/web/src/hooks/useFilterSheetState.ts | 8 +- apps/web/src/hooks/useFilters.ts | 2 - apps/web/src/hooks/useForwardLogRaw.ts | 10 +- apps/web/src/hooks/useSheetState.ts | 8 +- apps/web/src/hooks/useSubscriptions.ts | 7 +- apps/web/src/hooks/useSystemStatus.ts | 5 +- apps/web/src/lib/ThemeProvider.tsx | 6 +- apps/web/src/lib/describeFilter.ts | 9 - apps/web/src/lib/formatRelative.ts | 4 - apps/web/src/lib/queryClient.ts | 4 +- apps/web/src/lib/useNowTick.ts | 13 +- apps/web/src/lib/useTheme.ts | 25 +- apps/web/src/pages/ActivityPage.tsx | 60 +--- apps/web/src/pages/DestinationsPage.tsx | 5 +- apps/web/src/pages/FiltersPage.tsx | 5 +- apps/web/src/pages/LoginPage.tsx | 16 +- apps/web/src/pages/SettingsPage.tsx | 2 - apps/web/src/pages/SubscriptionsPage.tsx | 8 +- apps/web/src/test/setup.ts | 4 +- packages/shared/src/api.ts | 260 +++--------------- packages/shared/src/botConfig.ts | 50 +--- packages/shared/src/events.ts | 24 +- packages/shared/src/exportImport.ts | 88 +----- packages/shared/src/filters.ts | 26 +- packages/shared/src/forwardLog.ts | 9 +- packages/shared/src/index.ts | 7 - packages/shared/src/telegramAccount.ts | 29 +- 131 files changed, 428 insertions(+), 2776 deletions(-) diff --git a/apps/server/drizzle/0001_destinations.sql b/apps/server/drizzle/0001_destinations.sql index 0b1ae1e..8e60a40 100644 --- a/apps/server/drizzle/0001_destinations.sql +++ b/apps/server/drizzle/0001_destinations.sql @@ -1,4 +1,4 @@ --- Chapter 10 — Destinations + Subscriptions migration. +-- Destinations + Subscriptions migration. -- -- 1. Create `destinations` (named CRUD list). -- 2. Backfill destinations from each distinct `subscriptions.destination_chat_id` diff --git a/apps/server/drizzle/0002_library_filters.sql b/apps/server/drizzle/0002_library_filters.sql index e964c09..25f79f0 100644 --- a/apps/server/drizzle/0002_library_filters.sql +++ b/apps/server/drizzle/0002_library_filters.sql @@ -1,4 +1,4 @@ --- Chapter 11 — Library filters + M:N join. +-- Library filters + M:N join. -- -- Reusable named filter rules attachable to many subscriptions. The -- evaluator UNIONs these with per-sub filters at evaluation time. diff --git a/apps/server/drizzle/0005_drop_tg_session.sql b/apps/server/drizzle/0005_drop_tg_session.sql index a4d6c57..c98210e 100644 --- a/apps/server/drizzle/0005_drop_tg_session.sql +++ b/apps/server/drizzle/0005_drop_tg_session.sql @@ -2,7 +2,7 @@ -- -- Originally created in 0000 as an optional encrypted-at-rest cache for the -- gramjs StringSession. The MVP design ended up using `TG_SESSION_STRING` --- from .env as the source of truth (see PLAN.md "Session storage"). The +-- from .env as the source of truth. The -- table was never written to from app code, and `TG_SESSION_ENCRYPTION_KEY` -- — the env knob that would have driven AES around it — was removed in -- favour of plain env-only storage. diff --git a/apps/server/drizzle/0007_filter_mode.sql b/apps/server/drizzle/0007_filter_mode.sql index a591277..cdba104 100644 --- a/apps/server/drizzle/0007_filter_mode.sql +++ b/apps/server/drizzle/0007_filter_mode.sql @@ -1,12 +1,11 @@ --- Per-filter include/exclude mode (Ch 14). +-- Per-filter include/exclude mode. -- -- 'include' is the legacy AND-pass default — every include filter must match -- for the message to forward. 'exclude' inverts the rule for that one row: -- a message is rejected when its rule matches. -- --- The CHECK keeps hand-written rows honest (mirrors the precedent set by --- `forward_log.status` and `library_filters.rule_type`). Existing rows --- back-fill to 'include' via the DEFAULT, preserving prior behavior. +-- The CHECK keeps hand-written rows honest. Existing rows back-fill to +-- 'include' via the DEFAULT, preserving prior behavior. ALTER TABLE `subscription_filters` ADD COLUMN `mode` TEXT NOT NULL DEFAULT 'include' CHECK (`mode` IN ('include', 'exclude')); --> statement-breakpoint diff --git a/apps/server/src/api/auth.ts b/apps/server/src/api/auth.ts index a467cf4..110cbe9 100644 --- a/apps/server/src/api/auth.ts +++ b/apps/server/src/api/auth.ts @@ -1,25 +1,4 @@ -/** - * Auth primitives for the API server. - * - * Single-user model. A successful `POST /api/auth/login` mints a fresh - * opaque random token (256-bit, base64url), stores it in `web_sessions` with - * an expiry, and sets a signed cookie carrying that token as the value. - * - * The signature exists for tamper detection; the *security* comes from the - * token being unguessable and existing in the DB. Logout deletes the row, - * so a leaked cookie can be revoked. Sliding refresh on each authed request - * keeps the user signed in while limiting damage from a long-unused - * captured cookie. - * - * `requireWebAuthEnv` parallels `tg/client.ts#requireTelegramEnv`. Both - * `WEB_PASSWORD` and `SESSION_SECRET` stay `.optional()` in `config.ts` - * so `pnpm db:migrate` and `pnpm tg:login` can run without them; only - * the server boot path requires them. - * - * `verifyPassword` hashes both inputs to a fixed-size SHA-256 digest - * before `timingSafeEqual` so password length isn't observable as a - * timing signal and the equal-length precondition is automatic. - */ +// Opaque 256-bit DB-backed token in a signed cookie (the token is the security; the signature is tamper detection); sliding refresh, server-side revoke. import { createHash, timingSafeEqual } from 'node:crypto'; import type { CookieSerializeOptions } from '@fastify/cookie'; import type { FastifyReply, FastifyRequest } from 'fastify'; @@ -35,7 +14,6 @@ export interface WebAuth { sessionSecret: string; } -/** Bot token + admin allowlist for the Telegram Web App sign-in route. */ export interface TelegramAuth { botToken: string; adminIds: string[]; @@ -58,10 +36,9 @@ export function requireWebAuthEnv(cfg: Config): WebAuth { } export function verifyPassword(plain: string, expected: string): boolean { - // Defensive: refuse to match empty/missing credentials even if upstream - // validation lets them through. SHA-256 of '' is a deterministic constant — - // without this guard, two empty strings would compare equal and grant entry. + // Reject empty: SHA-256('')==SHA-256('') would grant entry. if (!plain || !expected) return false; + // Hash both first so password length isn't a timing signal. const a = createHash('sha256').update(plain).digest(); const b = createHash('sha256').update(expected).digest(); return timingSafeEqual(a, b); @@ -87,11 +64,7 @@ export function clearedCookieOptions(isProd: boolean): CookieSerializeOptions { }; } -/** - * Read a session token from the incoming signed cookie, or null if absent / - * tampered. Does NOT verify against the DB — the auth pre-handler does that - * via `sessionStore.verifyAndRefresh`. - */ +// Unwraps the signed cookie only; the DB check is verifyAndRefresh. export function readSessionToken(request: FastifyRequest): string | null { const raw = request.cookies[SESSION_COOKIE_NAME]; if (!raw) return null; @@ -102,11 +75,6 @@ export function readSessionToken(request: FastifyRequest): string | null { return result.value; } -/** - * Build the Fastify pre-handler that enforces auth on a scoped route plugin. - * The factory takes the session store so the pre-handler closes over a real - * DB-backed lookup; tests build their own via `buildTestApp`. - */ export function makeRequireAuth(sessionStore: SessionStore) { return async function requireAuth(request: FastifyRequest, _reply: FastifyReply): Promise { const token = readSessionToken(request); diff --git a/apps/server/src/api/errorHandler.ts b/apps/server/src/api/errorHandler.ts index f905215..40b4ded 100644 --- a/apps/server/src/api/errorHandler.ts +++ b/apps/server/src/api/errorHandler.ts @@ -1,17 +1,4 @@ -/** - * Single Fastify error handler. - * - * All errors thrown from route handlers, pre-handlers, and Fastify itself - * pass through here. The mapping: - * - * - `ZodError` (route handler called `schema.parse(body)` and it threw) - * → 400 with `{ code: 'validation_error', issues }` - * - `AppError` subclasses (`UnauthorizedError`, `NotFoundError`, ...) - * → `err.statusCode` with `{ code, message, ...(issues if ValidationError) }` - * - anything else → 500 generic; original message NOT echoed (no leaking - * of internal details). Logged with `request.log.error({ err })` for - * diagnosability. - */ +// Single Fastify error handler: ZodError → 400, AppError → its statusCode, anything else → generic 500 with the original message never echoed. import type { FastifyError, FastifyReply, FastifyRequest } from 'fastify'; import { ZodError } from 'zod'; import type { ErrorResponse } from '@tg-feed/shared'; @@ -48,10 +35,7 @@ export function makeErrorHandler(logger: Logger): ApiErrorHandler { return; } - // Fastify built-in errors (rate-limit, body too large, malformed JSON, - // etc.) carry an HTTP statusCode + a stable code. Pass 4xx through with - // their own message — they're already client-safe. 5xx still gets the - // generic 500 path below to avoid leaking internals. + // Fastify built-in 4xx (rate-limit, body too large, bad JSON) are client-safe; pass through. 5xx falls to the generic path. if (typeof err.statusCode === 'number' && err.statusCode >= 400 && err.statusCode < 500) { const body: ErrorResponse = { error: { code: err.code ?? 'client_error', message: err.message }, diff --git a/apps/server/src/api/routes/_params.ts b/apps/server/src/api/routes/_params.ts index 10534ad..2166e03 100644 --- a/apps/server/src/api/routes/_params.ts +++ b/apps/server/src/api/routes/_params.ts @@ -1,7 +1,4 @@ -/** - * Shared route param schemas. The single-`id` shape is used by every CRUD - * route; compound shapes live in their own route files. - */ +// Shared by every CRUD route; compound param shapes live in their own route files. import { z } from 'zod'; export const idParamsSchema = z.object({ diff --git a/apps/server/src/api/routes/auth.ts b/apps/server/src/api/routes/auth.ts index 5f27d1e..ff5fae3 100644 --- a/apps/server/src/api/routes/auth.ts +++ b/apps/server/src/api/routes/auth.ts @@ -1,21 +1,3 @@ -/** - * Auth routes. - * - * Split between two registration functions — `registerLoginRoute` for - * the unauthenticated `POST /api/auth/login`, and `registerAuthRoutes` - * for the authed-scope `POST /api/auth/logout` and `GET /api/me`. The - * factory wires each into the appropriate Fastify scope. - * - * Login mints an opaque random token (256-bit, base64url) via the session - * store and sets it as the signed cookie value. Logout deletes the row, so - * a leaked cookie is revoked on the server — not just cleared on the - * client. The login-route rate limit only counts failed attempts so a - * legitimate user mid-debugging doesn't burn the brute-force bucket. - * - * `registerTelegramAuthRoute` is the Mini App counterpart: it verifies a - * Telegram `initData` payload and, if the embedded user is on the admin - * allowlist, mints a session via the same store as the password login. - */ import type { FastifyInstance, FastifyRequest } from 'fastify'; import { loginRequestSchema, @@ -48,11 +30,7 @@ export function registerLoginRoute(app: FastifyInstance, deps: RegisterLoginDeps app.post( '/auth/login', { - // Rate-limit brute-force on the single-user password. Counted per IP - // (trustProxy is enabled at the factory level so we key on the real - // client IP behind a reverse proxy). `skipOnError: true` and the - // `skipSuccessfulRequests`-equivalent below keep the bucket reserved - // for failed attempts only. + // Brute-force limit on the password, per real client IP; reset on success below so only failures count. config: { rateLimit: { max: 10, @@ -69,15 +47,12 @@ export function registerLoginRoute(app: FastifyInstance, deps: RegisterLoginDeps ); throw new UnauthorizedError('invalid password'); } - // Successful login — reset the rate-limit bucket for this IP so a - // legitimate operator who logged in mid-debugging doesn't get throttled - // moments later. `request.rateLimit()` is provided by @fastify/rate-limit - // when the route's `config.rateLimit` is set. + // Reset the rate-limit bucket for this IP on success so only failures count. try { const rl = (request as FastifyRequest & { rateLimit?: () => Promise }).rateLimit; if (typeof rl === 'function') await rl(); } catch { - // Plugin not loaded (test path with custom server). Safe to ignore. + // Plugin not loaded (test path with custom server). } const token = deps.sessionStore.create(); reply.setCookie(SESSION_COOKIE_NAME, token, signedCookieOptions(deps.isProd)); @@ -91,11 +66,7 @@ export function registerLoginRoute(app: FastifyInstance, deps: RegisterLoginDeps const TELEGRAM_INITDATA_MAX_AGE_SEC = 5 * 60; export interface RegisterTelegramAuthDeps { - /** - * Live getter for the bot auth config (token + admin allowlist), resolved - * DB-over-env. Read per request so a config change made via Settings → Bot - * applies without a restart. Yields null when the bot isn't configured. - */ + // read per request (DB-over-env) so a Settings → Bot change applies without a restart; null when unconfigured getTelegramAuth: () => TelegramAuth | null; isProd: boolean; logger: Logger; @@ -154,7 +125,7 @@ export function registerTelegramAuthRoute( const rl = (request as FastifyRequest & { rateLimit?: () => Promise }).rateLimit; if (typeof rl === 'function') await rl(); } catch { - // Plugin not loaded (test path with custom server). Safe to ignore. + // Plugin not loaded (test path with custom server). } const token = deps.sessionStore.create(); @@ -173,9 +144,7 @@ export interface RegisterAuthDeps { export function registerAuthRoutes(app: FastifyInstance, deps: RegisterAuthDeps): void { app.post('/auth/logout', async (request, reply) => { - // Read the token from the signed cookie before clearing it; deleting the - // server-side row is what actually invalidates the session (the cookie - // bytes are otherwise still valid until they expire client-side). + // Deleting the server-side row is what invalidates the session; the cookie bytes stay valid until expiry. const token = readSessionToken(request); if (token) deps.sessionStore.revoke(token); reply.clearCookie(SESSION_COOKIE_NAME, clearedCookieOptions(deps.isProd)); diff --git a/apps/server/src/api/routes/botConfig.ts b/apps/server/src/api/routes/botConfig.ts index dfb9304..106b918 100644 --- a/apps/server/src/api/routes/botConfig.ts +++ b/apps/server/src/api/routes/botConfig.ts @@ -1,22 +1,4 @@ -/** - * Bot configuration routes — Settings → Bot. - * - * GET /api/config/bot → masked state (sources, key status, botRunning) - * PUT /api/config/bot → partial save (token / admins / publicUrl) - * DELETE /api/config/bot → clear the DB row, env fallback resumes - * POST /api/config/bot/resolve-admin → resolve @username → user (preview, no write) - * - * The token is encrypted with `TG_SESSION_ENCRYPTION_KEY` before it touches - * the DB, so a token write refuses with 412 when no key is configured (admins - * and public URL are not gated). After any change `reloadBot()` live-swaps the - * long-polling bot so the new config takes effect without a restart. The GET - * payload is masked — it never returns the token, only whether one exists and - * from where. - * - * `resolve-admin` turns a `@username` into a user id via the universal chat - * resolver (rejecting channels/groups); the resolved `{ id, displayName, - * username }` is then saved into `admins`. - */ +// Token encrypted with TG_SESSION_ENCRYPTION_KEY (token write 412s without a key); GET is masked, never returns the token. import type { FastifyInstance } from 'fastify'; import { type BotAdmin, @@ -36,22 +18,16 @@ import { BOT_TOKEN_AAD, encryptSecret } from '../../lib/sessionCrypto.js'; import type { ChatResolver } from '../../tg/chatResolver.js'; export interface RegisterBotConfigRoutesDeps { - /** Parsed env config — supplies the env fallbacks behind the masked view. */ + // Supplies the env fallbacks behind the masked view. cfg: Config; db: Db; - /** Returns the configured 32-byte key, or null when unset. */ + // 32-byte key, or null when unset. getEncryptionKey?: () => Buffer | null; - /** Live-swaps the bot so a saved config takes effect immediately. */ reloadBot?: () => Promise; - /** Whether the long-polling bot is currently running. */ getBotRunning?: () => boolean; - /** - * Lazy lookup for the universal chat resolver — reused by `resolve-admin` - * to turn a `@username` into a user id. Read per request (populated after - * Telegram init). When absent, the route returns 503. - */ + // Lazy (populated after Telegram init), read per request; absent → 503. getChatResolver?: () => ChatResolver | undefined; - /** Telegram lifecycle — picks the right 503 (`initializing` vs `unavailable`). */ + // Picks the right 503: `initializing` vs `unavailable`. getTelegramStatus: () => TelegramStatus; } @@ -112,8 +88,7 @@ export function registerBotConfigRoutes( writeBotConfig(db, patch); if (reloadBot) { - // Live-swap failures are logged inside `reloadBot`; the row is already - // saved, so the next restart picks it up. Don't fail the save. + // Row is already saved; don't fail the save on a live-swap error (logged in reloadBot). await reloadBot().catch(() => {}); } return info(); @@ -134,8 +109,7 @@ export function registerBotConfigRoutes( } const body = resolveBotAdminRequestSchema.parse(request.body); const resolved = await chatResolver(body.query); - // Admins are *users* — a bare positive id. Channels/supergroups resolve to - // a `-100…` id and unjoined private invites to null; reject both. + // Admins are users (bare positive id); reject `-100…` channels/groups and null invites. if (!resolved.chatId || resolved.chatId.startsWith('-')) { throw new AppError( 422, diff --git a/apps/server/src/api/routes/destinations.ts b/apps/server/src/api/routes/destinations.ts index efc0b5a..86d919d 100644 --- a/apps/server/src/api/routes/destinations.ts +++ b/apps/server/src/api/routes/destinations.ts @@ -1,10 +1,4 @@ -/** - * Destinations CRUD routes (Chapter 10). - * - * Destinations are the named CRUD list subscriptions pick from. Delete is - * pre-checked for usage in this layer; the FK is `ON DELETE RESTRICT` as a - * belt-and-suspenders DB-level guard. - */ +// Delete is usage-checked here; the FK is also ON DELETE RESTRICT as a DB-level guard. import { asc, count, eq } from 'drizzle-orm'; import type { FastifyInstance } from 'fastify'; import { @@ -49,36 +43,12 @@ interface DestinationRow { export interface RegisterDestinationDeps { db: Db; - /** - * Live status getter used to distinguish "Telegram is starting up — try - * again in a moment" from "Telegram is not configured at all". Drives - * the error code returned when a tg-dep getter yields undefined. - */ getTelegramStatus: () => TelegramStatus; - /** - * Lazy lookup for the universal "paste-anything" resolver — backs - * `POST /destinations/resolve`. Read per request because the boot path - * fills it asynchronously after `app.listen()`. When undefined the - * route returns 503 with `telegram_initializing` (during the boot - * window) or `telegram_unavailable` (steady-state disconnected). - */ + // Lazy getters: filled after app.listen(), so read per request. Undefined → 503. getChatResolver?: () => ChatResolver | undefined; - /** - * Lazy lookup for the `messages.ImportChatInvite` wrapper invoked from - * `POST /destinations` when the body carries `inviteHash`. Same - * lifecycle as `getChatResolver`. - */ getImportInvite?: () => ImportInviteFn | undefined; - /** - * Lazy lookup for the best-effort profile-photo fetcher invoked after - * the row is inserted. When undefined the row's `iconDataUrl` stays - * null and the access monitor's lazy backfill catches it later. - */ + // Undefined leaves iconDataUrl null; the access monitor backfills it later. getFetchProfilePhoto?: () => ProfilePhotoFetcher | undefined; - /** - * Lazy lookup for the forum-topic lister backing `POST /destinations/topics`. - * Same lifecycle as `getChatResolver`. When undefined the route returns 503. - */ getListForumTopics?: () => ForumTopicLister | undefined; } @@ -146,8 +116,7 @@ export function registerDestinationRoutes( throw new UpstreamError('failed to join via invite link', 'invite_join_failed'); } chatId = join.chatId; - // We just successfully joined — record access ok now so the access - // monitor's first sweep doesn't briefly mis-report. + // Just joined — record access ok so the monitor's first sweep doesn't mis-report. initialAccessStatus = 'ok'; accessCheckedAt = new Date(); } else { @@ -168,9 +137,7 @@ export function registerDestinationRoutes( .returning() .all(); const row = inserted[0]!; - // Best-effort: fetch the profile photo and stamp it on the row. The - // fetcher already swallows errors and returns null on failure, so we - // don't need a try/catch — the response just goes out without an icon. + // Best-effort icon: the fetcher swallows errors and returns null. let iconDataUrl: string | null = row.iconDataUrl; const fetchProfilePhoto = getFetchProfilePhoto?.(); if (fetchProfilePhoto) { @@ -244,8 +211,6 @@ export function registerDestinationRoutes( } function listDestinations(db: Db): DestinationRow[] { - // One query: destinations LEFT JOIN subscriptions GROUP BY destination, - // counting subscription references. const rows = db .select({ id: destinations.id, diff --git a/apps/server/src/api/routes/filters.ts b/apps/server/src/api/routes/filters.ts index 341fcf3..3c66bda 100644 --- a/apps/server/src/api/routes/filters.ts +++ b/apps/server/src/api/routes/filters.ts @@ -1,20 +1,5 @@ -/** - * Filter routes — catalog + per-subscription filter CRUD. - * - * The catalog (`GET /api/filters/catalog`) reflects what's actually - * registered in the live filter registry — not a hardcoded list. Adding - * a rule = drop a file + register it in `createDefaultRegistry()`; the - * catalog endpoint picks it up on next restart. - * - * Filter PATCH validates `params` against the EXISTING row's `ruleType` - * (rule type itself is immutable post-creation; to switch, delete and - * re-add). The shared schema's `updateSubscriptionFilterRequestSchema` - * keeps `params` loose (`z.record`); strict per-rule validation happens - * here in the handler. - * - * Cross-sub access (filter id belongs to a different sub than the URL - * names) returns 404 — prevents id-guessing across subscriptions. - */ +// ruleType is immutable; PATCH validates params against the existing row's ruleType. +// Cross-sub filter ids return 404 to block id-guessing across subscriptions. import { and, asc, eq } from 'drizzle-orm'; import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; diff --git a/apps/server/src/api/routes/forwardLog.ts b/apps/server/src/api/routes/forwardLog.ts index 7b4f049..63d140e 100644 --- a/apps/server/src/api/routes/forwardLog.ts +++ b/apps/server/src/api/routes/forwardLog.ts @@ -1,24 +1,5 @@ -/** - * Forward log route — paginated activity feed source. - * - * Two LEFT JOINs: `forward_log → subscriptions → destinations`. The - * subscription FK is `ON DELETE SET NULL`, so historic rows for deleted - * subscriptions survive with `subscriptionId`/`subscriptionTitle`/ - * `sourceHandle`/`destinationName` all `null`. The destination join hops - * through the subscription's current `destinationId` — `forward_log` - * doesn't store the destination, so a re-pointed subscription will surface - * its current destination on historical rows (matches the prior - * client-side enrichment behavior). - * - * Pagination uses the `limit + 1` trick: fetch one row past the asked - * `limit`, then if the extra row is present `nextOffset = offset + limit`, - * else `nextOffset = null`. Avoids a separate `COUNT(*)` query while still - * answering "is there more?". - * - * Sort is `desc(createdAt), desc(id)` — albums write N rows in one - * transaction sharing a `createdAt` ms; sorting by `id` as a tiebreaker - * makes pagination deterministic across calls. - */ +// Paginated activity feed. Sub FK is ON DELETE SET NULL, so rows for deleted subs survive with null titles; the dest join hops through the sub's current destinationId, so re-pointing surfaces the new dest on old rows. +// Pagination is the limit+1 trick (one row past limit → nextOffset, else null), avoiding a COUNT(*). Sort desc(createdAt),desc(id) so albums sharing a createdAt ms paginate deterministically. import { desc, eq, sql } from 'drizzle-orm'; import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; @@ -55,10 +36,7 @@ export function registerForwardLogRoutes(app: FastifyInstance, deps: RegisterFor status: forwardLog.status, error: forwardLog.error, createdAt: forwardLog.createdAt, - // Surface presence-only here; the raw JSON itself is fetched on - // demand via the dedicated /:id/raw route so list responses stay - // small. SQLite renders the boolean as `1`/`0` which the cast - // below normalises for the wire format. + // Presence only (raw JSON is fetched via /:id/raw); SQLite returns 1/0. hasRawMessage: sql`(${forwardLog.rawMessage} IS NOT NULL)`, }) .from(forwardLog) @@ -90,10 +68,7 @@ export function registerForwardLogRoutes(app: FastifyInstance, deps: RegisterFor return response; }); - // Deferred-fetch endpoint for the raw JSON snapshot. Kept off the list - // route so a page load doesn't drag tens of KB per row over the wire — - // the JSON is only useful when the user clicks "View raw" on a specific - // entry, and an album's array is identical across its N rows anyway. + // Deferred raw-JSON fetch, off the list route so page loads don't drag tens of KB per row. app.get('/forward-log/:id/raw', async (request, reply) => { const { id } = rawParamsSchema.parse(request.params); const row = db diff --git a/apps/server/src/api/routes/settings.ts b/apps/server/src/api/routes/settings.ts index 67d5ec5..0483e90 100644 --- a/apps/server/src/api/routes/settings.ts +++ b/apps/server/src/api/routes/settings.ts @@ -1,18 +1,4 @@ -/** - * Settings routes. - * - * Wire format is the flat `{ delayMs, albumDebounceMs }`; internally this - * maps to the `app_settings` row keyed `'global'` whose `value` JSON is the - * same shape. PUT accepts a partial body (either field optional) and merges - * into the existing row, so the UI can edit one knob at a time without - * round-tripping the other. - * - * `GET` never 404s. If the row is missing or malformed, fall back to the - * documented defaults (8 s throttle, 2 s album window) — same defensive - * read the forwarding pipeline does in `getGlobalDelayMs` / - * `getAlbumDebounceMs`. Settings always exist with sane defaults from the - * client's perspective. - */ +// GET never 404s: a missing/malformed row falls back to the same defaults the forwarding pipeline uses. import type { FastifyInstance } from 'fastify'; import { updateSettingsRequestSchema, type SettingsDto } from '@tg-feed/shared'; import type { Db } from '../../db/client.js'; @@ -49,10 +35,7 @@ export function registerSettingsRoutes(app: FastifyInstance, deps: RegisterSetti app.put('/settings', async (request) => { const body = updateSettingsRequestSchema.parse(request.body); - // Merge: read current values (with defaults for missing/malformed rows), - // overlay any fields the client supplied, write the unified row back. The - // throttle and digest knobs share this one row, so the merge keeps a - // caller that only knows about one of them from clobbering the others. + // Throttle and digest knobs share one row; merge so a partial body doesn't clobber the others. const digest = getStatsDigestConfig(db); const merged = { delayMs: body.delayMs ?? getGlobalDelayMs(db), diff --git a/apps/server/src/api/routes/stream.ts b/apps/server/src/api/routes/stream.ts index 8749976..e5b29be 100644 --- a/apps/server/src/api/routes/stream.ts +++ b/apps/server/src/api/routes/stream.ts @@ -1,42 +1,7 @@ -/** - * SSE stream — `GET /api/stream`. - * - * One persistent text/event-stream per authenticated client. The route - * subscribes to the in-process `EventBus` and pipes every emitted event to - * the socket as a single SSE frame; a `: heartbeat` comment frame goes out - * every 25 s so reverse proxies and Telegram-paranoid networks don't decide - * the idle connection is dead. - * - * Hardening (added during the security pass): - * - **Per-session connection cap.** A single authed cookie can hold at - * most `MAX_CONNECTIONS_PER_TOKEN` streams open at once; further - * attempts get a clean 429. Without the cap a stolen cookie or a - * misbehaving extension could open thousands of streams and pin event- - * loop time on every bus emit (the bus walks every listener O(N)). - * - **Backpressure-aware writes.** `socket.write` returns false when the - * OS send buffer fills; if we ignore that, a slow consumer accumulates - * unbounded data in node's internal write buffer. We drop events while - * backpressured and resume on the next `'drain'`. This loses fidelity - * for very slow clients (intentional) but never OOMs the process. - * - * Implementation notes: - * - `reply.hijack()` tells Fastify "I'm taking over the response — don't - * try to call .send() or .end() yourself." After hijack the handler - * owns `reply.raw` (the underlying `http.ServerResponse`). We never - * return data via `reply.send`; everything goes via `socket.write`. - * - The handler resolves immediately after wiring listeners. Without - * `hijack` Fastify would call `.end()` on return; with it the - * connection stays open until the client disconnects. - * - `request.raw.once('close')` fires whether the client gracefully - * closes the EventSource, the network drops, or the test aborts via - * `AbortController`. That's where the heartbeat interval is cleared - * and the bus listener is unsubscribed — no per-client leak. - * - `socket.writableEnded` guards the listener and the heartbeat against - * a write-after-close race. The actual safety net is the per-listener - * try/catch inside the bus, which logs and swallows any thrown errors. - * - `X-Accel-Buffering: no` disables nginx response buffering for SSE - * (`Cache-Control: no-cache, no-transform` covers most other proxies). - */ +// GET /api/stream: one text/event-stream per client piping every EventBus event; `: heartbeat` every 25s so proxies don't reap idle connections. +// Connection cap (429 past MAX_CONNECTIONS_PER_TOKEN): a stolen cookie could otherwise open thousands of streams and pin the O(N) bus walk. +// Backpressure: drop events while socket.write is false, resume on 'drain' — loses fidelity for slow clients but never OOMs. +// reply.hijack() stops Fastify calling .end() on return so the connection stays open until close. import type { FastifyInstance } from 'fastify'; import type { EventBus } from '../../events/bus.js'; import { readSessionToken } from '../auth.js'; @@ -46,21 +11,13 @@ const MAX_CONNECTIONS_PER_TOKEN = 4; export interface RegisterStreamDeps { bus: EventBus; - /** - * Override the heartbeat interval for tests. Real timers + a small value - * (e.g. 50 ms) is more reliable than fake-timer-driven heartbeat tests - * because `light-my-request` chunk delivery is `process.nextTick`-driven, - * which races with `vi.advanceTimersByTimeAsync`. - */ heartbeatMs?: number; } export function registerStreamRoutes(app: FastifyInstance, deps: RegisterStreamDeps): void { const { bus } = deps; const heartbeatMs = deps.heartbeatMs ?? SSE_HEARTBEAT_MS; - // Per-token open-connection counter. Lives in module scope so each Fastify - // factory instance gets its own map (tests build fresh instances per - // `beforeEach`, so no cross-test bleed). + // Scoped per factory instance so fresh test instances don't bleed across each other. const openByToken = new Map(); app.get('/stream', (request, reply) => { @@ -85,13 +42,9 @@ export function registerStreamRoutes(app: FastifyInstance, deps: RegisterStreamD Connection: 'keep-alive', 'X-Accel-Buffering': 'no', }); - // Initial flush so the client sees the stream is live before the first - // real event (which may be many seconds away). + // Initial flush so the client sees the stream is live before the first real event. socket.write(': open\n\n'); - // Backpressure: when `socket.write` returns false, the kernel send - // buffer is full and node's internal `writable` buffer would start - // growing without bound. Drop events until the socket emits `'drain'`. let backpressured = false; socket.on('drain', () => { backpressured = false; diff --git a/apps/server/src/api/routes/system.ts b/apps/server/src/api/routes/system.ts index de2b715..b2b3d7e 100644 --- a/apps/server/src/api/routes/system.ts +++ b/apps/server/src/api/routes/system.ts @@ -1,22 +1,4 @@ -/** - * System routes. - * - * - `GET /system/status`: Surface enough info for the web UI to explain - * why some features are unavailable (e.g. Telegram disconnected → - * subscribing/forwarding disabled). The status is supplied as a getter - * so the health monitor (apps/server/src/tg/healthMonitor.ts) can update - * it on each probe and the route always reads the current value. - * - * - `POST /system/export`, `POST /system/import`, `POST /system/wipe`: - * Settings → Data section. Versioned JSON envelope (`exportFileSchema`) - * carries selected sections (subscriptions / destinations / library - * filters / app settings) round-trip without ID collisions — IDs are - * intentionally omitted and import remaps via natural keys (chatId+name - * for destinations, name for library filters, sourceChatId for - * subscriptions). Import body limit is bumped to 2 MB on the route - * level — large enough for thousands of subscriptions, small enough to - * refuse abuse. - */ +// Import remaps by natural key (destinations: chatId+name; library filters: name; subscriptions: sourceChatId). import { eq, inArray } from 'drizzle-orm'; import type { FastifyInstance } from 'fastify'; import { @@ -64,9 +46,6 @@ import { import { getStatsDigestConfig } from '../../forwarding/statsDigestConfig.js'; import { replaceInlineFilters, replaceLibraryFilterAttachments } from './subscriptions.js'; -// Read the app version from the root package.json so exports carry a -// truthful tag. The path goes from this module up four levels: -// apps/server/src/api/routes → apps/server → repo root. import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import path from 'node:path'; @@ -92,26 +71,10 @@ export interface RegisterSystemRoutesDeps { bus: EventBus; logger: Logger; getTelegramStatus: () => TelegramStatus; - /** - * Returns the loaded `TG_SESSION_ENCRYPTION_KEY` as a 32-byte Buffer, or - * null when not set. Used by import to decide whether to write the - * encrypted account blob carried inside `appSettings.telegramAccount`. - */ + // null gates writing the imported account blob. getEncryptionKey?: () => Buffer | null; - /** - * Triggers a live-swap of the gramjs runtime so an imported account row - * activates without a process restart. Optional; when absent, the row is - * still written but takes effect on next boot. - */ + // Live-swaps gramjs so an imported account activates without restart. reloadTelegramSession?: () => Promise; - /** - * Best-effort Telegram profile-photo fetcher. Import never carries icon - * bytes (they're derived data, refetched per host), so after a successful - * import we backfill `iconDataUrl` for the rows we just created — mirroring - * the inline fetch that POST /subscriptions and POST /destinations already - * do. Optional: when absent (Telegram-less boot, tests) imported rows stay - * icon-less until the access monitor's 24h sweep catches them. - */ getFetchProfilePhoto?: () => ProfilePhotoFetcher | undefined; } @@ -133,10 +96,7 @@ export function registerSystemRoutes(app: FastifyInstance, deps: RegisterSystemR app.post( '/system/export', { - // Behind cookie auth and SameSite=strict, but cap nonetheless: a stolen - // cookie or compromised browser extension shouldn't be able to grind - // out unlimited full-DB dumps. Generous enough that legitimate - // back-to-back exports work; tight enough that abuse is bounded. + // Cap full-DB dumps against a stolen cookie. config: { rateLimit: { max: 10, timeWindow: '1 minute' } }, }, async (request): Promise => { @@ -158,23 +118,15 @@ export function registerSystemRoutes(app: FastifyInstance, deps: RegisterSystemR `unsupported export schemaVersion ${body.data.schemaVersion}`, ); } - // v1 files lack `appSettings.telegramAccount`; v2 importers handle - // both transparently because the field is `.optional()`. const sectionSet = new Set(body.sections); const result = applyImport(db, body.data, sectionSet, body.conflictStrategy, { ...(getEncryptionKey !== undefined ? { getEncryptionKey } : {}), }); - // If a telegram account was actually written, kick the live-swap so - // the running app picks it up without a restart. Failures are - // swallowed — the row is saved either way. + // Failures swallowed: the row is saved either way. if (result.telegramAccountWritten && reloadTelegramSession) { await reloadTelegramSession().catch(() => {}); } - // Backfill profile photos for the rows we just imported. Fire-and-forget: - // a large import shouldn't block the HTTP response on hundreds of - // sequential Telegram downloads. Icons stream into the UI as they land - // via the `subscription.changed` / `destination.changed` SSE events the - // backfill emits (the web client invalidates its lists on those). + // Fire-and-forget so a big import doesn't block on sequential TG downloads. const fetchProfilePhoto = getFetchProfilePhoto?.(); if (fetchProfilePhoto && result.iconTargets.length > 0) { void backfillImportedIcons({ @@ -194,9 +146,7 @@ export function registerSystemRoutes(app: FastifyInstance, deps: RegisterSystemR app.post( '/system/wipe', { - // Destructive — cap aggressively. A leaked cookie or stored-XSS - // shouldn't be able to nuke the DB in a tight loop. Three calls per - // minute is still plenty for a legitimate "clear & re-import" flow. + // Destructive — cap aggressively against a leaked cookie. config: { rateLimit: { max: 3, timeWindow: '1 minute' } }, }, async (request): Promise => { @@ -351,9 +301,7 @@ function buildExport(db: Db, sections: Set): ExportFile { statsDigestTime: digest.time, statsDigestTimezone: digest.timezone, }; - // v2: include the encrypted Telegram account row when present. The - // ciphertext + fingerprint travel as-is — the importing host decides - // (via fingerprint match) whether to write the row to its own DB. + // Ciphertext + fingerprint travel as-is; importing host decides via fingerprint match. const accountRow = db.select().from(telegramAccount).get(); if (accountRow) { settings.telegramAccount = { @@ -379,7 +327,6 @@ function destKey(chatId: string, name: string): string { const emptySectionResult = (): ImportSectionResult => ({ created: 0, skipped: 0, replaced: 0 }); -/** A subscription/destination row that import created, needing a profile photo. */ export interface IconBackfillTarget { kind: 'subscription' | 'destination'; id: number; @@ -388,13 +335,7 @@ export interface IconBackfillTarget { interface ApplyImportResult { body: ImportResult; - /** True when a `telegram_account` row was created or replaced. */ telegramAccountWritten: boolean; - /** - * Subscription/destination rows this import touched (created or replaced) - * that still have a null `iconDataUrl` — fed to `backfillImportedIcons` - * after the transaction commits. Empty when nothing needs an icon. - */ iconTargets: IconBackfillTarget[]; } @@ -413,18 +354,11 @@ function applyImport( warnings: [], }; let telegramAccountWritten = false; - // IDs of subscription / destination rows this import created or replaced. - // After the transaction commits we re-read these and keep only the ones - // still missing an icon, so the post-import backfill targets exactly the - // rows that need one (created rows always do; replaced rows only when they - // were icon-less to begin with). const touchedDestIds: number[] = []; const touchedSubIds: number[] = []; db.transaction((tx) => { - // Build initial maps from existing rows so subscriptions can resolve - // refs even when the user opted out of importing destinations / library - // filters in this run (and the records already exist). + // Seed from existing rows so subscriptions resolve refs even when those sections weren't imported. const destMap = new Map(); for (const d of tx.select().from(destinations).all() as Destination[]) { destMap.set(destKey(d.chatId, d.name), d.id); @@ -477,8 +411,7 @@ function applyImport( for (const item of data.libraryFilters) { const existingId = libMap.get(item.name); if (existingId !== undefined) { - // ruleType is immutable post-create; if it changed, surface a - // warning and skip this row regardless of strategy. + // ruleType is immutable; on mismatch, warn and skip regardless of strategy. const existingRow = tx .select() .from(libraryFilters) @@ -534,8 +467,6 @@ function applyImport( result.subscriptions.skipped += 1; continue; } - // Replace: overwrite mutable fields + bulk-replace filter sets via - // the existing helpers (same code path as PATCH /subscriptions/:id). tx.update(subscriptions) .set({ sourceTitle: item.sourceTitle, @@ -579,11 +510,7 @@ function applyImport( } if (sections.has('appSettings') && data.appSettings) { - // The throttle/debouncer fields go into `app_settings`. The optional - // `telegramAccount` sub-object is split off and routed to its own - // table (`telegram_account`) — the wire format embeds it under - // `appSettings` so the user can manage both with one checkbox in the - // UI, but at rest they live separately. + // telegramAccount rides under appSettings on the wire but persists to its own table. const { telegramAccount: importedAccount, ...appSettingsValue } = data.appSettings; const existing = tx .select() @@ -593,9 +520,7 @@ function applyImport( if (existing && strategy === 'skip') { result.appSettings.skipped += 1; } else { - // Merge onto the existing row rather than replacing it: the export - // may omit fields (e.g. an older file with no digest schedule), and a - // wholesale overwrite would wipe whatever the row already held. + // Merge, don't overwrite: an older export may omit fields the row already holds. const mergedValue = { ...((existing?.value as Record | undefined) ?? {}), ...appSettingsValue, @@ -626,10 +551,7 @@ function applyImport( } }); - // Re-read the touched rows (post-commit) and keep only those still missing - // an icon. Created rows are always null here; replaced rows pass through - // only when they were already icon-less, so we never re-download a photo a - // row already has. + // Keep only touched rows still missing an icon, so we never re-download. const iconTargets: IconBackfillTarget[] = []; if (touchedDestIds.length > 0) { const rows = db @@ -667,23 +589,7 @@ function applyImport( return { body: result, telegramAccountWritten, iconTargets }; } -/** - * Post-import icon backfill. Import envelopes never carry profile-photo bytes - * (they're derived data — large, and tied to the importing host's session), - * so freshly imported subscriptions / destinations land with `iconDataUrl = - * null` and render the lucide fallback. This mirrors the inline fetch that the - * create routes (POST /subscriptions, POST /destinations) run, but batched and - * fired after the import response so a big import isn't held hostage to - * sequential Telegram downloads. - * - * Best-effort throughout: `fetchProfilePhoto` already swallows its own errors - * and returns null (no photo, gramjs error, oversize), so a failure just - * leaves that one row icon-less for the access monitor's 24h sweep to retry. - * Each successful stamp emits the same `*.changed` event the access monitor - * uses, so the web client's SSE listener invalidates its lists and the icon - * appears without a manual refresh. Fetches are deduped per chatId so a chat - * used by several rows is downloaded once. - */ +// Best-effort, deduped per chatId; each stamp emits a *.changed event so the UI refreshes. export async function backfillImportedIcons(deps: { db: Db; bus: EventBus; @@ -717,12 +623,7 @@ export async function backfillImportedIcons(deps: { return stamped; } -/** - * Returns true when the row was actually upserted (caller triggers - * live-swap). Returns false when the row was skipped (no key, fingerprint - * mismatch, or strategy=skip with an existing row); the warning array is - * mutated in place either way. - */ +// false = skipped: no key, fingerprint mismatch, or skip strategy. type DbOrTx = Db | Parameters[0]>[0]; function importTelegramAccount( @@ -817,11 +718,7 @@ function resolveLibraryFilterIds( return ids; } -// The exported subscription's inline filters are already validated by the -// `exportFileSchema`'s discriminated union — every entry carries valid -// per-rule params. Pass them through, but keep the warning hook so a future -// schema bump or hand-edited file with stray entries gets a clear surface -// rather than a silent throw. +// Already validated by exportFileSchema; warning hook retained for future schema drift. function importableInlineFilters( item: ExportedSubscription, _warnings: ImportWarning[], @@ -840,16 +737,13 @@ function applyWipe(db: Db, sections: Set): WipeResult { deleted.subscriptions = result.changes; } if (sections.has('libraryFilters')) { - // Pre-detach to avoid the FK RESTRICT on `subscription_library_filters - // → library_filters` when subscriptions weren't selected for wipe. + // Pre-detach to dodge FK RESTRICT on subscription_library_filters → library_filters. tx.delete(subscriptionLibraryFilters).run(); const result = tx.delete(libraryFilters).run(); deleted.libraryFilters = result.changes; } if (sections.has('destinations')) { - // FK is ON DELETE SET NULL on `subscriptions.destination_id`, so - // subscriptions survive — just lose their destination. Order matters - // only relative to subscriptions wipe (already done above if selected). + // FK is ON DELETE SET NULL on subscriptions.destination_id, so subscriptions survive. const result = tx.delete(destinations).run(); deleted.destinations = result.changes; } diff --git a/apps/server/src/api/routes/telegramAccount.ts b/apps/server/src/api/routes/telegramAccount.ts index f4a0f66..7774a8a 100644 --- a/apps/server/src/api/routes/telegramAccount.ts +++ b/apps/server/src/api/routes/telegramAccount.ts @@ -1,25 +1,4 @@ -/** - * Telegram account routes — settings-page sign-in / sign-out. - * - * GET /api/tg/account → current state, source, key status - * POST /api/tg/login/start → send code to a phone, returns sessionId - * POST /api/tg/login/verify → verify code; may signal needsPassword - * POST /api/tg/login/password → 2FA password - * POST /api/tg/login/raw → paste a raw session string (validated) - * POST /api/tg/login/cancel → drop an in-progress login - * DELETE /api/tg/account → remove the DB row, env fallback resumes - * - * "Save" branches refuse with 412 unless `TG_SESSION_ENCRYPTION_KEY` is - * configured. The encrypted blob is written to `telegram_account` (single - * row, id=1) and `reloadTelegramSession()` is called so the gramjs runtime - * picks up the new credentials without a restart. - * - * Each in-progress login session is bound to the caller's web-session cookie - * token via `readSessionToken(request)`; verify/password/cancel reject if a - * second authed tab tries to drive someone else's flow. The phone-code and - * password endpoints are rate-limited per IP — brute-forcing a 5-digit - * Telegram code via this surface would shadow-ban the operator's phone. - */ +// Settings-page Telegram sign-in/out. Save needs TG_SESSION_ENCRYPTION_KEY (else 412); login sessions are bound to the caller's cookie token so a second tab can't drive someone else's flow. import type { FastifyInstance } from 'fastify'; import { type TelegramAccountInfo, @@ -43,28 +22,18 @@ import { readSessionToken } from '../auth.js'; export interface RegisterTelegramAccountRoutesDeps { db: Db; - /** Returns the configured 32-byte key, or null when unset. */ getEncryptionKey?: () => Buffer | null; - /** In-memory store for in-progress sign-ins. */ loginSessionStore?: LoginSessionStore; - /** Triggers the live-swap so a freshly-saved session takes effect immediately. */ reloadTelegramSession?: () => Promise; - /** Lifecycle of the gramjs subsystem (used to compute `present`). */ getTelegramStatus: () => TelegramStatus; - /** - * Profile-photo fetcher over the live userbot. Used to fetch the account's - * own avatar ("me"); absent when no client is up (tests / disconnected). - */ + // Over the live userbot; absent when no client is up (tests / disconnected). getFetchProfilePhoto?: () => ProfilePhotoFetcher | undefined; } -// Avatar cache TTL — long enough to avoid re-downloading on every settings -// poll, short enough that a changed/swapped photo or a transient miss is -// picked up without a sign-out or restart. const AVATAR_TTL_MS = 10 * 60_000; const AVATAR_FETCH_TIMEOUT_MS = 2500; -/** Resolves to null after the timeout so a stalled download can't hang the route. */ +// Resolves to null after the timeout so a stalled download can't hang the route. function avatarFetchTimeout(): Promise { return new Promise((resolve) => { const t = setTimeout(() => resolve(null), AVATAR_FETCH_TIMEOUT_MS); @@ -85,9 +54,7 @@ export function registerTelegramAccountRoutes( getFetchProfilePhoto, } = deps; - // The userbot's own avatar, downloaded lazily and cached with a short TTL so - // a transient miss or a changed/swapped photo is eventually picked up - // without re-downloading on every poll. Cleared outright on sign-in / out. + // Userbot's own avatar, lazy + short-TTL cached; cleared on sign-in/out. let avatarCache: { key: string; dataUrl: string | null; fetchedAt: number } | null = null; async function resolveAvatar(base: TelegramAccountInfo): Promise { @@ -102,7 +69,6 @@ export function registerTelegramAccountRoutes( if (!fetcher) return avatarCache?.key === key ? avatarCache.dataUrl : null; let dataUrl: string | null = null; try { - // Don't let a hung gramjs download block this frequently-polled route. dataUrl = await Promise.race([fetcher('me'), avatarFetchTimeout()]); } catch { dataUrl = null; @@ -125,9 +91,7 @@ export function registerTelegramAccountRoutes( app.post( '/tg/login/start', { - // Telegram throttles SendCode aggressively per phone number; cap our own - // call rate to avoid getting the operator's phone number shadow-banned - // when the UI fires repeated start requests. + // Cap SendCode rate: repeated starts can shadow-ban the operator's phone. config: { rateLimit: { max: 5, timeWindow: '5 minutes' } }, }, async (request) => { @@ -142,9 +106,7 @@ export function registerTelegramAccountRoutes( app.post( '/tg/login/verify', { - // Brute-forcing the SMS code via this surface would shadow-ban the - // phone — Telegram counts attempts per phone+app, not per IP. Stay - // well under the limit; legitimate users only need a handful of tries. + // SMS-code brute-force shadow-bans the phone (counted per phone+app, not per IP). config: { rateLimit: { max: 8, timeWindow: '5 minutes' } }, }, async (request): Promise => { @@ -170,8 +132,7 @@ export function registerTelegramAccountRoutes( app.post( '/tg/login/password', { - // Same reasoning as `/verify` — 2FA password brute-force is upstream- - // rate-limited but the operator pays the bill. + // Cap 2FA-password rate; the operator pays for upstream brute-force throttling. config: { rateLimit: { max: 8, timeWindow: '5 minutes' } }, }, async (request): Promise => { @@ -218,9 +179,7 @@ export function registerTelegramAccountRoutes( deleteAccount(db); avatarCache = null; if (reloadTelegramSession) { - // Live-swap to env (or degraded) — failures are logged inside - // `reloadTelegramSession` and don't block the user. The DB row is - // already gone; next boot will pick up the env fallback. + // Live-swap to env; failures don't block (row's gone, next boot picks up env). await reloadTelegramSession().catch(() => {}); } return { ok: true }; @@ -238,10 +197,7 @@ function buildAccountInfo(deps: { const row = getActiveAccount(deps.db); const status = deps.getTelegramStatus(); const connected = status.state === 'connected'; - // A row whose fingerprint doesn't match the current key (or no key set) - // is "stale" — the resolver fell through to env. Report the mismatch - // separately so the UI can prompt the operator without claiming the row - // is the live account. + // Fingerprint mismatch (or no key) = stale row, resolver fell through to env; reported separately so the UI can prompt. const rowUsable = row !== null && key !== null && getKeyFingerprint(key) === row.keyFingerprint; const source: 'db' | 'env' | null = !connected ? null : rowUsable ? 'db' : 'env'; @@ -300,9 +256,7 @@ async function commitLogin(deps: { try { await deps.reloadTelegramSession(); } catch { - // Live-swap failed; the DB row is still saved. The next process restart - // picks it up. The caller rebuilds the account info regardless so the UI - // can confirm the save. + // Live-swap failed; row is saved, next restart picks it up. } } } diff --git a/apps/server/src/api/sessionStore.ts b/apps/server/src/api/sessionStore.ts index ce08299..7bf1534 100644 --- a/apps/server/src/api/sessionStore.ts +++ b/apps/server/src/api/sessionStore.ts @@ -1,47 +1,24 @@ -/** - * Per-login session token store. - * - * Cookie semantics changed (vs. the legacy static-value design): - * - The cookie value IS the opaque token. Server signs it with - * `@fastify/cookie` HMAC so a tampered cookie fails at unsign. - * - `create()` mints a 256-bit random token at login and inserts a row with - * a hard expiry. - * - `verifyAndRefresh()` is the requireAuth hot path: look up by token, - * reject if expired, otherwise bump `lastSeenAt` and slide `expiresAt` - * forward by the configured window. Sliding refresh limits damage from - * a stolen-but-unused cookie. - * - `revoke()` deletes the row — logout becomes immediate and definitive. - * - `prune()` purges expired rows; called periodically from boot. - */ +// Opaque session tokens in an HMAC-signed cookie; sliding 7-day expiry, server-side revoke. import { randomBytes } from 'node:crypto'; import { lt, eq } from 'drizzle-orm'; import type { Db } from '../db/client.js'; import { webSessions } from '../db/schema.js'; const TOKEN_BYTES = 32; -// 7 days hard cap; sliding window refreshes on each authed request, but a -// completely abandoned cookie still ages out. +// Hard cap even with sliding refresh, so an abandoned cookie still ages out. export const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; export interface SessionStore { - /** Mint a new session. Returns the opaque token to send back in the cookie. */ create(now?: Date): string; - /** - * Look up a token; if found and unexpired, slide `expiresAt` forward and - * return true. Returns false for missing or expired tokens. Expired rows - * are deleted in passing so the caller doesn't have to. - */ + // Slides expiry on hit; deletes expired rows in passing. verifyAndRefresh(token: string, now?: Date): boolean; - /** Delete a token. Idempotent — returns true if a row was deleted. */ revoke(token: string): boolean; - /** Sweep expired rows. Returns the number of rows deleted. */ prune(now?: Date): number; } export interface CreateSessionStoreDeps { db: Db; - /** Override TTL (tests). Defaults to 7 days. */ - ttlMs?: number; + ttlMs?: number; // override for tests } export function createSessionStore(deps: CreateSessionStoreDeps): SessionStore { @@ -68,7 +45,6 @@ export function createSessionStore(deps: CreateSessionStoreDeps): SessionStore { const row = db.select().from(webSessions).where(eq(webSessions.token, token)).get(); if (!row) return false; if (row.expiresAt.getTime() <= now.getTime()) { - // Expired — delete lazily so subsequent lookups don't reuse it. db.delete(webSessions).where(eq(webSessions.token, token)).run(); return false; } diff --git a/apps/server/src/api/testing.ts b/apps/server/src/api/testing.ts index 90160fe..8b1640e 100644 --- a/apps/server/src/api/testing.ts +++ b/apps/server/src/api/testing.ts @@ -1,11 +1,4 @@ -/** - * API test scaffolding. - * - * Builds an isolated app instance backed by an in-memory DB and a fixed - * test `webAuth`. Returns a `loginAndGetCookie()` helper because cookie - * signing requires the live secret — tests can't fabricate a valid signed - * cookie without going through the real login route. - */ +// loginAndGetCookie() goes through the real login route: cookie signing needs the live secret. import type { FastifyInstance } from 'fastify'; import type { TelegramStatus } from '@tg-feed/shared'; import { type Config } from '../config.js'; @@ -41,48 +34,22 @@ export interface TestApp { } export interface BuildTestAppOptions { - /** Override the SSE heartbeat interval (default 25 s — too long for stream tests). */ + // Default 25 s — too long for stream tests. heartbeatMs?: number; - /** Stub for the universal chat resolver used by both /resolve endpoints. */ chatResolver?: ChatResolver; - /** Stub for the invite-import hook used by both /create endpoints when `inviteHash` is provided. */ importInvite?: ImportInviteFn; - /** Stub for the auto-join hook fired from POST /api/subscriptions (chatId path). */ joinChannel?: JoinChannelFn; - /** Stub for the best-effort profile photo fetcher fired from both /create endpoints. */ fetchProfilePhoto?: ProfilePhotoFetcher; - /** Stub for the forum-topic lister backing POST /api/destinations/topics. */ listForumTopics?: ForumTopicLister; - /** - * Override the Telegram status surfaced via `GET /api/system/status` and - * used by routes to choose between `telegram_initializing` (transient, - * during boot) and `telegram_unavailable` (steady-state). Defaults to a - * disconnected status so tests that omit Telegram stubs see the - * configure-style 503. - */ + // Default disconnected, so tests without Telegram stubs see the configure-style 503. telegramStatus?: TelegramStatus; - /** - * Returns the configured `TG_SESSION_ENCRYPTION_KEY` as a 32-byte Buffer, - * or null. Used by tests that exercise the export/import telegram-account - * flow. Defaults to undefined (key absent). - */ + // Default undefined (key absent). getEncryptionKey?: () => Buffer | null; - /** - * Override the SPA static root. Lets the SPA-history-fallback tests point - * the server at a temp dir containing a stub index.html. - */ webDistRoot?: string; - /** - * Telegram Web App auth config. Default undefined → the - * `POST /api/auth/telegram` route reports `telegram_auth_disabled`. - * Forwarded as a `getTelegramAuth` getter to the server factory. - */ + // Default undefined → POST /api/auth/telegram reports telegram_auth_disabled. telegramAuth?: TelegramAuth | null; - /** Parsed env config for the bot-config route's DB-over-env fallbacks. */ cfg?: Config; - /** Stub for the bot live-swap invoked by the bot-config route's PUT/DELETE. */ reloadBot?: () => Promise; - /** Whether the bot is "running" for the masked `GET /api/config/bot`. */ getBotRunning?: () => boolean; } @@ -145,9 +112,7 @@ export async function buildTestApp(options: BuildTestAppOptions = {}): Promise; - /** Stop long-polling and wait for the poll loop to unwind. */ stop(): Promise; - /** - * DM `text` (HTML) to every admin; returns how many sends succeeded. - * Per-admin failures are logged and swallowed so one unreachable admin can't - * block the rest — most commonly a 403 because that admin never opened the - * bot, so it can't initiate a chat. A return of 0 means nobody received it. - */ + // DMs `text` (HTML) to each admin, returning the success count; per-admin failures swallowed (often 403: admin never opened the bot). notifyAdmins(text: string): Promise; } export interface CreateBotDeps { token: string; adminIds: string[]; - /** Public base URL of the web client. Must be https to drive a Mini App. */ + // Must be https to drive a Mini App. publicUrl?: string | undefined; logger: Logger; } const MENU_BUTTON_TEXT = 'Open tg-feed'; -// Timeout for each await in `stop()` — grammy's `stop()` makes a final -// `getUpdates` call that has no timeout of its own. +// Bounds each await in stop(): grammy's final getUpdates call has no timeout of its own. const STOP_TIMEOUT_MS = 5000; -/** Whether the update's sender is on the admin allowlist. */ export function isBotAdmin(adminIds: string[], userId: number | string | undefined): boolean { if (userId === undefined) return false; return adminIds.includes(String(userId)); } -/** A Mini App URL is only usable over HTTPS; anything else is rejected by Telegram. */ +// A Mini App URL is only usable over HTTPS; anything else is rejected by Telegram. export function resolveWebAppUrl(publicUrl: string | undefined): string | undefined { if (!publicUrl) return undefined; return publicUrl.startsWith('https://') ? publicUrl : undefined; @@ -58,12 +37,11 @@ export function createTgFeedBot(deps: CreateBotDeps): TgFeedBot { const webAppUrl = resolveWebAppUrl(deps.publicUrl); const bot = new Bot(token); - // Long-poll promise; `stop()` awaits it for teardown. Never rejects — a - // polling crash is logged in the `.catch` below. + // Never rejects — a polling crash is logged in the `.catch` below. let pollLoop: Promise | undefined; bot.catch((err) => { - // Log and swallow per-update handler errors so one bad update doesn't stop polling. + // Swallow per-update handler errors so one bad update doesn't stop polling. logger.error( { err: err.error, update: err.ctx.update.update_id }, 'bot: update handler failed', @@ -89,7 +67,7 @@ export function createTgFeedBot(deps: CreateBotDeps): TgFeedBot { return { async start() { - // Fetch bot info and validate the token; a bad token rejects to the caller. + // Validates the token; a bad token rejects to the caller. await bot.init(); try { @@ -116,9 +94,7 @@ export function createTgFeedBot(deps: CreateBotDeps): TgFeedBot { ); } - // Run long-polling in the background; `bot.start()` resolves only when - // the bot stops, so it's not awaited here. `bot.init()` ran above, so - // grammy marks the bot running synchronously on this call. + // Not awaited: `bot.start()` resolves only when the bot stops. pollLoop = bot .start({ onStart: (info) => logger.info({ username: info.username }, 'bot: started long-polling'), @@ -157,7 +133,7 @@ export function createTgFeedBot(deps: CreateBotDeps): TgFeedBot { }; } -/** Settle with `p`, or reject after `ms` if it hasn't settled. Timer is unref'd. */ +// Reject after `ms` if `p` hasn't settled; timer is unref'd. function withTimeout(p: Promise, ms: number): Promise { return new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error(`timed out after ${ms}ms`)), ms); diff --git a/apps/server/src/bot/botConfig.ts b/apps/server/src/bot/botConfig.ts index c44b66e..7e2d993 100644 --- a/apps/server/src/bot/botConfig.ts +++ b/apps/server/src/bot/botConfig.ts @@ -1,6 +1,3 @@ -/** - * DB-over-env resolver for the Telegram bot configuration. - */ import type { BotAdmin, BotConfigInfo, BotConfigSource } from '@tg-feed/shared'; import type { TelegramAuth } from '../api/auth.js'; import type { Config } from '../config.js'; @@ -26,7 +23,6 @@ function dedupe(ids: string[]): string[] { return Array.from(new Set(ids.map((s) => s.trim()).filter((s) => s.length > 0))); } -/** Resolve the bot token DB→env. Logs (once per call) when it skips a stored row. */ function resolveBotToken(deps: ResolveBotDeps): { token: string | null; source: BotConfigSource | null; @@ -60,10 +56,7 @@ function resolveBotToken(deps: ResolveBotDeps): { return { token: null, source: null }; } -/** - * Resolve the admin allowlist DB→env. DB entries carry display names (from - * the `@username` lookup); env entries are raw ids with null names. - */ +// DB entries carry display names; env entries are raw ids with null names. function resolveAdmins(deps: ReadDeps): { admins: BotAdmin[]; source: BotConfigSource | null } { const { cfg, db } = deps; const stored = readBotConfigRaw(db); @@ -107,11 +100,7 @@ function resolvePublicUrlWithSource(deps: ReadDeps): { return { publicUrl: undefined, source: null }; } -/** - * Resolved bot auth (token + admin allowlist), or null when the bot is not - * configured. Enabled only when a token resolves AND at least one admin id - * resolves. - */ +// null unless both a token AND at least one admin id resolve. export function resolveTelegramAuth(deps: ResolveBotDeps): TelegramAuth | null { const { token } = resolveBotToken(deps); if (!token) return null; @@ -120,16 +109,11 @@ export function resolveTelegramAuth(deps: ResolveBotDeps): TelegramAuth | null { return { botToken: token, adminIds }; } -/** Resolved public URL DB→env (used for the bot's Mini App button). */ export function resolvePublicUrl(deps: ReadDeps): string | undefined { return resolvePublicUrlWithSource(deps).publicUrl; } -/** - * Masked, read-only view for `GET /api/config/bot`. Never logs and never - * returns the token. `keyFingerprintMismatch` is true when a token is stored - * but unusable with the current key (key unset or fingerprint differs). - */ +// Masked view for GET /api/config/bot; never returns the token. keyFingerprintMismatch = token stored but unusable with the current key. export function buildBotConfigInfo(deps: { cfg: Config; db: Db; diff --git a/apps/server/src/bot/initData.ts b/apps/server/src/bot/initData.ts index 14e70f3..16d0466 100644 --- a/apps/server/src/bot/initData.ts +++ b/apps/server/src/bot/initData.ts @@ -1,23 +1,6 @@ -/** - * Telegram Web App `initData` verification. - * - * When the web client is opened as a Telegram Mini App, Telegram injects a - * signed `initData` query string into `window.Telegram.WebApp`. The client - * POSTs it verbatim to `/api/auth/telegram`; this module proves it was - * minted by Telegram for *our* bot and extracts the calling user. - * - * Verification (per https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app): - * 1. Parse the query string and pull out the `hash` field. - * 2. Build the data-check-string: the remaining `key=value` pairs sorted - * by key, joined with `\n`. - * 3. secret_key = HMAC-SHA256(key = "WebAppData", data = bot_token) - * 4. expected = hex(HMAC-SHA256(key = secret_key, data = data-check-string)) - * 5. constant-time compare `expected` with the supplied `hash`. - * 6. reject payloads older than `maxAgeSec` via `auth_date`. - */ +// Verifies Telegram Mini App `initData` was minted for our bot, per https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app import { createHmac, timingSafeEqual } from 'node:crypto'; -/** Subset of the Telegram WebApp `user` object we care about. */ export interface TelegramWebAppUser { id: string; firstName: string | null; @@ -30,9 +13,9 @@ export type VerifyInitDataResult = | { ok: false; reason: string }; export interface VerifyInitDataOptions { - /** Reject initData whose `auth_date` is older than this many seconds. Default 24h. */ + // Reject initData older than this many seconds; default 24h. maxAgeSec?: number; - /** Injectable clock for tests. Defaults to `Date.now()`. */ + // Injectable clock for tests; defaults to `Date.now()`. now?: () => number; } @@ -55,8 +38,7 @@ export function verifyTelegramInitData( const hash = params.get('hash'); if (!hash) return { ok: false, reason: 'initData missing hash' }; - // Data-check-string: every field except `hash`, sorted by key, `key=value` - // joined by newlines (`signature` stays in — the HMAC base excludes only `hash`). + // Data-check-string: every field except `hash` (incl. `signature`), sorted by key, `key=value` joined by `\n`. const pairs: string[] = []; for (const [key, value] of params) { if (key === 'hash') continue; @@ -94,8 +76,7 @@ export function verifyTelegramInitData( if (parsed.id === undefined || parsed.id === null) { return { ok: false, reason: 'initData user missing id' }; } - // Telegram ids can exceed JS's safe-integer range, so take the exact - // digits from the raw JSON text rather than the (lossy) parsed number. + // Telegram ids exceed JS safe-integer range: take exact digits from raw JSON, not the lossy parsed number. const idMatch = /"id"\s*:\s*(-?\d+)/.exec(userRaw); user = { id: idMatch ? idMatch[1]! : String(parsed.id), diff --git a/apps/server/src/bot/statsDigest.ts b/apps/server/src/bot/statsDigest.ts index 2da4ca3..e3f5094 100644 --- a/apps/server/src/bot/statsDigest.ts +++ b/apps/server/src/bot/statsDigest.ts @@ -1,27 +1,4 @@ -/** - * Stats-digest scheduler. - * - * Once a day or once a week, at a chosen local time, the bot DMs its admins a - * forwarded/filtered/error summary. Telegram has no wall-clock scheduling - * primitive and the rest of the app uses fixed-interval pollers, so this is a - * minute-tick poller that decides on each tick whether the scheduled moment - * has passed and a digest is still owed. - * - * Dedup is by an *occurrence key* — a stable label for "the most recent - * scheduled time at-or-before now", derived purely from the time zone's - * wall-clock parts (no epoch/offset/DST math). We send at most once per - * occurrence and persist the key, which also yields free catch-up: if the - * server was down across a scheduled time, the next tick after boot still - * sees an unsent occurrence and sends it. - * - * Any change to the schedule (enable/disable, time, day, frequency, zone) - * re-baselines via a config fingerprint: the current occurrence is marked - * handled without sending, so editing the schedule never triggers an - * immediate or backlog blast — the first real send is the next occurrence. - * - * The window is [lastSentAt, now): every message is counted in exactly one - * digest, with no gaps or overlaps. - */ +// Minute-tick poller (Telegram has no wall-clock scheduler). Dedup + catch-up via an occurrence key derived from wall-clock parts (no DST math); send once per occurrence. Schedule changes re-baseline via config fingerprint so edits never blast. Window is [lastSentAt, now). import type { Db } from '../db/client.js'; import type { Logger } from '../lib/logger.js'; import { createPoller } from '../lib/poller.js'; @@ -45,19 +22,16 @@ const WEEKDAY_INDEX: Record = { export interface StatsDigestSchedulerDeps { db: Db; - /** Resolves the current bot; a getter because the ref is swapped on reload. */ + // Getter because the bot ref is swapped on reload. getBot: () => TgFeedBot | undefined; logger: Logger; - /** Injectable clock (epoch ms) for tests. Defaults to `Date.now`. */ now?: () => number; - /** Tick cadence; defaults to 60s. */ intervalMs?: number; } export interface StatsDigestScheduler { start(): void; stop(): void; - /** Evaluate the schedule once. Exposed for tests; the poller calls it. */ runOnce(): Promise; } @@ -70,7 +44,6 @@ interface WallClock { minute: number; } -/** Wall-clock parts of `epochMs` as seen in `timeZone`. */ function wallClockInZone(epochMs: number, timeZone: string): WallClock { const parts = new Intl.DateTimeFormat('en-US', { timeZone, @@ -93,12 +66,7 @@ function wallClockInZone(epochMs: number, timeZone: string): WallClock { }; } -/** - * Stable key for the most recent scheduled occurrence at-or-before `nowMs`, - * e.g. `"daily:2026-06-01"` / `"weekly:2026-05-29"`. The date is computed on - * the zone's wall-clock calendar; we render it via UTC arithmetic purely as an - * opaque label (no offset needed). - */ +// Stable label for the most recent scheduled occurrence at-or-before nowMs, e.g. "daily:2026-06-01"; UTC arithmetic is just an opaque renderer. export function occurrenceKey(nowMs: number, cfg: StatsDigestConfig): string { const wc = wallClockInZone(nowMs, cfg.timezone); const [hh, mm] = cfg.time.split(':'); @@ -134,7 +102,6 @@ function escapeHtml(s: string): string { return s.replace(/&/g, '&').replace(//g, '>'); } -/** Concise HTML body for the bot to DM. */ export function formatDigest( stats: DigestStats, sinceMs: number, @@ -174,8 +141,7 @@ export function createStatsDigestScheduler(deps: StatsDigestSchedulerDeps): Stat const state = readStatsDigestState(db); const nowMs = now(); - // Schedule changed (or first ever tick) → re-baseline, never send. Record - // the current occurrence as handled so the first real send is the next one. + // Schedule changed (or first tick) → re-baseline without sending; first real send is the next occurrence. if (state.configHash !== hash) { writeStatsDigestState(db, { lastSentAt: nowMs, @@ -187,13 +153,12 @@ export function createStatsDigestScheduler(deps: StatsDigestSchedulerDeps): Stat if (!cfg.enabled) return; - // Bot down — skip without advancing, so the unsent occurrence is caught up - // on a later tick once the bot is back. + // Bot down — skip without advancing so the occurrence is caught up later. const bot = getBot(); if (!bot) return; const key = occurrenceKey(nowMs, cfg); - if (key === state.lastKey) return; // already sent for this occurrence + if (key === state.lastKey) return; const since = state.lastSentAt ?? nowMs; const stats = getDigestStats(db, since, nowMs); @@ -211,10 +176,7 @@ export function createStatsDigestScheduler(deps: StatsDigestSchedulerDeps): Stat ); } - // Advance the occurrence key either way so we attempt at most once per - // occurrence (no per-tick retry storm against admins who 403). But only - // move the window start when something was actually delivered, so a - // fully-undelivered period's counts roll into the next successful digest. + // Advance the key either way (at most one attempt per occurrence); move the window start only on delivery so undelivered counts roll forward. writeStatsDigestState(db, { lastSentAt: delivered > 0 ? nowMs : (state.lastSentAt ?? nowMs), lastKey: key, diff --git a/apps/server/src/bot/testing.ts b/apps/server/src/bot/testing.ts index 81681f1..8c9c53a 100644 --- a/apps/server/src/bot/testing.ts +++ b/apps/server/src/bot/testing.ts @@ -1,13 +1,7 @@ -/** - * Test helpers for the Telegram Web App auth flow. `signInitData` reproduces - * Telegram's signing so tests can mint valid `initData` payloads (and, by - * mutating them, invalid ones). - */ import { createHmac } from 'node:crypto'; export const TEST_BOT_TOKEN = '123456:test-bot-token'; -/** Build a correctly-signed initData query string from the given fields. */ export function signInitData(fields: Record, token = TEST_BOT_TOKEN): string { const dataCheckString = Object.entries(fields) .map(([k, v]) => `${k}=${v}`) diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index ea1b9a1..949973f 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -1,13 +1,4 @@ -/** - * Single source of truth for environment configuration. - * - * Per docs/AGENTS.md, this is the ONLY module that reads `process.env`. - * Everything else imports the parsed `config` object. - * - * Fields consumed by later chapters are marked optional so the app boots - * (and migrations run) without filling them in. They tighten to required - * in the chapter that actually uses them. - */ +// The only module that reads process.env; everything else imports the parsed `config`. Fields are optional so the app boots (and migrations run) before they're filled in. import { z } from 'zod'; const envSchema = z.object({ @@ -19,34 +10,26 @@ const envSchema = z.object({ // --- Database --- DATABASE_PATH: z.string().min(1).default('./data/tg-feed.sqlite'), - // --- Telegram (Chapter 3) --- + // --- Telegram --- TG_API_ID: z.coerce.number().int().positive().optional(), TG_API_HASH: z.string().min(1).optional(), TG_SESSION_STRING: z.string().min(1).optional(), - // Base64-encoded 32 random bytes. When set, the settings page can store a - // signed-in Telegram account in the DB (encrypted at rest with this key) - // and the app prefers that account over `TG_SESSION_STRING`. When unset, - // the app falls back to env-only and refuses to write account rows. The - // key fingerprint travels in exports so an import on a host with a - // different key skips the encrypted blob with a clear warning. Generate: - // node -e "console.log(require('node:crypto').randomBytes(32).toString('base64'))" + // base64 32 bytes; when set, DB-stored accounts (encrypted at rest) win over TG_SESSION_STRING, else account writes are refused. Fingerprint travels in exports so a mismatched-key import skips the blob. + // Generate: node -e "console.log(require('node:crypto').randomBytes(32).toString('base64'))" TG_SESSION_ENCRYPTION_KEY: z .string() .regex(/^[A-Za-z0-9+/=]+$/, 'expected base64') .refine((s) => Buffer.from(s, 'base64').length === 32, 'must decode to 32 bytes') .optional(), - // --- Web auth (Chapter 7) --- + // --- Web auth --- WEB_PASSWORD: z.string().min(1).optional(), SESSION_SECRET: z.string().min(32).optional(), // --- Telegram Web App bot --- - // Bot token from @BotFather. With TG_BOT_ADMIN_IDS set, enables the Telegram - // Mini App login and the /start bot (password login stays as a fallback). + // @BotFather token; with TG_BOT_ADMIN_IDS set, enables Mini App login + /start bot (password login stays as fallback). TG_BOT_TOKEN: z.string().min(1).optional(), - // Comma-separated Telegram user ids allowed to use the Mini App / bot, parsed - // into a deduped string[] (compared as strings to keep 64-bit ids lossless). - // Empty/unset disables Telegram login. + // Comma-separated admin user ids → deduped string[]; compared as strings to keep 64-bit ids lossless. Empty disables Telegram login. TG_BOT_ADMIN_IDS: z .string() .optional() @@ -62,17 +45,14 @@ const envSchema = z.object({ ) : [], ), - // Public HTTPS base URL the web client is served from (e.g. - // https://tg-feed.example.com). The bot points its menu / start buttons here. + // Public HTTPS base URL the web client is served from; the bot's menu/start buttons point here. PUBLIC_URL: z.string().url().optional(), }); export type Config = z.infer; export function parseConfig(env: NodeJS.ProcessEnv = process.env): Config { - // dotenv parses `KEY=` as the empty string, but our optional fields use - // `.min(N)` / regex constraints that reject `""`. Treat empty strings as - // missing so a placeholder line in `.env` doesn't break startup. + // Treat empty strings as missing: dotenv parses `KEY=` as "" which our .min/regex optionals reject. const cleaned: NodeJS.ProcessEnv = Object.fromEntries( Object.entries(env).map(([k, v]) => [k, v === '' ? undefined : v]), ); diff --git a/apps/server/src/db/botConfigRepo.ts b/apps/server/src/db/botConfigRepo.ts index 2632e9c..a0dcd41 100644 --- a/apps/server/src/db/botConfigRepo.ts +++ b/apps/server/src/db/botConfigRepo.ts @@ -1,17 +1,4 @@ -/** - * Repository for the DB-backed bot configuration, stored as JSON in the - * `app_settings` row keyed `'bot'`. Shape: - * - * { token?: EncryptedEnvelope; admins?: BotAdmin[]; publicUrl?: string } - * - * (A legacy `adminIds: string[]` shape from before display-name support is - * still read and upgraded on the fly.) - * - * The token arrives already encrypted; this repo only stores and merges the - * envelope. Reads are defensive: a missing row or any malformed field yields - * an absent value, so a hand-edited or partially-written row can't crash the - * resolver — it just falls back to env. - */ +// Bot config as JSON in app_settings['bot']. Reads are defensive: a malformed field falls back to env, never throws. import { eq } from 'drizzle-orm'; import type { BotAdmin } from '@tg-feed/shared'; import type { Db } from './client.js'; @@ -26,10 +13,7 @@ export interface StoredBotConfig { publicUrl?: string; } -/** - * Partial update. Per field: `undefined` = leave unchanged, `null` = clear - * (the resolver then falls back to env), a value = set it. - */ +// Per field: undefined = unchanged, null = clear (resolver falls back to env), value = set. export interface BotConfigPatch { token?: EncryptedEnvelope | null; admins?: BotAdmin[] | null; @@ -62,7 +46,7 @@ function asAdmins(value: Record): BotAdmin[] | undefined { } return out; } - // Legacy: an older `adminIds: string[]` shape (pre display-name support). + // Legacy adminIds: string[] (pre display-name). if (Array.isArray(value.adminIds)) { return value.adminIds .filter((x): x is string => typeof x === 'string' && /^\d+$/.test(x)) diff --git a/apps/server/src/db/client.ts b/apps/server/src/db/client.ts index 3403a7e..708dfeb 100644 --- a/apps/server/src/db/client.ts +++ b/apps/server/src/db/client.ts @@ -1,9 +1,4 @@ -/** - * Singleton DB client. - * - * App code uses `getDb()`. Tests use `createDb(':memory:')` directly via the - * helper in `./testing.ts` and never touch the singleton. - */ +// App code uses getDb(); tests call createDb(':memory:') directly and never touch the singleton. import path from 'node:path'; import { existsSync, mkdirSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; @@ -23,8 +18,7 @@ let cachedProjectRoot: string | undefined; function projectRoot(): string { if (cachedProjectRoot) return cachedProjectRoot; let dir = path.dirname(fileURLToPath(import.meta.url)); - // Walk up looking for the workspace marker; pnpm-workspace.yaml is a - // stable anchor across both `src/` (tsx) and `dist/` (node) layouts. + // pnpm-workspace.yaml is a stable anchor across both src/ (tsx) and dist/ (node) layouts. while (dir !== path.dirname(dir)) { if (existsSync(path.join(dir, 'pnpm-workspace.yaml'))) { cachedProjectRoot = dir; diff --git a/apps/server/src/db/migrate.ts b/apps/server/src/db/migrate.ts index 100c200..a8fc3d0 100644 --- a/apps/server/src/db/migrate.ts +++ b/apps/server/src/db/migrate.ts @@ -1,9 +1,3 @@ -/** - * Apply pending drizzle migrations to the configured DATABASE_PATH. - * - * Invoked via `pnpm db:migrate`. Idempotent — drizzle tracks applied - * migrations in `__drizzle_migrations__`, so re-running is a no-op. - */ import '../lib/loadEnv.js'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -16,13 +10,7 @@ const migrationsFolder = path.resolve(moduleDir, '../../drizzle'); const handle = createDb(config.DATABASE_PATH); try { - // SQLite table-recreation migrations (the recipe for column drops + FK - // changes) require FKs disabled around the rename — otherwise CASCADE - // and SET NULL fire when the old table is dropped, wiping referencing - // rows. drizzle's migrate() wraps in a transaction where `PRAGMA - // foreign_keys` is a no-op, so we have to set it here. The - // `foreign_key_check` afterwards verifies the migration left no - // orphans before we re-enable enforcement. + // FKs off around table-recreation migrations, else CASCADE/SET NULL fire on the old-table drop and wipe referencing rows; migrate()'s transaction makes the PRAGMA a no-op so set it here. foreign_key_check verifies no orphans before re-enabling. handle.sqlite.pragma('foreign_keys = OFF'); try { migrate(handle.db, { migrationsFolder }); diff --git a/apps/server/src/db/schema.ts b/apps/server/src/db/schema.ts index 4797e9f..26487c9 100644 --- a/apps/server/src/db/schema.ts +++ b/apps/server/src/db/schema.ts @@ -1,14 +1,4 @@ -/** - * Drizzle schema for the tg-feed SQLite database. - * - * Telegram chat / message IDs are stored as `text` (lossless, opaque) since - * they're 64-bit identifiers, not numbers we range-query on. - * - * Timestamps: `timestamp_ms` (Date in JS, millis in SQLite). Defaults are - * applied JS-side via `$defaultFn` — all writes go through drizzle. - * - * JSON columns use drizzle's `mode: 'json'` for automatic parse/stringify. - */ +// Telegram 64-bit chat/message IDs stored as text (lossless, opaque, never range-queried). import { sql } from 'drizzle-orm'; import { sqliteTable, text, integer, index, check, primaryKey } from 'drizzle-orm/sqlite-core'; import { @@ -23,48 +13,22 @@ import { export { FORWARD_LOG_STATUSES, type ForwardLogStatus }; -/** - * Named forward target. Subscriptions pick from this list (Chapter 10). - * Same chat id can appear under multiple names — unique-by-id, not chatId. - */ export const destinations = sqliteTable('destinations', { id: integer('id').primaryKey({ autoIncrement: true }), name: text('name').notNull(), chatId: text('chat_id').notNull(), note: text('note'), - /** - * Forum topic to forward into, when `chatId` is a forum supergroup. Holds - * the topic's `top_msg_id`; stored as text for consistency with the other - * 64-bit ids and converted to a number only at the forward boundary. NULL - * means "no explicit topic" — the General topic for a forum, or the only - * behaviour for a normal chat. - */ + // forum topic top_msg_id (text for 64-bit parity); NULL = General/none topicId: text('topic_id'), - /** - * Topic title cached at create/edit time so the list and badges render - * without a live `channels.GetForumTopics` round-trip. NULL whenever - * `topicId` is NULL. - */ + // cached topic title; NULL whenever topicId is NULL topicTitle: text('topic_title'), - /** - * Channel/chat profile photo as a `data:image/jpeg;base64,...` URL, or - * NULL when we haven't fetched one yet (the trigger for the access - * monitor's lazy backfill). Populated on create when Telegram is - * configured and refreshed by the access monitor on the periodic sweep - * for rows that are still null. - */ + // data:image/jpeg;base64 URL; NULL until the access monitor's lazy backfill fetches it iconDataUrl: text('icon_data_url'), - /** - * Whether the userbot can currently see/post to this destination. Written - * by the access monitor (`apps/server/src/tg/accessMonitor.ts`) on the - * periodic sweep. Default 'ok' so existing rows aren't surfaced as broken - * before the first sweep runs. - */ + // written by the access monitor's sweep; default 'ok' so unswept rows aren't shown as broken accessStatus: text('access_status', { enum: ['ok', 'no_access'] }) .notNull() .default('ok') .$type<'ok' | 'no_access'>(), - /** Timestamp of the last access check; null until the first sweep runs. */ accessCheckedAt: integer('access_checked_at', { mode: 'timestamp_ms' }), createdAt: integer('created_at', { mode: 'timestamp_ms' }) .notNull() @@ -77,56 +41,27 @@ export const subscriptions = sqliteTable( id: integer('id').primaryKey({ autoIncrement: true }), sourceChatId: text('source_chat_id').notNull(), sourceTitle: text('source_title').notNull(), - /** - * Display handle (`@channel_username`). Populated on create from the - * resolve endpoint's response; nullable for legacy rows. - */ + // @channel_username; nullable for legacy rows handle: text('handle'), - /** - * Source channel profile photo as a `data:image/jpeg;base64,...` URL, - * or NULL when we haven't fetched one yet. Same lazy-backfill semantics - * as `destinations.iconDataUrl` — populated on create and refreshed by - * the access monitor for rows that are still null. - */ iconDataUrl: text('icon_data_url'), - /** - * FK → destinations. Nullable: a subscription can exist without a - * destination (created via import where the destination is missing, or - * detached by the user). The forwarder skips such rows. ON DELETE SET - * NULL so deleting a destination demotes its subscriptions to detached - * rather than blocking the delete. - */ + // nullable: subscription may be detached; ON DELETE SET NULL demotes rather than blocks destinationId: integer('destination_id').references(() => destinations.id, { onDelete: 'set null', }), enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true), - /** - * Timestamp of the last `CHAT_FORWARDS_RESTRICTED` from this source. - * Set by the forwarder when Telegram refuses to forward (the channel - * has "Restrict Saving Content" enabled). Cleared on the next - * successful forward. Surfaced in the API as `forwardingRestrictedAt` - * so the UI can render a "noforwards" badge on the subscription. - */ + // set on CHAT_FORWARDS_RESTRICTED (source has "Restrict Saving Content"), cleared on next success forwardingRestrictedAt: integer('forwarding_restricted_at', { mode: 'timestamp_ms' }), - /** - * Whether the userbot can currently read messages from this source - * channel. Written on subscription create (after `channels.JoinChannel`) - * and on every access-monitor sweep. Default 'ok' so existing rows - * aren't surfaced as broken before the first sweep runs. - */ sourceAccessStatus: text('source_access_status', { enum: ['ok', 'no_access'] }) .notNull() .default('ok') .$type<'ok' | 'no_access'>(), - /** Timestamp of the last access check; null until the first sweep runs. */ sourceAccessCheckedAt: integer('source_access_checked_at', { mode: 'timestamp_ms' }), createdAt: integer('created_at', { mode: 'timestamp_ms' }) .notNull() .$defaultFn(() => new Date()), }, (t) => ({ - // Listener queries by source_chat_id on every incoming TG message; index - // turns the per-event lookup from a full scan into an index probe. + // every incoming TG message looks up by source_chat_id bySourceChatId: index('idx_subscriptions_source_chat_id').on(t.sourceChatId), }), ); @@ -141,12 +76,7 @@ export const subscriptionFilters = sqliteTable( ruleType: text('rule_type').notNull().$type(), params: text('params', { mode: 'json' }).$type().notNull(), enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true), - /** - * Per-filter mode (Ch 14). 'include' is the legacy default and matches - * the original AND-pass semantics. 'exclude' inverts the rule for that - * one row — the filter rejects when its rule matches. SQL CHECK keeps - * hand-written rows honest, mirroring the `forward_log.status` precedent. - */ + // 'include' = AND-pass; 'exclude' inverts (reject when the rule matches) mode: text('mode').notNull().default('include').$type(), }, (t) => ({ @@ -158,14 +88,7 @@ export const subscriptionFilters = sqliteTable( }), ); -/** - * Reusable named filter rules (Chapter 11). Same per-rule params shape as - * `subscription_filters` but carry a `name` so users can recognise them when - * attaching across subscriptions. Rule type is constrained both via TS - * (`$type()`) and a SQL CHECK constraint, mirroring the - * `forward_log.status` precedent — the TS narrowing alone wouldn't reject - * a hand-written DB row. - */ +// Reusable named filter rules; rule type also guarded by a SQL CHECK against hand-written rows. export const libraryFilters = sqliteTable( 'library_filters', { @@ -173,7 +96,6 @@ export const libraryFilters = sqliteTable( name: text('name').notNull(), ruleType: text('rule_type').notNull().$type(), params: text('params', { mode: 'json' }).$type().notNull(), - /** Same semantics as `subscription_filters.mode`. */ mode: text('mode').notNull().default('include').$type(), createdAt: integer('created_at', { mode: 'timestamp_ms' }) .notNull() @@ -191,15 +113,7 @@ export const libraryFilters = sqliteTable( }), ); -/** - * M:N join — a library filter attached to a subscription. No `enabled` - * column: detach == off, reattach == on, single source of truth (matches - * the design's "X to detach" affordance). - * - * Composite PK on (subscriptionId, libraryFilterId) gives uniqueness - * automatically; `idx_subscription_library_filters_lib` accelerates the - * "where used" lookup the library tab needs for usage badges. - */ +// M:N join. No `enabled` column: detach == off, reattach == on. export const subscriptionLibraryFilters = sqliteTable( 'subscription_library_filters', { @@ -221,27 +135,12 @@ export const appSettings = sqliteTable('app_settings', { value: text('value', { mode: 'json' }).$type().notNull(), }); -/** - * Single-row table for the Telegram session signed in via the Settings page. - * Convention: always upserted at id=1 — the table is logically a singleton - * but uses a normal PK so `onConflictDoUpdate` works without an INSERT/UPDATE - * branch. - * - * `encrypted_session_string` is `base64(iv ‖ tag ‖ ct)` of the raw gramjs - * `StringSession.save()` value, encrypted with `TG_SESSION_ENCRYPTION_KEY`. - * `key_fingerprint` is the first 16 hex chars of `sha256(key)` so an - * exported row can be matched against the importing host's key without - * exposing either key. - * - * Metadata fields (`phone_number`, `display_name`, `username`, - * `telegram_user_id`) come from `getMe()` after a successful sign-in. They - * are nullable because the raw-paste flow may produce a session whose - * `getMe` returns partial data, and because future schema bumps may add - * fields. - */ +// Singleton, always upserted at id=1 (normal PK so onConflictDoUpdate has no INSERT/UPDATE branch). export const telegramAccount = sqliteTable('telegram_account', { id: integer('id').primaryKey(), + // base64(iv ‖ tag ‖ ct) of StringSession.save(), encrypted with TG_SESSION_ENCRYPTION_KEY encryptedSessionString: text('encrypted_session_string').notNull(), + // first 16 hex of sha256(key); matches an exported row against a host key without exposing it keyFingerprint: text('key_fingerprint').notNull(), phoneNumber: text('phone_number'), displayName: text('display_name'), @@ -266,13 +165,7 @@ export const forwardLog = sqliteTable( destMessageId: text('dest_message_id'), status: text('status', { enum: FORWARD_LOG_STATUSES }).notNull(), error: text('error'), - /** - * JSON snapshot of the raw gramjs `Message` (captured at the listener / - * history poller boundary). For album batches every row stores the same - * N-element array; for single-message forwards a plain object. Nullable - * for rows written before the column existed (and for `MessageEmpty` / - * `MessageService` events that we deliberately drop in `toJsonSafe`). - */ + // raw gramjs Message snapshot; album batches store the same N-element array on every row rawMessage: text('raw_message', { mode: 'json' }), createdAt: integer('created_at', { mode: 'timestamp_ms' }) .notNull() @@ -312,18 +205,8 @@ export type NewTelegramAccount = typeof telegramAccount.$inferInsert; export type ForwardLogEntry = typeof forwardLog.$inferSelect; export type NewForwardLogEntry = typeof forwardLog.$inferInsert; -/** - * Per-login session row. - * - * Replaces the legacy "static cookie value + signed by SESSION_SECRET" scheme - * with opaque random tokens, so logout actually invalidates a session and a - * stolen cookie has a single revocation point. The token in the cookie is the - * primary-key lookup against this table. - * - * `expiresAt` is the hard cap; `lastSeenAt` is updated on each authed request - * for the sliding-window refresh (limits damage from a leaked cookie that - * sits unused). A background prune sweeps rows past expiry. - */ +// Opaque per-login tokens (cookie value is the PK lookup); logout deletes the row to revoke. +// expiresAt is the hard cap; lastSeenAt drives the sliding-window refresh. export const webSessions = sqliteTable( 'web_sessions', { diff --git a/apps/server/src/db/statsDigestStateRepo.ts b/apps/server/src/db/statsDigestStateRepo.ts index 29b55cf..f92be10 100644 --- a/apps/server/src/db/statsDigestStateRepo.ts +++ b/apps/server/src/db/statsDigestStateRepo.ts @@ -1,11 +1,4 @@ -/** - * Scheduler state for the stats digest, stored as JSON in the `app_settings` - * row keyed `'stats_digest_state'`. Kept separate from the user-editable - * `'global'` settings row so the scheduler's bookkeeping never collides with - * the Settings PUT merge. - * - * Reads are defensive: a missing or malformed row reads as the empty baseline. - */ +// Stats-digest scheduler state, kept in its own app_settings row so it never collides with the user-editable 'global' Settings merge; missing/malformed reads as the empty baseline. import { eq } from 'drizzle-orm'; import type { Db } from './client.js'; import { appSettings } from './schema.js'; @@ -13,16 +6,10 @@ import { appSettings } from './schema.js'; export const STATS_DIGEST_STATE_KEY = 'stats_digest_state'; export interface StatsDigestState { - /** Epoch ms of the last send (or baseline). null before the first tick. */ + // Epoch ms of the last send/baseline. lastSentAt: number | null; - /** Occurrence key of the last send/baseline. null before the first tick. */ lastKey: string | null; - /** - * Fingerprint of the schedule config at the last tick. When it changes - * (enable/disable, time, day, frequency, time zone), the scheduler - * re-baselines instead of sending — so editing the schedule never fires an - * immediate or backlog digest. null before the first tick. - */ + // Schedule-config fingerprint; on change the scheduler re-baselines instead of firing a backlog digest. configHash: string | null; } diff --git a/apps/server/src/db/telegramAccountRepo.ts b/apps/server/src/db/telegramAccountRepo.ts index 01902b5..4b7f0d2 100644 --- a/apps/server/src/db/telegramAccountRepo.ts +++ b/apps/server/src/db/telegramAccountRepo.ts @@ -1,11 +1,4 @@ -/** - * Repository for the single-row `telegram_account` table. - * - * The row is logically a singleton: every write goes to id=1 via - * `onConflictDoUpdate`, every read returns the row or null. Callers never - * see the encryption key here — they pass already-encrypted blobs in and - * decryption happens at the call site (see `tg/client.ts`). - */ +// Singleton `telegram_account` row (id=1). Callers pass already-encrypted blobs; no crypto here. import { eq } from 'drizzle-orm'; import type { Db } from './client.js'; import { telegramAccount, type TelegramAccount } from './schema.js'; @@ -55,7 +48,6 @@ export function upsertAccount(db: Db, payload: UpsertTelegramAccountInput): Tele .run(); const row = getActiveAccount(db); if (!row) { - // Should be impossible — the upsert above just wrote id=1. throw new Error('telegram_account row vanished after upsert'); } return row; diff --git a/apps/server/src/db/testing.ts b/apps/server/src/db/testing.ts index 4fe7134..361a013 100644 --- a/apps/server/src/db/testing.ts +++ b/apps/server/src/db/testing.ts @@ -1,10 +1,3 @@ -/** - * Test fixture: an in-memory SQLite DB with all migrations applied. - * - * Use per-test via beforeEach/afterEach for clean isolation. Setup is - * sub-millisecond after the first call (better-sqlite3 keeps native code - * loaded; migrations are tiny CREATE TABLE statements). - */ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { migrate } from 'drizzle-orm/better-sqlite3/migrator'; @@ -20,9 +13,7 @@ export interface TestDbHandle { export function createTestDb(): TestDbHandle { const handle: DbHandle = createDb(':memory:'); - // Mirror migrate.ts — disable FKs around migrate() so table-recreation - // migrations (column drops, FK changes) don't trigger CASCADE/SET NULL - // on rename. + // FKs off around migrate() so table-recreation migrations don't trigger CASCADE/SET NULL on rename. handle.sqlite.pragma('foreign_keys = OFF'); try { migrate(handle.db, { migrationsFolder }); diff --git a/apps/server/src/events/bus.ts b/apps/server/src/events/bus.ts index 8906e81..977c1cb 100644 --- a/apps/server/src/events/bus.ts +++ b/apps/server/src/events/bus.ts @@ -1,23 +1,5 @@ -/** - * In-process event bus. - * - * Producers (forwarder, filter evaluator, subscription routes) call `emit` - * with a `StreamEventInput`; the bus stamps `occurredAt` and broadcasts to - * every subscriber. The SSE route is the only consumer in v1, but the bus - * stays general so future consumers (metrics, audit log, etc.) can attach - * without coupling to producers. - * - * Implementation choice: a plain `Set` instead of - * `node:events.EventEmitter`. `EventEmitter.emit` rethrows synchronously - * when a listener throws, which would propagate a buggy SSE write back up - * into a forwarding-pipeline call site. We catch per-listener errors here, - * log them, and keep going. `listenerCount()` becomes `set.size` — used by - * the SSE cleanup test to verify unsubscribe ran on disconnect. - * - * Listener iteration snapshots the set first (`[...listeners]`) so a - * listener that unsubscribes itself during dispatch doesn't mutate the - * iterator mid-loop. - */ +// Plain Set, not EventEmitter, so a throwing listener can't rethrow into a forwarding-pipeline call site. +// Dispatch snapshots the set so a listener unsubscribing itself mid-loop doesn't mutate the iterator. import type { StreamEvent, StreamEventInput } from '@tg-feed/shared'; import type { Logger } from '../lib/logger.js'; diff --git a/apps/server/src/filters/evaluate.ts b/apps/server/src/filters/evaluate.ts index 62457af..c58edaa 100644 --- a/apps/server/src/filters/evaluate.ts +++ b/apps/server/src/filters/evaluate.ts @@ -1,24 +1,5 @@ -/** - * Filter evaluator. - * - * Loads enabled filters for a subscription — both per-sub - * (`subscription_filters` where `enabled=true`) and library filters - * attached via `subscription_library_filters` — runs each through its - * registered rule, AND-combines, and on failure writes one `forward_log` - * row per source message id (status `'filtered'`, reasons joined into - * the `error` column). - * - * Reasons format: - * - per-sub: `": "` - * - library: `"library:: : "` so the activity UI - * can split on the `library:` prefix and render a Library chip. - * - * Fail-open semantics from Ch 6 are preserved across both sources: an - * unknown ruleType, a zod params parse failure, or a runtime throw inside - * a rule's `evaluate` skips that single row (with a warning). The - * remaining rules still gate the message. Empty surviving filter set - * passes (vacuous AND). - */ +// AND-combines a subscription's per-sub + library filter rules; fail-open (unknown rule, bad params, or a throw skips that one row with a warning). +// Reason format: `library:: : ` for library rows (UI splits on the `library:` prefix), else `: `. import { and, asc, eq } from 'drizzle-orm'; import type { Db } from '../db/client.js'; import { @@ -53,12 +34,7 @@ interface PureEvaluation { reasons: string[]; } -/** - * Row shape consumed by the pure evaluator. `source` distinguishes per-sub - * vs library; `label` is the library filter name (null for per-sub). `mode` - * inverts the rule for that one row when set to `'exclude'` — the filter - * rejects when its rule matches instead of when it doesn't. - */ +// mode 'exclude' inverts the row: it rejects when the rule matches. export interface EvaluatorRow { id: number; source: 'sub' | 'lib'; @@ -82,9 +58,7 @@ export function createFilterEvaluator(deps: CreateFilterEvaluatorDeps): FilterEv if (result.pass) return { pass: true }; const errorText = result.reasons.join('; '); - // Denormalize the raw payload onto every filtered row so each row is - // independently inspectable in the Activity JSON viewer, mirroring - // the sent/failed paths in `forwarder.writeLogs`. + // Denormalize the raw payload onto every row so each is independently inspectable. const rawMessage = context.rawMessage ?? null; const inserted = db .insert(forwardLog) @@ -121,19 +95,7 @@ export function createFilterEvaluator(deps: CreateFilterEvaluatorDeps): FilterEv }; } -/** - * Load all evaluatable filters for a subscription as a single ordered list. - * - * Per-sub filters (where `enabled=true`) and library filters attached via - * `subscription_library_filters` are loaded with two selects and merged in - * JS. Library rows come first (`source ASC: 'lib' < 'sub'`), then by id. - * - * Two queries instead of a SQL UNION because drizzle's mapped JSON columns - * (`mode: 'json'` in the schema) flow through cleanly per-table; a raw - * UNION returns columns as strings and would need manual `JSON.parse`. The - * cost is one extra round-trip on a hot path — for personal-use volumes - * (single-digit messages/sec at most) this is comfortably below noise. - */ +// Two selects merged in JS (library rows first, then by id) rather than a SQL UNION, which would stringify drizzle's JSON columns. export function loadEvaluatorRows(db: Db, subscriptionId: number): EvaluatorRow[] { const subRows = db .select({ @@ -190,10 +152,7 @@ export function loadEvaluatorRows(db: Db, subscriptionId: number): EvaluatorRow[ return merged; } -/** - * Pure evaluation — no side effects. Exported for unit tests; the public - * `FilterEvaluator.evaluate` method is what production wiring calls. - */ +// Pure evaluation, no side effects; the side-effecting wrapper is FilterEvaluator.evaluate. export function evaluateFilters( rows: readonly EvaluatorRow[], registry: FilterRegistry, @@ -237,9 +196,6 @@ export function evaluateFilters( continue; } - // include: row fails when the rule did NOT match; exclude inverts that — - // the row fails when the rule DID match. Either way, a failing row - // contributes a reason and the message is rejected (AND-combined). const blocks = row.mode === 'exclude' ? evaluation.pass : !evaluation.pass; if (blocks) { const reason = diff --git a/apps/server/src/filters/index.ts b/apps/server/src/filters/index.ts index de1007d..6146613 100644 --- a/apps/server/src/filters/index.ts +++ b/apps/server/src/filters/index.ts @@ -1,13 +1,3 @@ -/** - * Filter framework barrel. - * - * Adding a new rule: - * 1. Drop `apps/server/src/filters/rules/.ts` exporting a - * `FilterRule<''>` value. - * 2. Add a matching schema entry in `packages/shared/src/filters.ts`. - * 3. Register it in `apps/server/src/filters/rules/index.ts`'s - * `createDefaultRegistry()`. - */ export type { FilterEvaluationResult, FilterRule, diff --git a/apps/server/src/filters/registry.ts b/apps/server/src/filters/registry.ts index d706f1a..ac95050 100644 --- a/apps/server/src/filters/registry.ts +++ b/apps/server/src/filters/registry.ts @@ -1,16 +1,4 @@ -/** - * Filter rule registry. - * - * Factory pattern — `createRegistry()` returns a fresh, empty registry. - * Production wiring builds one via `createDefaultRegistry()` (in - * `rules/index.ts`) and registers all v1 rules; tests can build empty or - * partial registries without import-order side effects. - * - * `register` accepts a statically-typed `FilterRule` and stores it as a - * type-erased `RegisteredFilterRule`. The cast is the boundary: at register - * time the rule's `T` ↔ params correlation is known; once retrieved by string - * lookup, the relationship is gone and runtime zod validation takes over. - */ +// register() type-erases FilterRule to RegisteredFilterRule; the T↔params link is lost on lookup, so runtime zod validates. import type { FilterRuleType } from '@tg-feed/shared'; import type { FilterRule, RegisteredFilterRule } from './types.js'; diff --git a/apps/server/src/filters/rules/index.ts b/apps/server/src/filters/rules/index.ts index f4e054b..5174f68 100644 --- a/apps/server/src/filters/rules/index.ts +++ b/apps/server/src/filters/rules/index.ts @@ -1,8 +1,4 @@ -/** - * Default rule registry — instantiates a fresh registry and registers all - * v1 rules. Adding a rule = drop a file in this directory and add one - * `register(...)` line below. - */ +// Add a rule: drop a file here and add one register(...) line below. import { createRegistry, type FilterRegistry } from '../registry.js'; import { hasMediaRule } from './hasMedia.js'; import { minLengthRule } from './minLength.js'; diff --git a/apps/server/src/filters/types.ts b/apps/server/src/filters/types.ts index be11b4f..cbff6e9 100644 --- a/apps/server/src/filters/types.ts +++ b/apps/server/src/filters/types.ts @@ -1,17 +1,5 @@ -/** - * Internal filter types. - * - * `MessageContext` is the subset of message data filters need — text body, - * media presence, sender username. The forwarding pipeline's `MatchableEvent` - * structurally satisfies this, so the listener can hand a matchable event - * straight to the evaluator without transformation. - * - * Rule definitions are statically typed via `FilterRule`, but the - * registry/evaluator boundary erases the connection between a rule's `type` - * and its params shape (params come from a JSON column and are runtime- - * validated by the rule's own zod schema). The type-erased - * `RegisteredFilterRule` is what the registry stores and exposes. - */ +// MatchableEvent structurally satisfies MessageContext, so the listener feeds the evaluator without transforming. +// RegisteredFilterRule is the type-erased form the registry stores: the type->params link is lost across the JSON/zod boundary. import type { z } from 'zod'; import type { FilterRuleParamsFor, FilterRuleType } from '@tg-feed/shared'; @@ -19,13 +7,7 @@ export interface MessageContext { text: string; hasMedia: boolean; senderUsername?: string; - /** - * Carrier for the raw-message JSON snapshot. The filter rules themselves - * don't read this — it rides through `evaluate()` only so the rejection - * path can persist it on the `forward_log` row alongside the reasons. - * Single object for non-albums, array for albums (aligned with the - * `sourceMessageIds` the evaluator receives). - */ + // Unread by rules; rides through evaluate() so the rejection path can persist it on forward_log. Array for albums. rawMessage?: unknown; } diff --git a/apps/server/src/forwarding/floodwait.ts b/apps/server/src/forwarding/floodwait.ts index 59962b5..de35225 100644 --- a/apps/server/src/forwarding/floodwait.ts +++ b/apps/server/src/forwarding/floodwait.ts @@ -1,28 +1,5 @@ -/** - * Rate-limit detection for forward attempts. - * - * gramjs raises three closely-related errors that all carry a numeric - * `seconds` field and all mean "wait this long before retrying": - * - * - `FloodWaitError` ← `FLOOD_WAIT_X` (and `FLOOD_PREMIUM_WAIT_X`, - * mapped onto the same class by gramjs) - * - `SlowModeWaitError` ← `SLOWMODE_WAIT_X` — destination chat has slow - * mode enabled and we sent too soon - * - * The two are sibling subclasses of `FloodError`. Treating them as one - * "rate-limit" outcome is correct because the retry logic is identical - * (sleep `seconds` and retry the same job). We expose `kind` so the log / - * event stream can distinguish them for diagnostics. - * - * gramjs also auto-sleeps any of these errors whose `seconds` is below - * `floodSleepThreshold` (default 60 sec) inside the request itself — those - * never reach this guard. This guard only fires for waits longer than the - * threshold, which is the case worth treating specially. - * - * The structural fallback (match by class name) is needed because gramjs - * errors can cross realm boundaries (workers, dynamic imports) where - * `instanceof` fails. - */ +// Only fires for waits above gramjs's floodSleepThreshold (default 60s); shorter ones auto-sleep inside the request. +// Name-based fallback because gramjs errors cross realm boundaries where instanceof fails. import { FloodWaitError, SlowModeWaitError } from 'telegram/errors/index.js'; export { FloodWaitError, SlowModeWaitError }; diff --git a/apps/server/src/forwarding/forwarderClient.ts b/apps/server/src/forwarding/forwarderClient.ts index 5c4bad9..894366f 100644 --- a/apps/server/src/forwarding/forwarderClient.ts +++ b/apps/server/src/forwarding/forwarderClient.ts @@ -1,26 +1,9 @@ -/** - * Adapts the live gramjs `TelegramClient` to the forwarder's `ForwarderClient` - * interface, adding forum-topic support the high-level helper lacks. - * - * gramjs's `client.forwardMessages` accepts only `{ messages, fromPeer, - * silent, ... }` — there is no `topMsgId`. To forward into a forum topic we - * fall back to the raw `messages.ForwardMessages` request, which does carry - * `topMsgId`. gramjs auto-generates the required `randomId` vector in the - * request constructor (one per `id`), and `_getResponseMessage` is the same - * extractor the helper uses to turn the `Updates` result into messages — so - * the topic path produces the same `{ id }[]` shape the no-topic path does. - * - * The no-topic path delegates verbatim to the helper, leaving normal chats - * (and a forum's General topic, which omits `topMsgId`) on the proven code - * path. - */ +// Adapts gramjs TelegramClient to ForwarderClient, adding forum-topic forwarding: client.forwardMessages has no topMsgId, so the topic path drops to the raw messages.ForwardMessages request (which carries it). No-topic forwards delegate to the helper. import { Api } from 'telegram'; import type { TelegramClient } from 'telegram'; import type { ForwarderClient } from './forwarder.js'; -// `_getResponseMessage` is an internal-but-public method on TelegramClient -// (used by the high-level send/forward helpers). Narrow the surface we touch -// so the cast stays honest. +// _getResponseMessage is internal-but-public on TelegramClient; narrow the cast surface. interface ResponseMessageClient { _getResponseMessage( req: unknown, @@ -48,9 +31,7 @@ export function createForwarderClient(client: TelegramClient): ForwarderClient { ...(silent !== undefined ? { silent } : {}), }); } - // Raw request path: resolve both peers to input peers (the TL layer - // needs concrete `InputPeer`s, exactly as the helper does) and set - // `topMsgId` so the forward lands in the chosen forum topic. + // Resolve both peers to InputPeers and set topMsgId so the forward lands in the chosen topic. const toPeer = await client.getInputEntity(entity); const fromPeerResolved = await client.getInputEntity(fromPeer); const request = new Api.messages.ForwardMessages({ diff --git a/apps/server/src/forwarding/historyPoller.ts b/apps/server/src/forwarding/historyPoller.ts index 3c4593f..5e74b2a 100644 --- a/apps/server/src/forwarding/historyPoller.ts +++ b/apps/server/src/forwarding/historyPoller.ts @@ -1,29 +1,4 @@ -/** - * Periodic safety net that catches messages the gramjs listener missed. - * - * The listener (`apps/server/src/tg/listener.ts`) consumes the live update - * stream via gramjs's `NewMessage` handler. In practice that stream silently - * stops delivering `UpdateNewChannelMessage` for some subscribed channels: - * gramjs 2.26.x doesn't implement `client.catchUp()` and doesn't refresh - * per-channel pts, so once a channel's update state drifts the session - * keeps the connection alive but never dispatches events for it again. - * Observed in production: a public broadcast channel with 23k+ messages - * had zero `forward_log` rows ever, while a low-volume personal channel - * forwarded fine — both verified-member, both in dialogs, both `Message`. - * - * This poller calls `messages.getHistory` per subscription on a short cadence - * with `minId = lastSeen`, then enqueues anything new through the existing - * forwarding pipeline. The listener still runs; when it works, it gets there - * first and writes a `forward_log` row before the next poll. The poller's - * dedup check (`forward_log` row exists?) keeps the duplicate-forward race - * window down to the brief interval between listener-enqueue and - * forwarder-log-write — acceptable in practice. - * - * State is in-memory (`Map`) and is - * rebuilt on boot from `MAX(source_message_id)` in `forward_log`. For - * subscriptions with no forward history we initialise from the current - * channel top so we don't dump the entire backlog on first run. - */ +// Safety net for the gramjs bug where the live listener silently stops delivering channel updates: polls getHistory(minId=lastSeen) per sub, dedups against forward_log. import { eq, and } from 'drizzle-orm'; import { Api } from 'telegram'; import type { TelegramClient } from 'telegram'; @@ -36,7 +11,6 @@ import { extractRateLimit } from './floodwait.js'; import type { RawForwardingHandle, RawForwardJob } from './types.js'; export const DEFAULT_POLL_INTERVAL_MS = 2 * 60 * 1000; -/** Cap per-poll batch size. New subs won't dump their entire history. */ export const POLL_BATCH_LIMIT = 100; export interface HistoryPollerClient { @@ -54,7 +28,6 @@ export interface HistoryPollerDeps { export interface HistoryPoller { start(): void; stop(): void; - /** Run one sweep across all enabled subscriptions. Exposed for tests. */ poll(): Promise; } @@ -78,10 +51,7 @@ export function createHistoryPoller(deps: HistoryPollerDeps): HistoryPoller { const { client, db, logger, forwarding } = deps; const intervalMs = deps.intervalMs ?? DEFAULT_POLL_INTERVAL_MS; const lastSeen = new Map(); - // Tracks subscriptions whose seed was already established (either from - // forward_log or from a successful channel-top probe). Subs not in this set - // are seeded lazily on their first sweep — keeps boot fast and avoids - // hammering Telegram before the first real poll. + // Seeded lazily on first sweep, not at boot, to avoid hammering Telegram up front. const seeded = new Set(); let stopped = false; let inFlight: Promise | undefined; @@ -101,10 +71,7 @@ export function createHistoryPoller(deps: HistoryPollerDeps): HistoryPoller { } function maxLoggedId(subId: number): number | undefined { - // `source_message_id` is `text` in SQLite, so a SQL `MAX` would order - // lexicographically (`'9' > '10'`). Fetching rows and reducing in JS is - // correct numerically and cheap thanks to `idx_forward_log_subscription` - // bounding the scan to one subscription's history. + // source_message_id is text, so SQL MAX sorts lexicographically ('9' > '10'); reduce numerically in JS. const rows = db .select({ sourceMessageId: forwardLog.sourceMessageId }) .from(forwardLog) @@ -128,7 +95,7 @@ export function createHistoryPoller(deps: HistoryPollerDeps): HistoryPoller { logger.debug({ subId: sub.id, lastSeen: fromLog }, 'history poller: seeded from forward_log'); return; } - // No history — read current top so we don't backfill the archive. + // No history — seed from current top so we don't backfill the archive. try { const result = (await client.invoke( new Api.messages.GetHistory({ @@ -137,9 +104,7 @@ export function createHistoryPoller(deps: HistoryPollerDeps): HistoryPoller { }), )) as { messages?: RawMessage[] }; const topId = result.messages?.[0]?.id; - // Empty channel (no messages yet): seed at 0 and mark seeded so we don't - // re-probe the top every tick. The first real message is picked up by the - // next poll's `minId = 0` sweep. + // Empty channel: seed at 0 and mark seeded so we don't re-probe the top every tick. const seedId = typeof topId === 'number' ? topId : 0; lastSeen.set(sub.id, seedId); seeded.add(sub.id); @@ -206,8 +171,7 @@ export function createHistoryPoller(deps: HistoryPollerDeps): HistoryPoller { m != null && typeof m.id === 'number' && m.className === 'Message', ); if (messages.length === 0) return; - // Telegram returns descending by id; forward chronologically so the - // destination order matches the source. + // Telegram returns descending; forward ascending so destination order matches source. messages.sort((a, b) => a.id - b.id); let enqueued = 0; @@ -215,7 +179,7 @@ export function createHistoryPoller(deps: HistoryPollerDeps): HistoryPoller { let highest = since; for (const msg of messages) { if (msg.id <= since) { - // `minId` is exclusive on the server side, but be defensive. + // minId is server-side exclusive, but be defensive. continue; } const sourceMessageId = String(msg.id); diff --git a/apps/server/src/forwarding/stats.ts b/apps/server/src/forwarding/stats.ts index 10a2523..c3c89c8 100644 --- a/apps/server/src/forwarding/stats.ts +++ b/apps/server/src/forwarding/stats.ts @@ -1,11 +1,4 @@ -/** - * Aggregate counters over `forward_log`, used by the stats digest. - * - * The four buckets map 1:1 to the `status` enum: 'sent' = forwarded, - * 'filtered' = dropped by a filter, 'failed' = permanent error, - * 'flood_wait' = transient throttle (shown separately so it isn't - * double-counted against the later 'sent' row a retry produces). - */ +// forward_log counters for the stats digest. flood_wait is a transient throttle, counted separately so it isn't double-counted against the 'sent' row a retry later produces. import { sql } from 'drizzle-orm'; import type { Db } from '../db/client.js'; import { forwardLog } from '../db/schema.js'; @@ -17,11 +10,7 @@ export interface DigestStats { floodWait: number; } -/** - * Counts rows by status in the half-open window `[sinceMs, untilMs)`. - * `created_at` is stored as integer epoch-ms, so we compare against raw - * numbers rather than `Date` objects. - */ +// Counts by status over the half-open window [sinceMs, untilMs); created_at is epoch-ms. export function getDigestStats(db: Db, sinceMs: number, untilMs: number): DigestStats { const rows = db .select({ status: forwardLog.status, n: sql`count(*)` }) diff --git a/apps/server/src/forwarding/statsDigestConfig.ts b/apps/server/src/forwarding/statsDigestConfig.ts index d23ff5b..735b0d7 100644 --- a/apps/server/src/forwarding/statsDigestConfig.ts +++ b/apps/server/src/forwarding/statsDigestConfig.ts @@ -1,14 +1,5 @@ -/** - * Stats-digest config reader. - * - * The digest knobs live alongside the throttle knobs in the single - * `app_settings` row keyed `'global'` (see throttle.ts). The shared - * `statsDigestSettingsSchema` validates each field with a `.catch(default)`, - * so a missing row, missing field, or malformed value all read back as the - * documented default — a hand-edited or partially-written row can never crash - * the scheduler. The Settings UI mutates the same row and the scheduler reads - * it on every tick, so changes apply live without a restart. - */ +// Reads the digest knobs from the 'global' app_settings row on every tick, so changes apply live. +// `.catch(default)` per field means a hand-edited/partial row can never crash the scheduler. import { statsDigestSettingsSchema, type StatsDigestFrequency } from '@tg-feed/shared'; import type { Db } from '../db/client.js'; import { readGlobalValue } from './throttle.js'; diff --git a/apps/server/src/forwarding/throttle.ts b/apps/server/src/forwarding/throttle.ts index 45d494e..547e491 100644 --- a/apps/server/src/forwarding/throttle.ts +++ b/apps/server/src/forwarding/throttle.ts @@ -1,17 +1,5 @@ -/** - * Global app_settings readers. - * - * Both pipeline knobs (forward throttle delay + album debounce window) live - * in the single `app_settings` row keyed `'global'` whose `value` JSON is a - * `{ delayMs?, albumDebounceMs? }` shape. The Settings UI mutates the same - * row and the pipeline reads it on every send / album flush, so changes - * apply live without restart. - * - * Defensive defaults: a missing row, missing field, or non-positive number - * all fall back to the documented defaults. We never want a malformed DB - * row to disable throttling (would trip Telegram's anti-spam) or set a - * negative debounce (would break the album setTimeout). - */ +// Pipeline knobs in the single 'global' app_settings row; read on every send/flush so changes apply live. +// Missing/non-positive values fall back to defaults so a bad row can't disable throttling (Telegram anti-spam) or break the album setTimeout. import { eq } from 'drizzle-orm'; import type { Db } from '../db/client.js'; import { appSettings } from '../db/schema.js'; diff --git a/apps/server/src/forwarding/types.ts b/apps/server/src/forwarding/types.ts index 01d9035..a8be05c 100644 --- a/apps/server/src/forwarding/types.ts +++ b/apps/server/src/forwarding/types.ts @@ -1,45 +1,18 @@ -/** - * Shared types for the forwarding pipeline. - * - * Two job shapes flow through the system: - * - * - `RawForwardJob` is what the listener produces — one per `NewMessage` event, - * carrying an optional `groupedId` for album members. - * - `ForwardJob` is what the pipeline consumes after the album debouncer has - * collapsed N raw jobs sharing a `groupedId` into one. Single messages - * become 1-element arrays. - * - * `ForwardOutcome`'s discriminator drives the worker's retry/advance decision - * in `queue.ts`. - */ +// RawForwardJob = one per NewMessage; the album debouncer collapses raw jobs sharing a groupedId into one ForwardJob (singles become 1-element arrays). export interface RawForwardJob { subscriptionId: number; sourceChatId: string; destinationChatId: string; - /** - * Forum `top_msg_id` to forward into, or null/undefined for the General - * topic / a non-forum chat. Travels alongside `destinationChatId` from the - * destinations row to the forwarder, which converts it to `topMsgId`. - */ + // Forum top_msg_id; null/undefined = General / non-forum. destinationTopicId?: string | null; sourceMessageId: string; groupedId?: string; - /** - * Filter-relevant content carried with the job so the album debouncer can - * evaluate filters once per album (using the caption-bearing member) rather - * than per source message — albums put the caption only on the first - * message, so per-message text filtering would silently fragment them. - */ + // Albums put the caption only on the first member, so filters run once per album, not per message (else they fragment). text: string; hasMedia: boolean; senderUsername?: string; - /** - * JSON-safe snapshot of the source gramjs `Message` (already passed - * through `toJsonSafe` at the capture boundary). Stored verbatim on the - * `forward_log` row for later inspection. `null` when capture was skipped - * or the encoded payload exceeded the size cap (caller stores `null`). - */ + // JSON-safe snapshot (via toJsonSafe); null when capture was skipped or over the size cap. rawMessage?: unknown; } @@ -47,16 +20,10 @@ export interface ForwardJob { subscriptionId: number; sourceChatId: string; destinationChatId: string; - /** Forum `top_msg_id` to forward into; null/undefined for General / non-forum. */ + // Forum top_msg_id; null/undefined = General / non-forum. destinationTopicId?: string | null; sourceMessageIds: string[]; - /** - * Raw message payloads, aligned with `sourceMessageIds`. For an album - * (≥2 ids) this is an array of JSON-safe objects sorted to match - * `sourceMessageIds`; for a single message it's a plain object. The - * forwarder writes the whole value onto every inserted `forward_log` - * row of the batch so each row is independently inspectable. - */ + // Album (≥2) = array of JSON-safe objects aligned with sourceMessageIds; single = plain object. Written to every batch row. rawMessage?: unknown; } diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 5aa0a46..bf98a52 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -1,26 +1,4 @@ -/** - * @tg-feed/server — entrypoint. - * - * Boots the API listener first, then brings the gramjs Telegram client up - * in the background. The forwarding pipeline (NewMessage listener → - * debouncer → pipeline) and the chat-resolver / invite / join / profile - * fetcher helpers are attached to a mutable runtime box once the gramjs - * `client.connect()` finishes. The Fastify factory reads tg-deps via - * lazy getters, so route handlers pick up the post-init values without - * re-registration. This eliminates a `pnpm -r --parallel dev` race where - * Vite's proxy hit ECONNREFUSED before the server reached `app.listen()`. - * - * Telegram bring-up is best-effort: if env vars are missing or the connect - * step fails, the API still serves in a degraded mode (no listener, no - * forwarder, no entity resolver). `GET /api/system/status` exposes the - * lifecycle phase ('connecting' / 'connected' / 'disconnected') so the web - * UI can distinguish "starting up" from "configure your Telegram session". - * - * Shutdown order is HTTP-first → wait for in-flight Telegram init → tear - * down debouncer / pipeline / Telegram / DB. We wait for the init promise - * to settle because gramjs `client.connect()` doesn't accept an AbortSignal - * cleanly; cancelling mid-flight risks orphaned sockets. - */ +// Listen before connecting Telegram: avoids a dev-proxy ECONNREFUSED race; bring-up is best-effort. import './lib/loadEnv.js'; import process from 'node:process'; import type { TelegramClient } from 'telegram'; @@ -87,25 +65,8 @@ interface TryStartTelegramDeps { db: Db; bus: EventBus; filterEvaluator: FilterEvaluator; - /** - * Returns true if a SIGINT/SIGTERM landed while we were inside - * `client.connect()`. The function bails after connect with the client - * disconnected so we don't attach a NewMessage listener that nothing will - * tear down. - */ isShuttingDown: () => boolean; - /** - * Sink for status updates emitted by the health monitor. The boot path - * funnels these into `statusRef.current` so `/api/system/status` reflects - * runtime liveness changes (connected → disconnected, etc). - */ setStatus: (next: TelegramStatus) => void; - /** - * Forced session-reload callback. Passed into the health monitor so it - * can recover from gramjs getting stuck in a reconnect loop. The function - * is `reloadTelegramSession` from `main()`, which coalesces concurrent - * calls and atomically swaps the runtime box. - */ requestReload: () => Promise; } @@ -138,10 +99,6 @@ async function tryStartTelegram(deps: TryStartTelegramDeps): Promise): Promise { if (runtime.dialogsKeepalive) { try { @@ -310,10 +250,6 @@ async function main(): Promise { const filterRegistry = createDefaultRegistry(); const filterEvaluator = createFilterEvaluator({ db, registry: filterRegistry, logger, bus }); - // Mutable runtime box. Populated once Telegram bring-up finishes; the - // Fastify routes read these via getter closures (see createApiServer - // below) so they always see the current value without needing - // re-registration when the background init completes. const tgRuntime: Partial = {}; const statusRef: { current: TelegramStatus } = { current: { @@ -327,18 +263,13 @@ async function main(): Promise { statusRef.current = next; }; - // Singleton store for in-progress sign-ins from the Settings page. Holds - // temp gramjs clients between HTTP steps; GC sweeps stale entries every - // minute. Shut down with the rest of the app on SIGINT/SIGTERM. const loginSessionStore = createLoginSessionStore({ apiId: config.TG_API_ID ?? 0, apiHash: config.TG_API_HASH ?? '', logger, }); - // Live-swap mutex. Coalesces concurrent reload requests so two clicks of - // "Sign in" don't race two new clients into the runtime box. Shutdown - // awaits the current pending promise (if any) before tearing down. + // Mutex: coalesce concurrent reloads so two "Sign in" clicks don't race two clients in. let reloadPending: Promise | null = null; async function reloadTelegramSession(): Promise { @@ -347,7 +278,6 @@ async function main(): Promise { reloadPending = (async () => { logger.info('reloading Telegram session'); const oldRuntime: Partial = { ...tgRuntime }; - // Mark transient state so /system/status reflects the swap window. setStatus({ state: 'connecting', connected: false, @@ -361,16 +291,13 @@ async function main(): Promise { setStatus, requestReload: reloadTelegramSession, }); - // Replace refs in the box. Any unset keys on `next` clear the old - // value (so e.g. degraded mode after a sign-out actually drops the - // chat resolver). + // Clear first so unset keys on `next` actually drop old refs (e.g. resolver after sign-out). for (const key of Object.keys(tgRuntime) as (keyof TelegramRuntime)[]) { delete tgRuntime[key]; } Object.assign(tgRuntime, next); setStatus(next.status); - // Tear down old refs after the swap so any in-flight route handler - // dereferencing the live getter sees the new value first. + // Tear down after the swap so an in-flight handler sees the new value first. await teardownRuntime(oldRuntime); if (next.status.connected) { logger.info('Telegram session reloaded'); @@ -388,9 +315,6 @@ async function main(): Promise { } } - // Telegram Web App bot. Held in a mutable `bot` ref + a reload mutex so the - // bot-config route can live-swap it (new token / admin ids / public URL) - // without a restart. let bot: TgFeedBot | undefined; let botReloadPending: Promise | null = null; @@ -400,8 +324,7 @@ async function main(): Promise { botReloadPending = (async () => { const auth = resolveTelegramAuth({ cfg: config, db, logger }); const publicUrl = resolvePublicUrl({ cfg: config, db }); - // Stop the old bot FIRST: grammy long-polls and two getUpdates loops on - // the same token would 409. A brief no-bot window is acceptable. + // Stop the old bot FIRST: two getUpdates loops on the same token 409. const old = bot; bot = undefined; if (old) { @@ -438,8 +361,6 @@ async function main(): Promise { } } - // DB-backed session store. Shared with the Fastify factory so both the - // login route and the prune task see the same set of rows. const sessionStore = createSessionStore({ db }); const app = await createApiServer({ @@ -467,9 +388,6 @@ async function main(): Promise { await app.listen({ host: '0.0.0.0', port: config.PORT }); logger.info({ port: config.PORT }, 'API listening'); - // Background pruner: keeps `forward_log` bounded and reaps expired web - // sessions. One hour cadence is plenty — neither table changes fast enough - // to justify aggressive ticking, and a single sweep is cheap. const prunePoller = createPoller({ intervalMs: 60 * 60 * 1000, runOnStart: true, @@ -483,22 +401,14 @@ async function main(): Promise { }); prunePoller.start(); - // Telegram Web App bot — best-effort: a failed start leaves the API serving - // (password login still works). Started after `listen()`. `reloadBot` - // resolves the config DB-over-env and starts the bot when one is configured. + // Best-effort: a failed bot start leaves the API serving (password login still works). await reloadBot(); - // Stats digest: a minute-tick scheduler that DMs admins a forwarded/ - // filtered/error summary on the configured daily/weekly cadence. `getBot` - // reads the live `bot` ref so it always sees the current bot across reloads; - // when the digest is disabled (the default) every tick is a cheap no-op. + // getBot reads the live ref so the scheduler follows bot reloads. const statsDigest = createStatsDigestScheduler({ db, getBot: () => bot, logger }); statsDigest.start(); - // Background Telegram bring-up. Errors are caught inside - // tryStartTelegram; the outer .catch is a belt-and-suspenders for any - // throw that escapes (e.g. a logger crash). The promise is awaited from - // the shutdown handler so we don't tear down half-attached resources. + // Awaited from shutdown so we don't tear down half-attached resources. const tgInitPromise = (async () => { const tg = await tryStartTelegram({ db, @@ -509,10 +419,6 @@ async function main(): Promise { requestReload: reloadTelegramSession, }); if (shuttingDown) { - // Shutdown ran while we were connecting. Tear down what we built - // (tryStartTelegram's own early-shutdown branch already handled the - // pre-listener case; this path catches a SIGINT that landed *after* - // connect returned but before we got here). await teardownRuntime(tg); return; } @@ -533,16 +439,12 @@ async function main(): Promise { try { prunePoller.stop(); statsDigest.stop(); - // Let any in-flight bot reload settle before stopping the bot, so a - // reload triggered moments before SIGTERM can't leave a poll loop running. + // Settle an in-flight bot reload first, else it can leave a poll loop running past SIGTERM. if (botReloadPending) await botReloadPending.catch(() => {}); if (bot) await bot.stop(); await app.close(); - // gramjs client.connect() doesn't accept an AbortSignal cleanly, so - // wait for the in-flight init to settle before tearing down its - // outputs. Bounded by Telegram's internal connect timeout. + // connect() takes no AbortSignal cleanly; wait for init (and any live-swap) to settle. await tgInitPromise.catch(() => {}); - // Same for any in-flight live-swap. if (reloadPending) await reloadPending.catch(() => {}); await loginSessionStore.shutdown(); await teardownRuntime(tgRuntime); diff --git a/apps/server/src/lib/dbHelpers.ts b/apps/server/src/lib/dbHelpers.ts index f981626..9e06216 100644 --- a/apps/server/src/lib/dbHelpers.ts +++ b/apps/server/src/lib/dbHelpers.ts @@ -2,7 +2,6 @@ import { count, type SQL } from 'drizzle-orm'; import type { SQLiteTable } from 'drizzle-orm/sqlite-core'; import type { Db } from '../db/client.js'; -/** Single-shot row count using drizzle's typed `count()` aggregate. */ export function countWhere(db: Db, table: SQLiteTable, where: SQL): number { const row = db.select({ c: count() }).from(table).where(where).get(); return Number(row?.c ?? 0); diff --git a/apps/server/src/lib/errors.ts b/apps/server/src/lib/errors.ts index 3d573ea..f32cd0d 100644 --- a/apps/server/src/lib/errors.ts +++ b/apps/server/src/lib/errors.ts @@ -1,14 +1,4 @@ -/** - * Typed error hierarchy for HTTP route handlers. - * - * Throw these from inside route handlers; the Fastify error handler - * (`api/errorHandler.ts`) reads `statusCode` + `code` and maps them to - * the wire-format envelope `{ error: { code, message, issues? } }`. - * - * Anything not derived from `AppError` is treated as a 500 by the handler - * with a generic `internal` message — so do not throw raw `Error` for - * client-facing failure modes. - */ +// Throw these from route handlers; api/errorHandler.ts maps them to the wire envelope. A raw Error becomes a generic 500, so never throw one for client-facing failures. export class AppError extends Error { constructor( public readonly statusCode: number, @@ -47,25 +37,14 @@ export class ConflictError extends AppError { } } -/** - * Thrown when an upstream service (Telegram via gramjs) is unreachable or - * misbehaving. Maps to 503; the `code` is an explicit subtype the web UI - * keys on for retry / fallback messaging. - */ +// 503; `code` is a subtype the web UI keys on for retry/fallback messaging. export class UpstreamError extends AppError { constructor(message: string, code = 'upstream_unavailable') { super(503, code, message); } } -/** - * Builds the right 503 for a Telegram dep that's currently undefined. - * `telegram_initializing` (transient, retry) when the lifecycle phase is - * still 'connecting'; `telegram_unavailable` (configure / re-login) for - * the steady-state disconnected case. Both subscription and destination - * routes use the same logic, so it lives here to keep the message and - * code mapping consistent. - */ +// telegram_initializing (retry) while 'connecting', else telegram_unavailable (configure/re-login). export function telegramUnavailableError(status: { state: string }): UpstreamError { if (status.state === 'connecting') { return new UpstreamError('Telegram is starting up; retry in a moment', 'telegram_initializing'); @@ -73,12 +52,7 @@ export function telegramUnavailableError(status: { state: string }): UpstreamErr return new UpstreamError('Telegram client not configured', 'telegram_unavailable'); } -/** - * Thrown for "this shouldn't happen" cases — e.g. a row that exists at - * INSERT-time disappearing before the immediate follow-up read. Maps to - * 500; the message is logged but `errorHandler.ts` only echoes the - * generic envelope, so internal context never leaks to the client. - */ +// 500 for "shouldn't happen" cases; message is logged but never echoed to the client. export class InternalError extends AppError { constructor(message: string) { super(500, 'internal', message); diff --git a/apps/server/src/lib/jsonSafe.ts b/apps/server/src/lib/jsonSafe.ts index a349b46..4f7f6df 100644 --- a/apps/server/src/lib/jsonSafe.ts +++ b/apps/server/src/lib/jsonSafe.ts @@ -1,31 +1,4 @@ -/** - * Best-effort POJO snapshot of a gramjs `Message` (or any TL object) for - * persistence in `forward_log.raw_message`. - * - * gramjs's `TLObject` classes already implement `toJSON()` correctly — - * `BigInteger` instances stringify, `Buffer`s become base64. Round-tripping - * through `JSON.stringify` + `JSON.parse` invokes those `toJSON` hooks for - * us and drops class identity, which is exactly the POJO we want to store. - * The replacer adds two safety nets that `toJSON` alone doesn't cover: - * - * - **Cycle guard** via WeakSet. gramjs payloads are mostly tree-shaped, - * but `fwdFrom.fromId` chains can occasionally re-enter the original - * `peerId` graph; without the guard a single weird message blows the - * stack and we'd lose the row. - * - **Native `BigInt`** — newer Node versions (≥21) sometimes hand back - * raw `bigint` from MTProto deserialization where older versions used - * `big-integer`. `JSON.stringify` throws on these; coerce to string. - * - * After the round-trip we measure the encoded byte length. Pathological - * payloads (full thumbnail strips, file refs with embedded preview blobs) - * can balloon past 100KB; cap at `maxBytes` and replace the value with a - * marker so the row still inserts cleanly and the UI surfaces the reason. - * - * Returns `null` for `null`/`undefined`, `MessageEmpty`, and `MessageService` - * — none of those carry payloads worth viewing, and `MessageService` - * specifically is filtered out earlier in the pipeline anyway. Callers - * store the `null` directly. - */ +// POJO snapshot of a gramjs TL object for forward_log.raw_message. JSON round-trip invokes toJSON and drops class identity; the replacer adds a WeakSet cycle guard (fwdFrom.fromId can re-enter the peerId graph) and coerces native bigint (Node ≥21) since JSON.stringify throws on it. Returns null for null/undefined, MessageEmpty, MessageService. export interface ToJsonSafeOptions { /** Max serialized JSON byte length; over this, value is replaced with a truncation marker. */ @@ -52,17 +25,12 @@ export function toJsonSafe(value: unknown, opts: ToJsonSafeOptions = {}): unknow return v; }); } catch { - // Last-resort: gramjs surfaced a value we can't round-trip (e.g. a - // class with a throwing `toJSON`). Drop it rather than crash the - // forward — the row is still valuable without the snapshot. + // Unround-trippable value (e.g. a throwing toJSON); drop the snapshot, keep the row. return null; } if (encoded === undefined) return null; - // `Buffer.byteLength` measures real UTF-8 bytes — `.length` measures UTF-16 - // code units, which understates multibyte content (a 64KB cap allows ~256KB - // of emoji-heavy text through). The comment block at the top of this file - // promises "byte length"; honor it literally. + // Real UTF-8 bytes, not UTF-16 .length which understates multibyte content. const byteLen = Buffer.byteLength(encoded, 'utf8'); if (byteLen > maxBytes) { return { __truncated: true, size: byteLen }; diff --git a/apps/server/src/lib/loadEnv.ts b/apps/server/src/lib/loadEnv.ts index fda835d..94170bb 100644 --- a/apps/server/src/lib/loadEnv.ts +++ b/apps/server/src/lib/loadEnv.ts @@ -1,16 +1,5 @@ -/** - * Locate and load the workspace `.env` file. - * - * `dotenv/config` looks at `process.cwd() + '/.env'` only. In a pnpm - * monorepo `pnpm -F @tg-feed/server dev` runs with cwd = `apps/server`, - * which doesn't contain `.env` — so vars from the workspace-root `.env` - * never load. Walking up from this module's directory finds the closest - * `.env` regardless of cwd, so dev / migrate / tg:login all see the same - * config. - * - * Import this module instead of `dotenv/config` from any entrypoint that - * needs env vars before reading `process.env`. - */ +// Walk up to the workspace-root .env (dotenv/config only checks cwd, which is apps/server under pnpm). +// Import this instead of dotenv/config from any entrypoint. import { config as loadDotenv } from 'dotenv'; import { existsSync } from 'node:fs'; import path from 'node:path'; diff --git a/apps/server/src/lib/logger.ts b/apps/server/src/lib/logger.ts index 2ce3f11..7282988 100644 --- a/apps/server/src/lib/logger.ts +++ b/apps/server/src/lib/logger.ts @@ -9,24 +9,14 @@ export interface CreateLoggerOptions { silent?: boolean; } -/** - * Belt-and-suspenders redaction list. Existing call sites are audited not to - * log these, but this guards against future code paths accidentally bundling - * them into a log object — pino redacts BEFORE the transport sees the record, - * so they never reach disk / stdout / log shipping. - * - * Paths use pino's path notation. `*` matches one level; multi-level wildcards - * are not supported, so every common shape is enumerated. Cheap at runtime. - */ +// pino's `*` matches a single level only, so every nesting depth is enumerated by hand. const REDACT_PATHS = [ - // Request/response headers 'req.headers.cookie', 'req.headers.authorization', 'req.headers["set-cookie"]', 'res.headers["set-cookie"]', 'headers.cookie', 'headers.authorization', - // Web auth + cookies 'password', '*.password', '*.*.password', @@ -34,7 +24,6 @@ const REDACT_PATHS = [ '*.cookie', 'SESSION_SECRET', '*.SESSION_SECRET', - // Telegram session material 'sessionString', '*.sessionString', 'TG_SESSION_STRING', @@ -43,12 +32,10 @@ const REDACT_PATHS = [ '*.encryptedSessionString', 'TG_SESSION_ENCRYPTION_KEY', '*.TG_SESSION_ENCRYPTION_KEY', - // Bot token + Mini App init payload (the latter carries a user-bound HMAC) 'TG_BOT_TOKEN', '*.TG_BOT_TOKEN', 'initData', '*.initData', - // Login flow secrets / bearer tokens 'phoneCode', '*.phoneCode', 'phoneCodeHash', diff --git a/apps/server/src/lib/poller.ts b/apps/server/src/lib/poller.ts index b9cdfb4..f187148 100644 --- a/apps/server/src/lib/poller.ts +++ b/apps/server/src/lib/poller.ts @@ -1,21 +1,10 @@ -/** - * Periodic `run` invoker with idempotent start/stop semantics shared across - * the server's background monitors (history poller, access monitor, health - * monitor, dialogs keepalive). Each tick fires `run` without awaiting; - * rejections are caught and logged so one bad iteration never silently - * breaks the loop. - * - * In-flight / dedup handling stays in `run` — sites have materially different - * needs (Promise coalescing, boolean skip, none) so this utility deliberately - * stays out of that. - */ +// Periodic `run` invoker with idempotent start/stop; rejections are caught and logged so one bad tick never breaks the loop. In-flight/dedup deliberately stays in `run` — call sites differ. import type { Logger } from './logger.js'; export interface PollerOptions { intervalMs: number; run: () => Promise; logger: Logger; - /** Message logged at error level when `run` rejects. */ errorLogMessage: string; /** Invoke `run` immediately on `start()`. Default `true`. */ runOnStart?: boolean; diff --git a/apps/server/src/lib/sessionCrypto.ts b/apps/server/src/lib/sessionCrypto.ts index 2831caf..b996403 100644 --- a/apps/server/src/lib/sessionCrypto.ts +++ b/apps/server/src/lib/sessionCrypto.ts @@ -1,31 +1,11 @@ -/** - * AES-256-GCM helpers for secrets stored at rest — the Telegram session - * blob in `telegram_account.encrypted_session_string` and the bot token in - * `app_settings` key `'bot'`. - * - * Wire format: `base64(iv(12) || authTag(16) || ciphertext)`. - * Key: 32 raw bytes, supplied as base64 in `TG_SESSION_ENCRYPTION_KEY`. - * - * The fingerprint (first 16 hex chars of `sha256(key)`) lets exports - * advertise *which* key they were encrypted with so an import on a host - * with a different key can skip the row instead of attempting (and - * silently failing on) decryption. 64 bits is enough to detect mismatch - * with no realistic collision risk; not enough to be a brute-force lever - * against the underlying 256-bit key. - * - * Each blob is bound to a stable AAD label identifying the field it belongs - * to (`encryptSecret`/`decryptSecret`), so a valid ciphertext can't be - * swapped between fields (e.g. session string ↔ bot token) without the GCM - * tag failing. The two session-string wrappers keep the original AAD plus a - * legacy no-AAD decrypt path for blobs written before AAD binding existed. - */ +// AES-256-GCM for secrets at rest. Wire: base64(iv(12) ‖ authTag(16) ‖ ciphertext). +// Per-field AAD binds a ciphertext to its field so it can't be swapped (session string ↔ bot token). +// 64-bit key fingerprint lets a cross-host import skip rows it can't decrypt; too short to brute-force the key. import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'; import type { Config } from '../config.js'; export interface EncryptedEnvelope { - /** base64(iv ‖ authTag ‖ ciphertext) */ ciphertext: string; - /** First 16 hex chars of sha256(key). */ keyFingerprint: string; } @@ -34,17 +14,10 @@ const IV_BYTES = 12; const TAG_BYTES = 16; const KEY_BYTES = 32; -/** AAD for the Telegram session blob in `telegram_account`. */ export const TELEGRAM_ACCOUNT_AAD = 'tg-feed/telegram_account/v1'; -/** AAD for the bot token in `app_settings` key `'bot'`. */ export const BOT_TOKEN_AAD = 'tg-feed/bot_token/v1'; -/** - * Returns the decoded encryption key as a 32-byte Buffer, or null when the - * env var is not set. Throws if the var is set but malformed — config-time - * validation in `config.ts` already enforces base64 + length, but this is - * the second line of defence (and the runtime-typed return). - */ +// null when unset; throws if malformed — backstop behind config.ts's base64/length check. export function loadEncryptionKey(cfg: Config): Buffer | null { if (!cfg.TG_SESSION_ENCRYPTION_KEY) return null; const buf = Buffer.from(cfg.TG_SESSION_ENCRYPTION_KEY, 'base64'); @@ -60,10 +33,6 @@ export function getKeyFingerprint(key: Buffer): string { return createHash('sha256').update(key).digest('hex').slice(0, 16); } -/** - * Encrypt `plain` under `key`, binding the ciphertext to `aad` (a stable - * label identifying the field). The same `aad` must be supplied to decrypt. - */ export function encryptSecret(plain: string, key: Buffer, aad: string): EncryptedEnvelope { if (key.length !== KEY_BYTES) { throw new Error(`encryption key must be ${KEY_BYTES} bytes`); @@ -79,10 +48,7 @@ export function encryptSecret(plain: string, key: Buffer, aad: string): Encrypte }; } -/** - * Low-level GCM open. `aad === null` decrypts without binding (legacy - * pre-AAD blobs). Throws on a truncated envelope or a tag mismatch. - */ +// aad === null decrypts without binding (legacy pre-AAD blobs). function openEnvelope(envelope: EncryptedEnvelope, key: Buffer, aad: string | null): string { if (key.length !== KEY_BYTES) { throw new Error(`encryption key must be ${KEY_BYTES} bytes`); @@ -100,7 +66,6 @@ function openEnvelope(envelope: EncryptedEnvelope, key: Buffer, aad: string | nu return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8'); } -/** Decrypt a blob produced by `encryptSecret` with the same `aad`. */ export function decryptSecret(envelope: EncryptedEnvelope, key: Buffer, aad: string): string { return openEnvelope(envelope, key, aad); } @@ -110,9 +75,7 @@ export function encryptSessionString(plain: string, key: Buffer): EncryptedEnvel } export function decryptSessionString(envelope: EncryptedEnvelope, key: Buffer): string { - // Try with AAD first; fall back to no-AAD so envelopes produced by - // pre-v1 versions of this module (no AAD) still decrypt. The legacy - // path will be removed once all stored ciphertexts are re-encrypted. + // Fall back to no-AAD so pre-v1 (no-AAD) blobs still decrypt. try { return openEnvelope(envelope, key, TELEGRAM_ACCOUNT_AAD); } catch { diff --git a/apps/server/src/tg/chatResolver.ts b/apps/server/src/tg/chatResolver.ts index a0629fe..b474e34 100644 --- a/apps/server/src/tg/chatResolver.ts +++ b/apps/server/src/tg/chatResolver.ts @@ -1,15 +1,5 @@ -/** - * Universal "paste-anything" chat resolver. - * - * Backs both `POST /api/subscriptions/resolve` and `POST /api/destinations/resolve`. - * Accepts any of: `@username`, `t.me/username`, `t.me/+HASH` private invite, - * `+HASH` raw invite, or a numeric chat id (`-100…` or bare positive). - * - * For private invites where the userbot is not yet a member, `chatId` - * comes back `null` — only `inviteHash` is set, and the create endpoint - * is expected to call `messages.ImportChatInvite` to actually join and - * derive the resulting chatId. - */ +// Paste-anything chat resolver: @username, t.me/username, t.me/+HASH or +HASH invite, or numeric id. +// Not-yet-joined private invites return chatId=null with inviteHash set; create joins via ImportChatInvite. import type { TelegramClient } from 'telegram'; import { NotFoundError, UpstreamError } from '../lib/errors.js'; import { entityToResolved, parseInput } from './entityResolver.js'; @@ -21,7 +11,6 @@ export interface ChatResolveResult { handle: string | null; inviteHash: string | null; alreadyMember: boolean; - /** True when the resolved chat is a forum supergroup (has topics). */ isForum: boolean; } @@ -64,9 +53,7 @@ export function createChatResolver(client: ChatResolverClient): ChatResolver { handle: null, inviteHash: parsed.hash, alreadyMember: preview.alreadyMember, - // The chat isn't joined yet, so its forum status is unknown — the - // user sets the topic later via the edit sheet once joined. - isForum: false, + isForum: false, // unknown until joined; user sets topic later }; } @@ -88,9 +75,7 @@ export function createChatResolver(client: ChatResolverClient): ChatResolver { }; } - // chatId branch — verify access and pull the title via getEntity. The - // input is already in the storage form (`-100xxx` or bare positive), - // so we don't need entityToResolved's prefix logic here. + // chatId branch: input already in storage form, so skip entityToResolved's prefix logic. let entity: unknown; try { entity = await client.getEntity(parsed.value); diff --git a/apps/server/src/tg/client.ts b/apps/server/src/tg/client.ts index f6c3814..2040c2b 100644 --- a/apps/server/src/tg/client.ts +++ b/apps/server/src/tg/client.ts @@ -20,15 +20,12 @@ export interface TelegramEnv { export interface TelegramEnvResult { ok: boolean; env?: TelegramEnv; - /** Where the session came from when `ok === true`. */ source?: 'db' | 'env'; - /** Human-readable reason when `ok === false`; safe to surface in API. */ + // Safe to surface in the API when ok === false. reason?: string; } -// Non-throwing variant — used by the server entrypoint to support degraded -// boot (no Telegram client; API + DB still come up). Callers that genuinely -// require credentials use `requireTelegramEnv` below. +// Non-throwing variant supporting degraded boot (no client; API + DB still come up). export function readTelegramEnv(cfg: Config): TelegramEnvResult { const missing: string[] = []; if (cfg.TG_API_ID === undefined) missing.push('TG_API_ID'); @@ -61,17 +58,8 @@ export function requireTelegramEnv(cfg: Config): TelegramEnv { return result.env as TelegramEnv; } -/** - * Priority resolver: prefers the DB-stored account when its key fingerprint - * matches the configured `TG_SESSION_ENCRYPTION_KEY`, then falls back to the - * env `TG_SESSION_STRING`, then surfaces a degraded-mode reason. - * - * A row whose fingerprint *doesn't* match (key rotated, no key configured, - * or the row was imported from another host) is skipped — the resolver - * never auto-deletes it. The `/api/tg/account` endpoint reflects the - * mismatch state so the operator can either restore the original key or - * sign out + re-add. - */ +// DB account (when key fingerprint matches) → env TG_SESSION_STRING → degraded reason. +// A fingerprint mismatch is skipped, never auto-deleted; /api/tg/account surfaces the mismatch. export function resolveTelegramEnv(deps: { cfg: Config; db: Db; @@ -155,9 +143,7 @@ export interface CreateTelegramClientOptions { gramjsLogLevel?: LogLevel; } -// gramjs retries connect this many times with exponential backoff before -// giving up and throwing — the listener's safe-handler catches downstream -// failures, but `connect()` itself bubbles to the boot path's degraded mode. +// connect() bubbles to the boot path's degraded mode after this many retries. const CONNECTION_RETRIES = 5; export function createTelegramClient(opts: CreateTelegramClientOptions): TelegramClient { @@ -165,13 +151,12 @@ export function createTelegramClient(opts: CreateTelegramClientOptions): Telegra const client = new TelegramClient(session, opts.apiId, opts.apiHash, { connectionRetries: CONNECTION_RETRIES, }); - // gramjs's own logger is loud by default; pin it to warn unless overridden. + // gramjs's logger is loud by default; pin to warn unless overridden. client.setLogLevel(opts.gramjsLogLevel ?? LogLevel.WARN); return client; } -// `destroy` alone leaves the auto-reconnect loop running and the process -// won't exit cleanly on SIGINT. Order matters: disconnect first, then destroy. +// Order matters: disconnect before destroy, else the reconnect loop keeps the process alive on SIGINT. export async function disconnectClient(client: TelegramClient): Promise { await client.disconnect(); await client.destroy(); diff --git a/apps/server/src/tg/dialogsKeepalive.ts b/apps/server/src/tg/dialogsKeepalive.ts index f2f3134..49845a9 100644 --- a/apps/server/src/tg/dialogsKeepalive.ts +++ b/apps/server/src/tg/dialogsKeepalive.ts @@ -1,20 +1,4 @@ -/** - * Periodic `client.getDialogs` ping that keeps gramjs's update stream alive. - * - * The primary fix for "channel updates silently stop" is the `historyPoller` - * — it actually fetches missed messages. This keepalive is the secondary, - * cheap signal: gramjs maintainer painor documented in - * https://github.com/gram-js/gramjs/issues/280 that calling `getEntity` / - * `getMessages` / `getDialogs` periodically nudges Telegram to keep - * delivering NewMessage events. Issue - * https://github.com/gram-js/gramjs/issues/654 reports a 30s-interval - * `getDialogs` call stabilising the update stream for 24h+. - * - * On its own this is unreliable for high-volume channels — that's what the - * poller is for — but pairing the two narrows the window between a missed - * post and a poller sweep, and is essentially free (one cheap RPC every - * 30s). - */ +// Secondary keepalive to the historyPoller: periodic getDialogs nudges gramjs to keep delivering NewMessage (gram-js/gramjs#280, #654). Unreliable alone, but cheap and narrows the miss window. import type { TelegramClient } from 'telegram'; import type { Logger } from '../lib/logger.js'; import { createPoller, type Poller } from '../lib/poller.js'; @@ -45,9 +29,7 @@ export function createDialogsKeepalive(deps: DialogsKeepaliveDeps): DialogsKeepa try { await client.getDialogs({ limit: KEEPALIVE_LIMIT }); } catch (err) { - // Single bad call must not stop the timer — we want to keep retrying - // on the next tick. Log at debug to avoid noise on transient errors; - // the historyPoller and access monitor will surface persistent issues. + // Debug-level: transient failures are expected and the poller/access monitor surface persistent issues. logger.debug({ err }, 'dialogs keepalive ping failed'); } finally { inFlight = false; diff --git a/apps/server/src/tg/forumTopics.ts b/apps/server/src/tg/forumTopics.ts index f895fd6..0a63751 100644 --- a/apps/server/src/tg/forumTopics.ts +++ b/apps/server/src/tg/forumTopics.ts @@ -1,17 +1,5 @@ -/** - * Lists a forum supergroup's topics so the destination UI can offer a picker. - * - * Backs `POST /api/destinations/topics`. Calls `channels.GetForumTopics` and - * maps each live `ForumTopic` to `{ id, title }`; `ForumTopicDeleted` entries - * are dropped. The General topic (reserved `top_msg_id` 1) is returned like - * any other — the web layer represents "General" as the null/no-topic choice. - * - * Never throws: a non-forum chat, a missing channel, or any gramjs error - * resolves to `{ isForum: false, topics: [] }` so the picker degrades to - * "no topic" instead of failing the request. The channel is passed as a raw - * id string — gramjs resolves entity-likes inside raw requests, matching the - * history poller's `messages.GetHistory` usage. - */ +// Lists a forum's topics for the destination picker. Never throws — any error degrades to empty, +// so a non-forum chat or gramjs failure can't block saving a destination. import { Api } from 'telegram'; import type { TelegramClient } from 'telegram'; import type { ForumTopic } from '@tg-feed/shared'; @@ -24,12 +12,12 @@ export interface ForumTopicsResult { export type ForumTopicLister = (chatId: string) => Promise; -// Subset of `TelegramClient` we depend on so tests can pass a stub. +// Subset of `TelegramClient` so tests can pass a stub. export interface ForumTopicListerClient { invoke: TelegramClient['invoke']; } -/** Telegram caps `GetForumTopics` at 100 per page; one page is plenty for a picker. */ +// Telegram caps `GetForumTopics` at 100 per page; one page is plenty. const TOPICS_LIMIT = 100; export function createForumTopicLister( @@ -43,9 +31,7 @@ export function createForumTopicLister( new Api.channels.GetForumTopics({ channel: chatId, limit: TOPICS_LIMIT }), )) as { topics?: unknown[] }; } catch (err) { - // `CHANNEL_FORUM_MISSING` (not a forum) is the expected case; anything - // else is logged but still degrades to an empty list so the picker - // never blocks saving a destination. + // `CHANNEL_FORUM_MISSING` (not a forum) is the expected case here. logger.debug({ err, chatId }, 'forum topics: GetForumTopics failed'); return { isForum: false, topics: [] }; } diff --git a/apps/server/src/tg/healthMonitor.ts b/apps/server/src/tg/healthMonitor.ts index d9a1064..c54e49b 100644 --- a/apps/server/src/tg/healthMonitor.ts +++ b/apps/server/src/tg/healthMonitor.ts @@ -1,28 +1,6 @@ -/** - * Periodic Telegram connection health probe + auto-recovery. - * - * gramjs's `autoReconnect` papers over short transport hiccups but doesn't - * detect every failure mode that breaks NewMessage delivery — for example, - * `AUTH_KEY_UNREGISTERED` (logout from another device), a sender stuck in a - * half-open state, or a reconnect loop where `_sender.isReconnecting` - * stays `true` forever and the update loop silently skips every ping - * (see [updates.js:209]). Without an active probe `TelegramStatus` would - * stay `connected: true` forever, even when no events are flowing. - * - * The monitor calls `updates.getState` on a fixed interval. It's the lightest - * authenticated request that touches the same update sub-system the listener - * depends on, so a successful response is reasonable evidence that updates - * would also flow. On error we publish `connected: false` with the upstream - * reason; on recovery we publish `connected: true` again. - * - * After `RELOAD_THRESHOLD` consecutive failures we additionally invoke the - * caller-supplied `requestReload()` to force a full client teardown + - * recreate via the same path the Settings UI uses for re-login. This catches - * the "process alive, gramjs stuck" failure mode that no amount of internal - * retry will recover from. - * - * The monitor never throws — caller errors propagate via `onStatusChange`. - */ +// Active probe because gramjs's autoReconnect can sit in a stuck state (e.g. AUTH_KEY_UNREGISTERED, +// isReconnecting stuck true) that breaks NewMessage delivery while TelegramStatus stays connected:true. +// updates.getState hits the same update sub-system the listener needs; N failures trigger requestReload(). import { Api } from 'telegram'; import type { TelegramStatus } from '@tg-feed/shared'; import type { Logger } from '../lib/logger.js'; @@ -32,9 +10,6 @@ export const HEALTH_CHECK_INTERVAL_MS = 20_000; export const HEALTH_CHECK_PROBE_TIMEOUT_MS = 10_000; export const HEALTH_CHECK_RELOAD_THRESHOLD = 3; -/** - * Minimal slice of TelegramClient used here so tests can pass a stub. - */ export interface HealthProbeClient { invoke(request: unknown): Promise; } @@ -43,12 +18,7 @@ export interface HealthMonitorDeps { client: HealthProbeClient; logger: Logger; onStatusChange: (status: TelegramStatus) => void; - /** - * Called after `reloadThreshold` consecutive probe failures. Optional — - * tests that only assert status emission can omit it. In production this - * is wired to `reloadTelegramSession()` from the entrypoint, which - * coalesces concurrent reload requests via its own mutex. - */ + // Called after reloadThreshold consecutive failures; coalesces concurrent reloads via its own mutex. requestReload?: () => Promise; intervalMs?: number; probeTimeoutMs?: number; @@ -58,7 +28,7 @@ export interface HealthMonitorDeps { export interface HealthMonitor { start(): void; stop(): void; - /** Run one probe immediately. Exposed for tests. */ + // Run one probe immediately; exposed for tests. probe(): Promise; } @@ -86,9 +56,7 @@ export function createHealthMonitor(deps: HealthMonitorDeps): HealthMonitor { } async function probe(): Promise { - // Skip probes while a reload is mid-flight: the old client is about to - // be torn down, and a successful probe on it would just race with the - // swap. The new monitor (created post-reload) will resume probing. + // Skip while a reload is mid-flight: a probe on the about-to-be-torn-down client would race the swap. if (stopped || reloadInFlight) return; try { await invokeWithTimeout(); @@ -101,9 +69,7 @@ export function createHealthMonitor(deps: HealthMonitorDeps): HealthMonitor { } catch (err) { const reason = err instanceof Error ? err.message : 'unknown'; consecutiveFailures += 1; - // First failure after a healthy streak is the most diagnostic signal — - // surface as ERROR. Continued failures drop to WARN to avoid log spam, - // since the auto-reload below already escalates if it stays broken. + // First failure after a healthy streak logs ERROR; continued failures drop to WARN to avoid spam. if (lastConnected !== false) { logger.error({ err, consecutiveFailures }, 'Telegram health check failed'); } else { @@ -113,21 +79,14 @@ export function createHealthMonitor(deps: HealthMonitorDeps): HealthMonitor { onStatusChange({ state: 'disconnected', connected: false, reason }); if (requestReload && consecutiveFailures >= reloadThreshold) { - // Reset before firing so that if reload completes and the new client - // also fails, we don't immediately re-trigger on the very next probe - // (the new monitor will start its own counter from 0 anyway). The - // `reloadInFlight` gate covers the in-flight window. + // Reset before firing so a post-reload failure doesn't immediately re-trigger; reloadInFlight gates the window. consecutiveFailures = 0; reloadInFlight = true; logger.error( { threshold: reloadThreshold }, 'Telegram health check exceeded failure threshold — triggering session reload', ); - // Fire-and-forget. `requestReload` has its own coalescing mutex in - // the entrypoint, so we can't accidentally stack reloads. The - // `.finally` clears `reloadInFlight` even if the swap throws — but - // by then `stop()` will have already been called on this monitor as - // part of the old runtime teardown, so the flag is moot. + // Fire-and-forget; requestReload coalesces so reloads can't stack. void requestReload() .catch((reloadErr) => { logger.error({ err: reloadErr }, 'health-triggered Telegram reload failed'); diff --git a/apps/server/src/tg/inviteResolver.ts b/apps/server/src/tg/inviteResolver.ts index e66d940..7acf541 100644 --- a/apps/server/src/tg/inviteResolver.ts +++ b/apps/server/src/tg/inviteResolver.ts @@ -1,22 +1,4 @@ -/** - * gramjs wrappers for `messages.CheckChatInvite` (preview) and - * `messages.ImportChatInvite` (actually join). - * - * The check call is non-destructive — we use it from the resolve endpoint - * to show the user a preview card before they commit. The import call is - * destructive (joins the chat) — used from the create endpoint after the - * user confirms. - * - * For `ChatInvite` (not yet a member) the preview carries the title but no - * chatId — only `ImportChatInvite` returns the chat. The resolve flow - * surfaces `chatId: null` in that case and forwards the raw hash to the - * create endpoint. - * - * Errors are mapped to the typed `AppError` hierarchy for `checkInvite` - * (so the resolve endpoint propagates 4xx/5xx with stable codes), and - * coalesced into `'no_access'` for the import path (matching the - * `joinChannel` pattern — the create endpoint wants a single decision bit). - */ +// checkInvite = non-destructive preview (ChatInvite carries title but no chatId until joined); createImportInvite joins. Check errors map to typed AppError; import coalesces to 'no_access'. import { Api } from 'telegram'; import type { TelegramClient } from 'telegram'; import { NotFoundError, UpstreamError } from '../lib/errors.js'; @@ -57,13 +39,7 @@ const NOT_FOUND_PATTERNS = /INVITE_HASH_(EXPIRED|INVALID|EMPTY)/i; const PRIVATE_PATTERNS = /CHANNEL_PRIVATE|USER_BANNED_IN_CHANNEL|USERS_TOO_MUCH|CHAT_ADMIN_REQUIRED/i; -/** - * Convert a chat-shaped object (from `ChatInviteAlready.chat`, - * `ChatInvitePeek.chat`, or an entry in `Updates.chats[]`) into the - * `-100`-prefixed string the rest of the system uses. Mirrors the rule in - * `entityResolver.entityToResolved` so invite-derived ids match - * `-100`-prefixed ids stored elsewhere. - */ +// Normalize a chat-shaped object to the -100-prefixed id used everywhere else. export function chatIdFromInviteChat(chat: unknown): string | null { if (!chat || typeof chat !== 'object') return null; const c = chat as { id?: { toString: () => string } | number | string; className?: string }; @@ -82,10 +58,7 @@ function chatTitle(chat: unknown): string { return c.title ?? ''; } -// Invite hashes are bearer credentials — anyone with the full string can join -// the chat (subject to expiry/usage limits). Logs may be shipped to external -// systems; only the first 6 chars survive so the operator can still correlate -// without leaking the credential. +// Invite hashes are bearer credentials; keep only the first 6 chars out of logs. function redactInviteHash(hash: string): string { if (hash.length <= 6) return '***'; return `${hash.slice(0, 6)}…`; @@ -110,7 +83,7 @@ export async function checkInvite(client: InviteClient, hash: string): Promise Promise; -// Subset of gramjs `TelegramClient` we depend on, so tests can pass a stub -// without standing up the full client. +// Subset of gramjs TelegramClient we depend on, so tests can stub it. export interface JoinChannelClient { getInputEntity: TelegramClient['getInputEntity']; getEntity: TelegramClient['getEntity']; invoke: TelegramClient['invoke']; } -// Errors that mean "userbot can't access this chat" rather than a transient -// failure. Anything not in this list is logged at warn so unknown error -// shapes get diagnosed instead of silently classified. +// "Can't access this chat" errors; anything else is logged at warn, not silently classified. const NO_ACCESS_PATTERNS: readonly RegExp[] = [ /CHANNEL_PRIVATE/i, /CHANNEL_INVALID/i, @@ -52,9 +33,7 @@ function errorMessage(err: unknown): string { export function createJoinChannel(client: JoinChannelClient, logger: Logger): JoinChannelFn { return async (sourceChatId) => { - // -100xxx is the supergroup/channel marker. Bot/user DMs and basic - // groups can't be joined via channels.JoinChannel; for those we just - // verify access exists. + // -100xxx = channel/supergroup; DMs and basic groups can't JoinChannel, just probe access. const isChannelLike = sourceChatId.startsWith('-100'); if (!isChannelLike) { try { @@ -77,9 +56,7 @@ export function createJoinChannel(client: JoinChannelClient, logger: Logger): Jo return 'no_access'; } if (!(inputPeer instanceof Api.InputPeerChannel)) { - // -100xxx that resolved to something other than a channel — caller - // passed a misclassified id. No JoinChannel is meaningful; report - // no_access so the operator sees the mismatch in the UI. + // -100xxx that resolved to a non-channel: misclassified id, surface the mismatch. return 'no_access'; } diff --git a/apps/server/src/tg/loginSession.ts b/apps/server/src/tg/loginSession.ts index 136b531..7d1469e 100644 --- a/apps/server/src/tg/loginSession.ts +++ b/apps/server/src/tg/loginSession.ts @@ -1,19 +1,4 @@ -/** - * In-memory store for in-progress Telegram sign-ins from the Settings page. - * - * gramjs's `client.start()` uses callbacks (phoneNumber/phoneCode/password - * resolved synchronously inside one promise) which fights HTTP step - * boundaries — we need to break the flow across multiple requests so the - * UI can prompt the user, get the code, then return for the next step. - * The lower-level `Api.auth.SendCode` / `Api.auth.SignIn` / `CheckPassword` - * triplet maps onto three HTTP calls cleanly. - * - * Each pending login holds a temp `TelegramClient` (with an empty - * `StringSession`) that connects on `start()` and lives until either - * `verifyCode` / `verifyPassword` / `validateRaw` finalize, `cancel` is - * called, or the TTL GC sweeps it. The session string is only saved - * after the final auth.* RPC succeeds. - */ +// In-memory store for in-progress Telegram sign-ins, split across HTTP steps because gramjs's callback-based start() can't pause for UI prompts. import { randomBytes } from 'node:crypto'; import { Api, TelegramClient } from 'telegram'; import { computeCheck } from 'telegram/Password.js'; @@ -40,13 +25,7 @@ export interface LoginCompleted { } export interface LoginSessionStore { - /** - * Begin a phone-code sign-in. `ownerToken` ties this session to the - * calling web session so other authed tabs (or anyone who guesses a - * sessionId) can't drive someone else's in-progress login. Defaults to - * an empty string for raw-paste / legacy callers — those skip binding - * because they don't need to cross HTTP boundaries. - */ + // ownerToken binds the session to the calling web session so other tabs can't drive it; empty string (raw-paste/legacy) skips binding. start(phoneNumber: string, ownerToken?: string): Promise<{ sessionId: string }>; verifyCode( sessionId: string, @@ -71,12 +50,7 @@ interface PendingLogin { phoneNumber: string; phoneCodeHash: string; expiresAt: number; - /** - * Web-session token (the cookie value) of the caller who created this - * login session. Empty string means "no binding" (legacy / raw-paste). - * `verifyCode`/`verifyPassword`/`cancel` reject if the caller's token - * doesn't match — keeps a second authed tab from hijacking the flow. - */ + // empty string disables owner binding ownerToken: string; } @@ -99,15 +73,12 @@ export function createLoginSessionStore(deps: CreateLoginSessionStoreDeps): Logi } } }, GC_INTERVAL_MS); - // Don't keep the process alive solely for this timer. if (typeof gc.unref === 'function') gc.unref(); function generateSessionId(): string { return randomBytes(SESSION_ID_BYTES).toString('hex'); } - // Owner-binding check: skip when either side has no token (raw-paste / - // legacy path), enforce strictly when both sides have one. function assertOwner(pending: PendingLogin, ownerToken: string | undefined): void { if (pending.ownerToken && ownerToken && pending.ownerToken !== ownerToken) { throw new AppError( @@ -198,13 +169,10 @@ export function createLoginSessionStore(deps: CreateLoginSessionStoreDeps): Logi return { done: true, account }; } catch (err) { if (isPasswordNeeded(err)) { - // Keep the temp client alive for the follow-up `verifyPassword` call. - // Refresh the TTL window so the user has time to enter the 2FA password. + // Refresh TTL and keep the client alive for the follow-up verifyPassword. pending.expiresAt = Date.now() + ttlMs; return { needsPassword: true }; } - // Any other error terminates this attempt — drop the temp client so it - // doesn't leak. The caller can `start` again. dropSession(sessionId); await disconnectSilently(pending.client, logger); throw mapGramError(err); @@ -222,8 +190,7 @@ export function createLoginSessionStore(deps: CreateLoginSessionStoreDeps): Logi await disconnectSilently(pending.client, logger); return { done: true, account }; } catch (err) { - // Wrong password → keep the session alive so the user can retry; any - // other error tears it down. + // Wrong password keeps the session alive for retry; anything else tears it down. if (isPasswordWrong(err)) { pending.expiresAt = Date.now() + ttlMs; throw new AppError(401, 'wrong_2fa_password', 'incorrect 2FA password'); @@ -246,11 +213,7 @@ export function createLoginSessionStore(deps: CreateLoginSessionStoreDeps): Logi if (!sessionString || sessionString.length < 8) { throw new ValidationError('session string is empty or too short'); } - // gramjs's `StringSession` parser throws `Error("Not a valid string")` - // when the input doesn't start with the version byte, and its - // `BinaryReader` can throw on truncated payloads. Both cases need to - // surface as a 400 with a clear code instead of escaping the route as - // a generic 500. + // StringSession throws on a missing version byte or truncated payload; surface as 400, not a generic 500. let session: StringSession; try { session = new StringSession(sessionString); @@ -308,9 +271,7 @@ function newTempClient(apiId: number, apiHash: string): TelegramClient { async function finalize(pending: PendingLogin): Promise { const me = (await pending.client.getMe()) as Api.User; - // gramjs types `session.save()` as `void` even though `StringSession` - // returns the encoded string at runtime. Cast through `unknown` to avoid - // the strict-overlap warning. + // gramjs types session.save() as void though StringSession returns the encoded string at runtime. const sessionString = pending.client.session.save() as unknown as string; if (typeof sessionString !== 'string' || sessionString.length === 0) { throw new AppError(502, 'session_save_failed', 'failed to mint Telegram session'); @@ -355,7 +316,7 @@ function isPasswordWrong(err: unknown): boolean { function mapGramError(err: unknown): AppError { if (err instanceof AppError) return err; - // FloodWait — gramjs surfaces the wait time as `seconds`. + // gramjs FloodWait surfaces the wait time as `seconds`. const seconds = (err as { seconds?: unknown }).seconds; if (typeof seconds === 'number' && Number.isFinite(seconds)) { return new AppError( diff --git a/apps/server/src/tg/profilePhoto.ts b/apps/server/src/tg/profilePhoto.ts index c5fca73..40e5109 100644 --- a/apps/server/src/tg/profilePhoto.ts +++ b/apps/server/src/tg/profilePhoto.ts @@ -1,18 +1,5 @@ -/** - * Profile-photo fetcher used at subscription/destination create and as a - * lazy backfill from the access monitor. - * - * Telegram doesn't expose chat photos as public URLs — gramjs has to - * download the bytes via `client.downloadProfilePhoto`. We encode the - * small thumbnail as a `data:image/jpeg;base64,...` URL so it can ride - * inline in the list DTOs and the UI can drop it straight into an ``. - * - * The fetcher never throws: any failure (no photo, gramjs error, oversize - * buffer) returns `null` so callers can store the result without - * guarding. The 200 KB cap is defensive — small profile photos are - * typically <30 KB and a sudden jump would suggest a wrong-size variant - * was returned. - */ +// Downloads the chat photo (no public URL) as a data: URL. Never throws — any failure returns null. +// 200 KB cap is defensive: real thumbnails are <30 KB, so a jump implies a wrong-size variant. import type { TelegramClient } from 'telegram'; import type { Logger } from '../lib/logger.js'; @@ -20,7 +7,6 @@ export type ProfilePhotoFetcher = (chatId: string) => Promise; const MAX_PHOTO_BYTES = 200 * 1024; -// Subset of `TelegramClient` we depend on so tests can pass a stub. export interface ProfilePhotoClient { getEntity: TelegramClient['getEntity']; downloadProfilePhoto: TelegramClient['downloadProfilePhoto']; @@ -31,9 +17,7 @@ export function createProfilePhotoFetcher( logger: Logger, ): ProfilePhotoFetcher { return async (chatId) => { - // gramjs's `getEntity` is overloaded (single id → Entity, array → Entity[]); - // TS resolves the alias to the array form, so we widen to `unknown` here - // and let `downloadProfilePhoto`'s `EntityLike` signature accept it. + // Widen to unknown: the overloaded getEntity alias resolves to the array form. let entity: unknown; try { entity = await client.getEntity(chatId); @@ -52,8 +36,7 @@ export function createProfilePhotoFetcher( return null; } if (!buf || typeof buf === 'string' || buf.byteLength === 0) { - // gramjs returns an empty Buffer (or nothing) when the entity has - // no profile photo — not an error condition, just a null result. + // Empty buffer = no profile photo, not an error. return null; } if (buf.byteLength > MAX_PHOTO_BYTES) { diff --git a/apps/server/src/tg/subscriptions.ts b/apps/server/src/tg/subscriptions.ts index cb2ef00..5cbc79c 100644 --- a/apps/server/src/tg/subscriptions.ts +++ b/apps/server/src/tg/subscriptions.ts @@ -4,44 +4,20 @@ import type { Db } from '../db/client.js'; import { destinations, subscriptions } from '../db/schema.js'; import type { Logger } from '../lib/logger.js'; -// Minimal surface of TelegramClient we depend on, so tests can pass a stub. -// `getDialogs` is included so the cache-prime step can hit the same path -// `getEntity` does, just for every dialog at once. export interface TelegramEntityClient { getEntity: TelegramClient['getEntity']; getDialogs: TelegramClient['getDialogs']; } -/** - * Warm gramjs's per-session entity cache for every chat we'll talk to. - * - * Both source and destination chats need a valid `access_hash` in the cache - * before `forwardMessages` will accept them as `InputPeer`. A fresh - * `StringSession` (or one rebuilt after a long disconnect) doesn't carry - * those — the first reference to an unseen chat would fail with - * `PEER_ID_INVALID`/`CHANNEL_INVALID` until something else (a manual - * `getEntity`, `getDialogs`, etc.) populates the cache. - * - * We make a single `getDialogs` call up front so every chat the userbot is - * already a member of lands in the cache in one round-trip. The per-target - * `getEntity` loop afterwards then almost always hits the cache, and the - * tail of channels we're a member of but haven't dialoged with recently - * still fall back to the real RPC — but at least the common case (every - * subscribed source + destination) is primed. - * - * Resolves are sequential rather than `Promise.all` because parallel - * `resolveUsername`/`getFullChannel` calls scale linearly with the number - * of subscriptions and can easily trip Telegram's per-method rate limit on - * boot. Boot is fine to take a few extra seconds; a flood-wait isn't. - */ +// Warm gramjs's entity cache (one getDialogs + a getEntity loop) so forwardMessages has the access_hash +// each chat needs as an InputPeer; without it the first reference fails PEER_ID_INVALID/CHANNEL_INVALID. +// Resolves are sequential, not Promise.all, to avoid tripping Telegram's per-method rate limit on boot. export async function resolveSubscriptionsOnStartup( client: TelegramEntityClient, db: Db, logger: Logger, ): Promise { - // Prime the entity cache. A failure here just means the per-target loop - // below will pay the per-call resolve cost (and may fail loudly if the - // chat genuinely isn't in cache); it's never fatal. + // Never fatal: on failure the per-target loop below just pays the per-call resolve cost. try { await client.getDialogs({ limit: 200 }); logger.debug('warmed entity cache via getDialogs'); @@ -60,9 +36,7 @@ export async function resolveSubscriptionsOnStartup( .where(eq(subscriptions.enabled, true)) .all(); - // Dedupe — multiple subscriptions can share a destination, and a single - // chat could in principle appear as both source and destination. Each - // chat only needs to be warmed once per boot. + // Dedupe: a chat shared across subscriptions (or as both source and destination) is warmed once. const seen = new Set(); const targets: Array<{ chatId: string; role: 'source' | 'destination'; subscriptionId: number }> = []; diff --git a/apps/web/src/api/authApi.ts b/apps/web/src/api/authApi.ts index aacb7f3..32aa1b2 100644 --- a/apps/web/src/api/authApi.ts +++ b/apps/web/src/api/authApi.ts @@ -18,10 +18,7 @@ export async function login(password: string): Promise { return loginResponseSchema.parse(res); } -/** - * Sign in with a Telegram Mini App `initData` payload. `silent401` suppresses - * the global redirect so the LoginPage can fall back to the password form. - */ +// silent401 suppresses the global redirect so LoginPage can fall back to the password form. export async function loginWithTelegram(initData: string): Promise { const body = telegramAuthRequestSchema.parse({ initData }); const res = await apiFetch('/api/auth/telegram', { diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 481bdd3..7c57265 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -1,14 +1,4 @@ -/** - * Tiny fetch wrapper for the JSON API. - * - * - Always sends the session cookie (`credentials: 'include'`). - * - Parses JSON responses and returns the typed body, or throws an - * `ApiError` carrying the parsed `ErrorResponse` envelope. - * - On 401 from any authed call, redirects to /login. The redirect is - * global (location.assign) so React Router doesn't have to be in scope. - * We skip the redirect for the auth routes themselves so the LoginPage - * can show its own error message. - */ +// JSON fetch wrapper. On 401 from a non-auth route it does a global location.assign('/login') redirect. import { errorResponseSchema, type ErrorResponse } from '@tg-feed/shared'; export class ApiError extends Error { @@ -35,7 +25,7 @@ export class UnauthorizedError extends ApiError { export interface RequestInitJson extends Omit { body?: TBody; - /** When true, suppress the global 401 → /login redirect. Use for auth routes. */ + /** Suppress the global 401 → /login redirect. */ silent401?: boolean; } @@ -101,7 +91,6 @@ async function safeParseError(res: Response): Promise } } -/** Best-effort user-facing message for an API call failure. */ export function apiErrorMessage(err: unknown, fallback: string): string { if (err instanceof ApiError && err.body?.error.message) return err.body.error.message; if (err instanceof Error && err.message) return err.message; diff --git a/apps/web/src/components/domain/ActivityRow.tsx b/apps/web/src/components/domain/ActivityRow.tsx index bd7ff0f..45a1223 100644 --- a/apps/web/src/components/domain/ActivityRow.tsx +++ b/apps/web/src/components/domain/ActivityRow.tsx @@ -7,34 +7,21 @@ import { useNowTick } from '@/lib/useNowTick'; import { StatusBadge } from './StatusBadge'; export interface ActivityEvent { - /** Stable id for keying — DB row id (`db:`) or live event composite (`live:::`). */ + // `db:` or live composite `live:::`. id: string; kind: ForwardLogStatus; subscriptionId: number | null; subscriptionTitle: string | null; sourceHandle: string | null; destinationLabel: string | null; - /** Receive time (ms epoch) — used to compute relative `ago`. */ - occurredAt: number; + occurredAt: number; // ms epoch reasons?: string[]; - /** FloodWait seconds, when status='flood_wait'. */ seconds?: number; - /** Error string when status='failed'. */ error?: string | null; - /** Album: number of messages forwarded (>1 = album). */ - destMessageCount?: number; - /** Set true on a freshly-arrived live event for the flash animation. */ + destMessageCount?: number; // >1 = album isNew?: boolean; - /** - * `forward_log` row id for fetching the raw JSON payload. Set on hydrated - * rows and on live events whose SSE frame carried `forwardLogIds`. - */ forwardLogId?: number; - /** - * Whether the row has a stored raw payload — drives visibility of the - * "{}" button. Live events default to `true` since capture happens - * before the SSE emit. - */ + // Live events default true: capture precedes the SSE emit. hasRawMessage?: boolean; } @@ -128,9 +115,7 @@ function RelativeTime({ occurredAt }: RelativeTimeProps) { } interface ParsedReason { - /** Library filter name when present (the part between `library:` and the next `:`). */ library: string | null; - /** The remainder — typically `: `. */ text: string; } diff --git a/apps/web/src/components/domain/DestinationOption.tsx b/apps/web/src/components/domain/DestinationOption.tsx index 714603e..7bc7917 100644 --- a/apps/web/src/components/domain/DestinationOption.tsx +++ b/apps/web/src/components/domain/DestinationOption.tsx @@ -4,12 +4,6 @@ import type { DestinationDto } from '@tg-feed/shared'; import { cn } from '@/lib/cn'; import { EntityIcon } from '@/components/domain/EntityIcon'; -/** - * Selectable destination radio, shared by the subscription edit sheet and the - * quick destination-picker sheet. The secondary line carries the chat id, the - * forum topic (when set — this is what distinguishes two destinations that - * point at the same forum but different topics), and the note. - */ export interface DestinationOptionProps { destination: DestinationDto; selected: boolean; diff --git a/apps/web/src/components/domain/EntityIcon.tsx b/apps/web/src/components/domain/EntityIcon.tsx index 4327ac2..497f842 100644 --- a/apps/web/src/components/domain/EntityIcon.tsx +++ b/apps/web/src/components/domain/EntityIcon.tsx @@ -16,18 +16,10 @@ export type EntityIconSize = 'md' | 'sm'; export type EntityIconVariant = 'default' | 'active'; export interface EntityIconProps { - /** - * Base64 data URL for the channel/chat profile photo, or null when not - * yet fetched. When null, the lucide fallback icon is rendered instead. - */ iconDataUrl: string | null; fallback: EntityIconFallback; size?: EntityIconSize; - /** - * `active` flips the surface-2 / muted-text container to accent colors - * (used by SubRow when a subscription is expanded). Only applied to the - * fallback path — when a photo is present it fills the container. - */ + // `active` paints the fallback container with accent colors; no effect when a photo is present. variant?: EntityIconVariant; className?: string; } diff --git a/apps/web/src/components/domain/FilterRow.tsx b/apps/web/src/components/domain/FilterRow.tsx index ea34779..1233cad 100644 --- a/apps/web/src/components/domain/FilterRow.tsx +++ b/apps/web/src/components/domain/FilterRow.tsx @@ -11,13 +11,11 @@ import { Toggle } from '@/components/settings/primitives'; export interface FilterRowProps { filter: FilterLike & { id: number; enabled?: boolean; name?: string | null }; - /** Render a "Library" badge + accent icon styling. */ library?: boolean; - /** Toggle handler — only shown when set. */ onToggle?: () => void; onEdit?: () => void; onDelete?: () => void; - /** Optional label override for the delete button (e.g. "Detach"). */ + // Delete-button label override, e.g. "Detach". deleteLabel?: string; } diff --git a/apps/web/src/components/domain/JsonViewSheet.tsx b/apps/web/src/components/domain/JsonViewSheet.tsx index 8802e69..46e5003 100644 --- a/apps/web/src/components/domain/JsonViewSheet.tsx +++ b/apps/web/src/components/domain/JsonViewSheet.tsx @@ -1,18 +1,3 @@ -/** - * Sheet wrapper that surfaces the raw gramjs JSON for a `forward_log` row. - * - * Triggered from `ActivityRow`'s "{}" button. The actual JSON isn't on the - * list response — the Sheet fetches `GET /forward-log/:id/raw` via - * `useForwardLogRaw` so we don't bloat hydration. Rendering goes through - * `react-json-view-lite` for free collapsible nodes — essential for album - * payloads (JSON array of N nested gramjs `Message` objects). - * - * Theming: the library ships its own CSS module classes (which carry the - * expand/collapse glyphs as `::after` content), and exposes the per-token - * class names via `defaultStyles`. We spread those defaults and append our - * own Tailwind colour tokens with `!important` so the palette follows our - * oklch CSS variables (dark + light theme are handled automatically). - */ import { useEffect, useMemo, useState } from 'react'; import { AlertTriangle, Check, Copy } from 'lucide-react'; import { JsonView, defaultStyles } from 'react-json-view-lite'; @@ -28,47 +13,25 @@ interface JsonViewSheetProps { onOpenChange: (open: boolean) => void; } -/** - * Compose a class string for one of the library's per-token style slots. - * Keeps the library's structural class (margins, expand-glyph `::after`, - * etc.) and stacks our coloured token with `!` so Tailwind wins regardless - * of stylesheet order. - */ +// Keep the library's structural class, stack our coloured token with `!` so Tailwind wins regardless of stylesheet order. function withTone(libraryClass: string, ...tones: string[]): string { return [libraryClass, ...tones].filter(Boolean).join(' '); } -/** - * Expand only the root and its immediate children by default — anything - * deeper stays collapsed behind a `{...}` placeholder. Albums (root is an - * array of `Message` objects) then surface each member's existence on first - * paint, but keep their internals folded so the panel doesn't dump - * hundreds of lines on open. Single-message payloads similarly show the - * top-level fields but collapse nested objects like `media`, `fwdFrom`. - * - * `level === 0` is the root call; children increment from there, so - * `level < 2` covers root + first nesting level. - */ +// Root + immediate children only; keeps album payloads from dumping hundreds of lines on open. const expandFirstLevelOnly = (level: number): boolean => level < 2; const jsonViewStyles = { ...defaultStyles, - // Containers don't need recolouring — they inherit from
.
   container: withTone(defaultStyles.container, 'bg-transparent'),
-  // Keys (object field names).
   label: withTone(defaultStyles.label, '!text-accent'),
   clickableLabel: withTone(defaultStyles.clickableLabel, '!text-accent'),
-  // Value types.
   stringValue: withTone(defaultStyles.stringValue, '!text-success'),
   numberValue: withTone(defaultStyles.numberValue, '!text-warning'),
-  // true / false / null share the danger tone so they stand out from
-  // strings and numbers when skimming a deep payload.
   booleanValue: withTone(defaultStyles.booleanValue, '!text-danger'),
   nullValue: withTone(defaultStyles.nullValue, '!text-danger'),
   undefinedValue: withTone(defaultStyles.undefinedValue, '!text-text-muted', 'italic'),
   otherValue: withTone(defaultStyles.otherValue, '!text-text-muted'),
-  // Structural marks (commas, braces, the "..." collapsed indicator, and
-  // the expand/collapse triangles) all share the muted tone.
   punctuation: withTone(defaultStyles.punctuation, '!text-text-muted'),
   expandIcon: withTone(defaultStyles.expandIcon, '!text-text-muted'),
   collapseIcon: withTone(defaultStyles.collapseIcon, '!text-text-muted'),
@@ -86,7 +49,6 @@ export function JsonViewSheet({ open, forwardLogId, onOpenChange }: JsonViewShee
   }, [copied]);
 
   const raw = query.data?.rawMessage ?? null;
-  // JSON.stringify of potentially-large album payloads (array of gramjs Messages).
   const formatted = useMemo(() => (raw !== null ? JSON.stringify(raw, null, 2) : null), [raw]);
 
   const onCopy = async () => {
@@ -95,8 +57,7 @@ export function JsonViewSheet({ open, forwardLogId, onOpenChange }: JsonViewShee
       await navigator.clipboard.writeText(formatted);
       setCopied(true);
     } catch {
-      // Clipboard API can fail in non-secure contexts — silently ignore;
-      // the user can still select + copy from the rendered tree by hand.
+      // Clipboard API can fail in non-secure contexts; user can still select + copy by hand.
     }
   };
 
@@ -138,8 +99,7 @@ export function JsonViewSheet({ open, forwardLogId, onOpenChange }: JsonViewShee
           />
         
) : raw !== null ? ( - // Edge case: a stored primitive (string/number/null wrapper). The - // library only renders objects/arrays — fall back to plain text. + // Stored primitive: the library only renders objects/arrays, so fall back to plain text.
           {JSON.stringify(raw, null, 2)}
         
diff --git a/apps/web/src/components/domain/Logo.tsx b/apps/web/src/components/domain/Logo.tsx index cb3f6ac..48af098 100644 --- a/apps/web/src/components/domain/Logo.tsx +++ b/apps/web/src/components/domain/Logo.tsx @@ -2,9 +2,6 @@ import type { ReactNode } from 'react'; import type { LucideIcon } from 'lucide-react'; import { cn } from '@/lib/cn'; -/** - * Logo glyph — the design's hand-rolled SVG path. Sized by parent. - */ export interface LogoProps { size?: number; className?: string; @@ -65,7 +62,6 @@ export function LogoBadge({ size = 16, className }: LogoBadgeProps) { ); } -/** Same badge frame as the brand logo, but renders an arbitrary lucide icon. */ export interface IconBadgeProps { icon: LucideIcon; size?: number; diff --git a/apps/web/src/components/domain/RuleForm.tsx b/apps/web/src/components/domain/RuleForm.tsx index e2dd1c0..91e8b11 100644 --- a/apps/web/src/components/domain/RuleForm.tsx +++ b/apps/web/src/components/domain/RuleForm.tsx @@ -1,11 +1,3 @@ -/** - * Per-rule param form. Matches the design's RuleForm — one input set per - * rule type. Renders into the FilterSheet below the rule header card. - * - * The form is uncontrolled-ish: it reads + writes a single `params` object - * whose shape is the rule's param schema. The parent owns the params - * state. Validation happens at submit time via the shared zod schema. - */ import { X } from 'lucide-react'; import { type KeyboardEvent } from 'react'; import { filterRuleDefaultParams, type FilterMode, type FilterRuleType } from '@tg-feed/shared'; @@ -17,14 +9,9 @@ interface RuleFormProps { type: FilterRuleType; params: Record; setParams: (next: Record) => void; - /** - * `mode` toggle — shown when both `mode` and `setMode` are provided. When - * absent, callers (older flows that haven't been updated) get the legacy - * include-only behavior implicitly via the server default. - */ + // Shown only with setMode; else server default (include-only) applies. mode?: FilterMode; setMode?: (next: FilterMode) => void; - /** Library filters add a Name field at the top of the form. */ showName?: boolean; name?: string; setName?: (next: string) => void; @@ -128,8 +115,7 @@ export function RuleForm({ { v: true, l: 'Message has media' }, { v: false, l: 'Message has NO media' }, ].map((o) => { - // params.required: true=must have, false=must NOT have. Treat - // undefined as the default (true). + // undefined defaults to required (has media). const current = params.required !== false; const isThis = current === o.v; return ( diff --git a/apps/web/src/components/domain/SubRow.tsx b/apps/web/src/components/domain/SubRow.tsx index 915cdfa..e498e25 100644 --- a/apps/web/src/components/domain/SubRow.tsx +++ b/apps/web/src/components/domain/SubRow.tsx @@ -102,10 +102,7 @@ interface NoForwardsBadgeProps { } function NoForwardsBadge({ at }: NoForwardsBadgeProps) { - // Telegram returns CHAT_FORWARDS_RESTRICTED when the source channel has - // "Restrict Saving Content" enabled. The forwarder stamps this timestamp; - // it stays set until a forward succeeds again. Surfacing it inline tells - // the operator their messages aren't reaching the destination. + // Set on CHAT_FORWARDS_RESTRICTED; cleared when a forward next succeeds. return (
diff --git a/apps/web/src/components/settings/AppearanceSection.tsx b/apps/web/src/components/settings/AppearanceSection.tsx index c6eb91b..f48c19f 100644 --- a/apps/web/src/components/settings/AppearanceSection.tsx +++ b/apps/web/src/components/settings/AppearanceSection.tsx @@ -1,7 +1,4 @@ -/** - * Settings → Appearance card. Theme preference (system / light / dark); - * applies instantly, so there's no Save / Reset footer. - */ +// Theme applies instantly, so there's no Save / Reset footer. import { Monitor, Moon, Palette, Sun } from 'lucide-react'; import { useThemeContext } from '@/lib/ThemeProvider'; import { type ThemePreference } from '@/lib/useTheme'; diff --git a/apps/web/src/components/settings/DataSection.tsx b/apps/web/src/components/settings/DataSection.tsx index b893320..f063dd0 100644 --- a/apps/web/src/components/settings/DataSection.tsx +++ b/apps/web/src/components/settings/DataSection.tsx @@ -1,16 +1,4 @@ -/** - * Settings → Data section. - * - * Two cards: - * 1. Import / Export — two buttons that open the `ExportSheet` / `ImportSheet` - * flows. - * 2. Danger zone — a single button that opens the `WipeSheet` flow. - * - * Each flow lives in its own file under `./data/`; this entry just renders the - * cards and mounts the sheets. The whole feature is offline-safe — no Telegram - * dependencies. Imports never call joinChannel; the access monitor's sweep - * refreshes status afterwards. - */ +// Offline-safe: imports never call joinChannel; the access monitor's sweep refreshes status afterwards. import { useState } from 'react'; import { AlertTriangle, Database, Download, Trash, Upload } from 'lucide-react'; import { Button } from '@/components/ui/button'; diff --git a/apps/web/src/components/settings/ForwardingSection.tsx b/apps/web/src/components/settings/ForwardingSection.tsx index c695461..ea093a8 100644 --- a/apps/web/src/components/settings/ForwardingSection.tsx +++ b/apps/web/src/components/settings/ForwardingSection.tsx @@ -1,11 +1,3 @@ -/** - * Settings → Forwarding card. - * - * Global pipeline knobs stored in the 'global' app_settings row: the inter-send - * throttle delay and the album-debounce window. Both are number input + range - * slider; sub-spam-threshold delays surface a warning. Saved together via one - * Reset / Save, sending only the changed knob. - */ import { useEffect, useState } from 'react'; import { AlertTriangle, Check, Timer } from 'lucide-react'; import { Button } from '@/components/ui/button'; @@ -48,8 +40,7 @@ export function ForwardingSection() { const delayDirty = draft !== data.delayMs; const albumDirty = albumDraft !== data.albumDebounceMs; const dirty = delayDirty || albumDirty; - // A cleared field coerces to 0; block saving a non-positive value (the - // server's .positive() schema rejects it) and flag the field instead. + // Cleared field coerces to 0; block non-positive (server's .positive() rejects it) and flag instead. const delayValid = Number.isInteger(draft) && draft > 0; const albumValid = Number.isInteger(albumDraft) && albumDraft > 0; const canSave = dirty && delayValid && albumValid && !updateMut.isPending; diff --git a/apps/web/src/components/settings/TelegramAccountSection.tsx b/apps/web/src/components/settings/TelegramAccountSection.tsx index 76f58b3..2019ace 100644 --- a/apps/web/src/components/settings/TelegramAccountSection.tsx +++ b/apps/web/src/components/settings/TelegramAccountSection.tsx @@ -1,11 +1,3 @@ -/** - * Settings → Telegram Account Connection card. - * - * The userbot (gramjs) account that does the forwarding. A status pill in the - * header reflects the live Telegram connection; the body + footer switch on - * state: sign in (phone-code or paste-session), sign out, env-fallback upgrade, - * or a key-fingerprint-mismatch warning. Sign-in is gated on an encryption key. - */ import { useState } from 'react'; import { AlertTriangle, KeyRound, type LucideIcon, LogIn, LogOut, Plug, User } from 'lucide-react'; import { Button } from '@/components/ui/button'; @@ -42,8 +34,7 @@ export function TelegramAccountSection() { onError: (err) => toast.error(apiErrorMessage(err, 'Sign out failed')), }); - // Pick the status pill + body. Order matters: a key mismatch is the most - // actionable signal even when the env fallback keeps us connected. + // Order matters: key mismatch wins even when the env fallback keeps us connected. let pill = offline; let body: React.ReactNode; let footer: React.ReactNode; @@ -104,7 +95,6 @@ export function TelegramAccountSection() { /> ); } else { - // Disconnected (state='disconnected' or status missing). body = ( {tg?.reason ?? 'Telegram client is not available.'} Subscribing and forwarding are diff --git a/apps/web/src/components/settings/TelegramLoginSheet.tsx b/apps/web/src/components/settings/TelegramLoginSheet.tsx index 73c7441..ae360df 100644 --- a/apps/web/src/components/settings/TelegramLoginSheet.tsx +++ b/apps/web/src/components/settings/TelegramLoginSheet.tsx @@ -1,16 +1,4 @@ -/** - * Multi-step Telegram sign-in wizard rendered inside a Sheet. - * - * Steps: - * mode → phone → code → ?2fa → done - * ↘ raw ↗ - * - * The `sessionId` returned from /login/start is held in component state and - * passed to subsequent /verify and /password calls so the server can keep - * the temp client alive across HTTP boundaries. Closing the sheet (or - * clicking Cancel) fires /login/cancel as a best-effort tear-down so the - * server doesn't have to wait for the TTL GC. - */ +// sessionId threads through /verify and /password so the server keeps the temp client alive across HTTP calls; Cancel fires /login/cancel for best-effort teardown. import { useEffect, useMemo, useRef, useState } from 'react'; import { AlertTriangle, @@ -81,7 +69,7 @@ export function TelegramLoginSheet({ open, onClose }: TelegramLoginSheetProps) { const isPending = start.isPending || verifyCode.isPending || verifyPassword.isPending || loginRaw.isPending; - // Reset internal state when reopening so a stale step doesn't flash. + // Reset on reopen so a stale step doesn't flash. useEffect(() => { if (open) { setState(INITIAL); @@ -324,8 +312,6 @@ interface PrimaryActionProps { onClick: () => void; } -// Per-step primary-button config in one place: label (idle/pending), which -// FlowState field gates the button, and the minimum length to enable it. // Passwords aren't trimmed (spaces can be significant); the rest are. const STEP_ACTIONS: Record< Exclude, diff --git a/apps/web/src/components/settings/bot/AdminLookup.tsx b/apps/web/src/components/settings/bot/AdminLookup.tsx index 17157f0..d90f166 100644 --- a/apps/web/src/components/settings/bot/AdminLookup.tsx +++ b/apps/web/src/components/settings/bot/AdminLookup.tsx @@ -1,9 +1,3 @@ -/** - * Admin allowlist lookup: a debounced search that resolves a `@username` / - * t.me link / numeric id to a Telegram user and offers it for adding to the - * bot's admin set. Self-contained — owns its own query state and resolve - * mutation; the resolved entry is handed back to the parent via `onAdd`. - */ import { useState } from 'react'; import { Plus, Search } from 'lucide-react'; import type { BotAdmin } from '@tg-feed/shared'; diff --git a/apps/web/src/components/settings/bot/DigestSection.tsx b/apps/web/src/components/settings/bot/DigestSection.tsx index 346df4f..9ac0708 100644 --- a/apps/web/src/components/settings/bot/DigestSection.tsx +++ b/apps/web/src/components/settings/bot/DigestSection.tsx @@ -1,10 +1,3 @@ -/** - * The "Stats digest" panel of the Bot settings card: an enable toggle plus an - * inline-sentence schedule (frequency / day / time) and the captured browser - * time zone. Presentational — drafts and setters are owned by the card. The - * `botReady` flag drives the "add a token + admin first" warning; the time - * zone is display-only (captured on save, no picker). - */ import { AlertTriangle } from 'lucide-react'; import type { StatsDigestFrequency } from '@tg-feed/shared'; import { Hint } from '@/components/ui/input'; @@ -36,7 +29,6 @@ export interface DigestSectionProps { time: string; onTimeChange: (next: string) => void; localTz: string; - /** A token + at least one admin are present, so the digest can be delivered. */ botReady: boolean; } diff --git a/apps/web/src/components/settings/bot/useBotSettingsDraft.ts b/apps/web/src/components/settings/bot/useBotSettingsDraft.ts index d1acee5..b8a36ea 100644 --- a/apps/web/src/components/settings/bot/useBotSettingsDraft.ts +++ b/apps/web/src/components/settings/bot/useBotSettingsDraft.ts @@ -1,29 +1,15 @@ -/** - * Owns the editable drafts behind the Bot settings card — both halves: the - * bot-connection fields (token / admins / public URL) and the stats-digest - * schedule. Seeds them from the server payloads and exposes a single - * `seedFromServer` so the seeding effects and the Reset action share one field - * list (no drift between "what gets seeded" and "what gets reset"). - * - * Called unconditionally (before the card's loading/error guards), so the - * server payloads may be undefined on the first renders; the seeding effects - * tolerate that exactly as the inline versions did. Dirty derivation and save - * orchestration stay in the card, next to the mutations they drive — this hook - * only owns the draft values and their setters. - */ +// Called before the card's loading/error guards, so server payloads may be undefined on first renders and the seeding effects tolerate that. import { useEffect, useMemo, useState, type Dispatch, type SetStateAction } from 'react'; import type { BotAdmin, BotConfigInfo, SettingsDto, StatsDigestFrequency } from '@tg-feed/shared'; import { localTimeZone } from './utils'; export interface BotSettingsDraft { - // Bot-config drafts. tokenDraft: string; setTokenDraft: (value: string) => void; admins: BotAdmin[]; setAdmins: Dispatch>; urlDraft: string; setUrlDraft: (value: string) => void; - // Digest drafts. enabled: boolean; setEnabled: (value: boolean) => void; frequency: StatsDigestFrequency; @@ -33,13 +19,9 @@ export interface BotSettingsDraft { time: string; setTime: (value: string) => void; - /** Browser time zone the digest time is read in; captured once. */ localTz: string; - /** - * Re-seed every draft from the latest server payloads (used by Reset). A - * no-op until both payloads have loaded, matching the card's guards. - */ + // No-op until both payloads have loaded, matching the card's guards. seedFromServer: () => void; } @@ -49,18 +31,15 @@ export function useBotSettingsDraft( ): BotSettingsDraft { const localTz = useMemo(localTimeZone, []); - // Bot-config drafts. const [tokenDraft, setTokenDraft] = useState(''); const [admins, setAdmins] = useState([]); const [urlDraft, setUrlDraft] = useState(''); - // Digest drafts. const [enabled, setEnabled] = useState(false); const [frequency, setFrequency] = useState('daily'); const [dayOfWeek, setDayOfWeek] = useState(1); const [time, setTime] = useState('09:00'); - // Stable keys so the seeding effects only re-run when the underlying server - // values actually change (not on every cache reference swap). + // Stable keys so seeding re-runs on value changes, not every cache reference swap. const adminsJson = JSON.stringify(data?.admins ?? []); const urlKey = data?.publicUrl ?? ''; diff --git a/apps/web/src/components/settings/bot/utils.ts b/apps/web/src/components/settings/bot/utils.ts index 0c4dc20..7ad9964 100644 --- a/apps/web/src/components/settings/bot/utils.ts +++ b/apps/web/src/components/settings/bot/utils.ts @@ -1,7 +1,3 @@ -/** - * Small pure helpers shared across the Bot settings card and its sub-pieces - * (the connection/digest sections, the draft hook, and the admin lookup). - */ import type { BotAdmin } from '@tg-feed/shared'; export function isValidUrl(value: string): boolean { diff --git a/apps/web/src/components/settings/data/ExportSheet.tsx b/apps/web/src/components/settings/data/ExportSheet.tsx index 52688b9..09ab9d1 100644 --- a/apps/web/src/components/settings/data/ExportSheet.tsx +++ b/apps/web/src/components/settings/data/ExportSheet.tsx @@ -1,8 +1,3 @@ -/** - * Export sheet — section checkboxes that trigger a JSON file download - * (Blob + anchor click) of the selected sections. Versioned for forward / - * backward compatibility via the shared `exportFileSchema`. - */ import { useState } from 'react'; import { Download } from 'lucide-react'; import { EXPORT_SECTIONS, type ExportSection } from '@tg-feed/shared'; diff --git a/apps/web/src/components/settings/data/ImportSheet.tsx b/apps/web/src/components/settings/data/ImportSheet.tsx index 87c3e3d..b5a2f26 100644 --- a/apps/web/src/components/settings/data/ImportSheet.tsx +++ b/apps/web/src/components/settings/data/ImportSheet.tsx @@ -1,10 +1,3 @@ -/** - * Import sheet — file picker that validates against the shared - * `exportFileSchema`, shows a preview, and lets the user pick which sections - * to apply plus a conflict strategy (skip / replace) before POSTing to - * /api/system/import. Imports never touch Telegram; the access monitor's - * sweep refreshes channel status afterwards. - */ import { useMemo, useRef, useState } from 'react'; import { AlertTriangle, Check, Upload, X } from 'lucide-react'; import { diff --git a/apps/web/src/components/settings/data/WipeSheet.tsx b/apps/web/src/components/settings/data/WipeSheet.tsx index 02ca1a7..232e4af 100644 --- a/apps/web/src/components/settings/data/WipeSheet.tsx +++ b/apps/web/src/components/settings/data/WipeSheet.tsx @@ -1,8 +1,3 @@ -/** - * Wipe sheet — checkboxes per wipeable section, gated on a typed-in - * confirmation phrase, then POSTs to /api/system/wipe. Destructive and - * irreversible, so the confirm button stays disabled until the phrase matches. - */ import { useState } from 'react'; import { Trash } from 'lucide-react'; import { WIPE_SECTIONS, type WipeSection } from '@tg-feed/shared'; diff --git a/apps/web/src/components/settings/data/shared.ts b/apps/web/src/components/settings/data/shared.ts index 0417947..ff22a45 100644 --- a/apps/web/src/components/settings/data/shared.ts +++ b/apps/web/src/components/settings/data/shared.ts @@ -1,9 +1,3 @@ -/** - * Shared constants, types, and helpers for the Settings → Data sheets - * (export / import / wipe). Kept in one module so the section labels and the - * "which sections does this file carry" logic stay in a single place instead - * of being re-derived in each sheet. - */ import { EXPORT_SECTIONS, type ExportFile, type ExportSection } from '@tg-feed/shared'; export const SECTION_LABELS: Record = { @@ -29,16 +23,11 @@ export interface ParsedFile { fileName: string; } -/** - * Sections actually carried by an export file, in the canonical - * `EXPORT_SECTIONS` order so checkbox lists and result rows render - * consistently regardless of the file's key order. - */ +// Present sections in canonical EXPORT_SECTIONS order, ignoring the file's key order. export function presentSections(file: ExportFile): ExportSection[] { return EXPORT_SECTIONS.filter((s) => file[s] != null); } -/** Return a new Set with `value` toggled in/out — for checkbox selection state. */ export function toggleInSet(set: Set, value: T): Set { const next = new Set(set); if (next.has(value)) next.delete(value); diff --git a/apps/web/src/components/settings/primitives.tsx b/apps/web/src/components/settings/primitives.tsx index 5770a6c..9eca08c 100644 --- a/apps/web/src/components/settings/primitives.tsx +++ b/apps/web/src/components/settings/primitives.tsx @@ -1,12 +1,3 @@ -/** - * Shared building blocks for the Settings page cards, ported from the design - * direction (project/styles.css + screen-settings.jsx) onto our Tailwind - * tokens. Every Settings panel is a self-titled card: a `CardHeader` (icon - * badge + title + optional status pill / toggle), a body, and an optional - * `CardFooter` (Reset / Save). Inside the body, `PanelSection` groups related - * fields under an uppercase label, and `FieldHead` pairs a field label with a - * `SourceBadge` (database vs .env). - */ import type { ReactNode } from 'react'; import { ChevronDown, Lock, Zap } from 'lucide-react'; import type { BotConfigSource } from '@tg-feed/shared'; @@ -36,7 +27,6 @@ export interface CardHeaderProps { right?: ReactNode; } -/** Card header: icon badge + title on the left, an optional pill/toggle on the right. */ export function CardHeader({ icon, title, right }: CardHeaderProps) { return (
@@ -56,7 +46,6 @@ export interface CardFooterProps { children: ReactNode; } -/** Card footer on a tinted bar. `left` is pinned to the start (e.g. a danger reset). */ export function CardFooter({ left, children }: CardFooterProps) { return (
@@ -72,7 +61,6 @@ export interface PanelSectionProps { children: ReactNode; } -/** A labelled group within a card body; `right` sits opposite the label (e.g. a toggle). */ export function PanelSection({ label, right, children }: PanelSectionProps) { return (
@@ -92,7 +80,6 @@ export interface FieldHeadProps { source?: BotConfigSource | null; } -/** Field label paired with its source badge. */ export function FieldHead({ label, source }: FieldHeadProps) { return (
@@ -106,7 +93,6 @@ export interface SourceBadgeProps { source: BotConfigSource | null; } -/** Small uppercase pill showing where a value resolves from: database / .env / not set. */ export function SourceBadge({ source }: SourceBadgeProps) { if (!source) return not set; const isDb = source === 'db'; @@ -131,7 +117,6 @@ export interface ToggleProps { disabled?: boolean; } -/** Accessible on/off switch matching the design's `.toggle`. */ export function Toggle({ checked, onChange, label, disabled }: ToggleProps) { return (