- 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)
170 lines
5.8 KiB
TypeScript
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 });
|
|
}
|