wulf-pulse/app/api/webhooks/forgejo/route.ts
lorentz 414ad78c36 feat: repo commit tracking and OpenClaw notification
- Migration 058: repo_commits + openclaw_instances tables (seeded with overwatch)
- POST /api/webhooks/forgejo: receives Forgejo push events, stores commits,
  forwards HMAC-signed payload to all enabled OpenClaw instances, sends Telegram
- GET /api/openclaw/repo-commits: OpenClaw polling endpoint (filters: since, repo, branch, limit)
- GET/POST /api/admin/openclaw-instances: manage instance registry
- PATCH/DELETE /api/admin/openclaw-instances/[id]: update/remove instances
- FORGEJO_WEBHOOK_SECRET in .env.local (leave empty to skip HMAC verification)
2026-03-21 18:52:22 -04:00

170 lines
5.8 KiB
TypeScript

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<void> {
const result = await postgresClient.query<{ config: Record<string, string> }>(
`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<void> {
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) => `• <code>${(c.id ?? c.sha ?? '').substring(0, 7)}</code> — ${(c.message ?? '').split('\n')[0]}`)
.join('\n');
const extra = commits.length > 5 ? `\n<i>…and ${commits.length - 5} more</i>` : '';
const telegramMsg =
`🔀 <b>${repoName}</b> · ${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 });
}