Files
homefeed/backend/src/storage/db/articles.ts
T
Claude f8072ab339 Build out a functional Telegram adapter, rendered like the tweet card
Logs into a real Telegram account via MTProto (GramJS) rather than a bot,
so it can read any public channel's history. API ID/hash and the login
session are entered through the admin Connections panel and stored
encrypted at rest (new storage/crypto.ts AES-256-GCM helper) rather than
via .env. Messages render as their own TelegramCard (same treatment as
tweets) and open the original message on Telegram instead of an internal
article page; attached media/albums are downloaded and self-hosted at
ingestion time since Telegram has no public hotlinkable media URL.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014c1L8ghNBFjfiH64UMViP8
2026-07-23 20:23:28 +00:00

169 lines
5.7 KiB
TypeScript

import { randomUUID } from 'node:crypto';
import { db } from './index.js';
import type { MergedArticle } from './types.js';
import { listPrivateCategoryNames } from './categories.js';
function rowToArticle(row: any): MergedArticle {
return {
id: row.id,
title: row.title,
body: row.body,
heroImage: row.hero_image ? JSON.parse(row.hero_image) : null,
video: row.video ? JSON.parse(row.video) : null,
category: JSON.parse(row.category),
geo: row.geo,
eventId: row.event_id,
sourceCount: row.source_count,
sources: JSON.parse(row.sources),
publishedAt: row.published_at,
updatedAt: row.updated_at,
mergeConfidence: row.merge_confidence,
tags: JSON.parse(row.tags),
threadId: row.thread_id,
previousArticleId: row.previous_article_id,
nextArticleId: row.next_article_id,
topStories: !!row.top_stories,
tweet: row.tweet ? JSON.parse(row.tweet) : null,
telegramMessage: row.telegram_message ? JSON.parse(row.telegram_message) : null
};
}
export function insertArticle(article: Omit<MergedArticle, 'id'>): MergedArticle {
const id = `art-${randomUUID()}`;
db.prepare(
`INSERT INTO merged_articles
(id, title, body, hero_image, video, category, geo, event_id, source_count, sources, published_at, updated_at, merge_confidence, tags, thread_id, previous_article_id, next_article_id, top_stories, tweet, telegram_message)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
id,
article.title,
article.body,
article.heroImage ? JSON.stringify(article.heroImage) : null,
article.video ? JSON.stringify(article.video) : null,
JSON.stringify(article.category),
article.geo,
article.eventId,
article.sourceCount,
JSON.stringify(article.sources),
article.publishedAt,
article.updatedAt,
article.mergeConfidence,
JSON.stringify(article.tags),
article.threadId,
article.previousArticleId,
article.nextArticleId,
article.topStories ? 1 : 0,
article.tweet ? JSON.stringify(article.tweet) : null,
article.telegramMessage ? JSON.stringify(article.telegramMessage) : null
);
if (article.previousArticleId) {
db.prepare('UPDATE merged_articles SET next_article_id = ? WHERE id = ?').run(id, article.previousArticleId);
}
return { ...article, id };
}
export function getArticle(id: string): MergedArticle | null {
const row = db.prepare('SELECT * FROM merged_articles WHERE id = ?').get(id);
return row ? rowToArticle(row) : null;
}
/** Unpaginated — for internal use (retention sweep), not the public feed API which caps page size. */
export function allArticlesNewestFirst(): MergedArticle[] {
const rows = db.prepare('SELECT * FROM merged_articles ORDER BY published_at DESC').all();
return rows.map(rowToArticle);
}
export function queryFeed(
filters: {
category?: string;
geo?: string;
eventId?: string;
tag?: string;
before?: string;
limit?: number;
},
includePrivate = false
): MergedArticle[] {
let sql = 'SELECT * FROM merged_articles WHERE 1=1';
const params: unknown[] = [];
// Without a valid private-access cookie, an article belonging to ANY private
// category is excluded entirely — including from a public category it's also
// tagged with, so a private source can't leak in sideways through a shared tag.
if (!includePrivate) {
for (const name of listPrivateCategoryNames()) {
sql += ' AND category NOT LIKE ?';
params.push(`%"${name}"%`);
}
}
// The bare feed (no category/geo/eventId/tag — i.e. the homepage/"Top stories") only
// shows articles whose contributing source(s) opted into "Push to Top Stories?" —
// otherwise every ingested article from every source would flood the homepage.
// Any explicit filter (a real category page, Local's geo filter, a tag or event page)
// is unaffected — those show everything matching, regardless of this flag.
const isHomepage = !filters.category && !filters.geo && !filters.eventId && !filters.tag;
if (isHomepage) {
sql += ' AND top_stories = 1';
}
if (filters.category) {
sql += ' AND category LIKE ?';
params.push(`%"${filters.category}"%`);
}
if (filters.geo) {
sql += ' AND geo = ?';
params.push(filters.geo);
}
if (filters.eventId) {
sql += ' AND event_id = ?';
params.push(filters.eventId);
}
if (filters.tag) {
sql += ' AND tags LIKE ?';
params.push(`%"${filters.tag}"%`);
}
if (filters.before) {
sql += ' AND published_at < ?';
params.push(filters.before);
}
sql += ' ORDER BY published_at DESC LIMIT ?';
params.push(Math.min(filters.limit ?? 15, 50));
const rows = db.prepare(sql).all(...(params as any[]));
return rows.map(rowToArticle);
}
export function latestArticleInThread(threadId: string): MergedArticle | null {
const row = db
.prepare('SELECT * FROM merged_articles WHERE thread_id = ? ORDER BY published_at DESC LIMIT 1')
.get(threadId);
return row ? rowToArticle(row) : null;
}
export function articlesOlderThan(days: number): MergedArticle[] {
const cutoff = new Date(Date.now() - days * 86_400_000).toISOString();
const rows = db.prepare('SELECT * FROM merged_articles WHERE published_at < ?').all(cutoff);
return rows.map(rowToArticle);
}
export function findRecentArticleByTags(tagIds: string[], sinceDays: number): MergedArticle | null {
if (tagIds.length === 0) return null;
const cutoff = new Date(Date.now() - sinceDays * 86_400_000).toISOString();
const rows = db
.prepare('SELECT * FROM merged_articles WHERE published_at > ? ORDER BY published_at DESC')
.all(cutoff);
for (const row of rows) {
const article = rowToArticle(row);
if (article.tags.some((t) => tagIds.includes(t))) return article;
}
return null;
}
export function deleteArticle(id: string) {
db.prepare('DELETE FROM merged_articles WHERE id = ?').run(id);
}
export function deleteAllArticles() {
db.prepare('DELETE FROM merged_articles').run();
}