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:
parent
be1102119a
commit
48b3359792
13 changed files with 357 additions and 5 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
23
src/index.ts
23
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<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 {
|
||||
|
|
|
|||
107
src/modules/discord-webhooks/formatter.ts
Normal file
107
src/modules/discord-webhooks/formatter.ts
Normal 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]}`
|
||||
}
|
||||
108
src/modules/discord-webhooks/index.ts
Normal file
108
src/modules/discord-webhooks/index.ts
Normal 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)
|
||||
}
|
||||
37
src/modules/discord-webhooks/sender.ts
Normal file
37
src/modules/discord-webhooks/sender.ts
Normal 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))
|
||||
}
|
||||
33
src/modules/discord-webhooks/types.ts
Normal file
33
src/modules/discord-webhooks/types.ts
Normal 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[]
|
||||
}
|
||||
|
|
@ -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}`)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
12
src/types.ts
12
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[]
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue