← back to Slack Dm Viewer

server.js

1252 lines

const express = require('express');
const helmet = require('helmet');
const https = require('https');
const path = require('path');
const fs = require('fs');

const app = express();
// Security headers via helmet (added 2026-05-04 overnight YOLO loop)
app.use(helmet({ contentSecurityPolicy: false }));
const PORT = process.env.PORT || 7204;

// --- Auth ---
const AUTH_USER = 'admin';
const AUTH_PASS = process.env.GEORGE_BASIC_AUTH_PASS || '';

function basicAuth(req, res, next) {
  // Skip auth for health endpoint
  if (req.path === '/health') return next();

  const auth = req.headers.authorization;
  if (!auth || !auth.startsWith('Basic ')) {
    res.setHeader('WWW-Authenticate', 'Basic realm="Slacky"');
    return res.status(401).send('Authentication required');
  }
  const decoded = Buffer.from(auth.split(' ')[1], 'base64').toString();
  const [user, pass] = decoded.split(':');
  if (user === AUTH_USER && pass === AUTH_PASS) return next();
  res.setHeader('WWW-Authenticate', 'Basic realm="Slacky"');
  return res.status(401).send('Invalid credentials');
}

app.use(express.json({ limit: '50mb' }));
app.use(basicAuth);

// --- Slack API config ---
// designerbot token has im:history scope
const SLACK_BOT_TOKEN = '${SLACK_BOT_TOKEN}';
// dwmcp_bot token has users:read scope
const SLACK_USERS_TOKEN = '${SLACK_BOT_TOKEN}';

const STEVE_USER_ID = 'U03TV3UC2V7';
// Steve's user token for private channel access (#claude-to-steve)
const SLACK_USER_TOKEN = '${SLACK_USER_TOKEN}';
const CLAUDE_STEVE_CHANNEL = 'C09SW7VQ0RK';

// Persistent config
const CONFIG_FILE = path.join(__dirname, 'config.json');
function loadConfig() {
  try {
    if (fs.existsSync(CONFIG_FILE)) return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
  } catch (e) {}
  return { channelId: null, cachedMessages: [] };
}
function saveConfig(cfg) {
  fs.writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2));
}

// --- Slack API helpers ---
function slackApi(method, params, token) {
  return new Promise((resolve, reject) => {
    const qs = new URLSearchParams(params).toString();
    const reqPath = `/api/${method}${qs ? '?' + qs : ''}`;
    const options = {
      hostname: 'slack.com',
      path: reqPath,
      method: 'GET',
      headers: {
        'Authorization': `Bearer ${token || SLACK_BOT_TOKEN}`,
        'Content-Type': 'application/json'
      }
    };
    const req = https.request(options, res => {
      let data = '';
      res.on('data', chunk => data += chunk);
      res.on('end', () => {
        try { resolve(JSON.parse(data)); }
        catch (e) { reject(new Error('Invalid JSON from Slack')); }
      });
    });
    req.on('error', reject);
    req.setTimeout(15000, () => { req.destroy(); reject(new Error('Slack API timeout')); });
    req.end();
  });
}

function slackApiPost(method, body, token) {
  return new Promise((resolve, reject) => {
    const postData = JSON.stringify(body);
    const options = {
      hostname: 'slack.com',
      path: `/api/${method}`,
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${token || SLACK_BOT_TOKEN}`,
        'Content-Type': 'application/json; charset=utf-8',
        'Content-Length': Buffer.byteLength(postData)
      }
    };
    const req = https.request(options, res => {
      let data = '';
      res.on('data', chunk => data += chunk);
      res.on('end', () => {
        try { resolve(JSON.parse(data)); }
        catch (e) { reject(new Error('Invalid JSON from Slack')); }
      });
    });
    req.on('error', reject);
    req.setTimeout(15000, () => { req.destroy(); reject(new Error('Slack API timeout')); });
    req.write(postData);
    req.end();
  });
}

// --- API Endpoints ---

// Health check
app.get('/health', (req, res) => {
  res.json({ status: 'healthy', service: 'slacky', port: PORT, uptime: process.uptime() });
});

// Get config
app.get('/api/config', (req, res) => {
  const cfg = loadConfig();
  res.json({ channelId: cfg.channelId, hasCachedMessages: (cfg.cachedMessages || []).length > 0 });
});

// Set channel ID
app.post('/api/config/channel', (req, res) => {
  const { channelId } = req.body;
  if (!channelId) return res.status(400).json({ error: 'channelId required' });
  const cfg = loadConfig();
  cfg.channelId = channelId;
  saveConfig(cfg);
  res.json({ ok: true, channelId });
});

// Auto-detect Steve's self-DM channel
app.post('/api/detect-channel', async (req, res) => {
  try {
    // Try conversations.list with im type using the users token
    const result = await slackApi('conversations.list', { types: 'im', limit: '200' }, SLACK_USERS_TOKEN);
    if (result.ok && result.channels) {
      const selfDm = result.channels.find(c => c.user === STEVE_USER_ID);
      if (selfDm) {
        const cfg = loadConfig();
        cfg.channelId = selfDm.id;
        saveConfig(cfg);
        return res.json({ ok: true, channelId: selfDm.id, method: 'auto-detect' });
      }
      // If no self-DM, return all DM channels for manual selection
      return res.json({ ok: false, error: 'Self-DM not found', channels: result.channels });
    }
    // If missing_scope, try conversations.open as fallback
    if (result.error === 'missing_scope') {
      return res.json({
        ok: false,
        error: 'Bot token lacks im:read scope. Please enter your self-DM channel ID manually.',
        hint: 'In Slack, right-click on your self-DM chat, Copy Link. The channel ID is the last part of the URL (starts with D).'
      });
    }
    res.json({ ok: false, error: result.error || 'Unknown error' });
  } catch (e) {
    res.status(500).json({ ok: false, error: e.message });
  }
});

// Fetch messages from Slack
app.get('/api/messages', async (req, res) => {
  const cfg = loadConfig();
  const channelId = req.query.channel || cfg.channelId;
  if (!channelId) {
    // Return cached messages if available
    if (cfg.cachedMessages && cfg.cachedMessages.length > 0) {
      return res.json({ ok: true, messages: cfg.cachedMessages, source: 'cache' });
    }
    return res.status(400).json({ ok: false, error: 'No channel ID configured. Set it via /api/config/channel or use the setup page.' });
  }

  try {
    let allMessages = [];
    let cursor = undefined;
    let pages = 0;
    const maxPages = 20; // Limit to prevent runaway

    do {
      const params = { channel: channelId, limit: '200' };
      if (cursor) params.cursor = cursor;

      const result = await slackApi('conversations.history', params, SLACK_BOT_TOKEN);

      if (!result.ok) {
        // If we get not_in_channel, try joining first
        if (result.error === 'not_in_channel' || result.error === 'channel_not_found') {
          return res.status(400).json({
            ok: false,
            error: `Cannot access channel ${channelId}: ${result.error}. The bot must be added to this DM channel. Try the other token or upload JSON export.`,
            slackError: result.error
          });
        }
        return res.status(400).json({ ok: false, error: result.error });
      }

      // Filter to only Steve's messages (self-DMs from Steve to Steve)
      const steveMessages = result.messages.filter(m =>
        m.user === STEVE_USER_ID && m.subtype !== 'channel_join' && m.subtype !== 'channel_leave'
      );
      allMessages = allMessages.concat(steveMessages);

      cursor = result.response_metadata && result.response_metadata.next_cursor;
      pages++;
    } while (cursor && pages < maxPages);

    // Cache messages
    cfg.cachedMessages = allMessages;
    cfg.channelId = channelId;
    cfg.lastFetch = new Date().toISOString();
    saveConfig(cfg);

    res.json({ ok: true, messages: allMessages, count: allMessages.length, source: 'slack-api', pages });
  } catch (e) {
    // Return cached on error
    if (cfg.cachedMessages && cfg.cachedMessages.length > 0) {
      return res.json({ ok: true, messages: cfg.cachedMessages, source: 'cache-fallback', error: e.message });
    }
    res.status(500).json({ ok: false, error: e.message });
  }
});

// Upload JSON export
app.post('/api/upload', (req, res) => {
  try {
    let messages = req.body.messages || req.body;
    if (!Array.isArray(messages)) {
      // Try to parse as Slack export format (array of messages)
      if (typeof messages === 'string') {
        messages = JSON.parse(messages);
      }
      if (!Array.isArray(messages)) {
        return res.status(400).json({ ok: false, error: 'Expected an array of messages' });
      }
    }

    // Filter to Steve's messages only
    const steveMessages = messages.filter(m => {
      // Slack export format has user field or user_profile
      const userId = m.user || (m.user_profile && m.user_profile.real_name);
      return userId === STEVE_USER_ID ||
             (typeof userId === 'string' && userId.toLowerCase().includes('steve')) ||
             m.subtype === undefined; // In self-DM, all messages are from Steve
    });

    const cfg = loadConfig();
    cfg.cachedMessages = steveMessages.length > 0 ? steveMessages : messages;
    cfg.lastUpload = new Date().toISOString();
    saveConfig(cfg);

    res.json({ ok: true, count: cfg.cachedMessages.length, source: 'upload' });
  } catch (e) {
    res.status(400).json({ ok: false, error: e.message });
  }
});

// Get cached messages
app.get('/api/cached', (req, res) => {
  const cfg = loadConfig();
  res.json({
    ok: true,
    messages: cfg.cachedMessages || [],
    count: (cfg.cachedMessages || []).length,
    channelId: cfg.channelId,
    lastFetch: cfg.lastFetch,
    lastUpload: cfg.lastUpload
  });
});

// Clear cache
app.delete('/api/cached', (req, res) => {
  const cfg = loadConfig();
  cfg.cachedMessages = [];
  saveConfig(cfg);
  res.json({ ok: true });
});

// Serve frontend
app.get('/', (req, res) => {
  res.send(getHTML());
});

// --- Frontend ---
function getHTML() {
  return `<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Steve's Notes to Self | Slacky</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body {
      background: #0f1117;
      color: #e0e0e0;
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
      min-height: 100vh;
    }

    /* Agent Avatar */
    .agent-avatar { width: 56px; height: 56px; border-radius: 50%; border: 2px solid #4A154B; object-fit: cover; }
    .agent-avatar-fallback { width: 56px; height: 56px; border-radius: 50%; border: 2px solid #4A154B; background: #4A154B; color: #fff; font-size: 22px; font-weight: 700; display: none; align-items: center; justify-content: center; }

    /* Header */
    .header {
      background: #1a1d27;
      border-bottom: 1px solid #2d3041;
      padding: 16px 24px;
      display: flex;
      align-items: center;
      justify-content: space-between;
      position: sticky;
      top: 0;
      z-index: 100;
    }
    .header-left {
      display: flex;
      align-items: center;
      gap: 12px;
    }
    .header-icon {
      width: 36px;
      height: 36px;
      background: linear-gradient(135deg, #4a154b, #611f69);
      border-radius: 8px;
      display: flex;
      align-items: center;
      justify-content: center;
      font-size: 18px;
    }
    .header h1 {
      font-size: 18px;
      font-weight: 600;
      color: #fff;
    }
    .header .subtitle {
      font-size: 12px;
      color: #8b8fa3;
      margin-top: 2px;
    }
    .header-right {
      display: flex;
      align-items: center;
      gap: 12px;
    }
    .status-badge {
      padding: 4px 10px;
      border-radius: 12px;
      font-size: 11px;
      font-weight: 500;
    }
    .status-connected { background: #1a3a2a; color: #4ade80; }
    .status-offline { background: #3a1a1a; color: #f87171; }
    .status-cached { background: #3a2a1a; color: #fbbf24; }

    /* Controls */
    .controls {
      background: #1a1d27;
      border-bottom: 1px solid #2d3041;
      padding: 12px 24px;
      display: flex;
      align-items: center;
      gap: 12px;
      flex-wrap: wrap;
    }
    .search-box {
      flex: 1;
      min-width: 200px;
      position: relative;
    }
    .search-box input {
      width: 100%;
      padding: 8px 12px 8px 36px;
      background: #0f1117;
      border: 1px solid #2d3041;
      border-radius: 6px;
      color: #e0e0e0;
      font-size: 14px;
      outline: none;
      transition: border-color 0.2s;
    }
    .search-box input:focus {
      border-color: #611f69;
    }
    .search-box input::placeholder {
      color: #555;
    }
    .search-icon {
      position: absolute;
      left: 10px;
      top: 50%;
      transform: translateY(-50%);
      color: #555;
      font-size: 14px;
    }

    .btn {
      padding: 8px 16px;
      border-radius: 6px;
      border: none;
      font-size: 13px;
      font-weight: 500;
      cursor: pointer;
      transition: all 0.2s;
      white-space: nowrap;
    }
    .btn-primary {
      background: linear-gradient(135deg, #4a154b, #611f69);
      color: #fff;
    }
    .btn-primary:hover { background: linear-gradient(135deg, #611f69, #7c2d7e); }
    .btn-secondary {
      background: #2d3041;
      color: #e0e0e0;
    }
    .btn-secondary:hover { background: #3d4055; }
    .btn-danger {
      background: #3a1a1a;
      color: #f87171;
    }
    .btn-danger:hover { background: #4a2020; }
    .btn:disabled {
      opacity: 0.5;
      cursor: not-allowed;
    }

    .msg-count {
      font-size: 12px;
      color: #8b8fa3;
      white-space: nowrap;
    }

    /* Main content */
    .main {
      max-width: 800px;
      margin: 0 auto;
      padding: 20px;
    }

    /* Setup panel */
    .setup-panel {
      background: #1a1d27;
      border: 1px solid #2d3041;
      border-radius: 12px;
      padding: 24px;
      margin-bottom: 20px;
    }
    .setup-panel h2 {
      font-size: 16px;
      margin-bottom: 12px;
      color: #fff;
    }
    .setup-panel p {
      font-size: 13px;
      color: #8b8fa3;
      margin-bottom: 16px;
      line-height: 1.5;
    }
    .setup-row {
      display: flex;
      gap: 8px;
      margin-bottom: 12px;
    }
    .setup-row input {
      flex: 1;
      padding: 8px 12px;
      background: #0f1117;
      border: 1px solid #2d3041;
      border-radius: 6px;
      color: #e0e0e0;
      font-size: 14px;
      outline: none;
    }
    .setup-row input:focus { border-color: #611f69; }
    .setup-or {
      text-align: center;
      color: #555;
      font-size: 12px;
      margin: 16px 0;
      position: relative;
    }
    .setup-or::before, .setup-or::after {
      content: '';
      position: absolute;
      top: 50%;
      width: 40%;
      height: 1px;
      background: #2d3041;
    }
    .setup-or::before { left: 0; }
    .setup-or::after { right: 0; }

    .upload-zone {
      border: 2px dashed #2d3041;
      border-radius: 8px;
      padding: 24px;
      text-align: center;
      cursor: pointer;
      transition: all 0.2s;
    }
    .upload-zone:hover { border-color: #611f69; background: rgba(97, 31, 105, 0.05); }
    .upload-zone.dragover { border-color: #611f69; background: rgba(97, 31, 105, 0.1); }
    .upload-icon { font-size: 32px; margin-bottom: 8px; }
    .upload-text { font-size: 13px; color: #8b8fa3; }
    .upload-text strong { color: #e0e0e0; }

    /* Messages */
    .messages-container {
      display: flex;
      flex-direction: column;
      gap: 4px;
    }

    /* Date separator */
    .date-separator {
      display: flex;
      align-items: center;
      gap: 12px;
      margin: 16px 0 8px;
    }
    .date-separator .line {
      flex: 1;
      height: 1px;
      background: #2d3041;
    }
    .date-separator .date-label {
      font-size: 12px;
      font-weight: 600;
      color: #8b8fa3;
      background: #0f1117;
      padding: 2px 12px;
      border-radius: 12px;
      border: 1px solid #2d3041;
      white-space: nowrap;
    }

    /* Chat bubble */
    .message {
      display: flex;
      gap: 10px;
      padding: 6px 16px;
      border-radius: 4px;
      transition: background 0.15s;
    }
    .message:hover {
      background: rgba(255,255,255,0.02);
    }
    .message.highlight {
      background: rgba(97, 31, 105, 0.15);
      border-left: 3px solid #611f69;
    }
    .avatar {
      width: 36px;
      height: 36px;
      border-radius: 6px;
      background: linear-gradient(135deg, #2563eb, #1d4ed8);
      display: flex;
      align-items: center;
      justify-content: center;
      font-size: 14px;
      font-weight: 700;
      color: #fff;
      flex-shrink: 0;
      margin-top: 2px;
    }
    .message-content {
      flex: 1;
      min-width: 0;
    }
    .message-header {
      display: flex;
      align-items: baseline;
      gap: 8px;
      margin-bottom: 2px;
    }
    .sender-name {
      font-size: 14px;
      font-weight: 700;
      color: #fff;
    }
    .message-time {
      font-size: 11px;
      color: #555;
    }
    .message-text {
      font-size: 14px;
      line-height: 1.5;
      color: #d4d4d8;
      word-break: break-word;
    }
    .message-text a {
      color: #60a5fa;
      text-decoration: none;
    }
    .message-text a:hover { text-decoration: underline; }
    .message-text code {
      background: #2d3041;
      padding: 1px 4px;
      border-radius: 3px;
      font-family: 'SF Mono', Monaco, Consolas, monospace;
      font-size: 12px;
      color: #f0abfc;
    }
    .message-text pre {
      background: #1a1d27;
      border: 1px solid #2d3041;
      border-radius: 6px;
      padding: 12px;
      margin: 8px 0;
      overflow-x: auto;
      font-family: 'SF Mono', Monaco, Consolas, monospace;
      font-size: 12px;
      color: #d4d4d8;
    }
    .message-text blockquote {
      border-left: 3px solid #4a154b;
      padding-left: 12px;
      margin: 4px 0;
      color: #8b8fa3;
    }

    /* Files/attachments */
    .attachment {
      margin-top: 6px;
      padding: 8px 12px;
      background: #1a1d27;
      border: 1px solid #2d3041;
      border-left: 3px solid #611f69;
      border-radius: 4px;
      font-size: 13px;
    }
    .attachment-title {
      color: #60a5fa;
      font-weight: 500;
      margin-bottom: 4px;
    }
    .attachment-text {
      color: #8b8fa3;
      font-size: 12px;
    }
    .attachment img {
      max-width: 300px;
      max-height: 200px;
      border-radius: 4px;
      margin-top: 6px;
    }

    /* Loading / Empty */
    .empty-state {
      text-align: center;
      padding: 60px 20px;
      color: #555;
    }
    .empty-state .icon { font-size: 48px; margin-bottom: 16px; }
    .empty-state h3 { font-size: 18px; color: #8b8fa3; margin-bottom: 8px; }
    .empty-state p { font-size: 13px; }

    .loading {
      text-align: center;
      padding: 40px;
      color: #8b8fa3;
    }
    .spinner {
      width: 32px;
      height: 32px;
      border: 3px solid #2d3041;
      border-top-color: #611f69;
      border-radius: 50%;
      animation: spin 0.8s linear infinite;
      margin: 0 auto 12px;
    }
    @keyframes spin { to { transform: rotate(360deg); } }

    /* Toast */
    .toast {
      position: fixed;
      bottom: 20px;
      right: 20px;
      padding: 12px 20px;
      border-radius: 8px;
      font-size: 13px;
      z-index: 1000;
      transition: opacity 0.3s;
      max-width: 400px;
    }
    .toast-success { background: #1a3a2a; color: #4ade80; border: 1px solid #22543d; }
    .toast-error { background: #3a1a1a; color: #f87171; border: 1px solid #7f1d1d; }
    .toast-info { background: #1a2a3a; color: #60a5fa; border: 1px solid #1e3a5f; }

    /* Responsive */
    @media (max-width: 640px) {
      .header { padding: 12px 16px; }
      .controls { padding: 8px 16px; }
      .main { padding: 12px; }
      .message { padding: 6px 8px; }
      .avatar { width: 28px; height: 28px; font-size: 11px; }
    }

    /* Hidden file input */
    #fileInput { display: none; }
  </style>
</head>
<body>
  <div id="root"></div>

  <script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
  <script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
  <script crossorigin src="https://unpkg.com/@babel/standalone/babel.min.js"></script>

  <script type="text/babel">
    const { useState, useEffect, useRef, useMemo, useCallback } = React;

    // --- Helpers ---
    function formatTimestamp(ts) {
      if (!ts) return '';
      const d = new Date(typeof ts === 'string' && ts.includes('.') ? parseFloat(ts) * 1000 :
                         typeof ts === 'number' ? ts * 1000 : ts);
      if (isNaN(d.getTime())) return ts;
      return d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
    }

    function formatDate(ts) {
      if (!ts) return '';
      const d = new Date(typeof ts === 'string' && ts.includes('.') ? parseFloat(ts) * 1000 :
                         typeof ts === 'number' ? ts * 1000 : ts);
      if (isNaN(d.getTime())) return '';
      const today = new Date();
      const yesterday = new Date(today);
      yesterday.setDate(yesterday.getDate() - 1);
      if (d.toDateString() === today.toDateString()) return 'Today';
      if (d.toDateString() === yesterday.toDateString()) return 'Yesterday';
      return d.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' });
    }

    function getDateKey(ts) {
      if (!ts) return 'unknown';
      const d = new Date(typeof ts === 'string' && ts.includes('.') ? parseFloat(ts) * 1000 :
                         typeof ts === 'number' ? ts * 1000 : ts);
      if (isNaN(d.getTime())) return 'unknown';
      return d.toDateString();
    }

    function formatMessageText(text) {
      if (!text) return '';
      // Convert Slack mrkdwn to HTML
      let html = text
        // Escape HTML
        .replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
        // URLs
        .replace(/<(https?:\/\/[^|>]+)\|([^>]+)>/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$2</a>')
        .replace(/<(https?:\/\/[^>]+)>/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>')
        // Bold
        .replace(/\*([^*]+)\*/g, '<strong>$1</strong>')
        // Italic
        .replace(/_([^_]+)_/g, '<em>$1</em>')
        // Strikethrough
        .replace(/~([^~]+)~/g, '<del>$1</del>')
        // Inline code
        .replace(/\`([^\`]+)\`/g, '<code>$1</code>')
        // Code blocks
        .replace(/\`\`\`([\\s\\S]*?)\`\`\`/g, '<pre>$1</pre>')
        // Blockquote
        .replace(/^&gt; (.+)$/gm, '<blockquote>$1</blockquote>')
        // Newlines
        .replace(/\\n/g, '<br>');
      return html;
    }

    // --- Toast Component ---
    function Toast({ message, type, onClose }) {
      useEffect(() => {
        const timer = setTimeout(onClose, 4000);
        return () => clearTimeout(timer);
      }, [onClose]);

      return (
        <div className={"toast toast-" + type}>
          {message}
        </div>
      );
    }

    // --- Main App ---
    function App() {
      const [messages, setMessages] = useState([]);
      const [loading, setLoading] = useState(false);
      const [search, setSearch] = useState('');
      const [channelId, setChannelId] = useState('');
      const [channelInput, setChannelInput] = useState('');
      const [showSetup, setShowSetup] = useState(true);
      const [source, setSource] = useState('');
      const [toast, setToast] = useState(null);
      const fileInputRef = useRef(null);

      const showToast = useCallback((message, type = 'info') => {
        setToast({ message, type });
      }, []);

      // Load config on mount
      useEffect(() => {
        fetch('/api/config', { headers: { 'Authorization': 'Basic ' + btoa('admin:' + '') } })
          .then(r => r.json())
          .then(cfg => {
            if (cfg.channelId) {
              setChannelId(cfg.channelId);
              setChannelInput(cfg.channelId);
              setShowSetup(false);
              // Load cached messages
              loadMessages(cfg.channelId);
            }
          })
          .catch(() => {});
      }, []);

      const loadMessages = useCallback(async (chId) => {
        setLoading(true);
        try {
          const params = chId ? '?channel=' + chId : '';
          const res = await fetch('/api/messages' + params, {
            headers: { 'Authorization': 'Basic ' + btoa('admin:' + '') }
          });
          const data = await res.json();
          if (data.ok) {
            setMessages(data.messages || []);
            setSource(data.source || 'unknown');
            setShowSetup(false);
            showToast('Loaded ' + (data.messages || []).length + ' messages from ' + (data.source || 'Slack'), 'success');
          } else {
            // Try cached
            const cachedRes = await fetch('/api/cached', {
              headers: { 'Authorization': 'Basic ' + btoa('admin:' + '') }
            });
            const cachedData = await cachedRes.json();
            if (cachedData.messages && cachedData.messages.length > 0) {
              setMessages(cachedData.messages);
              setSource('cache');
              setShowSetup(false);
              showToast('Loaded ' + cachedData.messages.length + ' cached messages. API error: ' + data.error, 'info');
            } else {
              showToast(data.error || 'Failed to load messages', 'error');
            }
          }
        } catch (e) {
          showToast('Error: ' + e.message, 'error');
        } finally {
          setLoading(false);
        }
      }, [showToast]);

      const handleSetChannel = useCallback(async () => {
        if (!channelInput.trim()) return;
        try {
          const res = await fetch('/api/config/channel', {
            method: 'POST',
            headers: {
              'Authorization': 'Basic ' + btoa('admin:' + ''),
              'Content-Type': 'application/json'
            },
            body: JSON.stringify({ channelId: channelInput.trim() })
          });
          const data = await res.json();
          if (data.ok) {
            setChannelId(channelInput.trim());
            showToast('Channel ID set. Loading messages...', 'success');
            loadMessages(channelInput.trim());
          }
        } catch (e) {
          showToast('Error: ' + e.message, 'error');
        }
      }, [channelInput, loadMessages, showToast]);

      const handleAutoDetect = useCallback(async () => {
        setLoading(true);
        try {
          const res = await fetch('/api/detect-channel', {
            method: 'POST',
            headers: { 'Authorization': 'Basic ' + btoa('admin:' + '') }
          });
          const data = await res.json();
          if (data.ok) {
            setChannelId(data.channelId);
            setChannelInput(data.channelId);
            showToast('Auto-detected channel: ' + data.channelId, 'success');
            loadMessages(data.channelId);
          } else {
            showToast(data.hint || data.error || 'Auto-detect failed', 'error');
          }
        } catch (e) {
          showToast('Error: ' + e.message, 'error');
        } finally {
          setLoading(false);
        }
      }, [loadMessages, showToast]);

      const handleFileUpload = useCallback(async (e) => {
        const file = e.target.files[0];
        if (!file) return;

        setLoading(true);
        try {
          const text = await file.text();
          let parsed;
          try {
            parsed = JSON.parse(text);
          } catch {
            showToast('Invalid JSON file', 'error');
            setLoading(false);
            return;
          }

          const res = await fetch('/api/upload', {
            method: 'POST',
            headers: {
              'Authorization': 'Basic ' + btoa('admin:' + ''),
              'Content-Type': 'application/json'
            },
            body: JSON.stringify({ messages: parsed })
          });
          const data = await res.json();
          if (data.ok) {
            showToast('Uploaded ' + data.count + ' messages', 'success');
            // Reload from cache
            const cachedRes = await fetch('/api/cached', {
              headers: { 'Authorization': 'Basic ' + btoa('admin:' + '') }
            });
            const cachedData = await cachedRes.json();
            setMessages(cachedData.messages || []);
            setSource('upload');
            setShowSetup(false);
          } else {
            showToast(data.error || 'Upload failed', 'error');
          }
        } catch (e) {
          showToast('Error: ' + e.message, 'error');
        } finally {
          setLoading(false);
          e.target.value = '';
        }
      }, [showToast]);

      const handleDrop = useCallback((e) => {
        e.preventDefault();
        e.stopPropagation();
        const file = e.dataTransfer.files[0];
        if (file && file.name.endsWith('.json')) {
          // Simulate file input change
          const dt = new DataTransfer();
          dt.items.add(file);
          fileInputRef.current.files = dt.files;
          fileInputRef.current.dispatchEvent(new Event('change', { bubbles: true }));
        }
      }, []);

      // Filter messages by search
      const filteredMessages = useMemo(() => {
        if (!search.trim()) return messages;
        const q = search.toLowerCase();
        return messages.filter(m => {
          const text = (m.text || '').toLowerCase();
          return text.includes(q);
        });
      }, [messages, search]);

      // Group messages by date
      const groupedMessages = useMemo(() => {
        const groups = {};
        // Messages from Slack API come newest first, we want to display newest first too
        const sorted = [...filteredMessages].sort((a, b) => {
          const tsA = parseFloat(a.ts || 0);
          const tsB = parseFloat(b.ts || 0);
          return tsB - tsA;
        });

        sorted.forEach(m => {
          const key = getDateKey(m.ts);
          if (!groups[key]) groups[key] = { date: m.ts, messages: [] };
          groups[key].messages.push(m);
        });

        return Object.values(groups);
      }, [filteredMessages]);

      const statusType = source === 'slack-api' ? 'connected' :
                          source === 'upload' || source === 'cache' ? 'cached' : 'offline';
      const statusText = source === 'slack-api' ? 'Live from Slack' :
                          source === 'upload' ? 'From Upload' :
                          source === 'cache' ? 'Cached' :
                          source === 'cache-fallback' ? 'Cached (API Error)' : 'Not Connected';

      return (
        <div>
          {/* Header */}
          <div className="header">
            <div className="header-left">
              <img className="agent-avatar" src="http://45.61.58.125/agent-avatars/the-stager.png" onError="this.style.display='none';this.nextElementSibling.style.display='flex'" />
              <div className="agent-avatar-fallback">S</div>
              <div className="header-icon">S</div>
              <div>
                <h1>Steve's Notes to Self</h1>
                <div className="subtitle">Slacky</div>
              </div>
            </div>
            <div className="header-right">
              <span className={"status-badge status-" + statusType}>{statusText}</span>
              <button className="btn btn-secondary" onClick={() => setShowSetup(!showSetup)}>
                {showSetup ? 'Hide Setup' : 'Setup'}
              </button>
            </div>
          </div>

          {/* Controls */}
          <div className="controls">
            <div className="search-box">
              <span className="search-icon">&#128269;</span>
              <input
                type="text"
                placeholder="Search messages..."
                value={search}
                onChange={e => setSearch(e.target.value)}
              />
            </div>
            <span className="msg-count">
              {filteredMessages.length === messages.length
                ? messages.length + ' messages'
                : filteredMessages.length + ' of ' + messages.length + ' messages'}
            </span>
            {channelId && (
              <button className="btn btn-primary" onClick={() => loadMessages(channelId)} disabled={loading}>
                {loading ? 'Loading...' : 'Refresh'}
              </button>
            )}
          </div>

          <div className="main">
            {/* Setup Panel */}
            {showSetup && (
              <div className="setup-panel">
                <h2>Connect to Slack</h2>
                <p>
                  To view your self-DMs, enter your Slack DM channel ID below.<br/>
                  <strong>How to find it:</strong> In Slack, open your self-DM (message yourself).
                  Click the channel name at the top, then look at the URL in your browser.
                  The channel ID starts with <code>D</code> and looks like <code>D0XXXXXXX</code>.
                </p>

                <div className="setup-row">
                  <input
                    type="text"
                    placeholder="Channel ID (e.g., D03U65XXXXX)"
                    value={channelInput}
                    onChange={e => setChannelInput(e.target.value)}
                    onKeyDown={e => e.key === 'Enter' && handleSetChannel()}
                  />
                  <button className="btn btn-primary" onClick={handleSetChannel} disabled={loading}>
                    Connect
                  </button>
                  <button className="btn btn-secondary" onClick={handleAutoDetect} disabled={loading}>
                    Auto-Detect
                  </button>
                </div>

                <div className="setup-or">OR</div>

                <div
                  className="upload-zone"
                  onClick={() => fileInputRef.current && fileInputRef.current.click()}
                  onDrop={handleDrop}
                  onDragOver={e => { e.preventDefault(); e.currentTarget.classList.add('dragover'); }}
                  onDragLeave={e => e.currentTarget.classList.remove('dragover')}
                >
                  <div className="upload-icon">&#128228;</div>
                  <div className="upload-text">
                    <strong>Upload Slack Export JSON</strong><br/>
                    Drag and drop or click to browse.<br/>
                    Export from Slack: Workspace Settings &gt; Import/Export &gt; Export.
                  </div>
                </div>
                <input
                  ref={fileInputRef}
                  id="fileInput"
                  type="file"
                  accept=".json"
                  onChange={handleFileUpload}
                />
              </div>
            )}

            {/* Loading */}
            {loading && (
              <div className="loading">
                <div className="spinner"></div>
                Fetching messages from Slack...
              </div>
            )}

            {/* Empty state */}
            {!loading && messages.length === 0 && !showSetup && (
              <div className="empty-state">
                <div className="icon">&#128172;</div>
                <h3>No Messages Yet</h3>
                <p>Connect to Slack or upload a JSON export to view your notes.</p>
                <button className="btn btn-primary" style={{marginTop: '16px'}} onClick={() => setShowSetup(true)}>
                  Setup Connection
                </button>
              </div>
            )}

            {/* No search results */}
            {!loading && messages.length > 0 && filteredMessages.length === 0 && (
              <div className="empty-state">
                <div className="icon">&#128270;</div>
                <h3>No Results</h3>
                <p>No messages match "{search}"</p>
              </div>
            )}

            {/* Messages */}
            {!loading && filteredMessages.length > 0 && (
              <div className="messages-container">
                {groupedMessages.map((group, gi) => (
                  <div key={gi}>
                    <div className="date-separator">
                      <div className="line"></div>
                      <span className="date-label">{formatDate(group.date)}</span>
                      <div className="line"></div>
                    </div>
                    {group.messages.map((msg, mi) => (
                      <div
                        key={msg.ts || mi}
                        className={"message" + (search && msg.text && msg.text.toLowerCase().includes(search.toLowerCase()) ? ' highlight' : '')}
                      >
                        <div className="avatar">SA</div>
                        <div className="message-content">
                          <div className="message-header">
                            <span className="sender-name">Steve Abrams</span>
                            <span className="message-time">{formatTimestamp(msg.ts)}</span>
                          </div>
                          <div
                            className="message-text"
                            dangerouslySetInnerHTML={{ __html: formatMessageText(msg.text) }}
                          />
                          {/* Attachments */}
                          {msg.attachments && msg.attachments.map((att, ai) => (
                            <div key={ai} className="attachment">
                              {att.title && <div className="attachment-title">{att.title}</div>}
                              {att.text && <div className="attachment-text">{att.text}</div>}
                              {att.fallback && !att.title && !att.text && <div className="attachment-text">{att.fallback}</div>}
                              {att.image_url && <img src={att.image_url} alt="attachment" />}
                              {att.thumb_url && !att.image_url && <img src={att.thumb_url} alt="attachment" />}
                            </div>
                          ))}
                          {/* Files */}
                          {msg.files && msg.files.map((file, fi) => (
                            <div key={fi} className="attachment">
                              <div className="attachment-title">
                                {file.url_private ? (
                                  <a href={file.url_private} target="_blank" rel="noopener noreferrer">{file.name || file.title || 'File'}</a>
                                ) : (
                                  file.name || file.title || 'File'
                                )}
                              </div>
                              {file.mimetype && file.mimetype.startsWith('image/') && file.url_private && (
                                <img src={file.url_private} alt={file.name} />
                              )}
                            </div>
                          ))}
                        </div>
                      </div>
                    ))}
                  </div>
                ))}
              </div>
            )}
          </div>

          {/* Toast */}
          {toast && (
            <Toast
              message={toast.message}
              type={toast.type}
              onClose={() => setToast(null)}
            />
          )}
        </div>
      );
    }

    ReactDOM.createRoot(document.getElementById('root')).render(<App />);
  </script>
</body>
</html>`;
}

// --- Claude-Steve Channel Viewer ---

// Fetch messages from #claude-to-steve (Steve's messages only, no bots)
app.get('/api/claude-steve/messages', async (req, res) => {
  try {
    let allMessages = [];
    let cursor = undefined;
    let pages = 0;
    const maxPages = 50;

    do {
      const params = { channel: CLAUDE_STEVE_CHANNEL, limit: '200' };
      if (cursor) params.cursor = cursor;

      const result = await slackApi('conversations.history', params, SLACK_USER_TOKEN);

      if (!result.ok) {
        return res.status(400).json({ ok: false, error: result.error });
      }

      const steveMessages = result.messages.filter(m =>
        m.user === STEVE_USER_ID && !m.subtype && !m.bot_id
      );
      allMessages = allMessages.concat(steveMessages);

      cursor = result.response_metadata && result.response_metadata.next_cursor;
      pages++;
    } while (cursor && pages < maxPages);

    allMessages.sort((a, b) => parseFloat(b.ts) - parseFloat(a.ts));
    res.json({ ok: true, messages: allMessages, count: allMessages.length, pages });
  } catch (e) {
    res.status(500).json({ ok: false, error: e.message });
  }
});

// Serve Claude-Steve viewer
app.get('/claude-steve', (req, res) => {
  res.send(getClaudeSteveHTML());
});

// Claude-Steve viewer frontend (inline)
function getClaudeSteveHTML() {
  return fs.readFileSync(path.join(__dirname, 'claude-steve.html'), 'utf8');
}

// --- Start ---
app.listen(PORT, '0.0.0.0', () => {
  console.log(`Slacky running on http://0.0.0.0:${PORT}`);
  console.log(`Claude-Steve viewer: http://45.61.58.125:${PORT}/claude-steve`);
  console.log(`Dashboard: http://45.61.58.125:${PORT}`);
});