agentgate 0.3.2 → 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.
@@ -0,0 +1,588 @@
1
+ // Messages routes - agent messaging management
2
+ import { Router } from 'express';
3
+ import { nanoid } from 'nanoid';
4
+ import {
5
+ getMessagingMode, listAgentMessages,
6
+ approveAgentMessage, rejectAgentMessage, deleteAgentMessage,
7
+ clearAgentMessagesByStatus, getMessageCounts, getAgentMessage,
8
+ listApiKeys, createBroadcast, addBroadcastRecipient, listBroadcastsWithRecipients,
9
+ deleteBroadcast, clearBroadcasts
10
+ } from '../../lib/db.js';
11
+ import { notifyAgentMessage, notifyMessageRejected } from '../../lib/agentNotifier.js';
12
+ import { emitCountUpdate } from '../../lib/socketManager.js';
13
+ import { escapeHtml, statusBadge, formatDate, simpleNavHeader, socketScript, localizeScript, renderAvatar } from './shared.js';
14
+
15
+ const router = Router();
16
+
17
+ // Agent Messages Queue
18
+ router.get('/', (req, res) => {
19
+ const filter = req.query.filter || 'all';
20
+ let messages;
21
+ if (filter === 'all') {
22
+ messages = listAgentMessages();
23
+ } else {
24
+ messages = listAgentMessages(filter);
25
+ }
26
+ const counts = getMessageCounts();
27
+ const mode = getMessagingMode();
28
+ const broadcasts = listBroadcastsWithRecipients(10); // Last 10 broadcasts
29
+ res.send(renderMessagesPage(messages, filter, counts, mode, broadcasts));
30
+ });
31
+
32
+ router.post('/:id/approve', async (req, res) => {
33
+ const { id } = req.params;
34
+ const wantsJson = req.headers.accept?.includes('application/json');
35
+
36
+ const msg = getAgentMessage(id);
37
+ if (!msg) {
38
+ return wantsJson
39
+ ? res.status(404).json({ error: 'Message not found' })
40
+ : res.status(404).send('Message not found');
41
+ }
42
+
43
+ if (msg.status !== 'pending') {
44
+ return wantsJson
45
+ ? res.status(400).json({ error: 'Can only approve pending messages' })
46
+ : res.status(400).send('Can only approve pending messages');
47
+ }
48
+
49
+ approveAgentMessage(id);
50
+ const updated = getAgentMessage(id);
51
+ const counts = getMessageCounts();
52
+
53
+ emitCountUpdate();
54
+
55
+ notifyAgentMessage(updated).catch(err => {
56
+ console.error('[agentNotifier] Failed to notify agent:', err.message);
57
+ });
58
+
59
+ if (wantsJson) {
60
+ return res.json({ success: true, message: updated, counts });
61
+ }
62
+ res.redirect('/ui/messages');
63
+ });
64
+
65
+ router.post('/:id/reject', (req, res) => {
66
+ const { id } = req.params;
67
+ const { reason } = req.body;
68
+ const wantsJson = req.headers.accept?.includes('application/json');
69
+
70
+ const msg = getAgentMessage(id);
71
+ if (!msg) {
72
+ return wantsJson
73
+ ? res.status(404).json({ error: 'Message not found' })
74
+ : res.status(404).send('Message not found');
75
+ }
76
+
77
+ if (msg.status !== 'pending') {
78
+ return wantsJson
79
+ ? res.status(400).json({ error: 'Can only reject pending messages' })
80
+ : res.status(400).send('Can only reject pending messages');
81
+ }
82
+
83
+ rejectAgentMessage(id, reason);
84
+ const updated = getAgentMessage(id);
85
+ const counts = getMessageCounts();
86
+
87
+ emitCountUpdate();
88
+ notifyMessageRejected(updated);
89
+
90
+ if (wantsJson) {
91
+ return res.json({ success: true, message: updated, counts });
92
+ }
93
+ res.redirect('/ui/messages');
94
+ });
95
+
96
+ router.post('/:id/delete', (req, res) => {
97
+ const { id } = req.params;
98
+ const wantsJson = req.headers.accept?.includes('application/json');
99
+
100
+ deleteAgentMessage(id);
101
+ const counts = getMessageCounts();
102
+ emitCountUpdate();
103
+
104
+ if (wantsJson) {
105
+ return res.json({ success: true, counts });
106
+ }
107
+ res.redirect('/ui/messages');
108
+ });
109
+
110
+ router.post('/clear', (req, res) => {
111
+ const { status } = req.body;
112
+ const wantsJson = req.headers.accept?.includes('application/json');
113
+
114
+ clearAgentMessagesByStatus(status || 'all');
115
+ const counts = getMessageCounts();
116
+ emitCountUpdate();
117
+
118
+ if (wantsJson) {
119
+ return res.json({ success: true, counts });
120
+ }
121
+ res.redirect('/ui/messages');
122
+ });
123
+
124
+ // Broadcast message to all agents
125
+ router.post('/broadcast', async (req, res) => {
126
+ const { message } = req.body;
127
+ const wantsJson = req.headers.accept?.includes('application/json');
128
+
129
+ if (!message || !message.trim()) {
130
+ if (wantsJson) {
131
+ return res.status(400).json({ error: 'Message is required' });
132
+ }
133
+ return res.redirect('/ui/messages?broadcast_error=Message+is+required');
134
+ }
135
+
136
+ const mode = getMessagingMode();
137
+ if (mode === 'off') {
138
+ if (wantsJson) {
139
+ return res.status(403).json({ error: 'Agent messaging is disabled' });
140
+ }
141
+ return res.redirect('/ui/messages?broadcast_error=Messaging+disabled');
142
+ }
143
+
144
+ const apiKeys = listApiKeys();
145
+ const recipients = apiKeys.filter(k => k.webhook_url && k.enabled);
146
+
147
+ if (recipients.length === 0) {
148
+ if (wantsJson) {
149
+ return res.json({ broadcast_id: null, delivered: [], failed: [], total: 0 });
150
+ }
151
+ return res.redirect('/ui/messages?broadcast_result=No+agents+with+webhooks');
152
+ }
153
+
154
+ // Create broadcast record in database
155
+ const broadcastId = nanoid();
156
+ createBroadcast(broadcastId, 'admin', message, recipients.length);
157
+
158
+ const delivered = [];
159
+ const failed = [];
160
+
161
+ await Promise.all(recipients.map(async (agent) => {
162
+ const payload = {
163
+ type: 'broadcast',
164
+ from: 'admin',
165
+ message: message,
166
+ broadcast_id: broadcastId,
167
+ timestamp: new Date().toISOString(),
168
+ text: `📢 [agentgate] Broadcast from admin:\n${message.substring(0, 500)}`,
169
+ mode: 'now'
170
+ };
171
+
172
+ try {
173
+ const headers = { 'Content-Type': 'application/json' };
174
+ if (agent.webhook_token) {
175
+ headers['Authorization'] = `Bearer ${agent.webhook_token}`;
176
+ }
177
+
178
+ const response = await fetch(agent.webhook_url, {
179
+ method: 'POST',
180
+ headers,
181
+ body: JSON.stringify(payload)
182
+ });
183
+
184
+ if (response.ok) {
185
+ delivered.push(agent.name);
186
+ addBroadcastRecipient(broadcastId, agent.name, 'delivered');
187
+ } else {
188
+ const errorMsg = `HTTP ${response.status}`;
189
+ failed.push({ name: agent.name, error: errorMsg });
190
+ addBroadcastRecipient(broadcastId, agent.name, 'failed', errorMsg);
191
+ }
192
+ } catch (err) {
193
+ failed.push({ name: agent.name, error: err.message });
194
+ addBroadcastRecipient(broadcastId, agent.name, 'failed', err.message);
195
+ }
196
+ }));
197
+
198
+ if (wantsJson) {
199
+ return res.json({ broadcast_id: broadcastId, delivered, failed, total: recipients.length });
200
+ }
201
+
202
+ const resultMsg = `Delivered: ${delivered.length}, Failed: ${failed.length}`;
203
+ res.redirect(`/ui/messages?broadcast_result=${encodeURIComponent(resultMsg)}`);
204
+ });
205
+
206
+ // Delete a single broadcast
207
+ router.post('/broadcast/:id/delete', (req, res) => {
208
+ const { id } = req.params;
209
+ const wantsJson = req.headers.accept?.includes('application/json');
210
+
211
+ deleteBroadcast(id);
212
+
213
+ if (wantsJson) {
214
+ return res.json({ success: true });
215
+ }
216
+ res.redirect('/ui/messages');
217
+ });
218
+
219
+ // Clear all broadcasts
220
+ router.post('/broadcasts/clear', (req, res) => {
221
+ const wantsJson = req.headers.accept?.includes('application/json');
222
+
223
+ clearBroadcasts();
224
+
225
+ if (wantsJson) {
226
+ return res.json({ success: true });
227
+ }
228
+ res.redirect('/ui/messages');
229
+ });
230
+
231
+ // Render function
232
+ function renderMessagesPage(messages, filter, counts, mode, broadcasts = []) {
233
+ // Combine messages and broadcasts into a unified timeline
234
+ const messageItems = messages.map(m => ({ ...m, _type: 'message' }));
235
+ const broadcastItems = broadcasts.map(b => ({ ...b, _type: 'broadcast' }));
236
+ const timeline = [...messageItems, ...broadcastItems].sort((a, b) =>
237
+ new Date(b.created_at) - new Date(a.created_at)
238
+ );
239
+
240
+ const renderBroadcast = (b) => `
241
+ <div class="card message-entry broadcast-entry" style="margin-bottom: 20px; border-left: 4px solid #6366f1;">
242
+ <div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12px;">
243
+ <div class="entry-header">
244
+ <span style="background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); color: white; padding: 4px 10px; border-radius: 12px; font-size: 11px; font-weight: 600; margin-right: 8px;">📢 BROADCAST</span>
245
+ <span class="agent-with-avatar">${renderAvatar(b.from_agent, { size: 24 })}<strong>${escapeHtml(b.from_agent)}</strong></span>
246
+ <span class="help" style="margin-left: 8px;">→ ${b.total_recipients} recipient${b.total_recipients !== 1 ? 's' : ''}</span>
247
+ </div>
248
+ <div style="display: flex; align-items: center; gap: 12px;">
249
+ <span class="help" style="margin: 0;">${formatDate(b.created_at)}</span>
250
+ <button type="button" class="delete-btn" onclick="deleteBroadcast('${b.id}')" title="Delete">×</button>
251
+ </div>
252
+ </div>
253
+ <div class="message-content">
254
+ <pre style="white-space: pre-wrap; word-wrap: break-word; margin: 0; background: rgba(0,0,0,0.2); padding: 12px; border-radius: 8px;">${escapeHtml(b.message)}</pre>
255
+ </div>
256
+ <div style="display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px;">
257
+ ${(b.recipients || []).map(r => `
258
+ <span style="padding: 4px 10px; border-radius: 16px; font-size: 12px; display: inline-flex; align-items: center; gap: 6px; background: ${r.status === 'delivered' ? 'rgba(52, 211, 153, 0.15)' : 'rgba(239, 68, 68, 0.15)'}; color: ${r.status === 'delivered' ? '#34d399' : '#f87171'}; border: 1px solid ${r.status === 'delivered' ? 'rgba(52, 211, 153, 0.3)' : 'rgba(239, 68, 68, 0.3)'};">
259
+ ${renderAvatar(r.to_agent, { size: 18 })}${escapeHtml(r.to_agent)} ${r.status === 'delivered' ? '✓' : '✗'}
260
+ </span>
261
+ `).join('')}
262
+ </div>
263
+ </div>
264
+ `;
265
+
266
+ const renderMessage = (msg) => {
267
+ let actions = '';
268
+ if (msg.status === 'pending') {
269
+ actions = `
270
+ <div class="message-actions">
271
+ <button type="button" class="btn-primary btn-sm" onclick="approveMessage('${msg.id}')">Approve</button>
272
+ <input type="text" id="reason-${msg.id}" placeholder="Rejection reason (optional)" class="reject-input" style="width: 200px;">
273
+ <button type="button" class="btn-danger btn-sm" onclick="rejectMessage('${msg.id}')">Reject</button>
274
+ </div>
275
+ `;
276
+ }
277
+
278
+ let rejectionSection = '';
279
+ if (msg.rejection_reason) {
280
+ rejectionSection = `
281
+ <div class="rejection-reason">
282
+ <strong>Rejection reason:</strong> ${escapeHtml(msg.rejection_reason)}
283
+ </div>
284
+ `;
285
+ }
286
+
287
+ return `
288
+ <div class="card message-entry" id="message-${msg.id}" data-status="${msg.status}">
289
+ <div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12px;">
290
+ <div class="entry-header">
291
+ <span class="agent-with-avatar">${renderAvatar(msg.from_agent, { size: 24 })}<strong>${escapeHtml(msg.from_agent)}</strong></span>
292
+
293
+ <span class="agent-with-avatar">${renderAvatar(msg.to_agent, { size: 24 })}<strong>${escapeHtml(msg.to_agent)}</strong></span>
294
+ <span class="status-badge">${statusBadge(msg.status)}</span>
295
+ </div>
296
+ <div style="display: flex; align-items: center; gap: 12px;">
297
+ <span class="help" style="margin: 0;">${formatDate(msg.created_at)}</span>
298
+ <button type="button" class="delete-btn" onclick="deleteMessage('${msg.id}')" title="Delete">&times;</button>
299
+ </div>
300
+ </div>
301
+
302
+ <div class="message-content">
303
+ <pre style="white-space: pre-wrap; word-wrap: break-word; margin: 0; background: rgba(0,0,0,0.2); padding: 12px; border-radius: 8px;">${escapeHtml(msg.message)}</pre>
304
+ </div>
305
+
306
+ ${rejectionSection}
307
+ ${actions}
308
+ </div>
309
+ `;
310
+ };
311
+
312
+ const filters = ['all', 'pending', 'delivered', 'rejected'];
313
+ const filterLinks = filters.map(f =>
314
+ `<a href="/ui/messages?filter=${f}" class="filter-link ${filter === f ? 'active' : ''}">${f}${counts[f] > 0 ? ` (${counts[f]})` : ''}</a>`
315
+ ).join('');
316
+
317
+ return `<!DOCTYPE html>
318
+ <html>
319
+ <head>
320
+ <title>agentgate - Agent Messages</title>
321
+ <link rel="icon" type="image/svg+xml" href="/public/favicon.svg">
322
+ <link rel="stylesheet" href="/public/style.css">
323
+ <script src="/socket.io/socket.io.js"></script>
324
+ <style>
325
+ .filter-bar { display: flex; gap: 10px; margin-bottom: 24px; flex-wrap: wrap; align-items: center; }
326
+ .filter-link { padding: 10px 20px; border-radius: 25px; text-decoration: none; background: rgba(255, 255, 255, 0.05); color: #9ca3af; font-weight: 600; font-size: 13px; border: 1px solid rgba(255, 255, 255, 0.1); transition: all 0.3s ease; }
327
+ .filter-link:hover { background: rgba(255, 255, 255, 0.1); color: #e5e7eb; border-color: rgba(255, 255, 255, 0.2); }
328
+ .filter-link.active { background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); color: white; border-color: transparent; box-shadow: 0 4px 15px rgba(99, 102, 241, 0.4); }
329
+ .message-entry { margin-bottom: 20px; }
330
+ .message-actions { margin-top: 16px; padding-top: 16px; border-top: 1px solid rgba(255, 255, 255, 0.1); display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
331
+ .back-link { color: #818cf8; text-decoration: none; font-weight: 600; transition: color 0.2s ease; }
332
+ .back-link:hover { color: #ffffff; }
333
+ .delete-btn { background: rgba(239, 68, 68, 0.1); border: 1px solid rgba(239, 68, 68, 0.2); color: #f87171; font-size: 18px; cursor: pointer; padding: 4px 10px; line-height: 1; font-weight: bold; border-radius: 6px; transition: all 0.2s ease; }
334
+ .delete-btn:hover { background: rgba(239, 68, 68, 0.2); border-color: rgba(239, 68, 68, 0.4); }
335
+ .clear-section { margin-left: auto; display: flex; gap: 10px; }
336
+ .entry-header { display: flex; align-items: center; gap: 12px; }
337
+ .entry-header strong { color: #f3f4f6; font-size: 16px; }
338
+ .rejection-reason { margin-top: 16px; padding: 16px; background: rgba(239, 68, 68, 0.1); border-radius: 10px; border-left: 4px solid #f87171; color: #e5e7eb; }
339
+ .rejection-reason strong { color: #f87171; }
340
+ .empty-state { text-align: center; padding: 60px 40px; }
341
+ .empty-state p { color: #6b7280; margin: 0; font-size: 16px; }
342
+ .reject-input { padding: 10px 14px; margin: 0; font-size: 13px; background: rgba(15, 15, 25, 0.6); border: 2px solid rgba(239, 68, 68, 0.2); border-radius: 8px; color: #f3f4f6; }
343
+ .reject-input:focus { outline: none; border-color: #f87171; box-shadow: 0 0 0 4px rgba(239, 68, 68, 0.15); }
344
+ .reject-input::placeholder { color: #6b7280; }
345
+ .mode-badge { display: inline-flex; align-items: center; gap: 6px; padding: 6px 14px; border-radius: 20px; font-size: 12px; font-weight: 600; text-transform: uppercase; background: rgba(99, 102, 241, 0.15); color: #818cf8; border: 1px solid rgba(99, 102, 241, 0.3); }
346
+ </style>
347
+ </head>
348
+ <body>
349
+ ${simpleNavHeader()}
350
+ <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
351
+ <h2 style="margin: 0;">Agent Messages</h2>
352
+ <span class="mode-badge">Mode: ${mode}</span>
353
+ </div>
354
+ <p>Review and approve messages between agents${mode === 'supervised' ? ' (supervised mode)' : ''}.</p>
355
+
356
+ <div class="card" style="margin-bottom: 24px;">
357
+ <h3 style="margin-top: 0; display: flex; align-items: center; gap: 8px;">
358
+ <span>📢</span> Broadcast Message
359
+ </h3>
360
+ <p class="help" style="margin-bottom: 16px;">Send a message to all agents with webhooks configured.</p>
361
+ <form method="POST" action="/ui/messages/broadcast" id="broadcast-form">
362
+ <textarea name="message" id="broadcast-message" placeholder="Enter your broadcast message..." rows="3" style="width: 100%; margin-bottom: 12px; padding: 12px; background: rgba(15, 15, 25, 0.6); border: 2px solid rgba(99, 102, 241, 0.2); border-radius: 8px; color: #f3f4f6; font-family: inherit; resize: vertical;" required></textarea>
363
+ <div style="display: flex; gap: 12px; align-items: center;">
364
+ <button type="submit" class="btn-primary" id="broadcast-btn">Send Broadcast</button>
365
+ <span id="broadcast-status" class="help" style="margin: 0;"></span>
366
+ </div>
367
+ </form>
368
+ </div>
369
+
370
+ <h3>Timeline</h3>
371
+
372
+ <div class="filter-bar" id="filter-bar">
373
+ ${filterLinks}
374
+ <div class="clear-section">
375
+ ${filter === 'delivered' && counts.delivered > 0 ? '<button type="button" class="btn-sm btn-danger" onclick="clearByStatus(\'delivered\')">Clear Delivered</button>' : ''}
376
+ ${filter === 'rejected' && counts.rejected > 0 ? '<button type="button" class="btn-sm btn-danger" onclick="clearByStatus(\'rejected\')">Clear Rejected</button>' : ''}
377
+ ${filter === 'all' && (counts.delivered > 0 || counts.rejected > 0) ? '<button type="button" class="btn-sm btn-danger" onclick="clearByStatus(\'all\')">Clear All Non-Pending</button>' : ''}
378
+ ${broadcasts.length > 0 ? '<button type="button" class="btn-sm btn-danger" onclick="clearBroadcasts()">Clear Broadcasts</button>' : ''}
379
+ <a href="/ui/messages/export?format=json" class="btn-sm" style="text-decoration: none;">Export JSON</a>
380
+ <a href="/ui/messages/export?format=csv" class="btn-sm" style="text-decoration: none;">Export CSV</a>
381
+ </div>
382
+ </div>
383
+
384
+ <div id="messages-container">
385
+ ${timeline.length === 0 ? `
386
+ <div class="card empty-state">
387
+ <p>No ${filter === 'all' ? '' : filter + ' '}messages</p>
388
+ </div>
389
+ ` : timeline.map(item => item._type === 'broadcast' ? renderBroadcast(item) : renderMessage(item)).join('')}
390
+ </div>
391
+
392
+ <script>
393
+ function escapeHtml(str) {
394
+ if (typeof str !== 'string') str = JSON.stringify(str, null, 2);
395
+ return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
396
+ }
397
+
398
+ document.getElementById('broadcast-form').addEventListener('submit', async function(e) {
399
+ e.preventDefault();
400
+ const btn = document.getElementById('broadcast-btn');
401
+ const status = document.getElementById('broadcast-status');
402
+ const message = document.getElementById('broadcast-message').value;
403
+
404
+ btn.disabled = true;
405
+ btn.textContent = 'Sending...';
406
+ status.textContent = '';
407
+
408
+ try {
409
+ const res = await fetch('/ui/messages/broadcast', {
410
+ method: 'POST',
411
+ headers: { 'Accept': 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' },
412
+ body: 'message=' + encodeURIComponent(message)
413
+ });
414
+ const data = await res.json();
415
+
416
+ if (data.error) {
417
+ status.textContent = '❌ ' + data.error;
418
+ status.style.color = '#f87171';
419
+ } else {
420
+ const deliveredNames = data.delivered.join(', ') || 'none';
421
+ const failedCount = data.failed.length;
422
+ status.textContent = '✅ Delivered to: ' + deliveredNames + (failedCount > 0 ? ' | Failed: ' + failedCount : '');
423
+ status.style.color = '#34d399';
424
+ document.getElementById('broadcast-message').value = '';
425
+ }
426
+ } catch (err) {
427
+ status.textContent = '❌ Error: ' + err.message;
428
+ status.style.color = '#f87171';
429
+ }
430
+
431
+ btn.disabled = false;
432
+ btn.textContent = 'Send Broadcast';
433
+ });
434
+
435
+ async function approveMessage(id) {
436
+ const btn = event.target;
437
+ btn.disabled = true;
438
+ btn.textContent = 'Approving...';
439
+ try {
440
+ const res = await fetch('/ui/messages/' + id + '/approve', { method: 'POST', headers: { 'Accept': 'application/json' } });
441
+ const data = await res.json();
442
+ if (data.success) {
443
+ window.location.reload();
444
+ } else {
445
+ alert(data.error || 'Failed to approve');
446
+ btn.disabled = false;
447
+ btn.textContent = 'Approve';
448
+ }
449
+ } catch (err) {
450
+ alert('Error: ' + err.message);
451
+ btn.disabled = false;
452
+ btn.textContent = 'Approve';
453
+ }
454
+ }
455
+
456
+ async function rejectMessage(id) {
457
+ const btn = event.target;
458
+ const reason = document.getElementById('reason-' + id)?.value || '';
459
+ btn.disabled = true;
460
+ btn.textContent = 'Rejecting...';
461
+ try {
462
+ const res = await fetch('/ui/messages/' + id + '/reject', {
463
+ method: 'POST',
464
+ headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' },
465
+ body: JSON.stringify({ reason })
466
+ });
467
+ const data = await res.json();
468
+ if (data.success) {
469
+ window.location.reload();
470
+ } else {
471
+ alert(data.error || 'Failed to reject');
472
+ btn.disabled = false;
473
+ btn.textContent = 'Reject';
474
+ }
475
+ } catch (err) {
476
+ alert('Error: ' + err.message);
477
+ btn.disabled = false;
478
+ btn.textContent = 'Reject';
479
+ }
480
+ }
481
+
482
+ async function deleteMessage(id) {
483
+ if (!confirm('Delete this message?')) return;
484
+ try {
485
+ const res = await fetch('/ui/messages/' + id + '/delete', { method: 'POST', headers: { 'Accept': 'application/json' } });
486
+ const data = await res.json();
487
+ if (data.success) {
488
+ document.getElementById('message-' + id)?.remove();
489
+ const container = document.getElementById('messages-container');
490
+ if (container.querySelectorAll('.message-entry').length === 0) {
491
+ container.innerHTML = '<div class="card empty-state"><p>No messages</p></div>';
492
+ }
493
+ } else {
494
+ alert(data.error || 'Failed to delete');
495
+ }
496
+ } catch (err) {
497
+ alert('Error: ' + err.message);
498
+ }
499
+ }
500
+
501
+ async function deleteBroadcast(id) {
502
+ if (!confirm('Delete this broadcast?')) return;
503
+ try {
504
+ const res = await fetch('/ui/messages/broadcast/' + id + '/delete', { method: 'POST', headers: { 'Accept': 'application/json' } });
505
+ const data = await res.json();
506
+ if (data.success) {
507
+ window.location.reload();
508
+ } else {
509
+ alert(data.error || 'Failed to delete broadcast');
510
+ }
511
+ } catch (err) {
512
+ alert('Error: ' + err.message);
513
+ }
514
+ }
515
+
516
+ async function clearBroadcasts() {
517
+ if (!confirm('Clear all broadcasts?')) return;
518
+ try {
519
+ const res = await fetch('/ui/messages/broadcasts/clear', { method: 'POST', headers: { 'Accept': 'application/json' } });
520
+ const data = await res.json();
521
+ if (data.success) {
522
+ window.location.reload();
523
+ } else {
524
+ alert(data.error || 'Failed to clear broadcasts');
525
+ }
526
+ } catch (err) {
527
+ alert('Error: ' + err.message);
528
+ }
529
+ }
530
+
531
+ async function clearByStatus(status) {
532
+ const btn = event.target;
533
+ btn.disabled = true;
534
+ btn.textContent = 'Clearing...';
535
+ try {
536
+ const res = await fetch('/ui/messages/clear', {
537
+ method: 'POST',
538
+ headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' },
539
+ body: JSON.stringify({ status })
540
+ });
541
+ const data = await res.json();
542
+ if (data.success) {
543
+ window.location.reload();
544
+ }
545
+ } catch (err) {
546
+ alert('Error: ' + err.message);
547
+ btn.disabled = false;
548
+ }
549
+ }
550
+ </script>
551
+ ${socketScript()}
552
+ ${localizeScript()}
553
+ </body>
554
+ </html>`;
555
+ }
556
+
557
+ // Export messages data
558
+ router.get('/export', (req, res) => {
559
+ try {
560
+ const format = req.query.format || 'json';
561
+ const messages = listAgentMessages();
562
+
563
+ if (format === 'csv') {
564
+ const headers = ['id', 'from_agent', 'to_agent', 'message', 'status', 'rejection_reason', 'created_at', 'delivered_at'];
565
+ const csvRows = [headers.join(',')];
566
+ for (const msg of messages) {
567
+ const row = headers.map(h => {
568
+ const val = msg[h] ?? '';
569
+ const str = String(val).replace(/"/g, '""');
570
+ return str.includes(',') || str.includes('"') || str.includes('\n') ? `"${str}"` : str;
571
+ });
572
+ csvRows.push(row.join(','));
573
+ }
574
+ res.setHeader('Content-Type', 'text/csv');
575
+ res.setHeader('Content-Disposition', 'attachment; filename="messages-export.csv"');
576
+ return res.send(csvRows.join('\n'));
577
+ }
578
+
579
+ res.setHeader('Content-Type', 'application/json');
580
+ res.setHeader('Content-Disposition', 'attachment; filename="messages-export.json"');
581
+ res.json(messages);
582
+ } catch (err) {
583
+ console.error('Messages export error:', err);
584
+ res.status(500).json({ error: 'Export failed', message: err.message });
585
+ }
586
+ });
587
+
588
+ export default router;