feat: add discord-webhooks module for embed notifications

- New module with batching queue, per-webhook event filtering
- Hytale orange (#F26430) embeds with timestamps
- Retry on 429 rate limits
- Integrated into all 5 tracker modules
This commit is contained in:
devilreef 2026-01-24 00:17:27 +06:00
parent be1102119a
commit 48b3359792
13 changed files with 357 additions and 5 deletions

View file

@ -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

View file

@ -13,11 +13,16 @@ export interface ModuleFactory {
(config: unknown, deps: ModuleDependencies): Module
}
export interface WebhooksModule extends Module {
send: (eventType: string, embed: unknown) => Promise<void>
}
export interface ModuleDependencies {
telegram: TelegramClient
tokenManager?: TokenManager
stateStore: StateStore
logger: Logger
webhooks?: WebhooksModule
}
export type Logger = (module: string, message: string, ...args: unknown[]) => void

View file

@ -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<string, (config: unknown, deps: import('./core/module.js').ModuleDependencies) => Module> = {
const MODULE_FACTORIES: Record<string, (config: unknown, deps: ModuleDependencies) => 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 {

View file

@ -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]}`
}

View file

@ -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<void>
}
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<void> {
if (this.running)
return
this.dependencies.logger(this.name, `Starting with ${this.config.webhooks.length} webhook(s)`)
this.running = true
}
async stop(): Promise<void> {
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<void> {
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<void> {
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)
}

View file

@ -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<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)
}
}
async function sendBatch(url: string, embeds: DiscordEmbed[], retries = 3): Promise<void> {
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<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}

View file

@ -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[]
}

View file

@ -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}`)
}
}

View file

@ -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}`)
}
}

View file

@ -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}`)
}
}

View file

@ -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`)
}
}

View file

@ -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}`)
}
}

View file

@ -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[]
}