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 <noreply@anthropic.com>
This commit is contained in:
devilreef 2026-01-16 01:18:15 +06:00
parent 369a37903f
commit a7d4df6986
29 changed files with 1368 additions and 121 deletions

3
.gitignore vendored
View file

@ -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 ###
*~

118
CLAUDE.md
View file

@ -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/<patchline>.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

View file

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

23
src/core/module.ts Normal file
View file

@ -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<void>
stop: () => Promise<void>
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

84
src/core/state-store.ts Normal file
View file

@ -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<string, unknown>()
private dirty = new Set<string>()
private persistTimer: NodeJS.Timeout | null = null
private loaded = false
async load(): Promise<void> {
if (this.loaded) {
return
}
try {
const content = await fs.readFile(STATE_FILE, 'utf-8')
const data = JSON.parse(content) as Record<string, unknown>
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<T>(key: string): T | undefined {
return this.state.get(key) as T
}
set<T>(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<void> {
this.persistTimer = null
if (this.dirty.size === 0) {
return
}
const toSave: Record<string, unknown> = {}
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<void> {
if (this.persistTimer) {
clearTimeout(this.persistTimer)
this.persistTimer = null
}
await this.persist()
}
}

View file

@ -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<void> {
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
}
}

160
src/core/token-manager.ts Normal file
View file

@ -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<string, TokenConfig>()
private tokens = new Map<string, TokenData>()
private refreshTimers = new Map<string, NodeJS.Timeout>()
register(key: string, config: TokenConfig): void {
this.configs.set(key, config)
}
async getAccessToken(key: string): Promise<string> {
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<TokenData | undefined> {
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<void> {
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<void> {
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()
}
}

View file

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

View file

@ -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<void> {
await discord.login(env.discordToken)
console.log(`Discord logged in as ${discord.user?.tag}`)
}

View file

@ -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(),
}

View file

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

View file

@ -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<void> {
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<void> {
if (!message.guild)
return
@ -31,7 +40,7 @@ async function handleMessage(message: Message): Promise<void> {
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<void> {
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<void> {
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}`)
}
}

View file

@ -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<void> {
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<void> {
if (!this.running)
return
this.discord.destroy()
this.running = false
}
}
return new DiscordForwarderModule(config, deps)
}

View file

@ -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<void> {
const { topicId, author, role, channel, content, attachments, messageLink, replyTo } = opts
export async function forwardMessage(opts: ForwardMessageOptions & { telegram: TelegramClient }): Promise<void> {
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 += `<blockquote><b>${escapeHtml(replyTo.author)}</b>\n${escapeHtml(replyContent)}\n<a href="${replyTo.messageLink}">View original</a></blockquote>\n\n`
text += `<blockquote><b>${escapeHtml(replyTo.author)}</b>\n${escapeHtml(replyTo.content) || '(no text)'}\n<a href="${replyTo.messageLink}">View original</a></blockquote>\n\n`
}
const roleText = role ? ` (${escapeHtml(role)})` : ''
@ -20,12 +18,12 @@ export async function forwardMessage(opts: ForwardMessageOptions): Promise<void>
// 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<void>
}
}))
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<void>
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<void>
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,
})

View file

@ -0,0 +1,10 @@
import type { DownloaderUpdate } from './tracker.js'
export function formatDownloaderUpdate(update: DownloaderUpdate): string {
const { version, previousVersion } = update
return `<b>📦 Hytale Downloader Update</b>
<b>New version:</b> <code>${version}</code>
${previousVersion !== 'unknown' ? `<b>Previous:</b> <code>${previousVersion}</code>` : ''}`
}

View file

@ -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<void> {
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<void> {
if (!this.running)
return
if (this.intervalId) {
clearInterval(this.intervalId)
this.intervalId = null
}
this.running = false
}
private async check(): Promise<void> {
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)
}

View file

@ -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<DownloaderUpdate | null> {
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',
}
}

View file

@ -0,0 +1,33 @@
import type { LauncherUpdate } from './tracker.js'
function formatDownloadUrls(downloadUrl: Record<string, Record<string, { url: string, sha256: string }>>): 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}: <a href="${data.url}">Download</a> (SHA256: <code>${data.sha256.slice(0, 16)}...</code>)`)
}
else if (data.url) {
lines.push(`${os}/${arch}: <a href="${data.url}">Download</a>`)
}
}
}
return lines.length > 0 ? `\n<b>Downloads:</b>\n${lines.join('\n')}` : ''
}
export function formatLauncherUpdate(update: LauncherUpdate): string {
const { version, previousVersion, downloadUrl } = update
let message = `<b>🚀 Hytale Launcher Update</b>
<b>New version:</b> <code>${version}</code>
${previousVersion !== 'unknown' ? `<b>Previous:</b> <code>${previousVersion}</code>` : ''}`
if (downloadUrl && Object.keys(downloadUrl).length > 0) {
message += formatDownloadUrls(downloadUrl)
}
return message
}

View file

@ -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<void> {
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<void> {
if (!this.running)
return
if (this.intervalId) {
clearInterval(this.intervalId)
this.intervalId = null
}
this.running = false
}
private async check(): Promise<void> {
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)
}

View file

@ -0,0 +1,43 @@
import type { StateStore } from '../../core/state-store.js'
interface LauncherResponse {
version: string
download_url: Record<string, Record<string, { url: string, sha256: string }>>
}
export interface LauncherUpdate {
version: string
previousVersion: string
downloadUrl: Record<string, Record<string, { url: string, sha256: string }>>
}
const ENDPOINT = 'https://launcher.hytale.com/version/release/launcher.json'
const STATE_KEY = 'hytale-launcher'
export async function checkLauncherUpdate(stateStore: StateStore): Promise<LauncherUpdate | null> {
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,
}
}

View file

@ -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 `<b>${emoji} Hytale Game Patch Update</b>
<b>Patchline:</b> <code>${patchline}</code>
<b>New version:</b> <code>${version}</code>
${previousVersion !== 'unknown' ? `<b>Previous:</b> <code>${previousVersion}</code>` : ''}
${patchId ? `<b>Patch ID:</b> <code>${patchId}</code>` : ''}`
}

View file

@ -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<void> {
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<void> {
if (!this.running)
return
if (this.intervalId) {
clearInterval(this.intervalId)
this.intervalId = null
}
this.running = false
}
private async check(): Promise<void> {
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)
}

View file

@ -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<PatchesUpdate[]> {
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<PatchesState>(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
}

View file

@ -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 `<b>${emoji} Hytale Server Software Update</b>
<b>Patchline:</b> <code>${patchline}</code>
<b>New version:</b> <code>${version}</code>
${previousVersion !== 'unknown' ? `<b>Previous:</b> <code>${previousVersion}</code>` : ''}`
}

View file

@ -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<void> {
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<void> {
if (!this.running)
return
if (this.intervalId) {
clearInterval(this.intervalId)
this.intervalId = null
}
this.running = false
}
private async check(): Promise<void> {
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)
}

View file

@ -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<ServerUpdate[]> {
const state = stateStore.get<ServerState>(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
}

View file

@ -1,4 +0,0 @@
import { Telegram } from 'wrappergram'
import env from '../env.js'
export const telegram = new Telegram(env.telegramBotToken)

View file

@ -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<string, ModuleConfig>
hytaleAuth?: Record<string, {
clientId: string
tokenFile: string
tokenEndpoint?: string
}>
}
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[]
}

0
tokens/.gitkeep Normal file
View file