← back to Designer Wallcoverings

DW-Agents/dw-agents/agent-slack/slack-agent.ts

538 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: {
  slack_ts?: string; thread_ts?: string; channel_id: string; channel_name?: string;
  user_id?: string; user_name?: string; direction: string; message_text?: string;
  message_blocks?: any; source_agent?: string; template_name?: string;
  priority?: string; is_dm?: boolean; metadata?: any;
}) {
  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: any) { console.error('Log message error:', e.message); }
}

async function getMessages(opts: { channel?: string; agent?: string; direction?: string; limit?: number; offset?: number }) {
  const conditions: string[] = [];
  const params: any[] = [];
  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: string, body: any) {
  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() as Promise<any>;
}

// ─── Message Templates ────────────────────────────────────────
const MESSAGE_TEMPLATES: Record<string, (data: any) => any[]> = {
  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: any) => ({
      type: 'button', text: { type: 'plain_text', text: a.text }, url: a.url, ...(a.primary && { style: 'primary' })
    }))}] : [])
  ]
};

// ─── Core Slack Functions ─────────────────────────────────────
async function sendMessage(channel: string, text: string, opts: { threadTs?: string; sourceAgent?: string; username?: string; iconEmoji?: string } = {}) {
  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: string, template: string, data: any, opts: { threadTs?: string; sourceAgent?: string } = {}) {
  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: string, text: string, opts: { sourceAgent?: string } = {}) {
  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: string) {
  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: string, timestamp: string, emoji: string) {
  const result = await slackAPI('reactions.add', { channel, timestamp, name: emoji.replace(/:/g, '') });
  return { success: result.ok, error: result.error };
}

async function updateMsg(channel: string, ts: string, text: string, blocks?: any[]) {
  const result = await slackAPI('chat.update', { channel, ts, text, ...(blocks && { blocks }) });
  return { success: result.ok, error: result.error };
}

async function scheduleMsg(channel: string, text: string, sendAt: string | Date, opts: { sourceAgent?: string; scheduleName?: string } = {}) {
  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: string[], text: string, sourceAgent?: string) {
  const sent: any[] = []; const failed: any[] = [];
  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: any) { 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: any) => ({ id: c.id, name: c.name, isPrivate: c.is_private, memberCount: c.num_members, topic: c.topic?.value }));
  }
  return [];
}

async function getUserInfo(userId: string) {
  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: string, limit = 20) {
  const result = await slackAPI('conversations.history', { channel: channelId, limit });
  return result.ok ? result.messages : [];
}

// ─── Claude AI Chat ───────────────────────────────────────────
const conversationHistory = new Map<string, any[]>();

async function askClaude(message: string, channelId: string): Promise<string> {
  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() as any;
    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: any) { return `Error: ${e.message}`; }
}

// ─── Bolt Event Handling (manual) ─────────────────────────────
async function handleSlackEvent(body: any) {
  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: any) { 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 as string, agent: req.query.agent as string, direction: req.query.direction as string,
    limit: parseInt(req.query.limit as string) || 50, offset: parseInt(req.query.offset as string) || 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 as string) || 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: any[] = [];
  let agentBreakdown: any[] = [];
  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);
});