diff --git a/src/core/telegram-client.ts b/src/core/telegram-client.ts index 4e423ea..ebca1f8 100644 --- a/src/core/telegram-client.ts +++ b/src/core/telegram-client.ts @@ -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 { - 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 { + 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, }) })) } diff --git a/src/index.ts b/src/index.ts index 5eae1c5..92dd8e1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 Module> = { 'discord-forwarder': discordForwarderFactory, 'discord-webhooks': discordWebhooksFactory, + 'hytale-blog': hytaleBlogFactory, 'hytale-launcher': hytaleLauncherFactory, 'hytale-patches': hytalePatchesFactory, 'hytale-downloader': hytaleDownloaderFactory, diff --git a/src/modules/discord-webhooks/formatter.ts b/src/modules/discord-webhooks/formatter.ts index b51ba49..8840b54 100644 --- a/src/modules/discord-webhooks/formatter.ts +++ b/src/modules/discord-webhooks/formatter.ts @@ -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 +} diff --git a/src/modules/discord-webhooks/index.ts b/src/modules/discord-webhooks/index.ts index b8cf7cb..683fb2c 100644 --- a/src/modules/discord-webhooks/index.ts +++ b/src/modules/discord-webhooks/index.ts @@ -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 + send: (eventType: string, message: DiscordMessage) => Promise } 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 { + async send(eventType: string, message: DiscordMessage): Promise { 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}`) diff --git a/src/modules/discord-webhooks/sender.ts b/src/modules/discord-webhooks/sender.ts index cd7ebaf..9eb45f1 100644 --- a/src/modules/discord-webhooks/sender.ts +++ b/src/modules/discord-webhooks/sender.ts @@ -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 { - // 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 { + // 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 { - const payload: WebhookPayload = { embeds } - +async function sendPayload(url: string, payload: WebhookPayload, retries = 3): Promise { 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) { diff --git a/src/modules/discord-webhooks/types.ts b/src/modules/discord-webhooks/types.ts index 5a5e785..2185eac 100644 --- a/src/modules/discord-webhooks/types.ts +++ b/src/modules/discord-webhooks/types.ts @@ -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 } diff --git a/src/modules/hytale-blog/formatter.ts b/src/modules/hytale-blog/formatter.ts new file mode 100644 index 0000000..76fe050 --- /dev/null +++ b/src/modules/hytale-blog/formatter.ts @@ -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, '>') +} + +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)}` +} diff --git a/src/modules/hytale-blog/index.ts b/src/modules/hytale-blog/index.ts new file mode 100644 index 0000000..7a95858 --- /dev/null +++ b/src/modules/hytale-blog/index.ts @@ -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 { + 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 (newPosts.length === 0) { + this.dependencies.logger(this.name, 'No new posts') + } + } + } + + return new HytaleBlogModule(config, deps) +} diff --git a/src/modules/hytale-blog/tracker.ts b/src/modules/hytale-blog/tracker.ts new file mode 100644 index 0000000..4e48d0d --- /dev/null +++ b/src/modules/hytale-blog/tracker.ts @@ -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 { + 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(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}` +} diff --git a/src/types.ts b/src/types.ts index 69c36e5..3dfa82e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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