From a7d4df69867b3aade92c3ae9067edde4c33c0586 Mon Sep 17 00:00:00 2001 From: devilreef <86633411+devilr33f@users.noreply.github.com> Date: Fri, 16 Jan 2026 01:18:15 +0600 Subject: [PATCH] refactor: convert to modular system with Hytale update trackers - Core infrastructure: module interface, Telegram wrapper, OAuth token manager, state store - Migrate Discord forwarder to modules/discord-forwarder/ - Add Hytale update trackers: launcher, patches, downloader, server - Support multiple Telegram chats with per-chat topic IDs - Unified config with legacy migration and env var fallbacks - Auto-refresh OAuth tokens (5min buffer) Co-Authored-By: Claude --- .gitignore | 3 + CLAUDE.md | 118 +++++++++++-- src/config.ts | 99 ++++++++--- src/core/module.ts | 23 +++ src/core/state-store.ts | 84 +++++++++ src/core/telegram-client.ts | 43 +++++ src/core/token-manager.ts | 160 ++++++++++++++++++ src/debug-message.ts | 10 +- src/discord/client.ts | 9 - src/env.ts | 12 -- src/index.ts | 125 ++++++++++++-- .../discord-forwarder}/handlers.ts | 35 ++-- src/modules/discord-forwarder/index.ts | 51 ++++++ .../discord-forwarder}/sender.ts | 30 ++-- src/modules/hytale-downloader/formatter.ts | 10 ++ src/modules/hytale-downloader/index.ts | 73 ++++++++ src/modules/hytale-downloader/tracker.ts | 40 +++++ src/modules/hytale-launcher/formatter.ts | 33 ++++ src/modules/hytale-launcher/index.ts | 73 ++++++++ src/modules/hytale-launcher/tracker.ts | 43 +++++ src/modules/hytale-patches/formatter.ts | 13 ++ src/modules/hytale-patches/index.ts | 83 +++++++++ src/modules/hytale-patches/tracker.ts | 77 +++++++++ src/modules/hytale-server/formatter.ts | 12 ++ src/modules/hytale-server/index.ts | 83 +++++++++ src/modules/hytale-server/tracker.ts | 90 ++++++++++ src/telegram/client.ts | 4 - src/types.ts | 53 +++++- tokens/.gitkeep | 0 29 files changed, 1368 insertions(+), 121 deletions(-) create mode 100644 src/core/module.ts create mode 100644 src/core/state-store.ts create mode 100644 src/core/telegram-client.ts create mode 100644 src/core/token-manager.ts delete mode 100644 src/discord/client.ts delete mode 100644 src/env.ts rename src/{discord => modules/discord-forwarder}/handlers.ts (75%) create mode 100644 src/modules/discord-forwarder/index.ts rename src/{telegram => modules/discord-forwarder}/sender.ts (78%) create mode 100644 src/modules/hytale-downloader/formatter.ts create mode 100644 src/modules/hytale-downloader/index.ts create mode 100644 src/modules/hytale-downloader/tracker.ts create mode 100644 src/modules/hytale-launcher/formatter.ts create mode 100644 src/modules/hytale-launcher/index.ts create mode 100644 src/modules/hytale-launcher/tracker.ts create mode 100644 src/modules/hytale-patches/formatter.ts create mode 100644 src/modules/hytale-patches/index.ts create mode 100644 src/modules/hytale-patches/tracker.ts create mode 100644 src/modules/hytale-server/formatter.ts create mode 100644 src/modules/hytale-server/index.ts create mode 100644 src/modules/hytale-server/tracker.ts delete mode 100644 src/telegram/client.ts create mode 100644 tokens/.gitkeep diff --git a/.gitignore b/.gitignore index 51719fb..f7db440 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,10 @@ # Edit at https://www.toptal.com/developers/gitignore?templates=node,visualstudiocode,windows,macos,linux config.json +state.json .claude +tokens/* +!tokens/.gitkeep ### Linux ### *~ diff --git a/CLAUDE.md b/CLAUDE.md index bf4cc30..0cd10e7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,34 +12,118 @@ npx eslint src/ # ESLint check ## Architecture -Discord-to-Telegram message forwarder. Tracks messages from Discord servers and forwards them to Telegram topics. +Modular notification system supporting Discord message forwarding and Hytale update tracking. ``` src/ -├── index.ts # Entry point, initializes clients -├── env.ts # Environment variables (DISCORD_TOKEN, TELEGRAM_BOT_TOKEN) -├── config.ts # Loads config.json, validates server/role/channel structure +├── index.ts # Module orchestrator entry point +├── env.ts # Environment variables +├── config.ts # Unified config loader with legacy migration ├── types.ts # Shared TypeScript interfaces -├── discord/ -│ ├── client.ts # discord.js-selfbot-v13 client -│ └── handlers.ts # messageCreate handler, channel/role matching -└── telegram/ - ├── client.ts # wrappergram client - └── sender.ts # Message forwarding, media group handling +│ +├── core/ # Shared infrastructure +│ ├── module.ts # Module interface +│ ├── telegram-client.ts # Telegram client wrapper +│ ├── token-manager.ts # OAuth token storage + auto-refresh +│ └── state-store.ts # Persistent state for trackers +│ +└── modules/ # Pluggable modules + ├── discord-forwarder/ # Discord → Telegram message forwarding + ├── hytale-launcher/ # Launcher version tracker + ├── hytale-patches/ # Game patches tracker (auth:launcher) + ├── hytale-downloader/ # Downloader version tracker + └── hytale-server/ # Server software tracker (auth:downloader) ``` -**Flow**: Discord messageCreate → match by channel or role → forward to Telegram topic with attachments +## Modules -**Matching Priority**: -1. Channels checked first (if configured) -2. Roles checked second (highest priority role = first in config array) +### Discord Forwarder +Tracks Discord servers and forwards messages to Telegram topics based on channel/role matching. -Servers can have `channels`, `roles`, or both. Channel matches skip role display in Telegram message. +### Hytale Update Trackers +- **hytale-launcher**: Polls `launcher.hytale.com/version/release/launcher.json` (no auth) +- **hytale-patches**: Polls `account-data.hytale.com/my-account/get-launcher-data` (requires `auth:launcher` scope) +- **hytale-downloader**: Polls `downloader.hytale.com/version.json` (no auth) +- **hytale-server**: Two-step process via `account-data.hytale.com/game-assets/version/.json` (requires `auth:downloader` scope) ## Config -- `config.json` - Server/channel/role/topic mappings (see `config.json.example`) -- `.env` - Tokens (see `.env.example`) +### `.env` +``` +DISCORD_TOKEN=your_discord_user_token +TELEGRAM_BOT_TOKEN=your_telegram_bot_token +``` + +### `config.json` + +```json +{ + "telegram": { "botToken": "TELEGRAM_BOT_TOKEN" }, + "modules": { + "discord-forwarder": { + "enabled": true, + "ignoredUserIds": [], + "chatId": "-100123456789", + "servers": [ + { + "name": "Hytale", + "guildId": "123456789012345678", + "roles": [ + { "id": "111111111111111111", "name": "Developer", "topicId": 5 } + ] + } + ] + }, + "hytale-launcher": { + "enabled": true, + "pollIntervalMinutes": 5, + "chatIds": ["-100123456789"] + }, + "hytale-patches": { + "enabled": true, + "pollIntervalMinutes": 5, + "chatIds": ["-100123456789"], + "patchlines": ["release", "pre-release"] + }, + "hytale-downloader": { + "enabled": true, + "pollIntervalMinutes": 5, + "chatIds": ["-100123456789"] + }, + "hytale-server": { + "enabled": true, + "pollIntervalMinutes": 5, + "chatIds": ["-100123456789"], + "patchlines": ["release", "pre-release"] + } + }, + "hytaleAuth": { + "launcher": { + "clientId": "hytale-launcher", + "tokenFile": "./tokens/launcher.json" + }, + "downloader": { + "clientId": "hytale-downloader", + "tokenFile": "./tokens/downloader.json" + } + } +} +``` + +## Token Setup + +For authenticated endpoints (hytale-patches, hytale-server), create `tokens/*.json` files: + +```json +{ + "access_token": "...", + "refresh_token": "...", + "expires_at": 1737123456789, + "scope": "auth:launcher" +} +``` + +Tokens are automatically refreshed before expiry (5min buffer). ## Code Style diff --git a/src/config.ts b/src/config.ts index d36fb2d..ce53dc8 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,53 +1,106 @@ import type { Config } from './types.js' import { existsSync, readFileSync } from 'node:fs' +import process from 'node:process' const CONFIG_PATH = './config.json' +interface LegacyConfig { + telegram: { chatId: string } + servers: Array<{ + name: string + guildId: string + roles?: Array<{ id: string, name: string, topicId: number }> + channels?: Array<{ id: string, name: string, topicId: number }> + }> + ignoredUserIds?: string[] +} + +function isLegacyConfig(config: unknown): config is LegacyConfig { + const c = config as Record + return !('modules' in c) && 'servers' in c +} + +function migrateLegacyConfig(legacy: LegacyConfig): Config { + return { + telegram: { botToken: process.env.TELEGRAM_BOT_TOKEN ?? '' }, + modules: { + 'discord-forwarder': { + enabled: true, + ignoredUserIds: legacy.ignoredUserIds ?? [], + chatId: legacy.telegram.chatId, + discordToken: process.env.DISCORD_TOKEN ?? '', + servers: legacy.servers, + }, + }, + } +} + function loadConfig(): Config { if (!existsSync(CONFIG_PATH)) { throw new Error(`Config file not found: ${CONFIG_PATH}`) } const raw = readFileSync(CONFIG_PATH, 'utf-8') - const config = JSON.parse(raw) as Config + const parsed = JSON.parse(raw) - if (!config.telegram?.chatId) { - throw new Error('Config missing telegram.chatId') + if (isLegacyConfig(parsed)) { + console.log('[Config] Migrating legacy config to new format') + return migrateLegacyConfig(parsed) } - if (config.ignoredUserIds && !Array.isArray(config.ignoredUserIds)) { - throw new Error('ignoredUserIds must be an array') + const config = parsed as Config + + if (!config.telegram?.botToken) { + config.telegram = { botToken: process.env.TELEGRAM_BOT_TOKEN ?? '' } + if (!config.telegram.botToken) { + throw new Error('Config missing telegram.botToken (set in config.json or TELEGRAM_BOT_TOKEN env var)') + } } - if (!Array.isArray(config.servers) || config.servers.length === 0) { - throw new Error('Config missing servers array') + if (!config.modules || typeof config.modules !== 'object') { + throw new Error('Config missing modules object') } - for (const server of config.servers) { - if (!server.guildId) { - throw new Error(`Server "${server.name}" missing guildId`) + for (const [moduleName, moduleConfig] of Object.entries(config.modules)) { + if (!moduleConfig || typeof moduleConfig !== 'object') { + throw new Error(`Invalid module config: ${moduleName}`) } - const hasRoles = Array.isArray(server.roles) && server.roles.length > 0 - const hasChannels = Array.isArray(server.channels) && server.channels.length > 0 + const mc = moduleConfig as Record - if (!hasRoles && !hasChannels) { - throw new Error(`Server "${server.name}" must have roles or channels configured`) + if (typeof mc.enabled !== 'boolean') { + throw new TypeError(`Module ${moduleName} missing enabled field`) } - if (server.roles) { - for (const role of server.roles) { - if (!role.id || !role.name || typeof role.topicId !== 'number') { - throw new Error(`Invalid role config in server "${server.name}"`) + if (moduleName === 'discord-forwarder') { + if (typeof mc.chatId !== 'string') { + throw new TypeError(`discord-forwarder missing chatId`) + } + if (typeof mc.discordToken !== 'string') { + throw new TypeError(`discord-forwarder missing discordToken`) + } + if (!mc.discordToken) { + mc.discordToken = process.env.DISCORD_TOKEN ?? '' + if (!mc.discordToken) { + throw new Error('discord-forwarder missing discordToken (set in config.json or DISCORD_TOKEN env var)') + } + } + if (!Array.isArray(mc.servers)) { + throw new TypeError(`discord-forwarder missing servers array`) + } + for (const server of mc.servers as Array<{ guildId?: string }>) { + if (!server.guildId) { + throw new Error(`discord-forwarder server missing guildId`) } } } - if (server.channels) { - for (const channel of server.channels) { - if (!channel.id || !channel.name || typeof channel.topicId !== 'number') { - throw new Error(`Invalid channel config in server "${server.name}"`) - } + if (moduleName.startsWith('hytale-')) { + if (typeof mc.pollIntervalMinutes !== 'number') { + throw new TypeError(`${moduleName} missing pollIntervalMinutes`) + } + if (!Array.isArray(mc.chatIds)) { + throw new TypeError(`${moduleName} missing chatIds array`) } } } diff --git a/src/core/module.ts b/src/core/module.ts new file mode 100644 index 0000000..fd68f81 --- /dev/null +++ b/src/core/module.ts @@ -0,0 +1,23 @@ +import type { StateStore } from './state-store.js' +import type { TelegramClient } from './telegram-client.js' +import type { TokenManager } from './token-manager.js' + +export interface Module { + readonly name: string + start: () => Promise + stop: () => Promise + isRunning: () => boolean +} + +export interface ModuleFactory { + (config: unknown, deps: ModuleDependencies): Module +} + +export interface ModuleDependencies { + telegram: TelegramClient + tokenManager?: TokenManager + stateStore: StateStore + logger: Logger +} + +export type Logger = (module: string, message: string, ...args: unknown[]) => void diff --git a/src/core/state-store.ts b/src/core/state-store.ts new file mode 100644 index 0000000..51cd31c --- /dev/null +++ b/src/core/state-store.ts @@ -0,0 +1,84 @@ +import fs from 'node:fs/promises' + +const STATE_FILE = './state.json' +const DEBOUNCE_MS = 5000 + +export class StateStore { + private state = new Map() + private dirty = new Set() + private persistTimer: NodeJS.Timeout | null = null + private loaded = false + + async load(): Promise { + if (this.loaded) { + return + } + + try { + const content = await fs.readFile(STATE_FILE, 'utf-8') + const data = JSON.parse(content) as Record + + for (const [key, value] of Object.entries(data)) { + this.state.set(key, value) + } + } + catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + console.warn('[StateStore] Failed to load state file:', err) + } + } + + this.loaded = true + } + + get(key: string): T | undefined { + return this.state.get(key) as T + } + + set(key: string, value: T): void { + this.state.set(key, value) + this.dirty.add(key) + this.schedulePersist() + } + + private schedulePersist(): void { + if (this.persistTimer) { + return + } + + this.persistTimer = setTimeout(() => { + this.persist().catch((err) => { + console.error('[StateStore] Failed to persist state:', err) + }) + }, DEBOUNCE_MS) + } + + private async persist(): Promise { + this.persistTimer = null + + if (this.dirty.size === 0) { + return + } + + const toSave: Record = {} + for (const key of this.dirty) { + toSave[key] = this.state.get(key) + } + + try { + await fs.writeFile(STATE_FILE, JSON.stringify(toSave, null, 2)) + this.dirty.clear() + } + catch (err) { + console.error('[StateStore] Failed to write state file:', err) + } + } + + async flush(): Promise { + if (this.persistTimer) { + clearTimeout(this.persistTimer) + this.persistTimer = null + } + await this.persist() + } +} diff --git a/src/core/telegram-client.ts b/src/core/telegram-client.ts new file mode 100644 index 0000000..4e423ea --- /dev/null +++ b/src/core/telegram-client.ts @@ -0,0 +1,43 @@ +import type { ChatDestination } from '../types.js' +import { Telegram } from 'wrappergram' + +export interface SendMessageOptions { + text: string + chatIds: (string | ChatDestination)[] + topicId?: number + parseMode?: 'Markdown' | 'MarkdownV2' | 'HTML' + disableLinkPreview?: boolean +} + +export class TelegramClient { + private client: Telegram + + constructor(token: string) { + this.client = new Telegram(token) + } + + get api() { + return this.client.api + } + + async sendMessage(opts: SendMessageOptions): Promise { + const { text, chatIds, topicId, parseMode = 'HTML', disableLinkPreview = false } = opts + + 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.sendMessage({ + chat_id: chatId, + text, + message_thread_id: threadId, + parse_mode: parseMode, + link_preview_options: { is_disabled: disableLinkPreview }, + }) + })) + } + + destroy(): void { + // Wrappergram handles cleanup automatically + } +} diff --git a/src/core/token-manager.ts b/src/core/token-manager.ts new file mode 100644 index 0000000..db603ff --- /dev/null +++ b/src/core/token-manager.ts @@ -0,0 +1,160 @@ +import fs from 'node:fs/promises' +import path from 'node:path' + +export interface TokenConfig { + clientId: string + tokenEndpoint: string + tokenFile: string +} + +export interface TokenData { + access_token: string + refresh_token: string + expires_at: number + scope?: string +} + +const REFRESH_BUFFER_SECONDS = 300 + +export class TokenManager { + private configs = new Map() + private tokens = new Map() + private refreshTimers = new Map() + + register(key: string, config: TokenConfig): void { + this.configs.set(key, config) + } + + async getAccessToken(key: string): Promise { + const config = this.configs.get(key) + if (!config) { + throw new Error(`Token config not registered: ${key}`) + } + + let tokenData = this.tokens.get(key) + + if (!tokenData) { + tokenData = await this.loadTokens(key, config.tokenFile) + if (!tokenData) { + throw new Error(`No token data found for ${key}, please set up tokens/${key}.json`) + } + this.tokens.set(key, tokenData) + } + + if (this.needsRefresh(tokenData.expires_at)) { + await this.refreshToken(key) + tokenData = this.tokens.get(key)! + } + + this.scheduleRefresh(key, tokenData) + + return tokenData.access_token + } + + private needsRefresh(expiresAt: number): boolean { + return Date.now() >= (expiresAt - REFRESH_BUFFER_SECONDS * 1000) + } + + private async loadTokens(key: string, tokenFile: string): Promise { + try { + const content = await fs.readFile(tokenFile, 'utf-8') + const data = JSON.parse(content) + + const tokens: TokenData = { + access_token: data.access_token, + refresh_token: data.refresh_token, + expires_at: data.expires_at ?? (Date.now() + data.expires_in * 1000), + scope: data.scope, + } + + this.tokens.set(key, tokens) + return tokens + } + catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return undefined + } + throw err + } + } + + private async saveTokens(key: string): Promise { + const config = this.configs.get(key) + const tokenData = this.tokens.get(key) + if (!config || !tokenData) { + return + } + + const dir = path.dirname(config.tokenFile) + await fs.mkdir(dir, { recursive: true }) + + const data = { + access_token: tokenData.access_token, + refresh_token: tokenData.refresh_token, + expires_at: tokenData.expires_at, + scope: tokenData.scope, + } + + await fs.writeFile(config.tokenFile, JSON.stringify(data, null, 2)) + } + + private async refreshToken(key: string): Promise { + const config = this.configs.get(key) + const tokenData = this.tokens.get(key) + if (!config || !tokenData) { + throw new Error(`Cannot refresh ${key}: missing config or token data`) + } + + const params = new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: tokenData.refresh_token, + client_id: config.clientId, + }) + + const response = await fetch(config.tokenEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: params, + }) + + if (!response.ok) { + throw new Error(`Token refresh failed for ${key}: ${response.statusText}`) + } + + const data = await response.json() as TokenData & { expires_in?: number } + + const newTokens: TokenData = { + access_token: data.access_token, + refresh_token: data.refresh_token ?? tokenData.refresh_token, + expires_at: data.expires_at ?? (Date.now() + (data.expires_in ?? 3600) * 1000), + scope: data.scope, + } + + this.tokens.set(key, newTokens) + await this.saveTokens(key) + } + + private scheduleRefresh(key: string, tokenData: TokenData): void { + const existing = this.refreshTimers.get(key) + if (existing) { + clearTimeout(existing) + } + + const timeUntilRefresh = Math.max(0, tokenData.expires_at - Date.now() - REFRESH_BUFFER_SECONDS * 1000) + + const timer = setTimeout(() => { + this.refreshToken(key).catch((err) => { + console.error(`[TokenManager] Auto-refresh failed for ${key}:`, err) + }) + }, timeUntilRefresh) + + this.refreshTimers.set(key, timer) + } + + stopAll(): void { + for (const timer of this.refreshTimers.values()) { + clearTimeout(timer) + } + this.refreshTimers.clear() + } +} diff --git a/src/debug-message.ts b/src/debug-message.ts index 3853a2a..c08c3af 100644 --- a/src/debug-message.ts +++ b/src/debug-message.ts @@ -1,6 +1,12 @@ -import process from 'node:process' +import process, { env } from 'node:process' import { Client } from 'discord.js-selfbot-v13' -import env from './env.js' + +const DISCORD_TOKEN = env['DISCORD_TOKEN'] + +if (!DISCORD_TOKEN) { + console.error('DISCORD_TOKEN is not set') + process.exit(1) +} const link = process.argv[2] if (!link) { diff --git a/src/discord/client.ts b/src/discord/client.ts deleted file mode 100644 index 7ca88a3..0000000 --- a/src/discord/client.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Client } from 'discord.js-selfbot-v13' -import env from '../env.js' - -export const discord = new Client() - -export async function startDiscord(): Promise { - await discord.login(env.discordToken) - console.log(`Discord logged in as ${discord.user?.tag}`) -} diff --git a/src/env.ts b/src/env.ts deleted file mode 100644 index 4b376a5..0000000 --- a/src/env.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { existsSync } from 'node:fs' -import { loadEnvFile } from 'node:process' -import env from 'env-var' - -if (existsSync('.env')) - loadEnvFile('.env') - -export default { - mode: env.get('NODE_ENV').default('production').asString(), - discordToken: env.get('DISCORD_TOKEN').required().asString(), - telegramBotToken: env.get('TELEGRAM_BOT_TOKEN').required().asString(), -} diff --git a/src/index.ts b/src/index.ts index 50bf05d..c5506c7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,25 +1,114 @@ +import type { Module } from './core/module.js' import process from 'node:process' import { config } from './config.js' -import { discord, startDiscord } from './discord/client.js' -import { setupHandlers } from './discord/handlers.js' +import { StateStore } from './core/state-store.js' +import { TelegramClient } from './core/telegram-client.js' +import { TokenManager } from './core/token-manager.js' -console.log(`Loaded ${config.servers.length} server(s) to track`) +// Module factories +import { discordForwarderFactory } from './modules/discord-forwarder/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 { hytaleServerFactory } from './modules/hytale-server/index.js' -setupHandlers() +const MODULE_FACTORIES: Record Module> = { + 'discord-forwarder': discordForwarderFactory, + 'hytale-launcher': hytaleLauncherFactory, + 'hytale-patches': hytalePatchesFactory, + 'hytale-downloader': hytaleDownloaderFactory, + 'hytale-server': hytaleServerFactory, +} -startDiscord().catch((err: unknown) => { - console.error('Failed to start Discord client:', err) +async function main() { + // Initialize shared services + const telegram = new TelegramClient(config.telegram.botToken) + const tokenManager = new TokenManager() + const stateStore = new StateStore() + + await stateStore.load() + + // Register auth configs + if (config.hytaleAuth) { + for (const [key, authConfig] of Object.entries(config.hytaleAuth)) { + tokenManager.register(key, { + clientId: authConfig.clientId, + tokenEndpoint: authConfig.tokenEndpoint ?? 'https://oauth.accounts.hytale.com/oauth2/token', + tokenFile: authConfig.tokenFile, + }) + } + } + + // Initialize modules + const modules: Module[] = [] + const logger = (module: string, message: string, ...args: unknown[]) => { + console.log(`[${module}] ${message}`, ...args) + } + + const deps = { + telegram, + tokenManager, + stateStore, + logger, + } + + for (const [moduleName, moduleConfig] of Object.entries(config.modules)) { + if (!moduleConfig.enabled) { + continue + } + + const factory = MODULE_FACTORIES[moduleName] + if (!factory) { + console.warn(`No factory found for module: ${moduleName}`) + continue + } + + const module = factory(moduleConfig, deps) + modules.push(module) + console.log(`Loaded module: ${module.name}`) + } + + if (modules.length === 0) { + console.warn('No modules enabled, exiting') + process.exit(0) + } + + // Start all modules + console.log(`Starting ${modules.length} module(s)...`) + for (const module of modules) { + try { + await module.start() + } + catch (err) { + console.error(`Failed to start module ${module.name}:`, err) + process.exit(1) + } + } + + // Graceful shutdown + const shutdown = async () => { + console.log('Shutting down...') + await stateStore.flush() + tokenManager.stopAll() + telegram.destroy() + + for (const module of modules) { + try { + await module.stop() + } + catch (err) { + console.error(`Error stopping module ${module.name}:`, err) + } + } + + process.exit(0) + } + + process.once('SIGINT', shutdown) + process.once('SIGTERM', shutdown) +} + +main().catch((err) => { + console.error('Fatal error:', err) process.exit(1) }) - -process.once('SIGINT', () => { - console.log('Shutting down...') - discord.destroy() - process.exit(0) -}) - -process.once('SIGTERM', () => { - console.log('Shutting down...') - discord.destroy() - process.exit(0) -}) diff --git a/src/discord/handlers.ts b/src/modules/discord-forwarder/handlers.ts similarity index 75% rename from src/discord/handlers.ts rename to src/modules/discord-forwarder/handlers.ts index 233b17c..52bf270 100644 --- a/src/discord/handlers.ts +++ b/src/modules/discord-forwarder/handlers.ts @@ -1,12 +1,9 @@ -import type { Message } from 'discord.js-selfbot-v13' -import type { AttachmentInfo, ChannelConfig, RoleConfig } from '../types.js' -import { config } from '../config.js' -import { forwardMessage } from '../telegram/sender.js' -import { discord } from './client.js' +import type { Client, Message } from 'discord.js-selfbot-v13' -export function setupHandlers(): void { - discord.on('messageCreate', handleMessage) -} +import type { ModuleDependencies } from '../../core/module.js' +import type { AttachmentInfo, ChannelConfig, DiscordForwarderConfig, RoleConfig } from '../../types.js' + +import { forwardMessage } from './sender.js' interface MatchResult { topicId: number @@ -20,7 +17,19 @@ interface ReplyInfo { messageLink: string } -async function handleMessage(message: Message): Promise { +export function setupHandlers( + discord: Client, + config: DiscordForwarderConfig, + deps: ModuleDependencies, +): void { + discord.on('messageCreate', message => handleMessage(message, config, deps)) +} + +async function handleMessage( + message: Message, + config: DiscordForwarderConfig, + deps: ModuleDependencies, +): Promise { if (!message.guild) return @@ -31,7 +40,7 @@ async function handleMessage(message: Message): Promise { if (config.ignoredUserIds?.includes(message.author.id)) return - const serverConfig = config.servers.find((s: { guildId: string }) => s.guildId === message.guild!.id) + const serverConfig = config.servers.find(s => s.guildId === message.guild!.id) if (!serverConfig) return @@ -82,7 +91,9 @@ async function handleMessage(message: Message): Promise { try { await forwardMessage({ + telegram: deps.telegram, topicId: match.topicId, + chatId: config.chatId, author: message.author.displayName ?? message.author.username, role: match.type === 'role' ? match.label : null, channel: channelName, @@ -91,10 +102,10 @@ async function handleMessage(message: Message): Promise { messageLink, replyTo, }) - console.log(`Forwarded message from ${message.author.tag} (${match.type}: ${match.label}) in ${serverConfig.name}`) + deps.logger('discord-forwarder', `Forwarded from ${message.author.tag} (${match.type}: ${match.label})`) } catch (err) { - console.error('Failed to forward message:', err) + deps.logger('discord-forwarder', `Failed to forward: ${err}`) } } diff --git a/src/modules/discord-forwarder/index.ts b/src/modules/discord-forwarder/index.ts new file mode 100644 index 0000000..610624e --- /dev/null +++ b/src/modules/discord-forwarder/index.ts @@ -0,0 +1,51 @@ +import type { Module, ModuleDependencies } from '../../core/module.js' +import type { DiscordForwarderConfig } from '../../types.js' +import { Client } from 'discord.js-selfbot-v13' +import { setupHandlers } from './handlers.js' + +export function discordForwarderFactory( + moduleConfig: unknown, + deps: ModuleDependencies, +): Module { + const config = moduleConfig as DiscordForwarderConfig + + class DiscordForwarderModule implements Module { + readonly name = 'discord-forwarder' + private discord: Client + private dependencies: ModuleDependencies + private config: DiscordForwarderConfig + private running = false + + constructor(cfg: DiscordForwarderConfig, deps: ModuleDependencies) { + this.config = cfg + this.dependencies = deps + this.discord = new Client() + } + + isRunning(): boolean { + return this.running + } + + async start(): Promise { + if (this.running) + return + + await this.discord.login(this.config.discordToken) + this.dependencies.logger(this.name, `Logged in as ${this.discord.user?.tag}`) + this.dependencies.logger(this.name, `Tracking ${this.config.servers.length} server(s)`) + + setupHandlers(this.discord, this.config, this.dependencies) + this.running = true + } + + async stop(): Promise { + if (!this.running) + return + + this.discord.destroy() + this.running = false + } + } + + return new DiscordForwarderModule(config, deps) +} diff --git a/src/telegram/sender.ts b/src/modules/discord-forwarder/sender.ts similarity index 78% rename from src/telegram/sender.ts rename to src/modules/discord-forwarder/sender.ts index 5a8e70c..f435474 100644 --- a/src/telegram/sender.ts +++ b/src/modules/discord-forwarder/sender.ts @@ -1,17 +1,15 @@ -import type { ForwardMessageOptions } from '../types.js' +import type { TelegramClient } from '../../core/telegram-client.js' +import type { ForwardMessageOptions } from '../../types.js' import { MediaUpload } from 'wrappergram' -import { config } from '../config.js' -import { telegram } from './client.js' -export async function forwardMessage(opts: ForwardMessageOptions): Promise { - const { topicId, author, role, channel, content, attachments, messageLink, replyTo } = opts +export async function forwardMessage(opts: ForwardMessageOptions & { telegram: TelegramClient }): Promise { + const { telegram, topicId, chatId, author, role, channel, content, attachments, messageLink, replyTo } = opts let text = '' // Add reply context as blockquote if present if (replyTo) { - const replyContent = replyTo.content || '(no text)' - text += `
${escapeHtml(replyTo.author)}\n${escapeHtml(replyContent)}\nView original
\n\n` + text += `
${escapeHtml(replyTo.author)}\n${escapeHtml(replyTo.content) || '(no text)'}\nView original
\n\n` } const roleText = role ? ` (${escapeHtml(role)})` : '' @@ -20,12 +18,12 @@ export async function forwardMessage(opts: ForwardMessageOptions): Promise // Enable link preview only if content has URLs (not just the discord jump link) const hasLinks = /https?:\/\/\S+/i.test(content) - await telegram.api.sendMessage({ - chat_id: config.telegram.chatId, + await telegram.sendMessage({ text, - message_thread_id: topicId, - parse_mode: 'HTML', - link_preview_options: { is_disabled: !hasLinks }, + chatIds: [chatId], + topicId, + parseMode: 'HTML', + disableLinkPreview: !hasLinks, }) if (attachments.length === 0) @@ -54,7 +52,7 @@ export async function forwardMessage(opts: ForwardMessageOptions): Promise } })) await telegram.api.sendMediaGroup({ - chat_id: config.telegram.chatId, + chat_id: chatId, message_thread_id: topicId, media: mediaItems, }) @@ -68,14 +66,14 @@ export async function forwardMessage(opts: ForwardMessageOptions): Promise try { if (att.contentType?.startsWith('video/')) { await telegram.api.sendVideo({ - chat_id: config.telegram.chatId, + chat_id: chatId, video: await MediaUpload.url(att.url), message_thread_id: topicId, }) } else { await telegram.api.sendPhoto({ - chat_id: config.telegram.chatId, + chat_id: chatId, photo: await MediaUpload.url(att.url), message_thread_id: topicId, }) @@ -90,7 +88,7 @@ export async function forwardMessage(opts: ForwardMessageOptions): Promise for (const att of documents) { try { await telegram.api.sendDocument({ - chat_id: config.telegram.chatId, + chat_id: chatId, document: await MediaUpload.url(att.url), message_thread_id: topicId, }) diff --git a/src/modules/hytale-downloader/formatter.ts b/src/modules/hytale-downloader/formatter.ts new file mode 100644 index 0000000..f6bc9c5 --- /dev/null +++ b/src/modules/hytale-downloader/formatter.ts @@ -0,0 +1,10 @@ +import type { DownloaderUpdate } from './tracker.js' + +export function formatDownloaderUpdate(update: DownloaderUpdate): string { + const { version, previousVersion } = update + + return `📦 Hytale Downloader Update + +New version: ${version} +${previousVersion !== 'unknown' ? `Previous: ${previousVersion}` : ''}` +} diff --git a/src/modules/hytale-downloader/index.ts b/src/modules/hytale-downloader/index.ts new file mode 100644 index 0000000..886eb8d --- /dev/null +++ b/src/modules/hytale-downloader/index.ts @@ -0,0 +1,73 @@ +import type { Module, ModuleDependencies } from '../../core/module.js' +import type { HytaleTrackerConfig } from '../../types.js' +import { formatDownloaderUpdate } from './formatter.js' +import { checkDownloaderUpdate } from './tracker.js' + +export function hytaleDownloaderFactory( + moduleConfig: unknown, + deps: ModuleDependencies, +): Module { + const config = moduleConfig as HytaleTrackerConfig + + class HytaleDownloaderModule implements Module { + readonly name = 'hytale-downloader' + private dependencies: ModuleDependencies + private config: HytaleTrackerConfig + private running = false + private intervalId: NodeJS.Timeout | null = null + + constructor(cfg: HytaleTrackerConfig, 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 update = await checkDownloaderUpdate(this.dependencies.stateStore) + if (update) { + const message = formatDownloaderUpdate(update) + await this.dependencies.telegram.sendMessage({ + text: message, + chatIds: this.config.chatIds, + disableLinkPreview: true, + }) + this.dependencies.logger(this.name, `Update detected: ${update.version}`) + } + } + } + + return new HytaleDownloaderModule(config, deps) +} diff --git a/src/modules/hytale-downloader/tracker.ts b/src/modules/hytale-downloader/tracker.ts new file mode 100644 index 0000000..45141a0 --- /dev/null +++ b/src/modules/hytale-downloader/tracker.ts @@ -0,0 +1,40 @@ +import type { StateStore } from '../../core/state-store.js' + +interface DownloaderResponse { + latest: string +} + +export interface DownloaderUpdate { + version: string + previousVersion: string +} + +const ENDPOINT = 'https://downloader.hytale.com/version.json' +const STATE_KEY = 'hytale-downloader' + +export async function checkDownloaderUpdate(stateStore: StateStore): Promise { + const response = await fetch(ENDPOINT) + if (!response.ok) { + throw new Error(`Downloader endpoint returned ${response.status}`) + } + + const data = await response.json() as DownloaderResponse + const currentVersion = data.latest + + const state = stateStore.get<{ lastVersion?: string, lastCheck?: string }>(STATE_KEY) + const lastVersion = state?.lastVersion + + if (lastVersion === currentVersion) { + // No update, just update check time + stateStore.set(STATE_KEY, { lastVersion: currentVersion, lastCheck: new Date().toISOString() }) + return null + } + + // Update detected + stateStore.set(STATE_KEY, { lastVersion: currentVersion, lastCheck: new Date().toISOString() }) + + return { + version: currentVersion, + previousVersion: lastVersion ?? 'unknown', + } +} diff --git a/src/modules/hytale-launcher/formatter.ts b/src/modules/hytale-launcher/formatter.ts new file mode 100644 index 0000000..717b6ce --- /dev/null +++ b/src/modules/hytale-launcher/formatter.ts @@ -0,0 +1,33 @@ +import type { LauncherUpdate } from './tracker.js' + +function formatDownloadUrls(downloadUrl: Record>): string { + const lines: string[] = [] + + for (const [os, architectures] of Object.entries(downloadUrl)) { + for (const [arch, data] of Object.entries(architectures)) { + if (data.url && data.sha256) { + lines.push(` • ${os}/${arch}: Download (SHA256: ${data.sha256.slice(0, 16)}...)`) + } + else if (data.url) { + lines.push(` • ${os}/${arch}: Download`) + } + } + } + + return lines.length > 0 ? `\nDownloads:\n${lines.join('\n')}` : '' +} + +export function formatLauncherUpdate(update: LauncherUpdate): string { + const { version, previousVersion, downloadUrl } = update + + let message = `🚀 Hytale Launcher Update + +New version: ${version} +${previousVersion !== 'unknown' ? `Previous: ${previousVersion}` : ''}` + + if (downloadUrl && Object.keys(downloadUrl).length > 0) { + message += formatDownloadUrls(downloadUrl) + } + + return message +} diff --git a/src/modules/hytale-launcher/index.ts b/src/modules/hytale-launcher/index.ts new file mode 100644 index 0000000..ff06515 --- /dev/null +++ b/src/modules/hytale-launcher/index.ts @@ -0,0 +1,73 @@ +import type { Module, ModuleDependencies } from '../../core/module.js' +import type { HytaleTrackerConfig } from '../../types.js' +import { formatLauncherUpdate } from './formatter.js' +import { checkLauncherUpdate } from './tracker.js' + +export function hytaleLauncherFactory( + moduleConfig: unknown, + deps: ModuleDependencies, +): Module { + const config = moduleConfig as HytaleTrackerConfig + + class HytaleLauncherModule implements Module { + readonly name = 'hytale-launcher' + private dependencies: ModuleDependencies + private config: HytaleTrackerConfig + private running = false + private intervalId: NodeJS.Timeout | null = null + + constructor(cfg: HytaleTrackerConfig, 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 update = await checkLauncherUpdate(this.dependencies.stateStore) + if (update) { + const message = formatLauncherUpdate(update) + await this.dependencies.telegram.sendMessage({ + text: message, + chatIds: this.config.chatIds, + disableLinkPreview: true, + }) + this.dependencies.logger(this.name, `Update detected: ${update.version}`) + } + } + } + + return new HytaleLauncherModule(config, deps) +} diff --git a/src/modules/hytale-launcher/tracker.ts b/src/modules/hytale-launcher/tracker.ts new file mode 100644 index 0000000..ca7f1aa --- /dev/null +++ b/src/modules/hytale-launcher/tracker.ts @@ -0,0 +1,43 @@ +import type { StateStore } from '../../core/state-store.js' + +interface LauncherResponse { + version: string + download_url: Record> +} + +export interface LauncherUpdate { + version: string + previousVersion: string + downloadUrl: Record> +} + +const ENDPOINT = 'https://launcher.hytale.com/version/release/launcher.json' +const STATE_KEY = 'hytale-launcher' + +export async function checkLauncherUpdate(stateStore: StateStore): Promise { + const response = await fetch(ENDPOINT) + if (!response.ok) { + throw new Error(`Launcher endpoint returned ${response.status}`) + } + + const data = await response.json() as LauncherResponse + const currentVersion = data.version + + const state = stateStore.get<{ lastVersion?: string, lastCheck?: string }>(STATE_KEY) + const lastVersion = state?.lastVersion + + if (lastVersion === currentVersion) { + // No update, just update check time + stateStore.set(STATE_KEY, { lastVersion: currentVersion, lastCheck: new Date().toISOString() }) + return null + } + + // Update detected + stateStore.set(STATE_KEY, { lastVersion: currentVersion, lastCheck: new Date().toISOString() }) + + return { + version: currentVersion, + previousVersion: lastVersion ?? 'unknown', + downloadUrl: data.download_url, + } +} diff --git a/src/modules/hytale-patches/formatter.ts b/src/modules/hytale-patches/formatter.ts new file mode 100644 index 0000000..c930a38 --- /dev/null +++ b/src/modules/hytale-patches/formatter.ts @@ -0,0 +1,13 @@ +import type { PatchesUpdate } from './tracker.js' + +export function formatPatchesUpdate(update: PatchesUpdate): string { + const { patchline, version, previousVersion, patchId } = update + const emoji = patchline === 'release' ? '🎮' : '🧪' + + return `${emoji} Hytale Game Patch Update + +Patchline: ${patchline} +New version: ${version} +${previousVersion !== 'unknown' ? `Previous: ${previousVersion}` : ''} +${patchId ? `Patch ID: ${patchId}` : ''}` +} diff --git a/src/modules/hytale-patches/index.ts b/src/modules/hytale-patches/index.ts new file mode 100644 index 0000000..dc24b44 --- /dev/null +++ b/src/modules/hytale-patches/index.ts @@ -0,0 +1,83 @@ +import type { Module, ModuleDependencies } from '../../core/module.js' +import type { HytalePatchesConfig } from '../../types.js' +import { formatPatchesUpdate } from './formatter.js' +import { checkPatchesUpdate } from './tracker.js' + +export function hytalePatchesFactory( + moduleConfig: unknown, + deps: ModuleDependencies, +): Module { + const config = moduleConfig as HytalePatchesConfig + + class HytalePatchesModule implements Module { + readonly name = 'hytale-patches' + private dependencies: ModuleDependencies + private config: HytalePatchesConfig + private running = false + private intervalId: NodeJS.Timeout | null = null + + constructor(cfg: HytalePatchesConfig, deps: ModuleDependencies) { + this.config = cfg + this.dependencies = deps + } + + isRunning(): boolean { + return this.running + } + + async start(): Promise { + if (this.running) + return + + if (!this.dependencies.tokenManager) { + throw new Error('hytale-patches requires tokenManager') + } + + 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 token = await this.dependencies.tokenManager!.getAccessToken('launcher') + const updates = await checkPatchesUpdate( + this.dependencies.stateStore, + this.config.patchlines, + token, + ) + + for (const update of updates) { + const message = formatPatchesUpdate(update) + await this.dependencies.telegram.sendMessage({ + text: message, + chatIds: this.config.chatIds, + disableLinkPreview: true, + }) + this.dependencies.logger(this.name, `Update detected: ${update.patchline} ${update.version}`) + } + } + } + + return new HytalePatchesModule(config, deps) +} diff --git a/src/modules/hytale-patches/tracker.ts b/src/modules/hytale-patches/tracker.ts new file mode 100644 index 0000000..111677d --- /dev/null +++ b/src/modules/hytale-patches/tracker.ts @@ -0,0 +1,77 @@ +import type { StateStore } from '../../core/state-store.js' + +export interface PatchesResponse { + patchlines: { + [key: string]: { + buildVersion: string + newest: number + } + } +} + +export interface PatchesUpdate { + patchline: string + version: string + previousVersion: string + patchId: number +} + +const ENDPOINT = 'https://account-data.hytale.com/my-account/get-launcher-data?arch=amd64&os=windows' +const STATE_KEY = 'hytale-patches' + +export interface PatchesState { + [patchline: string]: { + lastVersion: string + lastCheck: string + } +} + +export async function checkPatchesUpdate( + stateStore: StateStore, + patchlines: string[], + accessToken: string, +): Promise { + const response = await fetch(ENDPOINT, { + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'User-Agent': 'Hytale-Launcher/2.3.4586', + }, + }) + + if (!response.ok) { + throw new Error(`Patches endpoint returned ${response.status}`) + } + + const data = await response.json() as PatchesResponse + const state = stateStore.get(STATE_KEY) || {} + const updates: PatchesUpdate[] = [] + + for (const patchline of patchlines) { + if (!(patchline in data.patchlines)) { + continue + } + + const currentVersion = data.patchlines[patchline].buildVersion + const patchId = data.patchlines[patchline].newest + const lastState = state[patchline] + + if (lastState?.lastVersion === currentVersion) { + // No update + state[patchline] = { lastVersion: currentVersion, lastCheck: new Date().toISOString() } + continue + } + + // Update detected + state[patchline] = { lastVersion: currentVersion, lastCheck: new Date().toISOString() } + + updates.push({ + patchline, + version: currentVersion, + previousVersion: lastState?.lastVersion ?? 'unknown', + patchId, + }) + } + + stateStore.set(STATE_KEY, state) + return updates +} diff --git a/src/modules/hytale-server/formatter.ts b/src/modules/hytale-server/formatter.ts new file mode 100644 index 0000000..47094b6 --- /dev/null +++ b/src/modules/hytale-server/formatter.ts @@ -0,0 +1,12 @@ +import type { ServerUpdate } from './tracker.js' + +export function formatServerUpdate(update: ServerUpdate): string { + const { patchline, version, previousVersion } = update + const emoji = patchline === 'release' ? '🖥️' : '🧪' + + return `${emoji} Hytale Server Software Update + +Patchline: ${patchline} +New version: ${version} +${previousVersion !== 'unknown' ? `Previous: ${previousVersion}` : ''}` +} diff --git a/src/modules/hytale-server/index.ts b/src/modules/hytale-server/index.ts new file mode 100644 index 0000000..999a75b --- /dev/null +++ b/src/modules/hytale-server/index.ts @@ -0,0 +1,83 @@ +import type { Module, ModuleDependencies } from '../../core/module.js' +import type { HytaleServerConfig } from '../../types.js' +import { formatServerUpdate } from './formatter.js' +import { checkServerUpdate } from './tracker.js' + +export function hytaleServerFactory( + moduleConfig: unknown, + deps: ModuleDependencies, +): Module { + const config = moduleConfig as HytaleServerConfig + + class HytaleServerModule implements Module { + readonly name = 'hytale-server' + private dependencies: ModuleDependencies + private config: HytaleServerConfig + private running = false + private intervalId: NodeJS.Timeout | null = null + + constructor(cfg: HytaleServerConfig, deps: ModuleDependencies) { + this.config = cfg + this.dependencies = deps + } + + isRunning(): boolean { + return this.running + } + + async start(): Promise { + if (this.running) + return + + if (!this.dependencies.tokenManager) { + throw new Error('hytale-server requires tokenManager') + } + + 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 token = await this.dependencies.tokenManager!.getAccessToken('downloader') + const updates = await checkServerUpdate( + this.dependencies.stateStore, + this.config.patchlines, + token, + ) + + for (const update of updates) { + const message = formatServerUpdate(update) + await this.dependencies.telegram.sendMessage({ + text: message, + chatIds: this.config.chatIds, + disableLinkPreview: true, + }) + this.dependencies.logger(this.name, `Update detected: ${update.patchline} ${update.version}`) + } + } + } + + return new HytaleServerModule(config, deps) +} diff --git a/src/modules/hytale-server/tracker.ts b/src/modules/hytale-server/tracker.ts new file mode 100644 index 0000000..fbf2643 --- /dev/null +++ b/src/modules/hytale-server/tracker.ts @@ -0,0 +1,90 @@ +import type { StateStore } from '../../core/state-store.js' + +interface VersionUrlResponse { + url: string +} + +interface VersionDataResponse { + version: string + download_url: string + sha256: string +} + +export interface ServerUpdate { + patchline: string + version: string + previousVersion: string +} + +const BASE_ENDPOINT = 'https://account-data.hytale.com/game-assets/version' +const STATE_KEY = 'hytale-server' + +interface ServerState { + [patchline: string]: { + lastVersion: string + lastCheck: string + } +} + +export async function checkServerUpdate( + stateStore: StateStore, + patchlines: string[], + accessToken: string, +): Promise { + const state = stateStore.get(STATE_KEY) || {} + const updates: ServerUpdate[] = [] + + for (const patchline of patchlines) { + let currentVersion: string + + try { + // Step 1: Get signed URL + const urlResponse = await fetch(`${BASE_ENDPOINT}/${patchline}.json`, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + + if (!urlResponse.ok) { + throw new Error(`Version URL endpoint returned ${urlResponse.status}`) + } + + const urlData = await urlResponse.json() as VersionUrlResponse + const signedUrl = urlData.url + + // Step 2: Fetch version data from signed URL + const versionResponse = await fetch(signedUrl) + if (!versionResponse.ok) { + throw new Error(`Version data endpoint returned ${versionResponse.status}`) + } + + const versionData = await versionResponse.json() as VersionDataResponse + currentVersion = versionData.version + } + catch (err) { + // Skip this patchline on error, continue with others + console.error(`[hytale-server] Failed to check ${patchline}:`, err) + continue + } + + const lastState = state[patchline] + + if (lastState?.lastVersion === currentVersion) { + // No update + state[patchline] = { lastVersion: currentVersion, lastCheck: new Date().toISOString() } + continue + } + + // Update detected + state[patchline] = { lastVersion: currentVersion, lastCheck: new Date().toISOString() } + + updates.push({ + patchline, + version: currentVersion, + previousVersion: lastState?.lastVersion ?? 'unknown', + }) + } + + stateStore.set(STATE_KEY, state) + return updates +} diff --git a/src/telegram/client.ts b/src/telegram/client.ts deleted file mode 100644 index 9744a01..0000000 --- a/src/telegram/client.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { Telegram } from 'wrappergram' -import env from '../env.js' - -export const telegram = new Telegram(env.telegramBotToken) diff --git a/src/types.ts b/src/types.ts index 7d08c46..fa02006 100644 --- a/src/types.ts +++ b/src/types.ts @@ -17,16 +17,9 @@ export interface ServerConfig { channels?: ChannelConfig[] } -export interface Config { - telegram: { - chatId: string - } - servers: ServerConfig[] - ignoredUserIds?: string[] -} - export interface ForwardMessageOptions { topicId: number + chatId: string author: string role: string | null channel: string @@ -45,3 +38,47 @@ export interface AttachmentInfo { name: string contentType: string | null } + +export interface Config { + telegram: { + botToken: string + } + modules: Record + hytaleAuth?: Record +} + +export interface ModuleConfig { + enabled: boolean + [key: string]: unknown +} + +export interface DiscordForwarderConfig extends ModuleConfig { + enabled: boolean + ignoredUserIds: string[] + chatId: string + discordToken: string + servers: ServerConfig[] +} + +export interface ChatDestination { + chatId: string + topicId?: number +} + +export interface HytaleTrackerConfig extends ModuleConfig { + enabled: boolean + pollIntervalMinutes: number + chatIds: (string | ChatDestination)[] +} + +export interface HytalePatchesConfig extends HytaleTrackerConfig { + patchlines: string[] +} + +export interface HytaleServerConfig extends HytaleTrackerConfig { + patchlines: string[] +} diff --git a/tokens/.gitkeep b/tokens/.gitkeep new file mode 100644 index 0000000..e69de29