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
129
src/modules/discord-forwarder/handlers.ts
Normal file
129
src/modules/discord-forwarder/handlers.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import type { Client, Message } from 'discord.js-selfbot-v13'
|
||||
|
||||
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
|
||||
label: string
|
||||
type: 'channel' | 'role'
|
||||
}
|
||||
|
||||
interface ReplyInfo {
|
||||
author: string
|
||||
content: string
|
||||
messageLink: string
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
// Skip forwarded messages
|
||||
if ((message.reference as any)?.type === 'FORWARD')
|
||||
return
|
||||
|
||||
if (config.ignoredUserIds?.includes(message.author.id))
|
||||
return
|
||||
|
||||
const serverConfig = config.servers.find(s => s.guildId === message.guild!.id)
|
||||
if (!serverConfig)
|
||||
return
|
||||
|
||||
// Check channels first (higher priority), then roles
|
||||
let match: MatchResult | null = null
|
||||
|
||||
if (serverConfig.channels) {
|
||||
const channelMatch = findMatchingChannel(message.channel.id, serverConfig.channels)
|
||||
if (channelMatch) {
|
||||
match = { topicId: channelMatch.topicId, label: channelMatch.name, type: 'channel' }
|
||||
}
|
||||
}
|
||||
|
||||
if (!match && serverConfig.roles && message.member) {
|
||||
const roleMatch = findHighestPriorityRole(message.member.roles.cache, serverConfig.roles)
|
||||
if (roleMatch) {
|
||||
match = { topicId: roleMatch.topicId, label: roleMatch.name, type: 'role' }
|
||||
}
|
||||
}
|
||||
|
||||
if (!match)
|
||||
return
|
||||
|
||||
const attachments: AttachmentInfo[] = message.attachments.map(att => ({
|
||||
url: att.url,
|
||||
name: att.name ?? 'file',
|
||||
contentType: att.contentType,
|
||||
}))
|
||||
|
||||
const messageLink = `https://discord.com/channels/${message.guild.id}/${message.channel.id}/${message.id}`
|
||||
const channelName = 'name' in message.channel ? (message.channel.name ?? 'unknown') : 'DM'
|
||||
|
||||
// Fetch replied message if this is a reply
|
||||
let replyTo: ReplyInfo | undefined
|
||||
if (message.type === 'REPLY' && message.reference?.messageId) {
|
||||
try {
|
||||
const repliedMessage = await message.channel.messages.fetch(message.reference.messageId)
|
||||
replyTo = {
|
||||
author: repliedMessage.author.displayName ?? repliedMessage.author.username,
|
||||
content: repliedMessage.content,
|
||||
messageLink: `https://discord.com/channels/${message.reference.guildId}/${message.reference.channelId}/${message.reference.messageId}`,
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// Replied message deleted or inaccessible
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
content: message.content,
|
||||
attachments,
|
||||
messageLink,
|
||||
replyTo,
|
||||
})
|
||||
deps.logger('discord-forwarder', `Forwarded from ${message.author.tag} (${match.type}: ${match.label})`)
|
||||
}
|
||||
catch (err) {
|
||||
deps.logger('discord-forwarder', `Failed to forward: ${err}`)
|
||||
}
|
||||
}
|
||||
|
||||
function findMatchingChannel(
|
||||
channelId: string,
|
||||
configChannels: ChannelConfig[],
|
||||
): ChannelConfig | null {
|
||||
return configChannels.find(ch => ch.id === channelId) ?? null
|
||||
}
|
||||
|
||||
function findHighestPriorityRole(
|
||||
memberRoles: Map<string, unknown>,
|
||||
configRoles: RoleConfig[],
|
||||
): RoleConfig | null {
|
||||
for (const role of configRoles) {
|
||||
if (memberRoles.has(role.id)) {
|
||||
return role
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
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)
|
||||
}
|
||||
107
src/modules/discord-forwarder/sender.ts
Normal file
107
src/modules/discord-forwarder/sender.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import type { TelegramClient } from '../../core/telegram-client.js'
|
||||
import type { ForwardMessageOptions } from '../../types.js'
|
||||
import { MediaUpload } from 'wrappergram'
|
||||
|
||||
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) {
|
||||
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)})` : ''
|
||||
text += `<b>${escapeHtml(author)}</b>${roleText} in <code>#${escapeHtml(channel)}</code>\n${escapeHtml(content)}\n\n<a href="${messageLink}">Jump to message</a>`
|
||||
|
||||
// Enable link preview only if content has URLs (not just the discord jump link)
|
||||
const hasLinks = /https?:\/\/\S+/i.test(content)
|
||||
|
||||
await telegram.sendMessage({
|
||||
text,
|
||||
chatIds: [chatId],
|
||||
topicId,
|
||||
parseMode: 'HTML',
|
||||
disableLinkPreview: !hasLinks,
|
||||
})
|
||||
|
||||
if (attachments.length === 0)
|
||||
return
|
||||
|
||||
// Separate media (photos/videos) from documents
|
||||
const media = attachments.filter((att) => {
|
||||
const isImage = att.contentType?.startsWith('image/')
|
||||
const isVideo = att.contentType?.startsWith('video/')
|
||||
return isImage || isVideo
|
||||
})
|
||||
const documents = attachments.filter((att) => {
|
||||
const isImage = att.contentType?.startsWith('image/')
|
||||
const isVideo = att.contentType?.startsWith('video/')
|
||||
return !isImage && !isVideo
|
||||
})
|
||||
|
||||
// Send media as group or single
|
||||
if (media.length > 1) {
|
||||
try {
|
||||
const mediaItems = await Promise.all(media.map(async (att) => {
|
||||
const file = await MediaUpload.url(att.url)
|
||||
return {
|
||||
type: att.contentType?.startsWith('video/') ? 'video' as const : 'photo' as const,
|
||||
media: file,
|
||||
}
|
||||
}))
|
||||
await telegram.api.sendMediaGroup({
|
||||
chat_id: chatId,
|
||||
message_thread_id: topicId,
|
||||
media: mediaItems,
|
||||
})
|
||||
}
|
||||
catch (err) {
|
||||
console.error('Failed to send media group:', err)
|
||||
}
|
||||
}
|
||||
else if (media.length === 1) {
|
||||
const att = media[0]
|
||||
try {
|
||||
if (att.contentType?.startsWith('video/')) {
|
||||
await telegram.api.sendVideo({
|
||||
chat_id: chatId,
|
||||
video: await MediaUpload.url(att.url),
|
||||
message_thread_id: topicId,
|
||||
})
|
||||
}
|
||||
else {
|
||||
await telegram.api.sendPhoto({
|
||||
chat_id: chatId,
|
||||
photo: await MediaUpload.url(att.url),
|
||||
message_thread_id: topicId,
|
||||
})
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
console.error(`Failed to send ${att.contentType?.startsWith('video/') ? 'video' : 'photo'}:`, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Send documents separately
|
||||
for (const att of documents) {
|
||||
try {
|
||||
await telegram.api.sendDocument({
|
||||
chat_id: chatId,
|
||||
document: await MediaUpload.url(att.url),
|
||||
message_thread_id: topicId,
|
||||
})
|
||||
}
|
||||
catch (err) {
|
||||
console.error(`Failed to send document ${att.name}:`, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
}
|
||||
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue