diff --git a/app/api/admin/openclaw-instances/[id]/route.ts b/app/api/admin/openclaw-instances/[id]/route.ts new file mode 100644 index 0000000..2be92a1 --- /dev/null +++ b/app/api/admin/openclaw-instances/[id]/route.ts @@ -0,0 +1,54 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { postgresClient } from '@/lib/services/postgres-client'; + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params; + const body = await request.json(); + const { name, webhook_url, webhook_secret, enabled } = body; + + const sets: string[] = []; + const values: unknown[] = []; + + if (name !== undefined) { values.push(name); sets.push(`name = $${values.length}`); } + if (webhook_url !== undefined) { values.push(webhook_url); sets.push(`webhook_url = $${values.length}`); } + if (webhook_secret !== undefined) { values.push(webhook_secret); sets.push(`webhook_secret = $${values.length}`); } + if (enabled !== undefined) { values.push(enabled); sets.push(`enabled = $${values.length}`); } + + if (sets.length === 0) { + return NextResponse.json({ error: 'No fields to update' }, { status: 400 }); + } + + values.push(id); + const result = await postgresClient.query( + `UPDATE openclaw_instances SET ${sets.join(', ')} + WHERE id = $${values.length} + RETURNING id, name, webhook_url, enabled, last_notified_at, created_at`, + values + ); + + if (result.rowCount === 0) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + return NextResponse.json({ data: result.rows[0] }); +} + +export async function DELETE( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params; + const result = await postgresClient.query( + `DELETE FROM openclaw_instances WHERE id = $1 RETURNING id`, + [id] + ); + + if (result.rowCount === 0) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + return NextResponse.json({ ok: true }); +} diff --git a/app/api/admin/openclaw-instances/route.ts b/app/api/admin/openclaw-instances/route.ts new file mode 100644 index 0000000..f411905 --- /dev/null +++ b/app/api/admin/openclaw-instances/route.ts @@ -0,0 +1,32 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { postgresClient } from '@/lib/services/postgres-client'; + +export async function GET() { + const result = await postgresClient.query( + `SELECT id, name, webhook_url, enabled, last_notified_at, created_at + FROM openclaw_instances + ORDER BY created_at` + ); + return NextResponse.json({ data: result.rows }); +} + +export async function POST(request: NextRequest) { + const body = await request.json(); + const { name, webhook_url, webhook_secret, enabled = true } = body; + + if (!name || !webhook_url || !webhook_secret) { + return NextResponse.json( + { error: 'name, webhook_url, and webhook_secret are required' }, + { status: 400 } + ); + } + + const result = await postgresClient.query( + `INSERT INTO openclaw_instances (name, webhook_url, webhook_secret, enabled) + VALUES ($1, $2, $3, $4) + RETURNING id, name, webhook_url, enabled, created_at`, + [name, webhook_url, webhook_secret, enabled] + ); + + return NextResponse.json({ data: result.rows[0] }, { status: 201 }); +} diff --git a/app/api/openclaw/repo-commits/route.ts b/app/api/openclaw/repo-commits/route.ts new file mode 100644 index 0000000..0534219 --- /dev/null +++ b/app/api/openclaw/repo-commits/route.ts @@ -0,0 +1,48 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { validateOpenClawKey } from '@/lib/utils/openclaw-auth'; +import { postgresClient } from '@/lib/services/postgres-client'; + +export async function GET(request: NextRequest) { + const authError = validateOpenClawKey(request); + if (authError) return authError; + + const params = request.nextUrl.searchParams; + const since = params.get('since'); + const repo = params.get('repo'); + const branch = params.get('branch'); + const limit = Math.min(500, Math.max(1, parseInt(params.get('limit') || '100', 10))); + + const conditions: string[] = []; + const values: unknown[] = []; + + if (since) { + values.push(since); + conditions.push(`received_at > $${values.length}`); + } + if (repo) { + values.push(repo); + conditions.push(`(repo_name = $${values.length} OR repo_full_name = $${values.length})`); + } + if (branch) { + values.push(branch); + conditions.push(`branch = $${values.length}`); + } + + const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''; + values.push(limit); + + const result = await postgresClient.query( + `SELECT id, repo_name, repo_full_name, branch, commit_sha, commit_message, + author_name, author_email, committed_at, pushed_by, received_at, notified_at + FROM repo_commits + ${where} + ORDER BY received_at DESC + LIMIT $${values.length}`, + values + ); + + return NextResponse.json({ + data: result.rows, + total: result.rowCount, + }); +} diff --git a/app/api/webhooks/forgejo/route.ts b/app/api/webhooks/forgejo/route.ts new file mode 100644 index 0000000..93a291a --- /dev/null +++ b/app/api/webhooks/forgejo/route.ts @@ -0,0 +1,170 @@ +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 }); +} diff --git a/migrations/058_create_repo_commit_tracking.sql b/migrations/058_create_repo_commit_tracking.sql new file mode 100644 index 0000000..22ea21f --- /dev/null +++ b/migrations/058_create_repo_commit_tracking.sql @@ -0,0 +1,42 @@ +-- Migration 058: Repo commit tracking and OpenClaw instance registry + +CREATE TABLE IF NOT EXISTS repo_commits ( + id SERIAL PRIMARY KEY, + repo_name TEXT NOT NULL, + repo_full_name TEXT NOT NULL, + branch TEXT NOT NULL, + commit_sha TEXT NOT NULL, + commit_message TEXT, + author_name TEXT, + author_email TEXT, + committed_at TIMESTAMPTZ, + pushed_by TEXT, + raw_payload JSONB, + received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + notified_at TIMESTAMPTZ, + UNIQUE (repo_full_name, commit_sha) +); + +CREATE INDEX IF NOT EXISTS idx_repo_commits_repo ON repo_commits (repo_name); +CREATE INDEX IF NOT EXISTS idx_repo_commits_received ON repo_commits (received_at DESC); +CREATE INDEX IF NOT EXISTS idx_repo_commits_branch ON repo_commits (branch); + +CREATE TABLE IF NOT EXISTS openclaw_instances ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + webhook_url TEXT NOT NULL, + webhook_secret TEXT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + last_notified_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Seed overwatch instance +INSERT INTO openclaw_instances (name, webhook_url, webhook_secret, enabled) +VALUES ( + 'overwatch', + 'http://100.89.248.105:8420/webhook/git-push', + 'e984b79196c91104a7ab8fa7d7f554e7c5b3ba5a4ebddb92f2ae2fbee675c14b', + TRUE +) +ON CONFLICT (name) DO NOTHING;