diff --git a/README.md b/README.md index 9fe45cf..f768e11 100644 --- a/README.md +++ b/README.md @@ -93,3 +93,10 @@ pm2 reload tssbot-web --update-env Only processes listed in `PM2_RESTART_TARGETS` are reloaded. The default is `tssbot-web`, so unrelated PM2 processes are left alone. The webhook exits after 24 hours so PM2 restarts it cleanly. + +The webhook listener reads `.env` on startup. To send a Discord notification +whenever the listener starts or restarts, set: + +```sh +DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/... +``` diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs index 4872326..e43f6f0 100644 --- a/ecosystem.config.cjs +++ b/ecosystem.config.cjs @@ -24,6 +24,7 @@ module.exports = { WEBHOOK_PORT: process.env.WEBHOOK_PORT || 3011, GITHUB_WEBHOOK_SECRET: process.env.GITHUB_WEBHOOK_SECRET || '', PM2_RESTART_TARGETS: process.env.PM2_RESTART_TARGETS || 'tssbot-web', + DISCORD_WEBHOOK_URL: process.env.DISCORD_WEBHOOK_URL || '', }, }, ], diff --git a/example.env b/example.env index bcf00b4..e9a392f 100644 --- a/example.env +++ b/example.env @@ -10,3 +10,4 @@ API_RATE_LIMIT_MAX=120 WEBHOOK_PORT=3011 GITHUB_WEBHOOK_SECRET=change-me PM2_RESTART_TARGETS=tssbot-web +DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/... diff --git a/webhook.cjs b/webhook.cjs index 6d7430d..09d13e8 100644 --- a/webhook.cjs +++ b/webhook.cjs @@ -1,9 +1,44 @@ const crypto = require('node:crypto') +const fs = require('node:fs') const http = require('node:http') +const https = require('node:https') +const os = require('node:os') +const path = require('node:path') const { spawn } = require('node:child_process') +function loadEnvFile() { + const envPath = path.join(__dirname, '.env') + if (!fs.existsSync(envPath)) return + + const lines = fs.readFileSync(envPath, 'utf8').split(/\r?\n/) + for (const line of lines) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) continue + + const separatorIndex = trimmed.indexOf('=') + if (separatorIndex === -1) continue + + const key = trimmed.slice(0, separatorIndex).trim() + let value = trimmed.slice(separatorIndex + 1).trim() + + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1) + } + + if (key && (!process.env[key] || process.env[key] === '')) { + process.env[key] = value + } + } +} + +loadEnvFile() + const PORT = Number(process.env.WEBHOOK_PORT || 3011) const SECRET = process.env.GITHUB_WEBHOOK_SECRET || '' +const DISCORD_WEBHOOK_URL = process.env.DISCORD_WEBHOOK_URL || '' const RESTART_TARGETS = (process.env.PM2_RESTART_TARGETS || 'tssbot-web') .split(',') .map((target) => target.trim()) @@ -47,6 +82,69 @@ function run(command, args) { }) } +function postDiscordWebhook(payload) { + if (!DISCORD_WEBHOOK_URL) return Promise.resolve() + + return new Promise((resolve) => { + let url + try { + url = new URL(DISCORD_WEBHOOK_URL) + } catch (error) { + console.error('Discord restart webhook URL is invalid:', error.message) + resolve() + return + } + + const body = Buffer.from(JSON.stringify(payload)) + const client = url.protocol === 'http:' ? http : https + const req = client.request( + url, + { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'content-length': body.length, + 'user-agent': 'tssbot-webhook', + }, + timeout: 5000, + }, + (res) => { + res.resume() + res.on('end', resolve) + }, + ) + + req.on('timeout', () => { + req.destroy(new Error('Discord restart webhook timed out')) + }) + req.on('error', (error) => { + console.error('Discord restart webhook failed:', error.message) + resolve() + }) + req.end(body) + }) +} + +function notifyDiscordRestart() { + const startedAt = new Date() + + postDiscordWebhook({ + username: 'tssbot webhook', + embeds: [ + { + title: 'Webhook listener restarted', + color: 0x00f2ff, + fields: [ + { name: 'Host', value: os.hostname(), inline: true }, + { name: 'Port', value: String(PORT), inline: true }, + { name: 'Restart targets', value: RESTART_TARGETS.join(', ') || 'none', inline: false }, + ], + timestamp: startedAt.toISOString(), + }, + ], + }) +} + async function deploy() { await run('git', ['pull', '--ff-only']) await run('npm', ['install']) @@ -103,6 +201,7 @@ http .listen(PORT, '0.0.0.0', () => { console.log(`tssbot webhook listening on http://localhost:${PORT}/github`) console.log(`restart targets: ${RESTART_TARGETS.join(', ')}`) + notifyDiscordRestart() }) setTimeout(() => {