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
|
||||
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,
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
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[]
|
||||
}
|
||||
|
||||
export interface HytaleBlogConfig extends ModuleConfig {
|
||||
enabled: boolean
|
||||
pollIntervalMinutes: number
|
||||
chatIds: (string | ChatDestination)[]
|
||||
}
|
||||
|
||||
export interface ApiConfig extends ModuleConfig {
|
||||
enabled: boolean
|
||||
port: number
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue