feat: add hytale-blog module for blog post tracking
- Track new posts from hytale.com/api/blog/post/published - Send Telegram alerts with cover image (sendPhoto) and inline URL button - Send Discord webhook embeds with full-size image and role mentions - Add sendPhoto method to TelegramClient with inline keyboard support - Extend discord-webhooks with DiscordMessage type, mentionRoles config
This commit is contained in:
parent
ea9dd0c13b
commit
24548fcac8
10 changed files with 347 additions and 23 deletions
|
|
@ -7,6 +7,21 @@ export interface SendMessageOptions {
|
||||||
topicId?: number
|
topicId?: number
|
||||||
parseMode?: 'Markdown' | 'MarkdownV2' | 'HTML'
|
parseMode?: 'Markdown' | 'MarkdownV2' | 'HTML'
|
||||||
disableLinkPreview?: boolean
|
disableLinkPreview?: boolean
|
||||||
|
buttons?: InlineButton[][]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendPhotoOptions {
|
||||||
|
photoUrl: string
|
||||||
|
caption: string
|
||||||
|
chatIds: (string | ChatDestination)[]
|
||||||
|
topicId?: number
|
||||||
|
parseMode?: 'Markdown' | 'MarkdownV2' | 'HTML'
|
||||||
|
buttons?: InlineButton[][]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InlineButton {
|
||||||
|
text: string
|
||||||
|
url: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export class TelegramClient {
|
export class TelegramClient {
|
||||||
|
|
@ -21,7 +36,9 @@ export class TelegramClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendMessage(opts: SendMessageOptions): Promise<void> {
|
async sendMessage(opts: SendMessageOptions): Promise<void> {
|
||||||
const { text, chatIds, topicId, parseMode = 'HTML', disableLinkPreview = false } = opts
|
const { text, chatIds, topicId, parseMode = 'HTML', disableLinkPreview = false, buttons } = opts
|
||||||
|
|
||||||
|
const replyMarkup = buttons ? { inline_keyboard: buttons } : undefined
|
||||||
|
|
||||||
await Promise.all(chatIds.map((dest) => {
|
await Promise.all(chatIds.map((dest) => {
|
||||||
const chatId = typeof dest === 'string' ? dest : dest.chatId
|
const chatId = typeof dest === 'string' ? dest : dest.chatId
|
||||||
|
|
@ -33,6 +50,27 @@ export class TelegramClient {
|
||||||
message_thread_id: threadId,
|
message_thread_id: threadId,
|
||||||
parse_mode: parseMode,
|
parse_mode: parseMode,
|
||||||
link_preview_options: { is_disabled: disableLinkPreview },
|
link_preview_options: { is_disabled: disableLinkPreview },
|
||||||
|
reply_markup: replyMarkup,
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendPhoto(opts: SendPhotoOptions): Promise<void> {
|
||||||
|
const { photoUrl, caption, chatIds, topicId, parseMode = 'HTML', buttons } = opts
|
||||||
|
|
||||||
|
const replyMarkup = buttons ? { inline_keyboard: buttons } : undefined
|
||||||
|
|
||||||
|
await Promise.all(chatIds.map((dest) => {
|
||||||
|
const chatId = typeof dest === 'string' ? dest : dest.chatId
|
||||||
|
const threadId = typeof dest === 'string' ? topicId : dest.topicId
|
||||||
|
|
||||||
|
return this.client.api.sendPhoto({
|
||||||
|
chat_id: chatId,
|
||||||
|
photo: photoUrl,
|
||||||
|
caption,
|
||||||
|
message_thread_id: threadId,
|
||||||
|
parse_mode: parseMode,
|
||||||
|
reply_markup: replyMarkup,
|
||||||
})
|
})
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { TokenManager } from './core/token-manager.js'
|
||||||
// Module factories
|
// Module factories
|
||||||
import { discordForwarderFactory } from './modules/discord-forwarder/index.js'
|
import { discordForwarderFactory } from './modules/discord-forwarder/index.js'
|
||||||
import { discordWebhooksFactory } from './modules/discord-webhooks/index.js'
|
import { discordWebhooksFactory } from './modules/discord-webhooks/index.js'
|
||||||
|
import { hytaleBlogFactory } from './modules/hytale-blog/index.js'
|
||||||
import { hytaleDownloaderFactory } from './modules/hytale-downloader/index.js'
|
import { hytaleDownloaderFactory } from './modules/hytale-downloader/index.js'
|
||||||
import { hytaleLauncherFactory } from './modules/hytale-launcher/index.js'
|
import { hytaleLauncherFactory } from './modules/hytale-launcher/index.js'
|
||||||
import { hytalePatchesFactory } from './modules/hytale-patches/index.js'
|
import { hytalePatchesFactory } from './modules/hytale-patches/index.js'
|
||||||
|
|
@ -18,6 +19,7 @@ import { hytaleServerFactory } from './modules/hytale-server/index.js'
|
||||||
const MODULE_FACTORIES: Record<string, (config: unknown, deps: ModuleDependencies) => Module> = {
|
const MODULE_FACTORIES: Record<string, (config: unknown, deps: ModuleDependencies) => Module> = {
|
||||||
'discord-forwarder': discordForwarderFactory,
|
'discord-forwarder': discordForwarderFactory,
|
||||||
'discord-webhooks': discordWebhooksFactory,
|
'discord-webhooks': discordWebhooksFactory,
|
||||||
|
'hytale-blog': hytaleBlogFactory,
|
||||||
'hytale-launcher': hytaleLauncherFactory,
|
'hytale-launcher': hytaleLauncherFactory,
|
||||||
'hytale-patches': hytalePatchesFactory,
|
'hytale-patches': hytalePatchesFactory,
|
||||||
'hytale-downloader': hytaleDownloaderFactory,
|
'hytale-downloader': hytaleDownloaderFactory,
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
|
import type { BlogPost } from '../hytale-blog/tracker.js'
|
||||||
import type { DownloaderUpdate } from '../hytale-downloader/tracker.js'
|
import type { DownloaderUpdate } from '../hytale-downloader/tracker.js'
|
||||||
import type { LauncherUpdate } from '../hytale-launcher/tracker.js'
|
import type { LauncherUpdate } from '../hytale-launcher/tracker.js'
|
||||||
import type { PatchesUpdate } from '../hytale-patches/tracker.js'
|
import type { PatchesUpdate } from '../hytale-patches/tracker.js'
|
||||||
import type { PresskitUpdate } from '../hytale-presskit/tracker.js'
|
import type { PresskitUpdate } from '../hytale-presskit/tracker.js'
|
||||||
import type { ServerUpdate } from '../hytale-server/tracker.js'
|
import type { ServerUpdate } from '../hytale-server/tracker.js'
|
||||||
import type { DiscordEmbed } from './types.js'
|
import type { DiscordEmbed } from './types.js'
|
||||||
|
import { getBlogUrl, getThumbnailUrl } from '../hytale-blog/tracker.js'
|
||||||
|
|
||||||
const HYTALE_ORANGE = 0xF26430
|
const HYTALE_ORANGE = 0xF26430
|
||||||
const HYTALE_LOGO = 'https://hytale.com/favicon.ico'
|
const HYTALE_LOGO = 'https://hytale.com/favicon.ico'
|
||||||
|
|
@ -105,3 +107,20 @@ function formatBytes(bytes: number): string {
|
||||||
const sign = bytes < 0 ? '-' : ''
|
const sign = bytes < 0 ? '-' : ''
|
||||||
return `${sign}${value.toFixed(i > 0 ? 2 : 0)} ${sizes[i]}`
|
return `${sign}${value.toFixed(i > 0 ? 2 : 0)} ${sizes[i]}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatBlogEmbed(post: BlogPost): DiscordEmbed {
|
||||||
|
const url = getBlogUrl(post)
|
||||||
|
const imageUrl = getThumbnailUrl(post)
|
||||||
|
|
||||||
|
const embed: DiscordEmbed = {
|
||||||
|
title: post.title,
|
||||||
|
description: `*by **${post.author}***\n\n[Read the full post](${url})`,
|
||||||
|
color: HYTALE_ORANGE,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imageUrl) {
|
||||||
|
embed.image = { url: imageUrl }
|
||||||
|
}
|
||||||
|
|
||||||
|
return embed
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
import type { Module, ModuleDependencies } from '../../core/module.js'
|
import type { Module, ModuleDependencies } from '../../core/module.js'
|
||||||
import type { DiscordEmbed, DiscordWebhooksConfig } from './types.js'
|
import type { DiscordMessage, DiscordWebhooksConfig } from './types.js'
|
||||||
import { sendWebhook } from './sender.js'
|
import { sendWebhook } from './sender.js'
|
||||||
|
|
||||||
export interface DiscordWebhooksModule extends Module {
|
export interface DiscordWebhooksModule extends Module {
|
||||||
send: (eventType: string, embed: DiscordEmbed) => Promise<void>
|
send: (eventType: string, message: DiscordMessage) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
interface QueuedMessage {
|
interface QueuedMessage {
|
||||||
eventType: string
|
eventType: string
|
||||||
embed: DiscordEmbed
|
message: DiscordMessage
|
||||||
}
|
}
|
||||||
|
|
||||||
export function discordWebhooksFactory(
|
export function discordWebhooksFactory(
|
||||||
|
|
@ -60,11 +60,11 @@ export function discordWebhooksFactory(
|
||||||
this.running = false
|
this.running = false
|
||||||
}
|
}
|
||||||
|
|
||||||
async send(eventType: string, embed: DiscordEmbed): Promise<void> {
|
async send(eventType: string, message: DiscordMessage): Promise<void> {
|
||||||
if (!this.running)
|
if (!this.running)
|
||||||
return
|
return
|
||||||
|
|
||||||
this.queue.push({ eventType, embed })
|
this.queue.push({ eventType, message })
|
||||||
|
|
||||||
// Schedule flush if not already scheduled
|
// Schedule flush if not already scheduled
|
||||||
if (!this.flushTimer) {
|
if (!this.flushTimer) {
|
||||||
|
|
@ -86,16 +86,16 @@ export function discordWebhooksFactory(
|
||||||
|
|
||||||
// Group messages by webhook based on event filtering
|
// Group messages by webhook based on event filtering
|
||||||
for (const webhook of this.config.webhooks) {
|
for (const webhook of this.config.webhooks) {
|
||||||
const relevantEmbeds = messages
|
const relevantMessages = messages
|
||||||
.filter(m => webhook.events.includes('*') || webhook.events.includes(m.eventType))
|
.filter(m => webhook.events.includes('*') || webhook.events.includes(m.eventType))
|
||||||
.map(m => m.embed)
|
.map(m => m.message)
|
||||||
|
|
||||||
if (relevantEmbeds.length === 0)
|
if (relevantMessages.length === 0)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sendWebhook(webhook.url, relevantEmbeds)
|
await sendWebhook(webhook.url, relevantMessages, webhook.mentionRoles)
|
||||||
this.dependencies.logger(this.name, `Sent ${relevantEmbeds.length} embed(s) to ${webhook.name}`)
|
this.dependencies.logger(this.name, `Sent ${relevantMessages.length} message(s) to ${webhook.name}`)
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
this.dependencies.logger(this.name, `Failed to send to ${webhook.name}: ${err}`)
|
this.dependencies.logger(this.name, `Failed to send to ${webhook.name}: ${err}`)
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,52 @@
|
||||||
import type { DiscordEmbed, WebhookPayload } from './types.js'
|
import type { DiscordMessage, WebhookPayload } from './types.js'
|
||||||
|
import { isWebhookPayload } from './types.js'
|
||||||
|
|
||||||
const MAX_EMBEDS_PER_MESSAGE = 10
|
const MAX_EMBEDS_PER_MESSAGE = 10
|
||||||
|
|
||||||
export async function sendWebhook(url: string, embeds: DiscordEmbed[]): Promise<void> {
|
export async function sendWebhook(url: string, messages: DiscordMessage[], mentionRoles?: string[]): Promise<void> {
|
||||||
// Discord allows max 10 embeds per message
|
// Separate full payloads (with components) from simple embeds
|
||||||
for (let i = 0; i < embeds.length; i += MAX_EMBEDS_PER_MESSAGE) {
|
const payloads: WebhookPayload[] = []
|
||||||
const batch = embeds.slice(i, i + MAX_EMBEDS_PER_MESSAGE)
|
const simpleEmbeds: WebhookPayload['embeds'] = []
|
||||||
await sendBatch(url, batch)
|
|
||||||
|
for (const msg of messages) {
|
||||||
|
if (isWebhookPayload(msg)) {
|
||||||
|
payloads.push(msg)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
simpleEmbeds.push(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build role mentions content
|
||||||
|
const roleContent = mentionRoles?.length
|
||||||
|
? mentionRoles.map(id => `<@&${id}>`).join(' ')
|
||||||
|
: undefined
|
||||||
|
const allowedMentions = mentionRoles?.length
|
||||||
|
? { roles: mentionRoles }
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
// Send full payloads individually (they may have components)
|
||||||
|
for (const payload of payloads) {
|
||||||
|
const payloadWithMentions: WebhookPayload = {
|
||||||
|
...payload,
|
||||||
|
content: roleContent ? `${roleContent}${payload.content ? `\n${payload.content}` : ''}` : payload.content,
|
||||||
|
allowed_mentions: allowedMentions ?? payload.allowed_mentions,
|
||||||
|
}
|
||||||
|
await sendPayload(url, payloadWithMentions)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch simple embeds (max 10 per message)
|
||||||
|
for (let i = 0; i < simpleEmbeds.length; i += MAX_EMBEDS_PER_MESSAGE) {
|
||||||
|
const batch = simpleEmbeds.slice(i, i + MAX_EMBEDS_PER_MESSAGE)
|
||||||
|
await sendPayload(url, {
|
||||||
|
content: roleContent,
|
||||||
|
embeds: batch,
|
||||||
|
allowed_mentions: allowedMentions,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendBatch(url: string, embeds: DiscordEmbed[], retries = 3): Promise<void> {
|
async function sendPayload(url: string, payload: WebhookPayload, retries = 3): Promise<void> {
|
||||||
const payload: WebhookPayload = { embeds }
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
|
@ -23,7 +57,7 @@ async function sendBatch(url: string, embeds: DiscordEmbed[], retries = 3): Prom
|
||||||
const retryAfter = response.headers.get('retry-after')
|
const retryAfter = response.headers.get('retry-after')
|
||||||
const delay = retryAfter ? Number.parseInt(retryAfter, 10) * 1000 : 1000
|
const delay = retryAfter ? Number.parseInt(retryAfter, 10) * 1000 : 1000
|
||||||
await sleep(delay)
|
await sleep(delay)
|
||||||
return sendBatch(url, embeds, retries - 1)
|
return sendPayload(url, payload, retries - 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ export interface WebhookConfig {
|
||||||
name: string
|
name: string
|
||||||
url: string
|
url: string
|
||||||
events: string[]
|
events: string[]
|
||||||
|
mentionRoles?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DiscordEmbed {
|
export interface DiscordEmbed {
|
||||||
|
|
@ -18,6 +19,7 @@ export interface DiscordEmbed {
|
||||||
color?: number
|
color?: number
|
||||||
fields?: EmbedField[]
|
fields?: EmbedField[]
|
||||||
thumbnail?: { url: string }
|
thumbnail?: { url: string }
|
||||||
|
image?: { url: string }
|
||||||
footer?: { text: string }
|
footer?: { text: string }
|
||||||
timestamp?: string
|
timestamp?: string
|
||||||
}
|
}
|
||||||
|
|
@ -28,6 +30,29 @@ export interface EmbedField {
|
||||||
inline?: boolean
|
inline?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WebhookPayload {
|
export interface ButtonComponent {
|
||||||
embeds: DiscordEmbed[]
|
type: 2
|
||||||
|
style: 5
|
||||||
|
url: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActionRow {
|
||||||
|
type: 1
|
||||||
|
components: ButtonComponent[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebhookPayload {
|
||||||
|
content?: string | null
|
||||||
|
embeds: DiscordEmbed[]
|
||||||
|
components?: ActionRow[]
|
||||||
|
allowed_mentions?: {
|
||||||
|
roles?: string[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DiscordMessage = DiscordEmbed | WebhookPayload
|
||||||
|
|
||||||
|
export function isWebhookPayload(msg: DiscordMessage): msg is WebhookPayload {
|
||||||
|
return 'embeds' in msg
|
||||||
}
|
}
|
||||||
|
|
|
||||||
29
src/modules/hytale-blog/formatter.ts
Normal file
29
src/modules/hytale-blog/formatter.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import type { BlogPost } from './tracker.js'
|
||||||
|
|
||||||
|
function formatDate(isoDate: string): string {
|
||||||
|
const date = new Date(isoDate)
|
||||||
|
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
|
||||||
|
const month = months[date.getUTCMonth()]
|
||||||
|
const day = date.getUTCDate()
|
||||||
|
const year = date.getUTCFullYear()
|
||||||
|
const hours = date.getUTCHours().toString().padStart(2, '0')
|
||||||
|
const minutes = date.getUTCMinutes().toString().padStart(2, '0')
|
||||||
|
return `${month} ${day}, ${year} ${hours}:${minutes} UTC`
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBlogUpdate(post: BlogPost): string {
|
||||||
|
return `<b>📰 New Blog Post</b>
|
||||||
|
|
||||||
|
<b>${escapeHtml(post.title)}</b>
|
||||||
|
By ${escapeHtml(post.author)}
|
||||||
|
|
||||||
|
<b>Published:</b> ${formatDate(post.publishedAt)}
|
||||||
|
<b>Created:</b> ${formatDate(post.createdAt)}`
|
||||||
|
}
|
||||||
99
src/modules/hytale-blog/index.ts
Normal file
99
src/modules/hytale-blog/index.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
import type { Module, ModuleDependencies } from '../../core/module.js'
|
||||||
|
import type { HytaleBlogConfig } from '../../types.js'
|
||||||
|
import { formatBlogEmbed } from '../discord-webhooks/formatter.js'
|
||||||
|
import { formatBlogUpdate } from './formatter.js'
|
||||||
|
import { checkBlogUpdates, getBlogUrl, getThumbnailUrl } 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
|
||||||
|
|
||||||
|
constructor(cfg: HytaleBlogConfig, deps: ModuleDependencies) {
|
||||||
|
this.config = cfg
|
||||||
|
this.dependencies = deps
|
||||||
|
}
|
||||||
|
|
||||||
|
isRunning(): boolean {
|
||||||
|
return this.running
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
if (!this.running)
|
||||||
|
return
|
||||||
|
|
||||||
|
if (this.intervalId) {
|
||||||
|
clearInterval(this.intervalId)
|
||||||
|
this.intervalId = null
|
||||||
|
}
|
||||||
|
this.running = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private async check(): Promise<void> {
|
||||||
|
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 (newPosts.length === 0) {
|
||||||
|
this.dependencies.logger(this.name, 'No new posts')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new HytaleBlogModule(config, deps)
|
||||||
|
}
|
||||||
72
src/modules/hytale-blog/tracker.ts
Normal file
72
src/modules/hytale-blog/tracker.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BlogState {
|
||||||
|
seenIds: string[]
|
||||||
|
lastCheck: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const ENDPOINT = 'https://hytale.com/api/blog/post/published'
|
||||||
|
const STATE_KEY = 'hytale-blog'
|
||||||
|
const MAX_SEEN_IDS = 100
|
||||||
|
|
||||||
|
export async function checkBlogUpdates(stateStore: StateStore): Promise<BlogPost[]> {
|
||||||
|
const response = await fetch(ENDPOINT)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Blog endpoint returned ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const posts = await response.json() as BlogPost[]
|
||||||
|
const state = stateStore.get<BlogState>(STATE_KEY)
|
||||||
|
const seenIds = new Set(state?.seenIds ?? [])
|
||||||
|
|
||||||
|
// 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() })
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find new posts
|
||||||
|
const newPosts = posts.filter(p => !seenIds.has(p._id))
|
||||||
|
|
||||||
|
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() })
|
||||||
|
}
|
||||||
|
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}`
|
||||||
|
}
|
||||||
|
|
@ -83,6 +83,12 @@ export interface HytaleServerConfig extends HytaleTrackerConfig {
|
||||||
patchlines: string[]
|
patchlines: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface HytaleBlogConfig extends ModuleConfig {
|
||||||
|
enabled: boolean
|
||||||
|
pollIntervalMinutes: number
|
||||||
|
chatIds: (string | ChatDestination)[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface ApiConfig extends ModuleConfig {
|
export interface ApiConfig extends ModuleConfig {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
port: number
|
port: number
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue