Files
TSSBOT-web/webhook.cjs
T
2026-05-14 21:11:32 +01:00

420 lines
11 KiB
JavaScript

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())
.filter(Boolean)
const RESTART_AFTER_MS = 24 * 60 * 60 * 1000
let deploying = false
function json(res, status, body) {
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
res.end(JSON.stringify(body))
}
function safeJsonParse(rawBody) {
try {
return JSON.parse(rawBody.toString('utf8'))
} catch {
return null
}
}
function shortSha(value) {
return value ? String(value).slice(0, 7) : 'unknown'
}
function branchName(ref) {
return String(ref || '').replace(/^refs\/heads\//, '') || 'unknown'
}
function truncate(value, maxLength = 900) {
const text = String(value || '')
if (text.length <= maxLength) return text
return `${text.slice(0, maxLength - 3)}...`
}
function codeBlock(value, language = '', maxLength = 1000) {
const fence = language ? `\`\`\`${language}\n` : '```\n'
const suffix = '\n```'
const body = truncate(value || 'No diff returned', maxLength - fence.length - suffix.length)
return `${fence}${body}${suffix}`
}
function validGitSha(value) {
return /^[0-9a-f]{7,40}$/i.test(String(value || ''))
}
function verifySignature(rawBody, signature) {
if (!SECRET) return true
if (!signature || !signature.startsWith('sha256=')) return false
const expected = `sha256=${crypto.createHmac('sha256', SECRET).update(rawBody).digest('hex')}`
const signatureBuffer = Buffer.from(signature)
const expectedBuffer = Buffer.from(expected)
return (
signatureBuffer.length === expectedBuffer.length &&
crypto.timingSafeEqual(signatureBuffer, expectedBuffer)
)
}
function commandFor(command) {
if (process.platform !== 'win32') return command
if (command === 'npm') return 'npm.cmd'
if (command === 'pm2') return 'pm2.cmd'
return command
}
function run(command, args, options = {}) {
return new Promise((resolve, reject) => {
const label = [command, ...args].join(' ')
console.log(`deploy step started: ${label}`)
const child = spawn(commandFor(command), args, {
cwd: __dirname,
env: { ...process.env, ...options.env },
shell: process.platform === 'win32',
stdio: 'inherit',
})
child.on('error', reject)
child.on('exit', (code) => {
if (code === 0) {
console.log(`deploy step completed: ${label}`)
resolve()
} else {
reject(new Error(`${label} exited with ${code}`))
}
})
})
}
function runCapture(command, args, options = {}) {
return new Promise((resolve, reject) => {
const label = [command, ...args].join(' ')
const child = spawn(commandFor(command), args, {
cwd: __dirname,
env: { ...process.env, ...options.env },
shell: process.platform === 'win32',
stdio: ['ignore', 'pipe', 'pipe'],
})
let stdout = ''
let stderr = ''
child.stdout.on('data', (chunk) => {
stdout += chunk
})
child.stderr.on('data', (chunk) => {
stderr += chunk
})
child.on('error', reject)
child.on('exit', (code) => {
if (code === 0) {
resolve(stdout.trim())
} else {
reject(new Error(`${label} exited with ${code}: ${truncate(stderr || stdout, 500)}`))
}
})
})
}
async function ensureBuildDependencies() {
await run('npm', ['install', '--production=false', '--include=dev', '--include=optional'], {
env: {
NODE_ENV: 'development',
npm_config_include: 'dev,optional',
npm_config_omit: '',
npm_config_production: 'false',
},
})
try {
require.resolve('@vitejs/plugin-react')
} catch {
throw new Error('@vitejs/plugin-react is missing after npm install; dev dependencies were not installed')
}
if (process.platform === 'linux' && process.arch === 'x64') {
const libc = process.report?.getReport?.().header?.glibcVersionRuntime ? 'gnu' : 'musl'
try {
require.resolve(`@rollup/rollup-linux-x64-${libc}`)
} catch {
throw new Error(`@rollup/rollup-linux-x64-${libc} is missing after npm install; optional dependencies were not installed`)
}
}
}
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()
sendDiscordEmbed({
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(),
})
}
function sendDiscordEmbed(embed) {
return postDiscordWebhook({
username: 'tssbot webhook',
embeds: [embed],
})
}
function deployFields(push) {
const headCommit = push?.head_commit
const fields = [
{ name: 'Host', value: os.hostname(), inline: true },
{ name: 'Branch', value: branchName(push?.ref), inline: true },
{ name: 'Commit', value: shortSha(push?.after || headCommit?.id), inline: true },
{ name: 'Restart targets', value: RESTART_TARGETS.join(', ') || 'none', inline: false },
]
if (push?.sender?.login) {
fields.push({ name: 'Sender', value: push.sender.login, inline: true })
}
if (headCommit?.message) {
fields.push({ name: 'Message', value: truncate(headCommit.message), inline: false })
}
return fields
}
async function deployDiff(push) {
const before = push?.before
const after = push?.after
if (!validGitSha(before) || !validGitSha(after) || /^0+$/.test(before)) {
return {
summary: 'Diff unavailable for this push range',
patch: push?.compare ? `Compare: ${push.compare}` : '',
}
}
const range = `${before}..${after}`
const [nameStatus, shortStat, patch] = await Promise.all([
runCapture('git', ['diff', '--name-status', '--find-renames', range]),
runCapture('git', ['diff', '--shortstat', range]),
runCapture('git', ['diff', '--find-renames', '--unified=1', range]),
])
return {
summary: [shortStat, nameStatus].filter(Boolean).join('\n'),
patch,
}
}
function diffFields(diff) {
if (!diff) return []
const fields = []
if (diff.summary) {
fields.push({ name: 'Diff summary', value: codeBlock(diff.summary, 'diff'), inline: false })
}
if (diff.patch) {
fields.push({ name: 'Patch preview', value: codeBlock(diff.patch, 'diff'), inline: false })
}
return fields
}
async function notifyDeployStarted(push) {
await sendDiscordEmbed({
title: 'GitHub push deploy started',
color: 0xf4ee3e,
fields: deployFields(push),
timestamp: new Date().toISOString(),
})
}
async function notifyDeployCompleted(push, diff) {
await sendDiscordEmbed({
title: 'GitHub push deploy completed',
color: 0x00f2ff,
fields: [...deployFields(push), ...diffFields(diff)],
timestamp: new Date().toISOString(),
})
}
async function notifyDeployFailed(push, error, diff) {
await sendDiscordEmbed({
title: 'GitHub push deploy failed',
color: 0xe82517,
fields: [
...deployFields(push),
...diffFields(diff),
{ name: 'Error', value: truncate(error?.message || error), inline: false },
],
timestamp: new Date().toISOString(),
})
}
async function deploy(push) {
let diff = null
try {
await notifyDeployStarted(push)
await run('git', ['pull', '--ff-only'])
diff = await deployDiff(push)
await ensureBuildDependencies()
await run('npm', ['run', 'build'])
for (const target of RESTART_TARGETS) {
await run('pm2', ['reload', target, '--update-env'])
}
await notifyDeployCompleted(push, diff)
} catch (error) {
error.deployDiff = diff
throw error
}
}
http
.createServer((req, res) => {
if (req.method === 'GET' && req.url === '/health') {
json(res, 200, { ok: true, deploying, restart_targets: RESTART_TARGETS })
return
}
if (req.method !== 'POST' || req.url !== '/github') {
json(res, 404, { error: 'Not found' })
return
}
const chunks = []
req.on('data', (chunk) => chunks.push(chunk))
req.on('end', () => {
const rawBody = Buffer.concat(chunks)
const push = safeJsonParse(rawBody)
if (!verifySignature(rawBody, req.headers['x-hub-signature-256'])) {
json(res, 401, { error: 'Invalid signature' })
return
}
if (req.headers['x-github-event'] !== 'push') {
json(res, 202, { skipped: true, reason: 'Only push events trigger deploys' })
return
}
if (deploying) {
json(res, 202, { queued: false, deploying: true })
return
}
deploying = true
json(res, 202, { accepted: true, restart_targets: RESTART_TARGETS })
deploy(push)
.then(() => console.log('GitHub push deploy completed'))
.catch((error) => {
console.error('GitHub push deploy failed:', error)
notifyDeployFailed(push, error, error.deployDiff)
})
.finally(() => {
deploying = false
})
})
})
.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(() => {
console.log('24 hour webhook refresh reached; exiting for PM2 restart')
process.exit(0)
}, RESTART_AFTER_MS).unref()