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:
devilreef 2026-01-24 20:09:02 +06:00
parent ea9dd0c13b
commit 24548fcac8
10 changed files with 347 additions and 23 deletions

View file

@ -7,6 +7,21 @@ export interface SendMessageOptions {
topicId?: number
parseMode?: 'Markdown' | 'MarkdownV2' | 'HTML'
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 {
@ -21,7 +36,9 @@ export class TelegramClient {
}
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) => {
const chatId = typeof dest === 'string' ? dest : dest.chatId
@ -33,6 +50,27 @@ export class TelegramClient {
message_thread_id: threadId,
parse_mode: parseMode,
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,
})
}))
}

View file

@ -9,6 +9,7 @@ import { TokenManager } from './core/token-manager.js'
// Module factories
import { discordForwarderFactory } from './modules/discord-forwarder/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 { hytaleLauncherFactory } from './modules/hytale-launcher/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> = {
'discord-forwarder': discordForwarderFactory,
'discord-webhooks': discordWebhooksFactory,
'hytale-blog': hytaleBlogFactory,
'hytale-launcher': hytaleLauncherFactory,
'hytale-patches': hytalePatchesFactory,
'hytale-downloader': hytaleDownloaderFactory,

View file

@ -1,9 +1,11 @@
import type { BlogPost } from '../hytale-blog/tracker.js'
import type { DownloaderUpdate } from '../hytale-downloader/tracker.js'
import type { LauncherUpdate } from '../hytale-launcher/tracker.js'
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 HYTALE_ORANGE = 0xF26430
const HYTALE_LOGO = 'https://hytale.com/favicon.ico'
@ -105,3 +107,20 @@ function formatBytes(bytes: number): string {
const sign = bytes < 0 ? '-' : ''
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
}

View file

@ -1,14 +1,14 @@
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'
export interface DiscordWebhooksModule extends Module {
send: (eventType: string, embed: DiscordEmbed) => Promise<void>
send: (eventType: string, message: DiscordMessage) => Promise<void>
}
interface QueuedMessage {
eventType: string
embed: DiscordEmbed
message: DiscordMessage
}
export function discordWebhooksFactory(
@ -60,11 +60,11 @@ export function discordWebhooksFactory(
this.running = false
}
async send(eventType: string, embed: DiscordEmbed): Promise<void> {
async send(eventType: string, message: DiscordMessage): Promise<void> {
if (!this.running)
return
this.queue.push({ eventType, embed })
this.queue.push({ eventType, message })
// Schedule flush if not already scheduled
if (!this.flushTimer) {
@ -86,16 +86,16 @@ export function discordWebhooksFactory(
// Group messages by webhook based on event filtering
for (const webhook of this.config.webhooks) {
const relevantEmbeds = messages
const relevantMessages = messages
.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
try {
await sendWebhook(webhook.url, relevantEmbeds)
this.dependencies.logger(this.name, `Sent ${relevantEmbeds.length} embed(s) to ${webhook.name}`)
await sendWebhook(webhook.url, relevantMessages, webhook.mentionRoles)
this.dependencies.logger(this.name, `Sent ${relevantMessages.length} message(s) to ${webhook.name}`)
}
catch (err) {
this.dependencies.logger(this.name, `Failed to send to ${webhook.name}: ${err}`)

View file

@ -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
export async function sendWebhook(url: string, embeds: DiscordEmbed[]): Promise<void> {
// Discord allows max 10 embeds per message
for (let i = 0; i < embeds.length; i += MAX_EMBEDS_PER_MESSAGE) {
const batch = embeds.slice(i, i + MAX_EMBEDS_PER_MESSAGE)
await sendBatch(url, batch)
export async function sendWebhook(url: string, messages: DiscordMessage[], mentionRoles?: string[]): Promise<void> {
// Separate full payloads (with components) from simple embeds
const payloads: WebhookPayload[] = []
const simpleEmbeds: WebhookPayload['embeds'] = []
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> {
const payload: WebhookPayload = { embeds }
async function sendPayload(url: string, payload: WebhookPayload, retries = 3): Promise<void> {
const response = await fetch(url, {
method: 'POST',
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 delay = retryAfter ? Number.parseInt(retryAfter, 10) * 1000 : 1000
await sleep(delay)
return sendBatch(url, embeds, retries - 1)
return sendPayload(url, payload, retries - 1)
}
if (!response.ok) {

View file

@ -10,6 +10,7 @@ export interface WebhookConfig {
name: string
url: string
events: string[]
mentionRoles?: string[]
}
export interface DiscordEmbed {
@ -18,6 +19,7 @@ export interface DiscordEmbed {
color?: number
fields?: EmbedField[]
thumbnail?: { url: string }
image?: { url: string }
footer?: { text: string }
timestamp?: string
}
@ -28,6 +30,29 @@ export interface EmbedField {
inline?: boolean
}
export interface WebhookPayload {
embeds: DiscordEmbed[]
export interface ButtonComponent {
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
}

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
}
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)}`
}

View 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)
}

View 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}`
}

View file

@ -83,6 +83,12 @@ export interface HytaleServerConfig extends HytaleTrackerConfig {
patchlines: string[]
}
export interface HytaleBlogConfig extends ModuleConfig {
enabled: boolean
pollIntervalMinutes: number
chatIds: (string | ChatDestination)[]
}
export interface ApiConfig extends ModuleConfig {
enabled: boolean
port: number