agentgate 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +118 -4
- package/package.json +3 -2
- package/public/mobile.css +178 -0
- package/public/style.css +129 -0
- package/src/index.js +232 -32
- package/src/lib/agentNotifier.js +82 -1
- package/src/lib/db.js +825 -8
- package/src/routes/agents.js +49 -8
- package/src/routes/linkedin.js +30 -10
- package/src/routes/memento.js +106 -0
- package/src/routes/queue.js +165 -6
- package/src/routes/services.js +87 -0
- package/src/routes/ui/access.js +290 -0
- package/src/routes/ui/calendar.js +65 -14
- package/src/routes/ui/fitbit.js +63 -14
- package/src/routes/ui/home.js +313 -0
- package/src/routes/ui/index.js +52 -35
- package/src/routes/ui/keys.js +561 -11
- package/src/routes/ui/linkedin.js +75 -19
- package/src/routes/ui/mastodon.js +70 -18
- package/src/routes/ui/mementos.js +363 -0
- package/src/routes/ui/messages.js +155 -18
- package/src/routes/ui/queue.js +193 -14
- package/src/routes/ui/reddit.js +63 -14
- package/src/routes/ui/services.js +46 -0
- package/src/routes/ui/shared.js +137 -7
- package/src/routes/ui/youtube.js +63 -14
- package/src/routes/webhooks.js +247 -0
- package/src/routes/ui.js +0 -2151
package/src/routes/ui/shared.js
CHANGED
|
@@ -21,6 +21,30 @@ export function renderMarkdownLinks(str) {
|
|
|
21
21
|
return escaped;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
// Generate a consistent color from a string (for avatar fallback)
|
|
25
|
+
export function stringToColor(str) {
|
|
26
|
+
if (!str) return '#6b7280';
|
|
27
|
+
let hash = 0;
|
|
28
|
+
for (let i = 0; i < str.length; i++) {
|
|
29
|
+
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
|
30
|
+
}
|
|
31
|
+
const hue = Math.abs(hash % 360);
|
|
32
|
+
return `hsl(${hue}, 60%, 45%)`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Render agent avatar with fallback to initials
|
|
36
|
+
export function renderAvatar(agentName, { size = 32, className = '' } = {}) {
|
|
37
|
+
if (!agentName) return '';
|
|
38
|
+
const safeName = escapeHtml(agentName);
|
|
39
|
+
const initial = agentName.charAt(0).toUpperCase();
|
|
40
|
+
const color = stringToColor(agentName);
|
|
41
|
+
|
|
42
|
+
return `<span class="avatar ${className}" style="width: ${size}px; height: ${size}px; background-color: ${color};" data-agent="${safeName}">
|
|
43
|
+
<img src="/ui/keys/avatar/${encodeURIComponent(agentName)}" alt="" onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';">
|
|
44
|
+
<span class="avatar-initials" style="display: none;">${initial}</span>
|
|
45
|
+
</span>`;
|
|
46
|
+
}
|
|
47
|
+
|
|
24
48
|
// Status badge HTML
|
|
25
49
|
export function statusBadge(status) {
|
|
26
50
|
const colors = {
|
|
@@ -30,16 +54,34 @@ export function statusBadge(status) {
|
|
|
30
54
|
completed: 'background: rgba(16, 185, 129, 0.15); color: #34d399; border: 1px solid rgba(16, 185, 129, 0.3);',
|
|
31
55
|
failed: 'background: rgba(239, 68, 68, 0.15); color: #f87171; border: 1px solid rgba(239, 68, 68, 0.3);',
|
|
32
56
|
rejected: 'background: rgba(156, 163, 175, 0.15); color: #9ca3af; border: 1px solid rgba(156, 163, 175, 0.3);',
|
|
57
|
+
withdrawn: 'background: rgba(168, 85, 247, 0.15); color: #c084fc; border: 1px solid rgba(168, 85, 247, 0.3);',
|
|
33
58
|
delivered: 'background: rgba(16, 185, 129, 0.15); color: #34d399; border: 1px solid rgba(16, 185, 129, 0.3);'
|
|
34
59
|
};
|
|
35
60
|
return `<span class="status" style="${colors[status] || ''}">${status}</span>`;
|
|
36
61
|
}
|
|
37
62
|
|
|
38
|
-
// Format date for display
|
|
63
|
+
// Format date for display - outputs span with data-utc for client-side localization
|
|
39
64
|
export function formatDate(dateStr) {
|
|
40
65
|
if (!dateStr) return '';
|
|
41
|
-
|
|
42
|
-
return
|
|
66
|
+
// Return span with UTC timestamp - client JS will localize
|
|
67
|
+
return `<span class="local-time" data-utc="${dateStr}"></span>`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Client-side script to localize all dates to browser timezone
|
|
71
|
+
export function localizeScript() {
|
|
72
|
+
return `
|
|
73
|
+
<script>
|
|
74
|
+
document.addEventListener('DOMContentLoaded', function() {
|
|
75
|
+
document.querySelectorAll('.local-time[data-utc]').forEach(function(el) {
|
|
76
|
+
const utc = el.getAttribute('data-utc');
|
|
77
|
+
if (utc) {
|
|
78
|
+
const d = new Date(utc);
|
|
79
|
+
el.textContent = d.toLocaleString();
|
|
80
|
+
el.title = utc + ' UTC';
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
</script>`;
|
|
43
85
|
}
|
|
44
86
|
|
|
45
87
|
// Shared HTML head with common styles/scripts
|
|
@@ -48,13 +90,15 @@ export function htmlHead(title, { includeSocket = false } = {}) {
|
|
|
48
90
|
<html>
|
|
49
91
|
<head>
|
|
50
92
|
<title>agentgate - ${title}</title>
|
|
93
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
51
94
|
<link rel="icon" type="image/svg+xml" href="/public/favicon.svg">
|
|
52
95
|
<link rel="stylesheet" href="/public/style.css">
|
|
96
|
+
<link rel="stylesheet" href="/public/mobile.css">
|
|
53
97
|
${includeSocket ? '<script src="/socket.io/socket.io.js"></script>' : ''}
|
|
54
98
|
</head>`;
|
|
55
99
|
}
|
|
56
100
|
|
|
57
|
-
// Navigation header
|
|
101
|
+
// Navigation header with real-time badge support
|
|
58
102
|
export function navHeader({ pendingQueueCount = 0, pendingMessagesCount = 0, messagingMode = 'off' } = {}) {
|
|
59
103
|
return `
|
|
60
104
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
|
|
@@ -63,18 +107,21 @@ export function navHeader({ pendingQueueCount = 0, pendingMessagesCount = 0, mes
|
|
|
63
107
|
<h1 style="margin: 0;">agentgate</h1>
|
|
64
108
|
</div>
|
|
65
109
|
<div style="display: flex; gap: 12px; align-items: center;">
|
|
66
|
-
<a href="/ui/keys" class="nav-btn nav-btn-default">
|
|
110
|
+
<a href="/ui/keys" class="nav-btn nav-btn-default">Agents</a>
|
|
111
|
+
<a href="/ui/access" class="nav-btn nav-btn-default">Access</a>
|
|
67
112
|
<a href="/ui/queue" class="nav-btn nav-btn-default" style="position: relative;">
|
|
68
|
-
|
|
113
|
+
Queue
|
|
69
114
|
<span id="queue-badge" class="badge" ${pendingQueueCount > 0 ? '' : 'style="display:none"'}>${pendingQueueCount}</span>
|
|
70
115
|
</a>
|
|
116
|
+
<a href="/ui/mementos" class="nav-btn nav-btn-default">Mementos</a>
|
|
71
117
|
<a href="/ui/messages" id="messages-nav" class="nav-btn nav-btn-default" style="position: relative;${messagingMode === 'off' ? ' display:none;' : ''}">
|
|
72
118
|
Messages
|
|
73
119
|
<span id="messages-badge" class="badge" ${pendingMessagesCount > 0 ? '' : 'style="display:none"'}>${pendingMessagesCount}</span>
|
|
74
120
|
</a>
|
|
75
121
|
<div class="nav-divider"></div>
|
|
122
|
+
<a href="/ui#settings" class="nav-btn nav-btn-default" title="Settings">⚙️</a>
|
|
76
123
|
<form method="POST" action="/ui/logout" style="margin: 0;">
|
|
77
|
-
<button type="submit" class="nav-btn nav-btn-
|
|
124
|
+
<button type="submit" class="nav-btn nav-btn-danger">Logout</button>
|
|
78
125
|
</form>
|
|
79
126
|
</div>
|
|
80
127
|
</div>`;
|
|
@@ -137,3 +184,86 @@ export function copyScript() {
|
|
|
137
184
|
}
|
|
138
185
|
</script>`;
|
|
139
186
|
}
|
|
187
|
+
|
|
188
|
+
// Styled error page for OAuth callbacks and other errors
|
|
189
|
+
export function renderErrorPage(title, message, { backUrl = '/ui', backText = 'Back to Settings' } = {}) {
|
|
190
|
+
return `<!DOCTYPE html>
|
|
191
|
+
<html>
|
|
192
|
+
<head>
|
|
193
|
+
<title>agentgate - ${escapeHtml(title)}</title>
|
|
194
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
195
|
+
<link rel="icon" type="image/svg+xml" href="/public/favicon.svg">
|
|
196
|
+
<link rel="stylesheet" href="/public/style.css">
|
|
197
|
+
<style>
|
|
198
|
+
.error-container {
|
|
199
|
+
max-width: 500px;
|
|
200
|
+
margin: 80px auto;
|
|
201
|
+
padding: 32px;
|
|
202
|
+
text-align: center;
|
|
203
|
+
}
|
|
204
|
+
.error-icon {
|
|
205
|
+
font-size: 64px;
|
|
206
|
+
margin-bottom: 16px;
|
|
207
|
+
}
|
|
208
|
+
.error-title {
|
|
209
|
+
color: #f87171;
|
|
210
|
+
margin-bottom: 16px;
|
|
211
|
+
}
|
|
212
|
+
.error-message {
|
|
213
|
+
color: #9ca3af;
|
|
214
|
+
margin-bottom: 24px;
|
|
215
|
+
line-height: 1.6;
|
|
216
|
+
word-break: break-word;
|
|
217
|
+
}
|
|
218
|
+
.error-actions {
|
|
219
|
+
display: flex;
|
|
220
|
+
gap: 12px;
|
|
221
|
+
justify-content: center;
|
|
222
|
+
}
|
|
223
|
+
</style>
|
|
224
|
+
</head>
|
|
225
|
+
<body>
|
|
226
|
+
<div class="container">
|
|
227
|
+
<div class="error-container">
|
|
228
|
+
<div class="error-icon">⚠️</div>
|
|
229
|
+
<h1 class="error-title">${escapeHtml(title)}</h1>
|
|
230
|
+
<p class="error-message">${escapeHtml(message)}</p>
|
|
231
|
+
<div class="error-actions">
|
|
232
|
+
<a href="${escapeHtml(backUrl)}" class="btn btn-primary">${escapeHtml(backText)}</a>
|
|
233
|
+
</div>
|
|
234
|
+
</div>
|
|
235
|
+
</div>
|
|
236
|
+
</body>
|
|
237
|
+
</html>`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Simple navigation header for sub-pages (includes badge elements for socket.io updates)
|
|
241
|
+
export function simpleNavHeader({ pendingQueueCount = 0, pendingMessagesCount = 0, messagingMode = 'off' } = {}) {
|
|
242
|
+
return `
|
|
243
|
+
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
|
|
244
|
+
<div style="display: flex; align-items: center; gap: 12px;">
|
|
245
|
+
<a href="/ui" style="display: flex; align-items: center; gap: 12px; text-decoration: none; color: inherit;">
|
|
246
|
+
<img src="/public/favicon.svg" alt="agentgate" style="height: 48px;">
|
|
247
|
+
<h1 style="margin: 0;">agentgate</h1>
|
|
248
|
+
</a>
|
|
249
|
+
</div>
|
|
250
|
+
<div style="display: flex; gap: 12px; align-items: center;">
|
|
251
|
+
<a href="/ui/keys" class="nav-btn nav-btn-default">Agents</a>
|
|
252
|
+
<a href="/ui/access" class="nav-btn nav-btn-default">Access</a>
|
|
253
|
+
<a href="/ui/queue" class="nav-btn nav-btn-default" style="position: relative;">
|
|
254
|
+
Queue
|
|
255
|
+
<span id="queue-badge" class="badge" ${pendingQueueCount > 0 ? '' : 'style="display:none"'}>${pendingQueueCount}</span>
|
|
256
|
+
</a>
|
|
257
|
+
<a href="/ui/mementos" class="nav-btn nav-btn-default">Mementos</a>
|
|
258
|
+
<a href="/ui/messages" id="messages-nav" class="nav-btn nav-btn-default" style="position: relative;${messagingMode === 'off' ? ' display:none;' : ''}">
|
|
259
|
+
Messages
|
|
260
|
+
<span id="messages-badge" class="badge" ${pendingMessagesCount > 0 ? '' : 'style="display:none"'}>${pendingMessagesCount}</span>
|
|
261
|
+
</a>
|
|
262
|
+
<div class="nav-divider"></div>
|
|
263
|
+
<a href="/ui#settings" class="nav-btn nav-btn-default" title="Settings">⚙️</a>
|
|
264
|
+
<form method="POST" action="/ui/logout" style="margin: 0;">
|
|
265
|
+
<button type="submit" class="nav-btn nav-btn-danger">Logout</button>
|
|
266
|
+
</form>
|
|
267
|
+
</div>
|
|
268
|
+
</div>`;
|
|
269
|
+
}
|
package/src/routes/ui/youtube.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { setAccountCredentials, deleteAccount, getAccountCredentials } from '../../lib/db.js';
|
|
2
|
+
import { renderErrorPage } from './shared.js';
|
|
2
3
|
|
|
3
4
|
export function registerRoutes(router, baseUrl) {
|
|
4
5
|
router.post('/youtube/setup', (req, res) => {
|
|
@@ -22,14 +23,19 @@ export function registerRoutes(router, baseUrl) {
|
|
|
22
23
|
|
|
23
24
|
router.get('/youtube/callback', async (req, res) => {
|
|
24
25
|
const { code, error, state } = req.query;
|
|
26
|
+
const accountName = state?.replace('agentgate_youtube_', '') || 'default';
|
|
27
|
+
|
|
25
28
|
if (error) {
|
|
26
|
-
|
|
29
|
+
const creds = getAccountCredentials('youtube', accountName);
|
|
30
|
+
if (creds) {
|
|
31
|
+
setAccountCredentials('youtube', accountName, { ...creds, authStatus: 'failed', authError: error });
|
|
32
|
+
}
|
|
33
|
+
return res.status(400).send(renderErrorPage('YouTube OAuth Error', `YouTube returned an error: ${error}`));
|
|
27
34
|
}
|
|
28
35
|
|
|
29
|
-
const accountName = state?.replace('agentgate_youtube_', '') || 'default';
|
|
30
36
|
const creds = getAccountCredentials('youtube', accountName);
|
|
31
37
|
if (!creds) {
|
|
32
|
-
return res.status(400).send('YouTube account not found. Please try setup again.');
|
|
38
|
+
return res.status(400).send(renderErrorPage('Setup Error', 'YouTube account not found. Please try setup again.'));
|
|
33
39
|
}
|
|
34
40
|
|
|
35
41
|
try {
|
|
@@ -51,19 +57,22 @@ export function registerRoutes(router, baseUrl) {
|
|
|
51
57
|
|
|
52
58
|
const tokens = await response.json();
|
|
53
59
|
if (tokens.error) {
|
|
54
|
-
|
|
60
|
+
setAccountCredentials('youtube', accountName, { ...creds, authStatus: 'failed', authError: tokens.error });
|
|
61
|
+
return res.status(400).send(renderErrorPage('Token Error', `YouTube token exchange failed: ${tokens.error}`));
|
|
55
62
|
}
|
|
56
63
|
|
|
57
64
|
setAccountCredentials('youtube', accountName, {
|
|
58
65
|
...creds,
|
|
59
66
|
accessToken: tokens.access_token,
|
|
60
67
|
refreshToken: tokens.refresh_token,
|
|
61
|
-
expiresAt: Date.now() + (tokens.expires_in * 1000) - 60000
|
|
68
|
+
expiresAt: Date.now() + (tokens.expires_in * 1000) - 60000,
|
|
69
|
+
authStatus: 'success'
|
|
62
70
|
});
|
|
63
71
|
|
|
64
72
|
res.redirect('/ui');
|
|
65
73
|
} catch (err) {
|
|
66
|
-
|
|
74
|
+
setAccountCredentials('youtube', accountName, { ...creds, authStatus: 'failed', authError: err.message });
|
|
75
|
+
res.status(500).send(renderErrorPage('Connection Error', `YouTube OAuth failed: ${err.message}`));
|
|
67
76
|
}
|
|
68
77
|
});
|
|
69
78
|
|
|
@@ -72,6 +81,25 @@ export function registerRoutes(router, baseUrl) {
|
|
|
72
81
|
deleteAccount('youtube', accountName);
|
|
73
82
|
res.redirect('/ui');
|
|
74
83
|
});
|
|
84
|
+
|
|
85
|
+
router.post('/youtube/retry', (req, res) => {
|
|
86
|
+
const { accountName } = req.body;
|
|
87
|
+
const creds = getAccountCredentials('youtube', accountName);
|
|
88
|
+
if (!creds || !creds.clientId || !creds.clientSecret) {
|
|
89
|
+
return res.status(400).send(renderErrorPage('Retry Error', 'Account credentials not found. Please set up the account again.'));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const redirectUri = `${baseUrl}/ui/youtube/callback`;
|
|
93
|
+
const scope = 'https://www.googleapis.com/auth/youtube.readonly';
|
|
94
|
+
const state = `agentgate_youtube_${accountName}`;
|
|
95
|
+
|
|
96
|
+
const authUrl = 'https://accounts.google.com/o/oauth2/v2/auth?' +
|
|
97
|
+
`client_id=${creds.clientId}&response_type=code&` +
|
|
98
|
+
`state=${encodeURIComponent(state)}&redirect_uri=${encodeURIComponent(redirectUri)}&` +
|
|
99
|
+
`scope=${encodeURIComponent(scope)}&access_type=offline&prompt=consent`;
|
|
100
|
+
|
|
101
|
+
res.redirect(authUrl);
|
|
102
|
+
});
|
|
75
103
|
}
|
|
76
104
|
|
|
77
105
|
export function renderCard(accounts, baseUrl) {
|
|
@@ -79,15 +107,36 @@ export function renderCard(accounts, baseUrl) {
|
|
|
79
107
|
|
|
80
108
|
const renderAccounts = () => {
|
|
81
109
|
if (serviceAccounts.length === 0) return '';
|
|
82
|
-
return serviceAccounts.map(acc =>
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
110
|
+
return serviceAccounts.map(acc => {
|
|
111
|
+
const { hasToken, hasCredentials, authStatus } = acc.status || {};
|
|
112
|
+
|
|
113
|
+
let statusBadge = '';
|
|
114
|
+
if (hasToken) {
|
|
115
|
+
statusBadge = '<span class="badge-success" style="margin-left: 8px;">✓ Connected</span>';
|
|
116
|
+
} else if (authStatus === 'failed') {
|
|
117
|
+
statusBadge = '<span class="badge-error" style="margin-left: 8px;">✗ Auth Failed</span>';
|
|
118
|
+
} else if (hasCredentials) {
|
|
119
|
+
statusBadge = '<span class="badge-warning" style="margin-left: 8px;">⏳ Pending</span>';
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const retryBtn = (!hasToken && hasCredentials) ? `
|
|
123
|
+
<form method="POST" action="/ui/youtube/retry" style="margin:0;">
|
|
86
124
|
<input type="hidden" name="accountName" value="${acc.name}">
|
|
87
|
-
<button type="submit" class="btn-sm btn-
|
|
88
|
-
</form
|
|
89
|
-
|
|
90
|
-
|
|
125
|
+
<button type="submit" class="btn-sm btn-primary">Retry Auth</button>
|
|
126
|
+
</form>` : '';
|
|
127
|
+
|
|
128
|
+
return `
|
|
129
|
+
<div class="account-item">
|
|
130
|
+
<span><strong>${acc.name}</strong>${statusBadge}</span>
|
|
131
|
+
<div style="display: flex; gap: 8px;">
|
|
132
|
+
${retryBtn}
|
|
133
|
+
<form method="POST" action="/ui/youtube/delete" style="margin:0;">
|
|
134
|
+
<input type="hidden" name="accountName" value="${acc.name}">
|
|
135
|
+
<button type="submit" class="btn-sm btn-danger">Remove</button>
|
|
136
|
+
</form>
|
|
137
|
+
</div>
|
|
138
|
+
</div>`;
|
|
139
|
+
}).join('');
|
|
91
140
|
};
|
|
92
141
|
|
|
93
142
|
return `
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import crypto from 'crypto';
|
|
3
|
+
import { getWebhookSecret, listApiKeys } from '../lib/db.js';
|
|
4
|
+
|
|
5
|
+
const router = Router();
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Verify GitHub webhook signature (HMAC SHA256)
|
|
9
|
+
* @param {string} payload - Raw request body
|
|
10
|
+
* @param {string} signature - X-Hub-Signature-256 header
|
|
11
|
+
* @param {string} secret - Webhook secret
|
|
12
|
+
* @returns {boolean}
|
|
13
|
+
*/
|
|
14
|
+
function verifyGitHubSignature(payload, signature, secret) {
|
|
15
|
+
if (!signature || !secret) return false;
|
|
16
|
+
|
|
17
|
+
const expected = 'sha256=' + crypto
|
|
18
|
+
.createHmac('sha256', secret)
|
|
19
|
+
.update(payload)
|
|
20
|
+
.digest('hex');
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
return crypto.timingSafeEqual(
|
|
24
|
+
Buffer.from(signature),
|
|
25
|
+
Buffer.from(expected)
|
|
26
|
+
);
|
|
27
|
+
} catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Parse GitHub event into normalized format
|
|
34
|
+
*/
|
|
35
|
+
function parseGitHubEvent(eventType, payload) {
|
|
36
|
+
const base = {
|
|
37
|
+
service: 'github',
|
|
38
|
+
event: eventType,
|
|
39
|
+
received_at: new Date().toISOString()
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
if (payload.repository) {
|
|
43
|
+
base.repo = payload.repository.full_name;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
switch (eventType) {
|
|
47
|
+
case 'push':
|
|
48
|
+
return {
|
|
49
|
+
...base,
|
|
50
|
+
data: {
|
|
51
|
+
ref: payload.ref,
|
|
52
|
+
commits: payload.commits?.length || 0,
|
|
53
|
+
pusher: payload.pusher?.name,
|
|
54
|
+
compare_url: payload.compare
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
case 'pull_request':
|
|
59
|
+
return {
|
|
60
|
+
...base,
|
|
61
|
+
event: `pull_request.${payload.action}`,
|
|
62
|
+
data: {
|
|
63
|
+
number: payload.pull_request?.number,
|
|
64
|
+
title: payload.pull_request?.title,
|
|
65
|
+
action: payload.action,
|
|
66
|
+
url: payload.pull_request?.html_url,
|
|
67
|
+
user: payload.pull_request?.user?.login,
|
|
68
|
+
merged: payload.pull_request?.merged || false
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
case 'issues':
|
|
73
|
+
return {
|
|
74
|
+
...base,
|
|
75
|
+
event: `issues.${payload.action}`,
|
|
76
|
+
data: {
|
|
77
|
+
number: payload.issue?.number,
|
|
78
|
+
title: payload.issue?.title,
|
|
79
|
+
action: payload.action,
|
|
80
|
+
url: payload.issue?.html_url,
|
|
81
|
+
user: payload.issue?.user?.login
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
case 'issue_comment':
|
|
86
|
+
return {
|
|
87
|
+
...base,
|
|
88
|
+
event: `issue_comment.${payload.action}`,
|
|
89
|
+
data: {
|
|
90
|
+
issue_number: payload.issue?.number,
|
|
91
|
+
issue_title: payload.issue?.title,
|
|
92
|
+
comment_id: payload.comment?.id,
|
|
93
|
+
action: payload.action,
|
|
94
|
+
url: payload.comment?.html_url,
|
|
95
|
+
user: payload.comment?.user?.login,
|
|
96
|
+
is_pr: !!payload.issue?.pull_request
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
case 'check_suite':
|
|
101
|
+
return {
|
|
102
|
+
...base,
|
|
103
|
+
event: `check_suite.${payload.action}`,
|
|
104
|
+
data: {
|
|
105
|
+
status: payload.check_suite?.status,
|
|
106
|
+
conclusion: payload.check_suite?.conclusion,
|
|
107
|
+
head_sha: payload.check_suite?.head_sha?.substring(0, 7)
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
case 'check_run':
|
|
112
|
+
return {
|
|
113
|
+
...base,
|
|
114
|
+
event: `check_run.${payload.action}`,
|
|
115
|
+
data: {
|
|
116
|
+
name: payload.check_run?.name,
|
|
117
|
+
status: payload.check_run?.status,
|
|
118
|
+
conclusion: payload.check_run?.conclusion,
|
|
119
|
+
head_sha: payload.check_run?.head_sha?.substring(0, 7)
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
case 'release':
|
|
124
|
+
return {
|
|
125
|
+
...base,
|
|
126
|
+
event: `release.${payload.action}`,
|
|
127
|
+
data: {
|
|
128
|
+
tag: payload.release?.tag_name,
|
|
129
|
+
name: payload.release?.name,
|
|
130
|
+
url: payload.release?.html_url,
|
|
131
|
+
prerelease: payload.release?.prerelease
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
case 'workflow_run':
|
|
136
|
+
return {
|
|
137
|
+
...base,
|
|
138
|
+
event: `workflow_run.${payload.action}`,
|
|
139
|
+
data: {
|
|
140
|
+
name: payload.workflow_run?.name,
|
|
141
|
+
status: payload.workflow_run?.status,
|
|
142
|
+
conclusion: payload.workflow_run?.conclusion,
|
|
143
|
+
head_sha: payload.workflow_run?.head_sha?.substring(0, 7),
|
|
144
|
+
url: payload.workflow_run?.html_url
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
default:
|
|
149
|
+
return {
|
|
150
|
+
...base,
|
|
151
|
+
data: { action: payload.action }
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Broadcast webhook event to all agents with webhooks configured
|
|
158
|
+
*/
|
|
159
|
+
async function broadcastToAgents(event) {
|
|
160
|
+
const agents = listApiKeys();
|
|
161
|
+
const results = { delivered: [], failed: [] };
|
|
162
|
+
|
|
163
|
+
for (const agent of agents) {
|
|
164
|
+
if (!agent.webhook_url) continue;
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
const response = await fetch(agent.webhook_url, {
|
|
168
|
+
method: 'POST',
|
|
169
|
+
headers: {
|
|
170
|
+
'Content-Type': 'application/json',
|
|
171
|
+
...(agent.webhook_token ? { 'Authorization': `Bearer ${agent.webhook_token}` } : {})
|
|
172
|
+
},
|
|
173
|
+
body: JSON.stringify({
|
|
174
|
+
type: 'service_webhook',
|
|
175
|
+
...event
|
|
176
|
+
})
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
if (response.ok) {
|
|
180
|
+
results.delivered.push(agent.name);
|
|
181
|
+
} else {
|
|
182
|
+
results.failed.push({ name: agent.name, status: response.status });
|
|
183
|
+
}
|
|
184
|
+
} catch (err) {
|
|
185
|
+
results.failed.push({ name: agent.name, error: err.message });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return results;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// POST /webhooks/github - Receive GitHub webhooks
|
|
193
|
+
// NOTE: This route does NOT use apiKeyAuth - it uses signature verification instead
|
|
194
|
+
router.post('/github', async (req, res) => {
|
|
195
|
+
const signature = req.headers['x-hub-signature-256'];
|
|
196
|
+
const eventType = req.headers['x-github-event'];
|
|
197
|
+
const deliveryId = req.headers['x-github-delivery'];
|
|
198
|
+
|
|
199
|
+
if (!eventType) {
|
|
200
|
+
return res.status(400).json({ error: 'Missing X-GitHub-Event header' });
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Get stored webhook secret
|
|
204
|
+
const secret = getWebhookSecret('github');
|
|
205
|
+
|
|
206
|
+
// Verify signature if secret is configured
|
|
207
|
+
if (secret) {
|
|
208
|
+
// Use raw body captured by express.json verify callback
|
|
209
|
+
const rawBody = req.rawBody;
|
|
210
|
+
if (!rawBody) {
|
|
211
|
+
console.error('GitHub webhook missing raw body - cannot verify signature');
|
|
212
|
+
return res.status(500).json({ error: 'Internal error: raw body not captured' });
|
|
213
|
+
}
|
|
214
|
+
if (!verifyGitHubSignature(rawBody, signature, secret)) {
|
|
215
|
+
console.error('GitHub webhook signature verification failed');
|
|
216
|
+
return res.status(401).json({ error: 'Invalid signature' });
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Handle ping event (GitHub sends this when webhook is first configured)
|
|
221
|
+
if (eventType === 'ping') {
|
|
222
|
+
console.log('GitHub webhook ping received:', req.body.zen);
|
|
223
|
+
return res.json({ ok: true, message: 'pong', zen: req.body.zen });
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Parse event
|
|
227
|
+
const event = parseGitHubEvent(eventType, req.body);
|
|
228
|
+
console.log('GitHub webhook received:', event.event, event.repo || '');
|
|
229
|
+
|
|
230
|
+
// Broadcast to agents
|
|
231
|
+
const results = await broadcastToAgents(event);
|
|
232
|
+
console.log('Webhook broadcast:', results.delivered.length, 'delivered,', results.failed.length, 'failed');
|
|
233
|
+
|
|
234
|
+
return res.json({
|
|
235
|
+
ok: true,
|
|
236
|
+
delivery_id: deliveryId,
|
|
237
|
+
event: event.event,
|
|
238
|
+
repo: event.repo,
|
|
239
|
+
broadcast: {
|
|
240
|
+
delivered: results.delivered.length,
|
|
241
|
+
failed: results.failed.length
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
export default router;
|
|
247
|
+
|