bus-agent 2.3.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.
@@ -0,0 +1,420 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * CoCo Webhook Gateway v2.1 — Receive external webhooks and post to Agent Bus
4
+ *
5
+ * Features:
6
+ * - GitHub push, PR, issues, Actions CI status
7
+ * - GitLab push/merge events
8
+ * - Slack messages
9
+ * - Telegram bot updates
10
+ * - Generic JSON webhooks
11
+ * - GitHub Actions CI Status endpoint
12
+ *
13
+ * Usage:
14
+ * node webhook-gateway.js [port]
15
+ * # Default port: 8080
16
+ */
17
+ const http = require('http');
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+
21
+ const BUS_DIR = path.join(__dirname, '.bus');
22
+ const MSGS_DIR = path.join(BUS_DIR, 'messages');
23
+ const CHANNELS_DIR = path.join(BUS_DIR, 'channels');
24
+
25
+ const PORT = parseInt(process.argv[2], 10) || 8080;
26
+ const AGENT_NAME = 'webhook';
27
+
28
+ // ── Bus Interface ──────────────────────────────────────
29
+
30
+ function ensureDir(dir) {
31
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
32
+ }
33
+
34
+ function getChannels() {
35
+ if (!fs.existsSync(CHANNELS_DIR)) return [];
36
+ return fs.readdirSync(CHANNELS_DIR)
37
+ .filter(f => f.endsWith('.json'))
38
+ .map(f => f.replace(/\.json$/, ''));
39
+ }
40
+
41
+ function postToChannel(channelId, from, message, metadata = {}) {
42
+ const chPath = path.join(CHANNELS_DIR, `${channelId}.json`);
43
+ if (!fs.existsSync(chPath)) return { ok: false, error: 'Channel not found' };
44
+
45
+ const ch = JSON.parse(fs.readFileSync(chPath, 'utf-8'));
46
+ const msg = {
47
+ id: `${Date.now()}_${Math.random().toString(36).slice(2, 10)}`,
48
+ channel: channelId,
49
+ from: from || AGENT_NAME,
50
+ message,
51
+ metadata: { ...metadata, source: 'webhook' },
52
+ timestamp: new Date().toISOString(),
53
+ };
54
+
55
+ const logDir = path.join(CHANNELS_DIR, channelId, 'log');
56
+ ensureDir(logDir);
57
+ fs.writeFileSync(path.join(logDir, `${msg.id}.json`), JSON.stringify(msg, null, 2), 'utf-8');
58
+
59
+ // DM all members
60
+ for (const member of ch.members) {
61
+ if (member !== (from || AGENT_NAME)) {
62
+ const inboxDir = path.join(MSGS_DIR, member);
63
+ ensureDir(inboxDir);
64
+ const dm = { ...msg, to: member };
65
+ fs.writeFileSync(path.join(inboxDir, `${msg.id}.json`), JSON.stringify(dm, null, 2), 'utf-8');
66
+ }
67
+ }
68
+
69
+ return { ok: true, message_id: msg.id, channel: channelId, recipients: ch.members.length - 1 };
70
+ }
71
+
72
+ function postToAgent(agentName, from, message, metadata = {}) {
73
+ const msg = {
74
+ id: `${Date.now()}_${Math.random().toString(36).slice(2, 10)}`,
75
+ from: from || AGENT_NAME,
76
+ to: agentName,
77
+ message,
78
+ metadata: { ...metadata, source: 'webhook' },
79
+ timestamp: new Date().toISOString(),
80
+ };
81
+
82
+ const inboxDir = path.join(MSGS_DIR, agentName);
83
+ ensureDir(inboxDir);
84
+ fs.writeFileSync(path.join(inboxDir, `${msg.id}.json`), JSON.stringify(msg, null, 2), 'utf-8');
85
+ return { ok: true, message_id: msg.id, to: agentName };
86
+ }
87
+
88
+ // ── Webhook Handlers ───────────────────────────────────
89
+
90
+ function formatBody(body, source) {
91
+ switch (source) {
92
+ case 'github': return formatGitHub(body);
93
+ case 'github-actions': return formatGitHubActions(body);
94
+ case 'gitlab': return formatGitLab(body);
95
+ case 'slack': return formatSlack(body);
96
+ case 'telegram': return formatTelegram(body);
97
+ default: return formatGeneric(body, source);
98
+ }
99
+ }
100
+
101
+ function formatGitHub(body) {
102
+ const ref = (body.ref || '').replace('refs/heads/', '');
103
+ const repo = body.repository?.full_name || body.repository?.name || 'unknown';
104
+ const sender = body.sender?.login || body.pusher?.name || 'unknown';
105
+
106
+ // Pull request
107
+ if (body.pull_request) {
108
+ const pr = body.pull_request;
109
+ const action = body.action || 'updated';
110
+ return `🔀 [${repo}] PR #${pr.number} ${action}: ${pr.title}\n by ${pr.user?.login} · ${pr.html_url}`;
111
+ }
112
+
113
+ // Issue
114
+ if (body.issue && !body.comment) {
115
+ const issue = body.issue;
116
+ const action = body.action || 'updated';
117
+ const labels = (issue.labels || []).map(l => l.name).join(', ');
118
+ return `🎫 [${repo}] Issue #${issue.number} ${action}: ${issue.title}\n by ${issue.user?.login}${labels ? ` [${labels}]` : ''}`;
119
+ }
120
+
121
+ // Issue comment
122
+ if (body.issue && body.comment) {
123
+ const issue = body.issue;
124
+ const comment = body.comment;
125
+ return `💬 [${repo}] Comment on #${issue.number}: ${issue.title}\n by ${comment.user?.login}: ${comment.body?.substring(0, 200)}`;
126
+ }
127
+
128
+ // Commits
129
+ const commits = body.commits || [];
130
+ if (commits.length > 0) {
131
+ const commitMessages = commits.slice(0, 5).map(c => ` • ${c.message?.split('\n')[0]} (${c.id?.substring(0, 7)})`).join('\n');
132
+ const extra = commits.length > 5 ? `\n ... and ${commits.length - 5} more` : '';
133
+ return `📦 [${repo}:${ref}] ${commits.length} commit(s) by ${sender}\n${commitMessages}${extra}`;
134
+ }
135
+
136
+ // Push
137
+ if (body.ref) {
138
+ return `📡 [${repo}:${ref}] push by ${sender}`;
139
+ }
140
+
141
+ return `🔔 [${repo}] ${body.action || 'event'} from ${sender}`;
142
+ }
143
+
144
+ function formatGitHubActions(body) {
145
+ const workflow = body.workflow || body.workflow_run?.name || 'Workflow';
146
+ const repo = body.repository?.full_name || 'unknown';
147
+ const branch = body.ref?.replace('refs/heads/', '') || body.workflow_run?.head_branch || 'unknown';
148
+ const status = body.action || body.workflow_run?.status || body.workflow_run?.conclusion || 'unknown';
149
+ const runId = body.workflow_run?.id || body.run_id || '?';
150
+ const actor = body.sender?.login || 'unknown';
151
+
152
+ let icon, statusText;
153
+ switch (status) {
154
+ case 'completed': {
155
+ const conclusion = body.workflow_run?.conclusion || body.conclusion || 'success';
156
+ if (conclusion === 'success') { icon = '✅'; statusText = 'passed'; }
157
+ else if (conclusion === 'failure') { icon = '❌'; statusText = 'failed'; }
158
+ else if (conclusion === 'cancelled') { icon = '🚫'; statusText = 'cancelled'; }
159
+ else { icon = '⚠️'; statusText = conclusion; }
160
+ break;
161
+ }
162
+ case 'in_progress': icon = '🔄'; statusText = 'in progress'; break;
163
+ case 'queued': icon = '⏳'; statusText = 'queued'; break;
164
+ case 'requested': icon = '🔁'; statusText = 'requested'; break;
165
+ default: icon = 'ℹ️'; statusText = status; break;
166
+ }
167
+
168
+ const url = body.workflow_run?.html_url ||
169
+ `https://github.com/${repo}/actions/runs/${runId}`;
170
+
171
+ let msg = `${icon} [${repo}] ${workflow} ${statusText}`;
172
+ msg += `\n Branch: ${branch} · by ${actor}`;
173
+ msg += `\n ${url}`;
174
+
175
+ // Annotations/errors
176
+ if (body.workflow_run?.conclusion === 'failure' && body.workflow_run?.head_commit) {
177
+ msg += `\n Commit: ${body.workflow_run.head_commit.id?.substring(0, 7)} ${body.workflow_run.head_commit.message?.split('\n')[0] || ''}`;
178
+ }
179
+
180
+ return msg;
181
+ }
182
+
183
+ function formatGitLab(body) {
184
+ const ref = (body.ref || '').replace('refs/heads/', '');
185
+ const project = body.project?.name || 'unknown';
186
+ const user = body.user_username || body.user?.name || 'unknown';
187
+
188
+ // Merge request
189
+ if (body.object_attributes?.action && body.object_kind === 'merge_request') {
190
+ const mr = body.object_attributes;
191
+ return `🔀 [${project}] MR #${mr.iid} ${mr.action}: ${mr.title}\n by ${user}`;
192
+ }
193
+
194
+ const commits = body.commits || [];
195
+ const msg = commits.length > 0
196
+ ? commits.slice(0, 3).map(c => ` • ${c.message?.split('\n')[0]}`).join('\n')
197
+ : '';
198
+
199
+ return `📦 [${project}] ${ref} — by ${user}\n${msg}`;
200
+ }
201
+
202
+ function formatSlack(body) {
203
+ const text = body.text || body.event?.text || body.message?.text || '(no text)';
204
+ const channel = body.event?.channel || body.channel_name || 'unknown';
205
+ const user = body.event?.user || body.user_name || 'unknown';
206
+ return `💬 [${channel}] ${user}: ${text}`;
207
+ }
208
+
209
+ function formatTelegram(body) {
210
+ const msg = body.message || body.edited_message || body.callback_query?.message || {};
211
+ const text = msg.text || msg.caption || '(no text)';
212
+ const from = msg.from?.first_name || msg.from?.username || 'unknown';
213
+ const chat = msg.chat?.title || msg.chat?.username || msg.chat?.id || 'unknown';
214
+ return `✈️ [${chat}] ${from}: ${text}`;
215
+ }
216
+
217
+ function formatGeneric(body, source) {
218
+ const text = body.text || body.message || body.content || body.msg || JSON.stringify(body);
219
+ return `[${source}] ${typeof text === 'string' ? text.substring(0, 500) : JSON.stringify(text).substring(0, 500)}`;
220
+ }
221
+
222
+ // ── HTTP Server ────────────────────────────────────────
223
+
224
+ function getBody(req) {
225
+ return new Promise((resolve) => {
226
+ let body = '';
227
+ req.on('data', chunk => body += chunk);
228
+ req.on('end', () => resolve(body));
229
+ });
230
+ }
231
+
232
+ function respondJson(res, code, data) {
233
+ res.writeHead(code, {
234
+ 'Content-Type': 'application/json',
235
+ 'Access-Control-Allow-Origin': '*',
236
+ });
237
+ res.end(JSON.stringify(data, null, 2));
238
+ }
239
+
240
+ const server = http.createServer(async (req, res) => {
241
+ res.setHeader('Access-Control-Allow-Origin', '*');
242
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, OPTIONS');
243
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-GitHub-Event, X-GitLab-Event');
244
+
245
+ if (req.method === 'OPTIONS') {
246
+ res.writeHead(204);
247
+ res.end();
248
+ return;
249
+ }
250
+
251
+ const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
252
+ const pathname = url.pathname;
253
+ const method = req.method;
254
+
255
+ // ── GET endpoints ──
256
+
257
+ if (method === 'GET' && pathname === '/') {
258
+ respondJson(res, 200, {
259
+ service: 'bus-agent-webhook-gateway',
260
+ version: '2.1.0',
261
+ endpoints: {
262
+ 'POST /webhook/github/:channel': 'GitHub webhook → bus channel',
263
+ 'POST /webhook/github-actions/:channel': 'GitHub Actions CI webhook → bus channel',
264
+ 'POST /webhook/gitlab/:channel': 'GitLab webhook → bus channel',
265
+ 'POST /webhook/slack/:channel': 'Slack webhook → bus channel',
266
+ 'POST /webhook/telegram/:channel': 'Telegram webhook → bus channel',
267
+ 'POST /webhook/generic/:channel': 'Generic webhook → bus channel',
268
+ 'POST /hook/:channel': 'Generic webhook (alias)',
269
+ 'POST /api/send': 'Direct: send to agent or channel',
270
+ 'GET /health': 'Health check',
271
+ 'GET /api/channels': 'List channels',
272
+ 'GET /api/agents': 'List agents',
273
+ },
274
+ channels: getChannels(),
275
+ });
276
+ return;
277
+ }
278
+
279
+ if (method === 'GET' && pathname === '/health') {
280
+ respondJson(res, 200, {
281
+ status: 'ok',
282
+ uptime: process.uptime(),
283
+ channels: getChannels().length,
284
+ bus_dir: fs.existsSync(BUS_DIR),
285
+ bus_messages: fs.existsSync(MSGS_DIR)
286
+ ? fs.readdirSync(MSGS_DIR).filter(d => fs.statSync(path.join(MSGS_DIR, d)).isDirectory()).length
287
+ : 0,
288
+ });
289
+ return;
290
+ }
291
+
292
+ if (method === 'GET' && pathname === '/api/channels') {
293
+ const channels = getChannels();
294
+ const details = channels.map(id => {
295
+ const p = path.join(CHANNELS_DIR, `${id}.json`);
296
+ try { return JSON.parse(fs.readFileSync(p, 'utf-8')); } catch { return { id }; }
297
+ });
298
+ respondJson(res, 200, { channels: details });
299
+ return;
300
+ }
301
+
302
+ if (method === 'GET' && pathname === '/api/agents') {
303
+ const agentsFile = path.join(BUS_DIR, 'agents.json');
304
+ const agents = fs.existsSync(agentsFile) ? JSON.parse(fs.readFileSync(agentsFile, 'utf-8')) : {};
305
+ respondJson(res, 200, { agents: Object.keys(agents), count: Object.keys(agents).length });
306
+ return;
307
+ }
308
+
309
+ // ── POST /api/send — Direct message API ──
310
+
311
+ if (method === 'POST' && pathname === '/api/send') {
312
+ const body = await getBody(req);
313
+ try {
314
+ const data = JSON.parse(body);
315
+ const { to, message, from } = data;
316
+
317
+ if (!to || !message) {
318
+ respondJson(res, 400, { error: 'Missing "to" or "message"' });
319
+ return;
320
+ }
321
+
322
+ let result;
323
+ if (to.startsWith('#')) {
324
+ result = postToChannel(to.slice(1), from || 'api', message);
325
+ } else {
326
+ result = postToAgent(to, from || 'api', message);
327
+ }
328
+ respondJson(res, 200, { received: true, result });
329
+ } catch (err) {
330
+ respondJson(res, 400, { error: `Invalid JSON: ${err.message}` });
331
+ }
332
+ return;
333
+ }
334
+
335
+ // ── POST /webhook/:source/:channel or POST /hook/:channel ──
336
+
337
+ const match = pathname.match(/^\/(?:webhook\/(\w+)\/)?(\w+)$/);
338
+ if (!match || method !== 'POST') {
339
+ respondJson(res, 404, { error: 'Not found. See GET / for endpoints.' });
340
+ return;
341
+ }
342
+
343
+ const source = match[1] || 'generic';
344
+ const channelId = match[2];
345
+ const body = await getBody(req);
346
+
347
+ try {
348
+ // Detect GitHub Actions from event header
349
+ let effectiveSource = source;
350
+ const ghEvent = req.headers['x-github-event'];
351
+ if (ghEvent === 'workflow_job' || ghEvent === 'workflow_run') {
352
+ effectiveSource = 'github-actions';
353
+ }
354
+
355
+ const parsed = JSON.parse(body);
356
+ const message = formatBody(parsed, effectiveSource);
357
+ const result = postToChannel(channelId, `webhook:${effectiveSource}`, message);
358
+
359
+ if (result.ok) {
360
+ console.log(`[${new Date().toISOString()}] ${effectiveSource} → #${channelId}: ${message.substring(0, 80)}...`);
361
+ respondJson(res, 200, { received: true, source: effectiveSource, result });
362
+ } else {
363
+ respondJson(res, 404, { error: result.error, source: effectiveSource });
364
+ }
365
+ } catch (err) {
366
+ console.error(`[${new Date().toISOString()}] Error:`, err.message);
367
+ respondJson(res, 400, { error: `Invalid webhook: ${err.message}` });
368
+ }
369
+ });
370
+
371
+ // ── Start ──────────────────────────────────────────────
372
+
373
+ ensureDir(BUS_DIR);
374
+ ensureDir(path.join(MSGS_DIR, AGENT_NAME));
375
+ ensureDir(CHANNELS_DIR);
376
+
377
+ // Register webhook agent on bus with profile
378
+ const agentsFile = path.join(BUS_DIR, 'agents.json');
379
+ const agents = fs.existsSync(agentsFile) ? JSON.parse(fs.readFileSync(agentsFile, 'utf-8')) : {};
380
+ agents[AGENT_NAME] = {
381
+ name: AGENT_NAME,
382
+ description: 'Webhook Gateway — receives external webhooks and posts to channels',
383
+ capabilities: ['webhook', 'github', 'gitlab', 'slack', 'telegram', 'ci-cd'],
384
+ tags: ['webhook', 'integration', 'ci'],
385
+ status: 'idle',
386
+ version: '2.1.0',
387
+ last_seen: new Date().toISOString(),
388
+ registered_at: agents[AGENT_NAME]?.registered_at || new Date().toISOString(),
389
+ };
390
+ fs.writeFileSync(agentsFile, JSON.stringify(agents, null, 2), 'utf-8');
391
+
392
+ server.listen(PORT, () => {
393
+ console.log(`
394
+ ╔══════════════════════════════════════════════╗
395
+ ║ MCP CoCo — Webhook Gateway v2.1 ║
396
+ ║ http://localhost:${PORT} ║
397
+ ╚══════════════════════════════════════════════╝
398
+
399
+ Endpoints:
400
+ POST /webhook/github/:channel GitHub push/PR/issues
401
+ POST /webhook/github-actions/:channel GitHub Actions CI status
402
+ POST /webhook/gitlab/:channel GitLab push/MRs
403
+ POST /webhook/slack/:channel Slack messages
404
+ POST /webhook/telegram/:channel Telegram bot updates
405
+ POST /webhook/generic/:channel Generic JSON webhook
406
+ POST /hook/:channel Generic (alias)
407
+ POST /api/send Direct: {"to":"agent/#channel","message":"..."}
408
+
409
+ GET /health Health check
410
+ GET /api/channels List channels
411
+ GET /api/agents List agents
412
+ GET / This help
413
+
414
+ Scheduled tasks: ${getChannels().join(', ') || '(none)'}
415
+ `);
416
+
417
+ // Log incoming events to the bus as system events
418
+ const evDir = path.join(BUS_DIR, 'events');
419
+ ensureDir(evDir);
420
+ });