diff --git a/src/config.ts b/src/config.ts index ce53dc8..e4fa453 100644 --- a/src/config.ts +++ b/src/config.ts @@ -103,6 +103,23 @@ function loadConfig(): Config { throw new TypeError(`${moduleName} missing chatIds array`) } } + + if (moduleName === 'discord-webhooks') { + if (!Array.isArray(mc.webhooks)) { + throw new TypeError(`discord-webhooks missing webhooks array`) + } + for (const webhook of mc.webhooks as Array<{ name?: string, url?: string, events?: string[] }>) { + if (typeof webhook.name !== 'string') { + throw new TypeError(`discord-webhooks webhook missing name`) + } + if (typeof webhook.url !== 'string') { + throw new TypeError(`discord-webhooks webhook missing url`) + } + if (!Array.isArray(webhook.events)) { + throw new TypeError(`discord-webhooks webhook ${webhook.name} missing events array`) + } + } + } } return config diff --git a/src/core/module.ts b/src/core/module.ts index fd68f81..ca42bb7 100644 --- a/src/core/module.ts +++ b/src/core/module.ts @@ -13,11 +13,16 @@ export interface ModuleFactory { (config: unknown, deps: ModuleDependencies): Module } +export interface WebhooksModule extends Module { + send: (eventType: string, embed: unknown) => Promise +} + export interface ModuleDependencies { telegram: TelegramClient tokenManager?: TokenManager stateStore: StateStore logger: Logger + webhooks?: WebhooksModule } export type Logger = (module: string, message: string, ...args: unknown[]) => void diff --git a/src/index.ts b/src/index.ts index 3cf5449..5eae1c5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,21 +1,23 @@ -import type { Module } from './core/module.js' +import type { Module, ModuleDependencies, WebhooksModule } from './core/module.js' import process from 'node:process' +import { startApiServer } from './api/index.js' import { config } from './config.js' import { StateStore } from './core/state-store.js' import { TelegramClient } from './core/telegram-client.js' import { TokenManager } from './core/token-manager.js' -import { startApiServer } from './api/index.js' // Module factories import { discordForwarderFactory } from './modules/discord-forwarder/index.js' +import { discordWebhooksFactory } from './modules/discord-webhooks/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' import { hytalePresskitFactory } from './modules/hytale-presskit/index.js' import { hytaleServerFactory } from './modules/hytale-server/index.js' -const MODULE_FACTORIES: Record Module> = { +const MODULE_FACTORIES: Record Module> = { 'discord-forwarder': discordForwarderFactory, + 'discord-webhooks': discordWebhooksFactory, 'hytale-launcher': hytaleLauncherFactory, 'hytale-patches': hytalePatchesFactory, 'hytale-downloader': hytaleDownloaderFactory, @@ -48,14 +50,25 @@ async function main() { console.log(`[${module}] ${message}`, ...args) } - const deps = { + const deps: ModuleDependencies = { telegram, tokenManager, stateStore, logger, } + // Initialize discord-webhooks first if enabled (other modules depend on it) + const webhooksConfig = config.modules['discord-webhooks'] + if (webhooksConfig?.enabled) { + const webhooksModule = discordWebhooksFactory(webhooksConfig, deps) as WebhooksModule + modules.push(webhooksModule) + deps.webhooks = webhooksModule + console.log(`Loaded module: ${webhooksModule.name}`) + } + for (const [moduleName, moduleConfig] of Object.entries(config.modules)) { + if (moduleName === 'discord-webhooks') + continue if (!moduleConfig.enabled) { continue } @@ -92,7 +105,7 @@ async function main() { const apiConfig = config.modules.api as any if (apiConfig?.enabled) { const jwtSecret = process.env.JWT_SECRET || apiConfig.jwtSecret || 'your-secret-key' - const port = process.env.API_PORT ? parseInt(process.env.API_PORT, 10) : (apiConfig.port || 3000) + const port = process.env.API_PORT ? Number.parseInt(process.env.API_PORT, 10) : (apiConfig.port || 3000) const accountUuid = process.env.ACCOUNT_UUID || apiConfig.accountUuid try { diff --git a/src/modules/discord-webhooks/formatter.ts b/src/modules/discord-webhooks/formatter.ts new file mode 100644 index 0000000..3646c90 --- /dev/null +++ b/src/modules/discord-webhooks/formatter.ts @@ -0,0 +1,107 @@ +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' + +const HYTALE_ORANGE = 0xF26430 +const HYTALE_LOGO = 'https://hytale.com/favicon.png' + +export function formatLauncherEmbed(update: LauncherUpdate): DiscordEmbed { + return { + title: 'Launcher Update', + description: `New launcher version available`, + color: HYTALE_ORANGE, + fields: [ + { name: 'Version', value: `\`${update.version}\``, inline: true }, + { name: 'Previous', value: `\`${update.previousVersion}\``, inline: true }, + ], + thumbnail: { url: HYTALE_LOGO }, + timestamp: new Date().toISOString(), + } +} + +export function formatPatchesEmbed(update: PatchesUpdate): DiscordEmbed { + const fields = [ + { name: 'Patchline', value: `\`${update.patchline}\``, inline: true }, + { name: 'Version', value: `\`${update.version}\``, inline: true }, + { name: 'Previous', value: `\`${update.previousVersion}\``, inline: true }, + { name: 'Patch ID', value: `\`${update.patchId}\``, inline: true }, + ] + + if (update.patchSize !== undefined) { + fields.push({ name: 'Patch Size', value: formatBytes(update.patchSize), inline: true }) + } + + return { + title: 'Game Patches Update', + description: `New game patch available for ${update.patchline}`, + color: HYTALE_ORANGE, + fields, + thumbnail: { url: HYTALE_LOGO }, + timestamp: new Date().toISOString(), + } +} + +export function formatServerEmbed(update: ServerUpdate): DiscordEmbed { + return { + title: 'Server Software Update', + description: `New server version for ${update.patchline}`, + color: HYTALE_ORANGE, + fields: [ + { name: 'Patchline', value: `\`${update.patchline}\``, inline: true }, + { name: 'Version', value: `\`${update.version}\``, inline: true }, + { name: 'Previous', value: `\`${update.previousVersion}\``, inline: true }, + ], + thumbnail: { url: HYTALE_LOGO }, + timestamp: new Date().toISOString(), + } +} + +export function formatDownloaderEmbed(update: DownloaderUpdate): DiscordEmbed { + return { + title: 'Downloader Update', + description: `New downloader version available`, + color: HYTALE_ORANGE, + fields: [ + { name: 'Version', value: `\`${update.version}\``, inline: true }, + { name: 'Previous', value: `\`${update.previousVersion}\``, inline: true }, + ], + thumbnail: { url: HYTALE_LOGO }, + timestamp: new Date().toISOString(), + } +} + +export function formatPresskitEmbed(update: PresskitUpdate): DiscordEmbed { + const fields = [ + { name: 'Size', value: formatBytes(update.size), inline: true }, + ] + + if (update.previousSize !== null) { + fields.push({ name: 'Previous', value: formatBytes(update.previousSize), inline: true }) + const sign = update.sizeDiff >= 0 ? '+' : '' + fields.push({ name: 'Change', value: `${sign}${formatBytes(update.sizeDiff)}`, inline: true }) + } + + return { + title: 'Creator Presskit Update', + description: `Presskit archive has been updated`, + color: HYTALE_ORANGE, + fields, + thumbnail: { url: HYTALE_LOGO }, + timestamp: new Date().toISOString(), + } +} + +function formatBytes(bytes: number): string { + if (bytes === 0) + return '0 B' + const absBytes = Math.abs(bytes) + const k = 1024 + const sizes = ['B', 'KB', 'MB', 'GB'] + const i = Math.floor(Math.log(absBytes) / Math.log(k)) + const value = absBytes / k ** i + const sign = bytes < 0 ? '-' : '' + return `${sign}${value.toFixed(i > 0 ? 2 : 0)} ${sizes[i]}` +} diff --git a/src/modules/discord-webhooks/index.ts b/src/modules/discord-webhooks/index.ts new file mode 100644 index 0000000..b8cf7cb --- /dev/null +++ b/src/modules/discord-webhooks/index.ts @@ -0,0 +1,108 @@ +import type { Module, ModuleDependencies } from '../../core/module.js' +import type { DiscordEmbed, DiscordWebhooksConfig } from './types.js' +import { sendWebhook } from './sender.js' + +export interface DiscordWebhooksModule extends Module { + send: (eventType: string, embed: DiscordEmbed) => Promise +} + +interface QueuedMessage { + eventType: string + embed: DiscordEmbed +} + +export function discordWebhooksFactory( + moduleConfig: unknown, + deps: ModuleDependencies, +): DiscordWebhooksModule { + const config = moduleConfig as DiscordWebhooksConfig + + class DiscordWebhooksModuleImpl implements DiscordWebhooksModule { + readonly name = 'discord-webhooks' + private dependencies: ModuleDependencies + private config: DiscordWebhooksConfig + private running = false + private queue: QueuedMessage[] = [] + private flushTimer: NodeJS.Timeout | null = null + private batchDelayMs: number + + constructor(cfg: DiscordWebhooksConfig, deps: ModuleDependencies) { + this.config = cfg + this.dependencies = deps + this.batchDelayMs = cfg.batchDelayMs ?? 100 + } + + isRunning(): boolean { + return this.running + } + + async start(): Promise { + if (this.running) + return + + this.dependencies.logger(this.name, `Starting with ${this.config.webhooks.length} webhook(s)`) + this.running = true + } + + async stop(): Promise { + if (!this.running) + return + + // Flush any pending messages + if (this.flushTimer) { + clearTimeout(this.flushTimer) + this.flushTimer = null + } + if (this.queue.length > 0) { + await this.flush() + } + + this.running = false + } + + async send(eventType: string, embed: DiscordEmbed): Promise { + if (!this.running) + return + + this.queue.push({ eventType, embed }) + + // Schedule flush if not already scheduled + if (!this.flushTimer) { + this.flushTimer = setTimeout(() => { + this.flushTimer = null + this.flush().catch((err) => { + this.dependencies.logger(this.name, `Flush failed: ${err}`) + }) + }, this.batchDelayMs) + } + } + + private async flush(): Promise { + if (this.queue.length === 0) + return + + const messages = [...this.queue] + this.queue = [] + + // Group messages by webhook based on event filtering + for (const webhook of this.config.webhooks) { + const relevantEmbeds = messages + .filter(m => webhook.events.includes('*') || webhook.events.includes(m.eventType)) + .map(m => m.embed) + + if (relevantEmbeds.length === 0) + continue + + try { + await sendWebhook(webhook.url, relevantEmbeds) + this.dependencies.logger(this.name, `Sent ${relevantEmbeds.length} embed(s) to ${webhook.name}`) + } + catch (err) { + this.dependencies.logger(this.name, `Failed to send to ${webhook.name}: ${err}`) + } + } + } + } + + return new DiscordWebhooksModuleImpl(config, deps) +} diff --git a/src/modules/discord-webhooks/sender.ts b/src/modules/discord-webhooks/sender.ts new file mode 100644 index 0000000..cd7ebaf --- /dev/null +++ b/src/modules/discord-webhooks/sender.ts @@ -0,0 +1,37 @@ +import type { DiscordEmbed, WebhookPayload } 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) + } +} + +async function sendBatch(url: string, embeds: DiscordEmbed[], retries = 3): Promise { + const payload: WebhookPayload = { embeds } + + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) + + if (response.status === 429 && retries > 0) { + 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) + } + + if (!response.ok) { + const text = await response.text().catch(() => '') + throw new Error(`Webhook failed with ${response.status}: ${text}`) + } +} + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)) +} diff --git a/src/modules/discord-webhooks/types.ts b/src/modules/discord-webhooks/types.ts new file mode 100644 index 0000000..5a5e785 --- /dev/null +++ b/src/modules/discord-webhooks/types.ts @@ -0,0 +1,33 @@ +import type { ModuleConfig } from '../../types.js' + +export interface DiscordWebhooksConfig extends ModuleConfig { + enabled: boolean + webhooks: WebhookConfig[] + batchDelayMs?: number +} + +export interface WebhookConfig { + name: string + url: string + events: string[] +} + +export interface DiscordEmbed { + title?: string + description?: string + color?: number + fields?: EmbedField[] + thumbnail?: { url: string } + footer?: { text: string } + timestamp?: string +} + +export interface EmbedField { + name: string + value: string + inline?: boolean +} + +export interface WebhookPayload { + embeds: DiscordEmbed[] +} diff --git a/src/modules/hytale-downloader/index.ts b/src/modules/hytale-downloader/index.ts index 886eb8d..8d3f9ac 100644 --- a/src/modules/hytale-downloader/index.ts +++ b/src/modules/hytale-downloader/index.ts @@ -1,5 +1,6 @@ import type { Module, ModuleDependencies } from '../../core/module.js' import type { HytaleTrackerConfig } from '../../types.js' +import { formatDownloaderEmbed } from '../discord-webhooks/formatter.js' import { formatDownloaderUpdate } from './formatter.js' import { checkDownloaderUpdate } from './tracker.js' @@ -64,6 +65,9 @@ export function hytaleDownloaderFactory( chatIds: this.config.chatIds, disableLinkPreview: true, }) + if (this.dependencies.webhooks) { + await this.dependencies.webhooks.send('downloader', formatDownloaderEmbed(update)) + } this.dependencies.logger(this.name, `Update detected: ${update.version}`) } } diff --git a/src/modules/hytale-launcher/index.ts b/src/modules/hytale-launcher/index.ts index ff06515..1c355b3 100644 --- a/src/modules/hytale-launcher/index.ts +++ b/src/modules/hytale-launcher/index.ts @@ -1,5 +1,6 @@ import type { Module, ModuleDependencies } from '../../core/module.js' import type { HytaleTrackerConfig } from '../../types.js' +import { formatLauncherEmbed } from '../discord-webhooks/formatter.js' import { formatLauncherUpdate } from './formatter.js' import { checkLauncherUpdate } from './tracker.js' @@ -64,6 +65,9 @@ export function hytaleLauncherFactory( chatIds: this.config.chatIds, disableLinkPreview: true, }) + if (this.dependencies.webhooks) { + await this.dependencies.webhooks.send('launcher', formatLauncherEmbed(update)) + } this.dependencies.logger(this.name, `Update detected: ${update.version}`) } } diff --git a/src/modules/hytale-patches/index.ts b/src/modules/hytale-patches/index.ts index dc24b44..c56f973 100644 --- a/src/modules/hytale-patches/index.ts +++ b/src/modules/hytale-patches/index.ts @@ -1,5 +1,6 @@ import type { Module, ModuleDependencies } from '../../core/module.js' import type { HytalePatchesConfig } from '../../types.js' +import { formatPatchesEmbed } from '../discord-webhooks/formatter.js' import { formatPatchesUpdate } from './formatter.js' import { checkPatchesUpdate } from './tracker.js' @@ -74,6 +75,9 @@ export function hytalePatchesFactory( chatIds: this.config.chatIds, disableLinkPreview: true, }) + if (this.dependencies.webhooks) { + await this.dependencies.webhooks.send('patches', formatPatchesEmbed(update)) + } this.dependencies.logger(this.name, `Update detected: ${update.patchline} ${update.version}`) } } diff --git a/src/modules/hytale-presskit/index.ts b/src/modules/hytale-presskit/index.ts index e6310e5..0998066 100644 --- a/src/modules/hytale-presskit/index.ts +++ b/src/modules/hytale-presskit/index.ts @@ -1,5 +1,6 @@ import type { Module, ModuleDependencies } from '../../core/module.js' import type { HytaleTrackerConfig } from '../../types.js' +import { formatPresskitEmbed } from '../discord-webhooks/formatter.js' import { formatPresskitUpdate } from './formatter.js' import { checkPresskitUpdate } from './tracker.js' @@ -64,6 +65,9 @@ export function hytalePresskitFactory( chatIds: this.config.chatIds, disableLinkPreview: true, }) + if (this.dependencies.webhooks) { + await this.dependencies.webhooks.send('presskit', formatPresskitEmbed(update)) + } this.dependencies.logger(this.name, `Update detected: ${update.size} bytes`) } } diff --git a/src/modules/hytale-server/index.ts b/src/modules/hytale-server/index.ts index 999a75b..a943c41 100644 --- a/src/modules/hytale-server/index.ts +++ b/src/modules/hytale-server/index.ts @@ -1,5 +1,6 @@ import type { Module, ModuleDependencies } from '../../core/module.js' import type { HytaleServerConfig } from '../../types.js' +import { formatServerEmbed } from '../discord-webhooks/formatter.js' import { formatServerUpdate } from './formatter.js' import { checkServerUpdate } from './tracker.js' @@ -74,6 +75,9 @@ export function hytaleServerFactory( chatIds: this.config.chatIds, disableLinkPreview: true, }) + if (this.dependencies.webhooks) { + await this.dependencies.webhooks.send('server', formatServerEmbed(update)) + } this.dependencies.logger(this.name, `Update detected: ${update.patchline} ${update.version}`) } } diff --git a/src/types.ts b/src/types.ts index 6235054..69c36e5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -101,3 +101,15 @@ export interface ApiContext { scope: string sub?: string } + +export interface DiscordWebhooksConfig extends ModuleConfig { + enabled: boolean + webhooks: WebhookDestination[] + batchDelayMs?: number +} + +export interface WebhookDestination { + name: string + url: string + events: string[] +}