import { Tweet, TwitterClient } from './twitter.ts'; import { SqliteStore, Store } from './store.ts'; import { StaticTokenProvider } from './token.ts'; import { timelineAsHTML, timelineAsJSON } from './format.ts'; import { z } from "https://deno.land/x/zod@v3.8.0/mod.ts"; const MAX_INT = "9223372036854775807"; class Server { constructor( readonly store: Store, readonly client: TwitterClient, ) {} async getLatestTweets(username: string, interval: number = 600): Promise { const storedAuthorId = this.store.getAuthorId(username); const latestTweet = storedAuthorId === null ? null : this.store.getLatestTweet(storedAuthorId); let authorId = storedAuthorId; let timeline = await this.client.getTimeline(username, latestTweet?.id_str); console.log(`Fetched ${timeline.length} new tweets`); for (const tweet of timeline) { this.store.saveTweet(tweet); } const newerThan = new Date(); newerThan.setSeconds(newerThan.getSeconds() - interval); if (timeline.length === 0) { if (storedAuthorId === null) { return []; } else { timeline = this.store.getLatestTweets(storedAuthorId, MAX_INT, newerThan); } } else { const lastTweet = timeline[timeline.length - 1]; const olderTweets = this.store.getLatestTweets(lastTweet.user.id_str, lastTweet.id_str, newerThan); timeline = timeline.concat(olderTweets); authorId = lastTweet.user.id_str; } timeline = timeline.filter(({ in_reply_to_user_id_str }) => { return in_reply_to_user_id_str === null || in_reply_to_user_id_str === authorId; }); return timeline; } async start() { const server = Deno.listen({ port: 8000 }); console.log("Server running at http://localhost:8000"); for await (const conn of server) { this.serveHttp(conn); } } async serveHttp(conn: Deno.Conn) { const httpConn = Deno.serveHttp(conn); for await (const requestEvent of httpConn) { this.handle(requestEvent) .then(response => requestEvent.respondWith(response)) .catch(err => console.error(err)); } } async handle(requestEvent: Deno.RequestEvent): Promise { const url = new URL(requestEvent.request.url); const path = url.pathname; if (path === '/__proxy') { const target = url.searchParams.get('target'); return this.handleProxy(requestEvent.request.headers, target); } else if (path.startsWith('/timeline/')) { const username = path.substring(10); return this.handleTimeline(username); } else if (path.startsWith('/json/')) { const username = path.substring(6); return this.handleJson(username); } else { return new Response('Not found', { status: 404, }); } } async handleTimeline(username: string): Promise { const tweets = await this.getLatestTweets(username, 24 * 60 * 60); console.time('renderHTML'); const html = timelineAsHTML(tweets); console.timeEnd('renderHTML'); return new Response(html, { status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' } }); } async handleJson(username: string): Promise { const tweets = await this.getLatestTweets(username, 24 * 60 * 60); console.time('renderJSON'); const html = timelineAsJSON(username, tweets); console.timeEnd('renderJSON'); return new Response(html, { status: 200, headers: { 'Content-Type': 'application/feed+json; charset=utf-8' } }); } async handleProxy(headers: Headers, target: string | null): Promise { if (target !== null) { console.log(`Proxying ${target}`); try { const proxyRequest = await fetch(target, { headers: headers, }); return new Response(proxyRequest.body, { status: proxyRequest.status, headers: proxyRequest.headers, }); } catch (e) { return new Response('Bad request', { status: 400, }); } } else { return new Response('Not found', { status: 404, }); } } } const ConfigSchema = z.strictObject({ bearer: z.string(), store: z.string(), }); const configFile = Deno.args.length === 0 ? 'config.json' : Deno.args[0]; const json = JSON.parse(Deno.readTextFileSync(configFile)); const config = ConfigSchema.parse(json); const fetcher = new Server( new SqliteStore(config.store), new TwitterClient(new StaticTokenProvider(config.bearer)), ); fetcher.start();