← 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);
});