← back to Designer Wallcoverings
DW-Agents/dw-agents/agent-slack/slack-agent.js
531 lines
/**
* DW Slack Agent — Unified Slack Integration
* Port: 9887 | Auth: admin/<password>
* Consolidates all Slack API functionality into one agent
*/
import express from 'express';
import { requireGlobalAuth } from '../shared-global-auth';
import cookieParser from 'cookie-parser';
import session from 'express-session';
import { Pool } from 'pg';
const PORT = parseInt(process.env.PORT || '9887');
const SLACK_BOT_TOKEN = process.env.SLACK_BOT_TOKEN || '${SLACK_BOT_TOKEN}';
const SLACK_SIGNING_SECRET = process.env.SLACK_SIGNING_SECRET || 'b1b92b95a310728b7f2f26373da10ba3';
const SLACK_WEBHOOK = process.env.SLACK_WEBHOOK || '${SLACK_WEBHOOK_URL}';
const STEVE_USER_ID = 'U03TV3UC2V7';
const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY || '';
const DB_URL = (process.env.DATABASE_URL || 'postgresql://dw_admin@127.0.0.1:5432/dw_unified');
// ─── Database ─────────────────────────────────────────────────
const pool = new Pool({ connectionString: DB_URL, max: 5 });
async function logMessage(msg) {
try {
await pool.query(`INSERT INTO slack_messages
(slack_ts, thread_ts, channel_id, channel_name, user_id, user_name, direction, message_text, message_blocks, source_agent, template_name, priority, is_dm, metadata)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`, [msg.slack_ts, msg.thread_ts, msg.channel_id, msg.channel_name, msg.user_id, msg.user_name,
msg.direction, msg.message_text, msg.message_blocks ? JSON.stringify(msg.message_blocks) : null,
msg.source_agent, msg.template_name, msg.priority || 'normal', msg.is_dm || false,
msg.metadata ? JSON.stringify(msg.metadata) : null]);
}
catch (e) {
console.error('Log message error:', e.message);
}
}
async function getMessages(opts) {
const conditions = [];
const params = [];
let idx = 1;
if (opts.channel) {
conditions.push(`(channel_id = $${idx} OR channel_name = $${idx})`);
params.push(opts.channel);
idx++;
}
if (opts.agent) {
conditions.push(`source_agent = $${idx}`);
params.push(opts.agent);
idx++;
}
if (opts.direction) {
conditions.push(`direction = $${idx}`);
params.push(opts.direction);
idx++;
}
const where = conditions.length ? 'WHERE ' + conditions.join(' AND ') : '';
const limit = opts.limit || 50;
const offset = opts.offset || 0;
const result = await pool.query(`SELECT * FROM slack_messages ${where} ORDER BY created_at DESC LIMIT ${limit} OFFSET ${offset}`, params);
return result.rows;
}
async function getStats() {
const r = await pool.query(`SELECT
COUNT(*) as total,
COUNT(*) FILTER (WHERE direction='outbound') as sent,
COUNT(*) FILTER (WHERE direction='inbound') as received,
COUNT(*) FILTER (WHERE created_at > NOW() - INTERVAL '24 hours') as last_24h,
COUNT(DISTINCT source_agent) FILTER (WHERE source_agent IS NOT NULL) as active_agents,
COUNT(DISTINCT channel_id) as active_channels
FROM slack_messages`);
return r.rows[0];
}
async function getAgentBreakdown() {
const r = await pool.query(`SELECT source_agent, COUNT(*) as count FROM slack_messages
WHERE source_agent IS NOT NULL AND created_at > NOW() - INTERVAL '7 days'
GROUP BY source_agent ORDER BY count DESC LIMIT 20`);
return r.rows;
}
// ─── Slack Web API (using fetch) ──────────────────────────────
async function slackAPI(method, body) {
const resp = await fetch(`https://slack.com/api/${method}`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${SLACK_BOT_TOKEN}`, 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
return resp.json();
}
// ─── Message Templates ────────────────────────────────────────
const MESSAGE_TEMPLATES = {
order: (data) => [
{ type: 'header', text: { type: 'plain_text', text: `🛒 New Order #${data.orderNumber}` } },
{ type: 'section', fields: [
{ type: 'mrkdwn', text: `*Customer:*\n${data.customer}` },
{ type: 'mrkdwn', text: `*Total:*\n$${data.total}` },
{ type: 'mrkdwn', text: `*Items:*\n${data.items}` },
{ type: 'mrkdwn', text: `*Status:*\n${data.status}` }
] },
...(data.orderUrl ? [{ type: 'actions', elements: [
{ type: 'button', text: { type: 'plain_text', text: 'View Order' }, url: data.orderUrl, style: 'primary' }
] }] : [])
],
alert: (data) => [
{ type: 'header', text: { type: 'plain_text', text: `${data.emoji || '🚨'} ${data.title}` } },
{ type: 'section', text: { type: 'mrkdwn', text: data.message } },
...(data.severity ? [{ type: 'context', elements: [{ type: 'mrkdwn', text: `Severity: *${data.severity.toUpperCase()}* | ${new Date().toLocaleString()}` }] }] : [])
],
report: (data) => [
{ type: 'header', text: { type: 'plain_text', text: `📊 ${data.title}` } },
{ type: 'section', fields: Object.entries(data.metrics || {}).map(([k, v]) => ({ type: 'mrkdwn', text: `*${k}:*\n${v}` })) },
{ type: 'context', elements: [{ type: 'mrkdwn', text: `Generated: ${new Date().toLocaleString()}` }] }
],
announcement: (data) => [
{ type: 'header', text: { type: 'plain_text', text: `📢 ${data.title}`, emoji: true } },
{ type: 'section', text: { type: 'mrkdwn', text: data.message } },
...(data.actions ? [{ type: 'actions', elements: data.actions.map((a) => ({
type: 'button', text: { type: 'plain_text', text: a.text }, url: a.url, ...(a.primary && { style: 'primary' })
})) }] : [])
]
};
// ─── Core Slack Functions ─────────────────────────────────────
async function sendMessage(channel, text, opts = {}) {
const result = await slackAPI('chat.postMessage', {
channel, text,
...(opts.threadTs && { thread_ts: opts.threadTs }),
...(opts.username && { username: opts.username }),
...(opts.iconEmoji && { icon_emoji: opts.iconEmoji })
});
if (result.ok) {
await logMessage({ slack_ts: result.ts, channel_id: result.channel, channel_name: channel, direction: 'outbound', message_text: text, source_agent: opts.sourceAgent });
}
return { success: result.ok, ts: result.ts, channel: result.channel, error: result.error };
}
async function sendRichMessage(channel, template, data, opts = {}) {
const templateFn = MESSAGE_TEMPLATES[template];
if (!templateFn)
return { success: false, error: 'Unknown template: ' + template };
const blocks = templateFn(data);
const result = await slackAPI('chat.postMessage', {
channel, blocks, text: data.fallbackText || data.title || 'New message',
...(opts.threadTs && { thread_ts: opts.threadTs })
});
if (result.ok) {
await logMessage({ slack_ts: result.ts, channel_id: result.channel, channel_name: channel, direction: 'outbound',
message_text: data.fallbackText || data.title, message_blocks: blocks, source_agent: opts.sourceAgent, template_name: template });
}
return { success: result.ok, ts: result.ts, channel: result.channel, error: result.error };
}
async function sendDM(userId, text, opts = {}) {
const dm = await slackAPI('conversations.open', { users: userId });
if (!dm.ok)
return { success: false, error: dm.error };
const channelId = dm.channel.id;
const result = await slackAPI('chat.postMessage', { channel: channelId, text });
if (result.ok) {
await logMessage({ slack_ts: result.ts, channel_id: channelId, user_id: userId, direction: 'outbound', message_text: text, source_agent: opts.sourceAgent, is_dm: true });
}
return { success: result.ok, ts: result.ts, channel: channelId, error: result.error };
}
async function sendWebhook(text) {
const resp = await fetch(SLACK_WEBHOOK, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text })
});
const ok = resp.status === 200;
if (ok)
await logMessage({ channel_id: 'webhook', channel_name: '#claude-to-steve', direction: 'outbound', message_text: text, source_agent: 'webhook' });
return { success: ok };
}
async function addReaction(channel, timestamp, emoji) {
const result = await slackAPI('reactions.add', { channel, timestamp, name: emoji.replace(/:/g, '') });
return { success: result.ok, error: result.error };
}
async function updateMsg(channel, ts, text, blocks) {
const result = await slackAPI('chat.update', { channel, ts, text, ...(blocks && { blocks }) });
return { success: result.ok, error: result.error };
}
async function scheduleMsg(channel, text, sendAt, opts = {}) {
const postAt = Math.floor(new Date(sendAt).getTime() / 1000);
const result = await slackAPI('chat.scheduleMessage', { channel, text, post_at: postAt });
if (result.ok) {
await pool.query(`INSERT INTO slack_scheduled_messages (channel_id, channel_name, message_text, source_agent, schedule_name, scheduled_at, status, sent_at)
VALUES ($1,$2,$3,$4,$5,$6,'sent',NOW())`, [result.channel || channel, channel, text, opts.sourceAgent, opts.scheduleName, new Date(sendAt)]);
}
return { success: result.ok, scheduledMessageId: result.scheduled_message_id, error: result.error };
}
async function broadcastMsg(channels, text, sourceAgent) {
const sent = [];
const failed = [];
for (const ch of channels) {
try {
const r = await sendMessage(ch, text, { sourceAgent });
if (r.success)
sent.push({ channel: ch, ts: r.ts });
else
failed.push({ channel: ch, error: r.error });
}
catch (e) {
failed.push({ channel: ch, error: e.message });
}
await new Promise(r => setTimeout(r, 500));
}
return { sent, failed };
}
async function listChannels() {
const result = await slackAPI('conversations.list', { types: 'public_channel,private_channel', limit: 200 });
if (result.ok && result.channels) {
for (const ch of result.channels) {
await pool.query(`INSERT INTO slack_channels (channel_id, channel_name, is_private, member_count, topic, purpose, is_archived, last_synced_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,NOW()) ON CONFLICT (channel_id) DO UPDATE SET
channel_name=$2, member_count=$4, topic=$5, purpose=$6, is_archived=$7, last_synced_at=NOW()`, [ch.id, ch.name, ch.is_private, ch.num_members, ch.topic?.value, ch.purpose?.value, ch.is_archived]);
}
return result.channels.map((c) => ({ id: c.id, name: c.name, isPrivate: c.is_private, memberCount: c.num_members, topic: c.topic?.value }));
}
return [];
}
async function getUserInfo(userId) {
const result = await slackAPI('users.info', { user: userId });
if (result.ok) {
const u = result.user;
return { id: u.id, name: u.name, realName: u.real_name, email: u.profile?.email, avatar: u.profile?.image_192, isBot: u.is_bot };
}
return null;
}
async function getChannelHistory(channelId, limit = 20) {
const result = await slackAPI('conversations.history', { channel: channelId, limit });
return result.ok ? result.messages : [];
}
// ─── Claude AI Chat ───────────────────────────────────────────
const conversationHistory = new Map();
async function askClaude(message, channelId) {
if (!ANTHROPIC_API_KEY)
return 'Claude AI not configured (missing API key)';
try {
const history = conversationHistory.get(channelId) || [];
const body = {
model: 'claude-opus-4-8', max_tokens: 1024,
system: `You are Claude, an AI assistant in the Designer Wallcoverings Slack workspace. Help with Shopify product management, vendor data, AI analysis, and business operations. Server: 45.61.58.125. Be concise, use Slack formatting.`,
messages: [...history.slice(-10), { role: 'user', content: message }]
};
const resp = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: { 'x-api-key': ANTHROPIC_API_KEY, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' },
body: JSON.stringify(body)
});
const data = await resp.json();
const reply = data.content?.[0]?.text || 'No response';
history.push({ role: 'user', content: message }, { role: 'assistant', content: reply });
conversationHistory.set(channelId, history.slice(-20));
return reply;
}
catch (e) {
return `Error: ${e.message}`;
}
}
// ─── Bolt Event Handling (manual) ─────────────────────────────
async function handleSlackEvent(body) {
if (body.type === 'url_verification')
return { challenge: body.challenge };
if (body.event) {
const evt = body.event;
if (evt.bot_id)
return { ok: true };
const text = (evt.text || '').replace(/<@[A-Z0-9]+>/g, '').trim();
if (!text)
return { ok: true };
await logMessage({ slack_ts: evt.ts, channel_id: evt.channel, user_id: evt.user, direction: 'inbound', message_text: text });
const reply = await askClaude(text, evt.channel);
await sendMessage(evt.channel, reply, { threadTs: evt.ts, sourceAgent: 'slack-agent-ai' });
return { ok: true };
}
return { ok: true };
}
// ─── Express App ──────────────────────────────────────────────
const app = express();
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true }));
app.use(cookieParser());
app.use(session({ secret: 'dw-slack-agent-2025', resave: false, saveUninitialized: true, cookie: { secure: false, httpOnly: true, maxAge: 30 * 24 * 60 * 60 * 1000 } }));
// Slack events endpoint (no auth - Slack sends directly)
app.post('/slack/events', async (req, res) => {
try {
const result = await handleSlackEvent(req.body);
res.json(result);
}
catch (e) {
console.error('Slack event error:', e.message);
res.json({ ok: true });
}
});
// Health (no auth)
app.get('/api/health', (_req, res) => res.json({ status: 'ok', uptime: process.uptime(), port: PORT }));
// Auth for all other routes
app.use(requireGlobalAuth);
// ─── Agent API (other agents call these) ──────────────────────
app.post('/api/slack/send', async (req, res) => {
const { channel, text, threadTs, sourceAgent, username, iconEmoji } = req.body;
if (!channel || !text)
return res.status(400).json({ error: 'channel and text required' });
const result = await sendMessage(channel, text, { threadTs, sourceAgent, username, iconEmoji });
res.json(result);
});
app.post('/api/slack/send-rich', async (req, res) => {
const { channel, template, data, threadTs, sourceAgent } = req.body;
if (!channel || !template || !data)
return res.status(400).json({ error: 'channel, template, data required' });
const result = await sendRichMessage(channel, template, data, { threadTs, sourceAgent });
res.json(result);
});
app.post('/api/slack/send-dm', async (req, res) => {
const { userId, text, sourceAgent } = req.body;
if (!userId || !text)
return res.status(400).json({ error: 'userId and text required' });
const result = await sendDM(userId, text, { sourceAgent });
res.json(result);
});
app.post('/api/slack/webhook', async (req, res) => {
const { text } = req.body;
if (!text)
return res.status(400).json({ error: 'text required' });
const result = await sendWebhook(text);
res.json(result);
});
app.post('/api/slack/schedule', async (req, res) => {
const { channel, text, sendAt, sourceAgent, scheduleName } = req.body;
if (!channel || !text || !sendAt)
return res.status(400).json({ error: 'channel, text, sendAt required' });
const result = await scheduleMsg(channel, text, sendAt, { sourceAgent, scheduleName });
res.json(result);
});
app.post('/api/slack/broadcast', async (req, res) => {
const { channels, text, sourceAgent } = req.body;
if (!channels || !text)
return res.status(400).json({ error: 'channels[] and text required' });
const result = await broadcastMsg(channels, text, sourceAgent);
res.json(result);
});
app.post('/api/slack/react', async (req, res) => {
const { channel, timestamp, emoji } = req.body;
const result = await addReaction(channel, timestamp, emoji);
res.json(result);
});
app.post('/api/slack/update', async (req, res) => {
const { channel, timestamp, text, blocks } = req.body;
const result = await updateMsg(channel, timestamp, text, blocks);
res.json(result);
});
// ─── Dashboard API ────────────────────────────────────────────
app.get('/api/status', async (_req, res) => {
const stats = await getStats();
res.json({ agent: 'slack-agent', port: PORT, uptime: process.uptime(), stats, steveId: STEVE_USER_ID, webhookConfigured: !!SLACK_WEBHOOK, botTokenConfigured: !!SLACK_BOT_TOKEN });
});
app.get('/api/messages', async (req, res) => {
const msgs = await getMessages({ channel: req.query.channel, agent: req.query.agent, direction: req.query.direction,
limit: parseInt(req.query.limit) || 50, offset: parseInt(req.query.offset) || 0 });
res.json({ success: true, data: msgs, count: msgs.length });
});
app.get('/api/channels', async (_req, res) => {
try {
const cached = await pool.query('SELECT * FROM slack_channels WHERE is_archived = false ORDER BY channel_name');
res.json({ success: true, data: cached.rows, count: cached.rows.length });
}
catch {
res.json({ success: true, data: [], count: 0 });
}
});
app.post('/api/channels/sync', async (_req, res) => {
const channels = await listChannels();
res.json({ success: true, synced: channels.length });
});
app.get('/api/channels/:channelId/history', async (req, res) => {
const messages = await getChannelHistory(req.params.channelId, parseInt(req.query.limit) || 20);
res.json({ success: true, data: messages });
});
app.get('/api/stats', async (_req, res) => {
const stats = await getStats();
const agents = await getAgentBreakdown();
res.json({ success: true, stats, agentBreakdown: agents });
});
app.get('/api/scheduled', async (req, res) => {
const status = req.query.status || 'pending';
const r = await pool.query('SELECT * FROM slack_scheduled_messages WHERE status = $1 ORDER BY scheduled_at DESC LIMIT 50', [status]);
res.json({ success: true, data: r.rows });
});
app.get('/api/users/:userId', async (req, res) => {
const info = await getUserInfo(req.params.userId);
res.json({ success: true, data: info });
});
// ─── Chat endpoint ────────────────────────────────────────────
app.post('/api/chat', async (req, res) => {
const { message } = req.body;
if (!message)
return res.status(400).json({ error: 'message required' });
const reply = await askClaude(message, 'dashboard-chat');
res.json({ success: true, response: reply });
});
// ─── Dashboard HTML ───────────────────────────────────────────
app.get('/', async (_req, res) => {
let stats = { total: 0, sent: 0, received: 0, last_24h: 0, active_agents: 0, active_channels: 0 };
let recentMsgs = [];
let agentBreakdown = [];
try {
stats = await getStats();
recentMsgs = await getMessages({ limit: 15 });
agentBreakdown = await getAgentBreakdown();
}
catch (e) {
console.error('Dashboard data error:', e);
}
const msgsHTML = recentMsgs.map(m => `
<tr>
<td>${m.direction === 'outbound' ? '📤' : '📥'}</td>
<td>${m.channel_name || m.channel_id}</td>
<td>${(m.message_text || '').substring(0, 80)}${(m.message_text || '').length > 80 ? '...' : ''}</td>
<td>${m.source_agent || '-'}</td>
<td>${new Date(m.created_at).toLocaleString()}</td>
</tr>`).join('');
const agentsHTML = agentBreakdown.map(a => `
<tr><td>${a.source_agent}</td><td>${a.count}</td></tr>`).join('');
res.send(`<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Slack Agent | DW-Agents</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#0f172a;color:#e2e8f0;min-height:100vh}
.top-bar{background:#1e293b;padding:12px 24px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #334155;position:sticky;top:0;z-index:100}
.top-bar h1{font-size:20px;color:#f1f5f9}
.top-bar .badge{background:#3b82f6;color:white;padding:3px 10px;border-radius:12px;font-size:12px}
.container{max-width:1400px;margin:0 auto;padding:20px}
.kpi-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:16px;margin-bottom:24px}
.kpi{background:#1e293b;border:1px solid #334155;border-radius:10px;padding:16px;text-align:center}
.kpi .value{font-size:28px;font-weight:700;color:#3b82f6}
.kpi .label{font-size:12px;color:#94a3b8;margin-top:4px;text-transform:uppercase}
.section{background:#1e293b;border:1px solid #334155;border-radius:10px;padding:20px;margin-bottom:20px}
.section h2{font-size:16px;margin-bottom:14px;color:#f1f5f9;border-bottom:1px solid #334155;padding-bottom:8px}
table{width:100%;border-collapse:collapse;font-size:13px}
th{text-align:left;padding:8px 10px;color:#94a3b8;border-bottom:1px solid #334155;font-weight:600}
td{padding:8px 10px;border-bottom:1px solid #1e293b}
tr:hover td{background:#1e293b80}
.tabs{display:flex;gap:8px;margin-bottom:20px}
.tab{padding:8px 16px;background:#1e293b;border:1px solid #334155;border-radius:8px;cursor:pointer;color:#94a3b8;font-size:13px}
.tab.active{background:#3b82f6;color:white;border-color:#3b82f6}
.tab-content{display:none}.tab-content.active{display:block}
.send-form{display:grid;grid-template-columns:1fr 2fr auto;gap:10px;margin-top:12px}
.send-form input,.send-form textarea{background:#0f172a;border:1px solid #334155;color:#e2e8f0;padding:8px 12px;border-radius:6px;font-size:13px}
.send-form button{background:#3b82f6;color:white;border:none;padding:8px 20px;border-radius:6px;cursor:pointer;font-weight:600}
.send-form button:hover{background:#2563eb}
.two-col{display:grid;grid-template-columns:2fr 1fr;gap:20px}
@media(max-width:768px){.two-col{grid-template-columns:1fr}.kpi-grid{grid-template-columns:repeat(2,1fr)}}
</style></head><body>
<div class="top-bar">
<h1>💬 Slack Agent</h1>
<div><span class="badge">Port ${PORT}</span></div>
</div>
<div class="container">
<div class="kpi-grid">
<div class="kpi"><div class="value">${stats.total}</div><div class="label">Total Messages</div></div>
<div class="kpi"><div class="value">${stats.sent}</div><div class="label">Sent</div></div>
<div class="kpi"><div class="value">${stats.received}</div><div class="label">Received</div></div>
<div class="kpi"><div class="value">${stats.last_24h}</div><div class="label">Last 24h</div></div>
<div class="kpi"><div class="value">${stats.active_agents}</div><div class="label">Active Agents</div></div>
<div class="kpi"><div class="value">${stats.active_channels}</div><div class="label">Channels</div></div>
</div>
<div class="tabs">
<div class="tab active" onclick="showTab('messages')">Messages</div>
<div class="tab" onclick="showTab('send')">Send Message</div>
<div class="tab" onclick="showTab('agents')">Agent Usage</div>
<div class="tab" onclick="showTab('api')">API Reference</div>
</div>
<div id="tab-messages" class="tab-content active">
<div class="section"><h2>Recent Messages</h2>
<table><thead><tr><th></th><th>Channel</th><th>Message</th><th>Agent</th><th>Time</th></tr></thead>
<tbody>${msgsHTML || '<tr><td colspan="5" style="text-align:center;color:#64748b">No messages yet</td></tr>'}</tbody></table>
</div>
</div>
<div id="tab-send" class="tab-content">
<div class="section"><h2>Send Message</h2>
<div class="send-form">
<input id="sendChannel" placeholder="#channel or user ID" value="#claude-to-steve">
<input id="sendText" placeholder="Message text...">
<button onclick="doSend()">Send</button>
</div>
<div id="sendResult" style="margin-top:10px;font-size:13px;color:#94a3b8"></div>
</div>
<div class="section"><h2>Send Rich Message</h2>
<div class="send-form">
<select id="richTemplate" style="background:#0f172a;border:1px solid #334155;color:#e2e8f0;padding:8px;border-radius:6px">
<option value="alert">Alert</option><option value="report">Report</option><option value="announcement">Announcement</option><option value="order">Order</option>
</select>
<input id="richTitle" placeholder="Title">
<button onclick="doRichSend()">Send Rich</button>
</div>
<input id="richMessage" placeholder="Message body" style="width:100%;margin-top:8px;background:#0f172a;border:1px solid #334155;color:#e2e8f0;padding:8px 12px;border-radius:6px">
</div>
</div>
<div id="tab-agents" class="tab-content">
<div class="section"><h2>Messages by Agent (Last 7 Days)</h2>
<table><thead><tr><th>Agent</th><th>Messages</th></tr></thead>
<tbody>${agentsHTML || '<tr><td colspan="2" style="text-align:center;color:#64748b">No data</td></tr>'}</tbody></table>
</div>
</div>
<div id="tab-api" class="tab-content">
<div class="section"><h2>API Endpoints</h2>
<table><thead><tr><th>Method</th><th>Endpoint</th><th>Description</th></tr></thead><tbody>
<tr><td>POST</td><td>/api/slack/send</td><td>Send message {channel, text, sourceAgent}</td></tr>
<tr><td>POST</td><td>/api/slack/send-rich</td><td>Rich template {channel, template, data, sourceAgent}</td></tr>
<tr><td>POST</td><td>/api/slack/send-dm</td><td>Direct message {userId, text}</td></tr>
<tr><td>POST</td><td>/api/slack/webhook</td><td>Backward-compatible webhook {text}</td></tr>
<tr><td>POST</td><td>/api/slack/schedule</td><td>Schedule message {channel, text, sendAt}</td></tr>
<tr><td>POST</td><td>/api/slack/broadcast</td><td>Multi-channel {channels[], text}</td></tr>
<tr><td>POST</td><td>/api/slack/react</td><td>Add reaction {channel, timestamp, emoji}</td></tr>
<tr><td>GET</td><td>/api/messages</td><td>Message history ?channel=&agent=&limit=</td></tr>
<tr><td>GET</td><td>/api/channels</td><td>Channel list</td></tr>
<tr><td>GET</td><td>/api/stats</td><td>Message statistics</td></tr>
<tr><td>GET</td><td>/api/scheduled</td><td>Scheduled messages</td></tr>
<tr><td>GET</td><td>/api/users/:id</td><td>User info</td></tr>
</tbody></table>
</div>
</div>
</div>
<script>
function showTab(name){document.querySelectorAll('.tab-content').forEach(t=>t.classList.remove('active'));document.querySelectorAll('.tab').forEach(t=>t.classList.remove('active'));document.getElementById('tab-'+name).classList.add('active');event.target.classList.add('active')}
async function doSend(){const ch=document.getElementById('sendChannel').value;const tx=document.getElementById('sendText').value;if(!ch||!tx)return;const r=await fetch('/api/slack/send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({channel:ch,text:tx,sourceAgent:'dashboard'})});const d=await r.json();document.getElementById('sendResult').textContent=d.success?'Sent! ts='+d.ts:'Error: '+d.error;document.getElementById('sendText').value=''}
async function doRichSend(){const tpl=document.getElementById('richTemplate').value;const title=document.getElementById('richTitle').value;const msg=document.getElementById('richMessage').value;const r=await fetch('/api/slack/send-rich',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({channel:'#claude-to-steve',template:tpl,data:{title,message:msg,severity:'medium'},sourceAgent:'dashboard'})});const d=await r.json();document.getElementById('sendResult').textContent=d.success?'Rich message sent!':'Error: '+d.error}
setTimeout(()=>location.reload(),60000);
</script></body></html>`);
});
// ─── Startup ──────────────────────────────────────────────────
app.listen(PORT, '0.0.0.0', () => {
console.log(`💬 Slack Agent running on port ${PORT}`);
console.log(` Dashboard: http://45.61.58.125:${PORT}`);
console.log(` Slack Events: http://45.61.58.125:${PORT}/slack/events`);
console.log(` Bot Token: ${SLACK_BOT_TOKEN ? '✅' : '❌'}`);
// Sync channels on startup (delayed)
setTimeout(() => listChannels().then(ch => console.log(` Synced ${ch.length} channels`)).catch(() => { }), 5000);
});