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:
parent
369a37903f
commit
a7d4df6986
29 changed files with 1368 additions and 121 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -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
118
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/<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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
23
src/core/module.ts
Normal 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
84
src/core/state-store.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
43
src/core/telegram-client.ts
Normal file
43
src/core/telegram-client.ts
Normal 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
160
src/core/token-manager.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
}
|
||||
12
src/env.ts
12
src/env.ts
|
|
@ -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(),
|
||||
}
|
||||
125
src/index.ts
125
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<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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
}
|
||||
}
|
||||
|
||||
51
src/modules/discord-forwarder/index.ts
Normal file
51
src/modules/discord-forwarder/index.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
})
|
||||
10
src/modules/hytale-downloader/formatter.ts
Normal file
10
src/modules/hytale-downloader/formatter.ts
Normal 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>` : ''}`
|
||||
}
|
||||
73
src/modules/hytale-downloader/index.ts
Normal file
73
src/modules/hytale-downloader/index.ts
Normal 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)
|
||||
}
|
||||
40
src/modules/hytale-downloader/tracker.ts
Normal file
40
src/modules/hytale-downloader/tracker.ts
Normal 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',
|
||||
}
|
||||
}
|
||||
33
src/modules/hytale-launcher/formatter.ts
Normal file
33
src/modules/hytale-launcher/formatter.ts
Normal 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
|
||||
}
|
||||
73
src/modules/hytale-launcher/index.ts
Normal file
73
src/modules/hytale-launcher/index.ts
Normal 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)
|
||||
}
|
||||
43
src/modules/hytale-launcher/tracker.ts
Normal file
43
src/modules/hytale-launcher/tracker.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
13
src/modules/hytale-patches/formatter.ts
Normal file
13
src/modules/hytale-patches/formatter.ts
Normal 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>` : ''}`
|
||||
}
|
||||
83
src/modules/hytale-patches/index.ts
Normal file
83
src/modules/hytale-patches/index.ts
Normal 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)
|
||||
}
|
||||
77
src/modules/hytale-patches/tracker.ts
Normal file
77
src/modules/hytale-patches/tracker.ts
Normal 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
|
||||
}
|
||||
12
src/modules/hytale-server/formatter.ts
Normal file
12
src/modules/hytale-server/formatter.ts
Normal 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>` : ''}`
|
||||
}
|
||||
83
src/modules/hytale-server/index.ts
Normal file
83
src/modules/hytale-server/index.ts
Normal 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)
|
||||
}
|
||||
90
src/modules/hytale-server/tracker.ts
Normal file
90
src/modules/hytale-server/tracker.ts
Normal 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
|
||||
}
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
import { Telegram } from 'wrappergram'
|
||||
import env from '../env.js'
|
||||
|
||||
export const telegram = new Telegram(env.telegramBotToken)
|
||||
53
src/types.ts
53
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<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
0
tokens/.gitkeep
Normal file
Loading…
Add table
Add a link
Reference in a new issue