import { NextRequest, NextResponse } from 'next/server'; import { createHmac, timingSafeEqual } from 'crypto'; import { postgresClient } from '@/lib/services/postgres-client'; function signPayload(secret: string, body: string): string { return 'sha256=' + createHmac('sha256', secret).update(body).digest('hex'); } function verifySignature(secret: string, body: string, header: string): boolean { try { const expected = Buffer.from(signPayload(secret, body)); const actual = Buffer.from(header); if (expected.length !== actual.length) return false; return timingSafeEqual(expected, actual); } catch { return false; } } async function sendTelegram(message: string): Promise { const result = await postgresClient.query<{ config: Record }>( `SELECT config FROM notification_channels WHERE channel_type = 'telegram' AND is_active = true LIMIT 1` ); if (result.rows.length === 0) return; const { bot_token, chat_id, parse_mode } = result.rows[0].config; if (!bot_token || !chat_id) return; await fetch(`https://api.telegram.org/bot${bot_token}/sendMessage`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ chat_id, text: message, parse_mode: parse_mode || 'HTML', }), }); } async function notifyOpenClawInstances(payload: object): Promise { const instances = await postgresClient.query<{ id: number; name: string; webhook_url: string; webhook_secret: string; }>(`SELECT id, name, webhook_url, webhook_secret FROM openclaw_instances WHERE enabled = true`); const body = JSON.stringify(payload); await Promise.allSettled( instances.rows.map(async (instance) => { const signature = signPayload(instance.webhook_secret, body); try { await fetch(instance.webhook_url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-webhook-signature': signature, }, body, signal: AbortSignal.timeout(10000), }); await postgresClient.query( `UPDATE openclaw_instances SET last_notified_at = NOW() WHERE id = $1`, [instance.id] ); } catch (err) { console.error(`[forgejo-webhook] Failed to notify OpenClaw instance "${instance.name}":`, err); } }) ); } export async function POST(request: NextRequest) { const rawBody = await request.text(); const webhookSecret = process.env.FORGEJO_WEBHOOK_SECRET; if (webhookSecret) { const sigHeader = request.headers.get('x-gitea-signature') || request.headers.get('x-hub-signature-256') || ''; const sigToVerify = sigHeader.startsWith('sha256=') ? sigHeader : `sha256=${sigHeader}`; if (!verifySignature(webhookSecret, rawBody, sigToVerify)) { return NextResponse.json({ error: 'Invalid signature' }, { status: 401 }); } } let payload: any; try { payload = JSON.parse(rawBody); } catch { return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); } const eventType = request.headers.get('x-gitea-event') || request.headers.get('x-github-event') || ''; if (eventType !== 'push' && eventType !== '') { return NextResponse.json({ ok: true, skipped: true, event: eventType }); } const repoName: string = payload?.repository?.name ?? 'unknown'; const repoFullName: string = payload?.repository?.full_name ?? repoName; const branch: string = (payload?.ref ?? '').replace('refs/heads/', '') || 'unknown'; const pushedBy: string = payload?.pusher?.login ?? payload?.sender?.login ?? 'unknown'; const commits: any[] = Array.isArray(payload?.commits) ? payload.commits : []; if (commits.length === 0) { return NextResponse.json({ ok: true, stored: 0 }); } let stored = 0; for (const commit of commits) { const sha: string = commit.id ?? commit.sha ?? ''; const message: string = (commit.message ?? '').split('\n')[0]; const authorName: string = commit.author?.name ?? pushedBy; const authorEmail: string = commit.author?.email ?? ''; const committedAt: string | null = commit.timestamp ?? null; try { await postgresClient.query( `INSERT INTO repo_commits (repo_name, repo_full_name, branch, commit_sha, commit_message, author_name, author_email, committed_at, pushed_by, raw_payload) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) ON CONFLICT (repo_full_name, commit_sha) DO NOTHING`, [repoName, repoFullName, branch, sha, message, authorName, authorEmail, committedAt, pushedBy, payload] ); stored++; } catch (err) { console.error('[forgejo-webhook] Failed to insert commit:', err); } } await postgresClient.query( `UPDATE repo_commits SET notified_at = NOW() WHERE repo_full_name = $1 AND branch = $2 AND notified_at IS NULL`, [repoFullName, branch] ); const forwardPayload = { repository: { name: repoName, full_name: repoFullName }, pusher: { login: pushedBy }, ref: payload?.ref, commits: commits.map((c: any) => ({ id: c.id ?? c.sha, message: (c.message ?? '').split('\n')[0], author: c.author, timestamp: c.timestamp, })), }; void notifyOpenClawInstances(forwardPayload); const commitLines = commits .slice(0, 5) .map((c: any) => `• ${(c.id ?? c.sha ?? '').substring(0, 7)} — ${(c.message ?? '').split('\n')[0]}`) .join('\n'); const extra = commits.length > 5 ? `\n…and ${commits.length - 5} more` : ''; const telegramMsg = `🔀 ${repoName} · ${branch}\n` + `👤 ${pushedBy} · ${commits.length} commit${commits.length === 1 ? '' : 's'}\n\n` + commitLines + extra; void sendTelegram(telegramMsg); return NextResponse.json({ ok: true, stored, commits: commits.length }); }