diff --git a/src/modules/discord-webhooks/formatter.ts b/src/modules/discord-webhooks/formatter.ts index f1a1147..4b82d01 100644 --- a/src/modules/discord-webhooks/formatter.ts +++ b/src/modules/discord-webhooks/formatter.ts @@ -5,7 +5,6 @@ import type { PatchesUpdate } from '../hytale-patches/tracker.js' import type { PresskitUpdate } from '../hytale-presskit/tracker.js' import type { ServerUpdate } from '../hytale-server/tracker.js' import type { DiscordEmbed } from './types.js' -import { getBlogUrl, getThumbnailUrl } from '../hytale-blog/tracker.js' const EMBED_COLOR = 0x64A7D1 const HYTALE_LOGO = 'https://files.femboy.page/hytale-logo.png' @@ -109,20 +108,11 @@ function formatBytes(bytes: number): string { } export function formatBlogEmbed(post: BlogPost): DiscordEmbed { - const url = getBlogUrl(post) - const imageUrl = getThumbnailUrl(post) - - const embed: DiscordEmbed = { + return { title: post.title, - description: `*by **${post.author}***\n\n[Read the full post](${url})`, + description: `[Read the full post](${post.link})`, color: EMBED_COLOR, footer: { text: 'Published' }, - timestamp: post.publishedAt, + timestamp: post.pubDate, } - - if (imageUrl) { - embed.image = { url: imageUrl } - } - - return embed } diff --git a/src/modules/hytale-blog/formatter.ts b/src/modules/hytale-blog/formatter.ts index 76fe050..1511e8f 100644 --- a/src/modules/hytale-blog/formatter.ts +++ b/src/modules/hytale-blog/formatter.ts @@ -1,7 +1,7 @@ import type { BlogPost } from './tracker.js' -function formatDate(isoDate: string): string { - const date = new Date(isoDate) +function formatDate(dateStr: string): string { + const date = new Date(dateStr) const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] const month = months[date.getUTCMonth()] const day = date.getUTCDate() @@ -22,8 +22,6 @@ export function formatBlogUpdate(post: BlogPost): string { return `📰 New Blog Post ${escapeHtml(post.title)} -By ${escapeHtml(post.author)} -Published: ${formatDate(post.publishedAt)} -Created: ${formatDate(post.createdAt)}` +Published: ${formatDate(post.pubDate)}` } diff --git a/src/modules/hytale-blog/index.ts b/src/modules/hytale-blog/index.ts index 7a95858..375779c 100644 --- a/src/modules/hytale-blog/index.ts +++ b/src/modules/hytale-blog/index.ts @@ -1,99 +1,38 @@ -import type { Module, ModuleDependencies } from '../../core/module.js' +import type { ModuleDependencies } from '../../core/module.js' import type { HytaleBlogConfig } from '../../types.js' +import { createPollingModule } from '../../core/create-polling-module.js' import { formatBlogEmbed } from '../discord-webhooks/formatter.js' import { formatBlogUpdate } from './formatter.js' -import { checkBlogUpdates, getBlogUrl, getThumbnailUrl } from './tracker.js' +import { checkBlogUpdates } from './tracker.js' export function hytaleBlogFactory( moduleConfig: unknown, deps: ModuleDependencies, -): Module { +) { const config = moduleConfig as HytaleBlogConfig - class HytaleBlogModule implements Module { - readonly name = 'hytale-blog' - private dependencies: ModuleDependencies - private config: HytaleBlogConfig - private running = false - private intervalId: NodeJS.Timeout | null = null + return createPollingModule('hytale-blog', config, deps, async () => { + const newPosts = await checkBlogUpdates(deps.stateStore) - constructor(cfg: HytaleBlogConfig, deps: ModuleDependencies) { - this.config = cfg - this.dependencies = deps - } + for (const post of newPosts) { + const buttons = [[{ text: 'Read the full post', url: post.link }]] - isRunning(): boolean { - return this.running - } + await deps.telegram.sendMessage({ + text: formatBlogUpdate(post), + chatIds: config.chatIds, + disableLinkPreview: false, + buttons, + }) - async start(): Promise { - if (this.running) - return - - this.dependencies.logger(this.name, `Starting (poll interval: ${this.config.pollIntervalMinutes}min)`) - - // Initial check - await this.check() - - // Start polling - this.intervalId = setInterval(() => { - this.check().catch((err) => { - this.dependencies.logger(this.name, `Check failed: ${err}`) - }) - }, this.config.pollIntervalMinutes * 60 * 1000) - - this.running = true - } - - async stop(): Promise { - if (!this.running) - return - - if (this.intervalId) { - clearInterval(this.intervalId) - this.intervalId = null - } - this.running = false - } - - private async check(): Promise { - const newPosts = await checkBlogUpdates(this.dependencies.stateStore) - - for (const post of newPosts) { - const message = formatBlogUpdate(post) - const thumbnail = getThumbnailUrl(post) - const blogUrl = getBlogUrl(post) - const buttons = [[{ text: 'Read the full post', url: blogUrl }]] - - if (thumbnail) { - await this.dependencies.telegram.sendPhoto({ - photoUrl: thumbnail, - caption: message, - chatIds: this.config.chatIds, - buttons, - }) - } - else { - await this.dependencies.telegram.sendMessage({ - text: message, - chatIds: this.config.chatIds, - disableLinkPreview: false, - buttons, - }) - } - - if (this.dependencies.webhooks) { - await this.dependencies.webhooks.send('blog', formatBlogEmbed(post)) - } - - this.dependencies.logger(this.name, `New post: ${post.title}`) + if (deps.webhooks) { + await deps.webhooks.send('blog', formatBlogEmbed(post)) } - if (newPosts.length === 0) { - this.dependencies.logger(this.name, 'No new posts') - } + deps.logger('hytale-blog', `new post: ${post.title}`) } - } - return new HytaleBlogModule(config, deps) + if (newPosts.length === 0) { + deps.logger('hytale-blog', 'no new posts') + } + }) } diff --git a/src/modules/hytale-blog/tracker.ts b/src/modules/hytale-blog/tracker.ts index 804a42b..299f5e9 100644 --- a/src/modules/hytale-blog/tracker.ts +++ b/src/modules/hytale-blog/tracker.ts @@ -1,72 +1,80 @@ import type { StateStore } from '../../core/state-store.js' export interface BlogPost { - _id: string title: string - author: string - slug: string - publishedAt: string - createdAt: string - coverImage?: { - variants: string[] - s3Key: string - } - bodyExcerpt: string + link: string + guid: string + description: string + pubDate: string } interface BlogState { - seenIds: string[] + seenGuids: string[] lastCheck: string } -const ENDPOINT = 'https://hytale.com/api/blog/post/published' +const RSS_URL = 'https://hytale.com/rss.xml' const STATE_KEY = 'hytale-blog' -const MAX_SEEN_IDS = 100 +const MAX_SEEN = 100 -export async function checkBlogUpdates(stateStore: StateStore): Promise { - const response = await fetch(`${ENDPOINT}?_=${Date.now()}`) - if (!response.ok) { - throw new Error(`Blog endpoint returned ${response.status}`) +function parseItems(xml: string): BlogPost[] { + const items: BlogPost[] = [] + const itemRegex = /([\s\S]*?)<\/item>/g + + for (const match of xml.matchAll(itemRegex)) { + const block = match[1] + const tag = (name: string) => + block.match(new RegExp(`<${name}>([\\s\\S]*?)<\\/${name}>`))?.[1]?.trim() ?? '' + + items.push({ + title: unescape(tag('title')), + link: tag('link'), + guid: tag('guid'), + description: unescape(tag('description')), + pubDate: tag('pubDate'), + }) } - const posts = await response.json() as BlogPost[] - const state = stateStore.get(STATE_KEY) - const seenIds = new Set(state?.seenIds ?? []) + return items +} + +function unescape(text: string): string { + return text + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/'/g, '\'') + .replace(/"/g, '"') +} + +export async function checkBlogUpdates(stateStore: StateStore): Promise { + const response = await fetch(RSS_URL, { + headers: { 'user-agent': 'HytaleSays/1.0' }, + }) + if (!response.ok) { + throw new Error(`rss feed returned ${response.status}`) + } + + const xml = await response.text() + const posts = parseItems(xml) + const state = stateStore.get(STATE_KEY) + const seenGuids = new Set(state?.seenGuids ?? []) - // First run: mark all current posts as seen, don't notify if (!state) { - const allIds = posts.map(p => p._id).slice(0, MAX_SEEN_IDS) - stateStore.set(STATE_KEY, { seenIds: allIds, lastCheck: new Date().toISOString() }) + const allGuids = posts.map(p => p.guid).slice(0, MAX_SEEN) + stateStore.set(STATE_KEY, { seenGuids: allGuids, lastCheck: new Date().toISOString() }) return [] } - // Find new posts - const newPosts = posts.filter(p => !seenIds.has(p._id)) + const newPosts = posts.filter(p => !seenGuids.has(p.guid)) if (newPosts.length > 0) { - // Add new IDs to seen list, keep bounded - const updatedIds = [...newPosts.map(p => p._id), ...state.seenIds].slice(0, MAX_SEEN_IDS) - stateStore.set(STATE_KEY, { seenIds: updatedIds, lastCheck: new Date().toISOString() }) + const updatedGuids = [...newPosts.map(p => p.guid), ...state.seenGuids].slice(0, MAX_SEEN) + stateStore.set(STATE_KEY, { seenGuids: updatedGuids, lastCheck: new Date().toISOString() }) } else { - // Just update check time stateStore.set(STATE_KEY, { ...state, lastCheck: new Date().toISOString() }) } return newPosts } - -export function getBlogUrl(post: BlogPost): string { - const date = new Date(post.publishedAt) - const year = date.getUTCFullYear() - const month = date.getUTCMonth() + 1 - return `https://hytale.com/news/${year}/${month}/${post.slug}` -} - -export function getThumbnailUrl(post: BlogPost): string | undefined { - if (!post.coverImage?.variants?.length || !post.coverImage.s3Key) { - return undefined - } - const variant = post.coverImage.variants.at(-1) - return `https://cdn.hytale.com/variants/${variant}_${post.coverImage.s3Key}` -}