[object Object]

← back to Slack Dm Viewer

initial scaffold (gitify-all 2026-05-06)

fe31fd7ad47c90152e14f1499957088fd42e0771 · 2026-05-06 10:25:48 -0700 · Steve Abrams

Files touched

Diff

commit fe31fd7ad47c90152e14f1499957088fd42e0771
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed May 6 10:25:48 2026 -0700

    initial scaffold (gitify-all 2026-05-06)
---
 .gitignore          |   12 +
 claude-steve.html   |  361 +++++++++++++++
 ecosystem.config.js |   20 +
 package-lock.json   |  837 ++++++++++++++++++++++++++++++++++
 package.json        |   14 +
 server.js           | 1251 +++++++++++++++++++++++++++++++++++++++++++++++++++
 6 files changed, 2495 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..7e6a9c3
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,12 @@
+node_modules/
+.env
+.env.local
+.env.*.local
+.env.*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
+*.bak
diff --git a/claude-steve.html b/claude-steve.html
new file mode 100644
index 0000000..0b386de
--- /dev/null
+++ b/claude-steve.html
@@ -0,0 +1,361 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
+  <title>Claude + Steve | Message Viewer</title>
+  <link rel="preconnect" href="https://fonts.googleapis.com">
+  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
+  <style>
+    :root {
+      --bg-primary: #0a0a0f;
+      --bg-secondary: #111118;
+      --bg-card: #16161f;
+      --bg-hover: #1c1c28;
+      --border: #252535;
+      --border-accent: #3b3b55;
+      --text-primary: #e8e8f0;
+      --text-secondary: #8888a0;
+      --text-muted: #555568;
+      --accent: #6c5ce7;
+      --accent-light: #a29bfe;
+      --accent-glow: rgba(108,92,231,0.15);
+      --green: #00b894;
+      --red: #e17055;
+      --link: #74b9ff;
+      --radius: 12px;
+      --radius-sm: 8px;
+      --radius-xs: 4px;
+      --shadow: 0 4px 24px rgba(0,0,0,0.3);
+    }
+    * { margin: 0; padding: 0; box-sizing: border-box; }
+    body {
+      background: var(--bg-primary); color: var(--text-primary);
+      font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
+      min-height: 100vh; overflow-x: hidden;
+    }
+    body::before {
+      content: ''; position: fixed; top: -200px; left: 50%; transform: translateX(-50%);
+      width: 800px; height: 600px;
+      background: radial-gradient(ellipse, rgba(108,92,231,0.06) 0%, transparent 70%);
+      pointer-events: none; z-index: 0;
+    }
+    .topnav {
+      position: sticky; top: 0; z-index: 100;
+      background: rgba(10,10,15,0.85); backdrop-filter: blur(20px);
+      border-bottom: 1px solid var(--border); padding: 0 24px; height: 56px;
+      display: flex; align-items: center; justify-content: space-between;
+    }
+    .topnav-left { display: flex; align-items: center; gap: 12px; }
+    .topnav-logo {
+      width: 32px; height: 32px; border-radius: 8px;
+      background: linear-gradient(135deg, var(--accent), #8b5cf6);
+      display: flex; align-items: center; justify-content: center;
+      font-size: 16px; font-weight: 700; color: #fff;
+      box-shadow: 0 0 20px rgba(108,92,231,0.3);
+    }
+    .topnav-title { font-size: 15px; font-weight: 600; letter-spacing: -0.3px; }
+    .topnav-subtitle { font-size: 11px; color: var(--text-muted); margin-top: 1px; }
+    .topnav-right { display: flex; align-items: center; gap: 10px; }
+    .badge { padding: 4px 10px; border-radius: 20px; font-size: 11px; font-weight: 500; letter-spacing: 0.3px; }
+    .badge-live { background: rgba(0,184,148,0.15); color: var(--green); }
+    .badge-count { background: var(--accent-glow); color: var(--accent-light); }
+    .btn {
+      padding: 7px 14px; border-radius: var(--radius-sm); border: 1px solid var(--border);
+      background: var(--bg-card); color: var(--text-primary); font-size: 12px;
+      font-weight: 500; cursor: pointer; transition: all 0.2s; font-family: inherit;
+    }
+    .btn:hover { background: var(--bg-hover); border-color: var(--border-accent); }
+    .btn:disabled { opacity: 0.4; cursor: not-allowed; }
+    .search-bar {
+      background: var(--bg-secondary); border-bottom: 1px solid var(--border);
+      padding: 10px 24px; display: flex; align-items: center; gap: 10px;
+    }
+    .search-wrap { flex: 1; position: relative; }
+    .search-wrap .s-icon { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); color: var(--text-muted); font-size: 14px; }
+    .search-wrap input {
+      width: 100%; padding: 9px 12px 9px 36px; background: var(--bg-card);
+      border: 1px solid var(--border); border-radius: var(--radius-sm);
+      color: var(--text-primary); font-size: 13px; font-family: inherit; outline: none;
+    }
+    .search-wrap input:focus { border-color: var(--accent); }
+    .search-wrap input::placeholder { color: var(--text-muted); }
+    .filter-chip {
+      padding: 5px 12px; border-radius: 20px; border: 1px solid var(--border);
+      background: transparent; color: var(--text-secondary); font-size: 12px;
+      cursor: pointer; transition: all 0.2s; font-family: inherit; white-space: nowrap;
+    }
+    .filter-chip:hover, .filter-chip.active { background: var(--accent-glow); border-color: var(--accent); color: var(--accent-light); }
+    .main { max-width: 820px; margin: 0 auto; padding: 16px 20px 80px; position: relative; z-index: 1; }
+    .date-divider { display: flex; align-items: center; gap: 16px; margin: 24px 0 12px; }
+    .date-divider::before, .date-divider::after { content: ''; flex: 1; height: 1px; background: linear-gradient(90deg, transparent, var(--border), transparent); }
+    .date-label { font-size: 11px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 1px; white-space: nowrap; }
+    .msg { display: flex; gap: 12px; padding: 12px 16px; border-radius: var(--radius); transition: background 0.15s; margin-bottom: 2px; }
+    .msg:hover { background: var(--bg-hover); }
+    .msg.search-hit { background: var(--accent-glow); border-left: 3px solid var(--accent); }
+    .msg-avatar {
+      width: 36px; height: 36px; border-radius: 10px;
+      background: linear-gradient(135deg, #2563eb, #1e40af);
+      display: flex; align-items: center; justify-content: center;
+      font-size: 13px; font-weight: 700; color: #fff; flex-shrink: 0; margin-top: 2px;
+    }
+    .msg-body { flex: 1; min-width: 0; }
+    .msg-head { display: flex; align-items: baseline; gap: 8px; margin-bottom: 3px; }
+    .msg-name { font-size: 14px; font-weight: 600; }
+    .msg-time { font-size: 11px; color: var(--text-muted); }
+    .msg-text { font-size: 14px; line-height: 1.6; color: var(--text-secondary); word-break: break-word; }
+    .msg-text a { color: var(--link); text-decoration: none; }
+    .msg-text a:hover { text-decoration: underline; }
+    .msg-text code { background: var(--bg-card); border: 1px solid var(--border); padding: 1px 5px; border-radius: var(--radius-xs); font-family: monospace; font-size: 12px; color: var(--accent-light); }
+    .msg-text pre { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 12px 16px; margin: 8px 0; overflow-x: auto; font-family: monospace; font-size: 12px; line-height: 1.5; }
+    .msg-text blockquote { border-left: 3px solid var(--accent); padding-left: 12px; margin: 6px 0; color: var(--text-muted); font-style: italic; }
+    .att { margin-top: 8px; padding: 10px 14px; background: var(--bg-card); border: 1px solid var(--border); border-left: 3px solid var(--accent); border-radius: var(--radius-sm); }
+    .att-title { font-size: 13px; font-weight: 600; color: var(--link); margin-bottom: 4px; }
+    .att-title a { color: var(--link); text-decoration: none; }
+    .att-title a:hover { text-decoration: underline; }
+    .att-desc { font-size: 12px; color: var(--text-muted); line-height: 1.5; }
+    .att img { max-width: 400px; max-height: 280px; border-radius: var(--radius-sm); margin-top: 8px; border: 1px solid var(--border); }
+    .loading-wrap { text-align: center; padding: 60px 20px; }
+    .loading-spinner { width: 36px; height: 36px; border: 3px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.8s linear infinite; margin: 0 auto 14px; }
+    @keyframes spin { to { transform: rotate(360deg); } }
+    .loading-text { font-size: 13px; color: var(--text-muted); }
+    .empty { text-align: center; padding: 80px 20px; color: var(--text-muted); }
+    .empty-icon { font-size: 40px; margin-bottom: 12px; opacity: 0.5; }
+    .empty h3 { font-size: 16px; color: var(--text-secondary); margin-bottom: 6px; }
+    .stats-row { display: flex; gap: 12px; margin-bottom: 20px; flex-wrap: wrap; }
+    .stat-card { flex: 1; min-width: 140px; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 16px; }
+    .stat-card .label { font-size: 11px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; }
+    .stat-card .value { font-size: 22px; font-weight: 700; }
+    .stat-card .sub { font-size: 11px; color: var(--text-muted); margin-top: 2px; }
+    .toast { position: fixed; bottom: 24px; right: 24px; padding: 10px 18px; border-radius: var(--radius-sm); font-size: 13px; z-index: 1000; animation: slideIn 0.3s ease; max-width: 360px; box-shadow: var(--shadow); }
+    .toast-ok { background: rgba(0,184,148,0.15); color: var(--green); border: 1px solid rgba(0,184,148,0.3); }
+    .toast-err { background: rgba(225,112,85,0.15); color: var(--red); border: 1px solid rgba(225,112,85,0.3); }
+    @keyframes slideIn { from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
+    .back-link { color: var(--text-muted); text-decoration: none; font-size: 12px; }
+    .back-link:hover { color: var(--text-primary); }
+    @media (max-width: 640px) {
+      .topnav { padding: 0 14px; } .search-bar { padding: 8px 14px; }
+      .main { padding: 10px 12px 60px; } .msg { padding: 10px 8px; }
+      .msg-avatar { width: 28px; height: 28px; font-size: 11px; }
+      .stat-card { min-width: 100px; } .att img { max-width: 100%; }
+    }
+  </style>
+</head>
+<body>
+  <div id="app"></div>
+  <script src="https://unpkg.com/react@18/umd/react.production.min.js" crossorigin></script>
+  <script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js" crossorigin></script>
+  <script src="https://unpkg.com/@babel/standalone/babel.min.js" crossorigin></script>
+  <script src="https://unpkg.com/dompurify@3.0.6/dist/purify.min.js" crossorigin></script>
+  <script type="text/babel">
+    const { useState, useEffect, useMemo, useCallback } = React;
+    const AUTH = 'Basic ' + btoa('admin:I3YusisdESUNdxtrmlb3QJeu9q8ODKJO');
+
+    function fmtTime(ts) {
+      if (!ts) return '';
+      const d = new Date(parseFloat(ts) * 1000);
+      return isNaN(d.getTime()) ? '' : d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true, timeZone: 'America/Los_Angeles' });
+    }
+    function fmtDate(ts) {
+      if (!ts) return '';
+      const d = new Date(parseFloat(ts) * 1000);
+      if (isNaN(d.getTime())) return '';
+      const now = new Date();
+      const y = new Date(now); y.setDate(y.getDate() - 1);
+      if (d.toDateString() === now.toDateString()) return 'Today';
+      if (d.toDateString() === y.toDateString()) return 'Yesterday';
+      return d.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', timeZone: 'America/Los_Angeles' });
+    }
+    function dateKey(ts) { return ts ? new Date(parseFloat(ts) * 1000).toDateString() : '-'; }
+
+    function renderText(raw) {
+      if (!raw) return '';
+      let html = raw
+        .replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
+        .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>')
+        .replace(/```([\s\S]*?)```/g, '<pre>$1</pre>')
+        .replace(/`([^`]+)`/g, '<code>$1</code>')
+        .replace(/\*([^*]+)\*/g, '<strong>$1</strong>')
+        .replace(/_([^_]+)_/g, '<em>$1</em>')
+        .replace(/~([^~]+)~/g, '<del>$1</del>')
+        .replace(/^&gt; (.+)$/gm, '<blockquote>$1</blockquote>')
+        .replace(/\n/g, '<br>');
+      return DOMPurify.sanitize(html, { ALLOWED_TAGS: ['a','strong','em','del','code','pre','blockquote','br'], ALLOWED_ATTR: ['href','target','rel'] });
+    }
+
+    function Toast({ msg, type, onClose }) {
+      useEffect(() => { const t = setTimeout(onClose, 4000); return () => clearTimeout(t); }, [onClose]);
+      return React.createElement('div', { className: 'toast toast-' + type }, msg);
+    }
+
+    function Msg({ m, search }) {
+      const isHit = search && m.text && m.text.toLowerCase().includes(search.toLowerCase());
+      const sanitized = renderText(m.text);
+      const textEl = React.createElement('div', { className: 'msg-text', dangerouslySetInnerHTML: { __html: sanitized } });
+
+      const atts = (m.attachments || []).map((a, ai) =>
+        React.createElement('div', { key: ai, className: 'att' },
+          a.title && React.createElement('div', { className: 'att-title' },
+            a.title_link ? React.createElement('a', { href: DOMPurify.sanitize(a.title_link), target: '_blank', rel: 'noopener noreferrer' }, a.title) : a.title),
+          a.text && React.createElement('div', { className: 'att-desc' }, a.text),
+          !a.title && !a.text && a.fallback && React.createElement('div', { className: 'att-desc' }, a.fallback),
+          a.image_url && React.createElement('img', { src: DOMPurify.sanitize(a.image_url), alt: '', loading: 'lazy' }),
+          a.thumb_url && !a.image_url && React.createElement('img', { src: DOMPurify.sanitize(a.thumb_url), alt: '', loading: 'lazy' })
+        )
+      );
+
+      const files = (m.files || []).map((f, fi) =>
+        React.createElement('div', { key: fi, className: 'att' },
+          React.createElement('div', { className: 'att-title' },
+            f.url_private ? React.createElement('a', { href: DOMPurify.sanitize(f.url_private), target: '_blank', rel: 'noopener noreferrer' }, f.name || 'File') : (f.name || 'File')),
+          f.mimetype && f.mimetype.startsWith('image/') && f.url_private && React.createElement('img', { src: DOMPurify.sanitize(f.url_private), alt: f.name || '', loading: 'lazy' })
+        )
+      );
+
+      return React.createElement('div', { className: 'msg' + (isHit ? ' search-hit' : '') },
+        React.createElement('div', { className: 'msg-avatar' }, 'SA'),
+        React.createElement('div', { className: 'msg-body' },
+          React.createElement('div', { className: 'msg-head' },
+            React.createElement('span', { className: 'msg-name' }, 'Steve Abrams'),
+            React.createElement('span', { className: 'msg-time' }, fmtTime(m.ts))
+          ),
+          textEl,
+          ...atts,
+          ...files
+        )
+      );
+    }
+
+    function App() {
+      const [messages, setMessages] = useState([]);
+      const [loading, setLoading] = useState(true);
+      const [search, setSearch] = useState('');
+      const [filter, setFilter] = useState('all');
+      const [toast, setToast] = useState(null);
+
+      const load = useCallback(async () => {
+        setLoading(true);
+        try {
+          const r = await fetch('/api/claude-steve/messages', { headers: { Authorization: AUTH } });
+          const data = await r.json();
+          if (data.ok) {
+            setMessages(data.messages || []);
+            setToast({ msg: data.count + ' messages loaded', type: 'ok' });
+          } else {
+            setToast({ msg: data.error || 'Failed', type: 'err' });
+          }
+        } catch (e) { setToast({ msg: e.message, type: 'err' }); }
+        finally { setLoading(false); }
+      }, []);
+
+      useEffect(() => { load(); }, [load]);
+
+      const filtered = useMemo(() => {
+        let msgs = messages;
+        if (filter === 'links') msgs = msgs.filter(m => /https?:\/\//.test(m.text || ''));
+        else if (filter === 'media') msgs = msgs.filter(m => (m.attachments && m.attachments.length) || (m.files && m.files.length));
+        if (search.trim()) { const q = search.toLowerCase(); msgs = msgs.filter(m => (m.text || '').toLowerCase().includes(q)); }
+        return msgs;
+      }, [messages, search, filter]);
+
+      const grouped = useMemo(() => {
+        const g = {};
+        filtered.forEach(m => { const k = dateKey(m.ts); if (!g[k]) g[k] = { ts: m.ts, msgs: [] }; g[k].msgs.push(m); });
+        return Object.values(g);
+      }, [filtered]);
+
+      const stats = useMemo(() => {
+        if (!messages.length) return { total: 0, days: 0, links: 0, oldest: '' };
+        const links = messages.filter(m => /https?:\/\//.test(m.text || '')).length;
+        const dates = new Set(messages.map(m => dateKey(m.ts)));
+        const oldest = messages[messages.length - 1];
+        const od = oldest ? new Date(parseFloat(oldest.ts) * 1000) : null;
+        return { total: messages.length, days: dates.size, links,
+          oldest: od ? od.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'America/Los_Angeles' }) : '?' };
+      }, [messages]);
+
+      return React.createElement('div', null,
+        // TopNav
+        React.createElement('div', { className: 'topnav' },
+          React.createElement('div', { className: 'topnav-left' },
+            React.createElement('a', { href: '/', className: 'back-link' }, '\u2190 DMs'),
+            React.createElement('div', { className: 'topnav-logo' }, 'CS'),
+            React.createElement('div', null,
+              React.createElement('div', { className: 'topnav-title' }, 'Claude + Steve'),
+              React.createElement('div', { className: 'topnav-subtitle' }, '#claude-to-steve')
+            )
+          ),
+          React.createElement('div', { className: 'topnav-right' },
+            React.createElement('span', { className: 'badge badge-live' }, 'LIVE'),
+            React.createElement('span', { className: 'badge badge-count' }, filtered.length + ' msgs'),
+            React.createElement('button', { className: 'btn', onClick: load, disabled: loading }, loading ? 'Loading...' : 'Refresh')
+          )
+        ),
+        // Search
+        React.createElement('div', { className: 'search-bar' },
+          React.createElement('div', { className: 'search-wrap' },
+            React.createElement('span', { className: 's-icon' }, '\uD83D\uDD0D'),
+            React.createElement('input', { placeholder: 'Search messages...', value: search, onChange: e => setSearch(e.target.value) })
+          ),
+          React.createElement('button', { className: 'filter-chip' + (filter === 'all' ? ' active' : ''), onClick: () => setFilter('all') }, 'All'),
+          React.createElement('button', { className: 'filter-chip' + (filter === 'links' ? ' active' : ''), onClick: () => setFilter('links') }, 'Links'),
+          React.createElement('button', { className: 'filter-chip' + (filter === 'media' ? ' active' : ''), onClick: () => setFilter('media') }, 'Media')
+        ),
+        // Main
+        React.createElement('div', { className: 'main' },
+          // Stats
+          !loading && messages.length > 0 && React.createElement('div', { className: 'stats-row' },
+            React.createElement('div', { className: 'stat-card' },
+              React.createElement('div', { className: 'label' }, 'Messages'),
+              React.createElement('div', { className: 'value' }, stats.total),
+              React.createElement('div', { className: 'sub' }, "Steve's notes")
+            ),
+            React.createElement('div', { className: 'stat-card' },
+              React.createElement('div', { className: 'label' }, 'Active Days'),
+              React.createElement('div', { className: 'value' }, stats.days),
+              React.createElement('div', { className: 'sub' }, 'Since ' + stats.oldest)
+            ),
+            React.createElement('div', { className: 'stat-card' },
+              React.createElement('div', { className: 'label' }, 'Links Shared'),
+              React.createElement('div', { className: 'value' }, stats.links),
+              React.createElement('div', { className: 'sub' }, Math.round(stats.links / Math.max(stats.total, 1) * 100) + '% of messages')
+            )
+          ),
+          // Loading
+          loading && React.createElement('div', { className: 'loading-wrap' },
+            React.createElement('div', { className: 'loading-spinner' }),
+            React.createElement('div', { className: 'loading-text' }, 'Fetching from #claude-to-steve...')
+          ),
+          // Empty states
+          !loading && filtered.length === 0 && messages.length > 0 && React.createElement('div', { className: 'empty' },
+            React.createElement('div', { className: 'empty-icon' }, '\uD83D\uDD0D'),
+            React.createElement('h3', null, 'No matches')
+          ),
+          !loading && messages.length === 0 && React.createElement('div', { className: 'empty' },
+            React.createElement('div', { className: 'empty-icon' }, '\uD83D\uDCAC'),
+            React.createElement('h3', null, 'No Messages')
+          ),
+          // Messages
+          !loading && filtered.length > 0 && grouped.map((group, gi) =>
+            React.createElement('div', { key: gi },
+              React.createElement('div', { className: 'date-divider' },
+                React.createElement('span', { className: 'date-label' }, fmtDate(group.ts))
+              ),
+              group.msgs.map((m, mi) => React.createElement(Msg, { key: m.ts || mi, m: m, search: search }))
+            )
+          )
+        ),
+        // Toast
+        toast && React.createElement(Toast, { msg: toast.msg, type: toast.type, onClose: () => setToast(null) })
+      );
+    }
+
+    ReactDOM.createRoot(document.getElementById('app')).render(React.createElement(App));
+  </script>
+  <script>
+    window.claudettePageContext = function() { return { dashboard: 'claude-steve-viewer' }; };
+  </script>
+  <script src="http://45.61.58.125:9872/widget.js" data-claudette-endpoint="http://45.61.58.125:9872" defer></script>
+</body>
+</html>
diff --git a/ecosystem.config.js b/ecosystem.config.js
new file mode 100644
index 0000000..7691177
--- /dev/null
+++ b/ecosystem.config.js
@@ -0,0 +1,20 @@
+module.exports = {
+  apps: [{
+    name: 'slack-dm-viewer',
+    script: 'server.js',
+    cwd: '/root/DW-Agents/slack-dm-viewer',
+    env: {
+      NODE_ENV: 'production',
+      PORT: 7204
+    },
+    instances: 1,
+    exec_mode: 'fork',
+    max_memory_restart: '128M',
+    autorestart: true,
+    watch: false,
+    log_date_format: 'YYYY-MM-DD HH:mm:ss',
+    error_file: '/root/DW-Agents/slack-dm-viewer/error.log',
+    out_file: '/root/DW-Agents/slack-dm-viewer/out.log',
+    merge_logs: true
+  }]
+};
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..b6f8b9e
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,837 @@
+{
+  "name": "slack-dm-viewer",
+  "version": "1.0.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "slack-dm-viewer",
+      "version": "1.0.0",
+      "dependencies": {
+        "express": "^4.18.2",
+        "helmet": "^8.1.0"
+      }
+    },
+    "node_modules/accepts": {
+      "version": "1.3.8",
+      "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
+      "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-types": "~2.1.34",
+        "negotiator": "0.6.3"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/array-flatten": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+      "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
+      "license": "MIT"
+    },
+    "node_modules/body-parser": {
+      "version": "1.20.4",
+      "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
+      "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "~3.1.2",
+        "content-type": "~1.0.5",
+        "debug": "2.6.9",
+        "depd": "2.0.0",
+        "destroy": "~1.2.0",
+        "http-errors": "~2.0.1",
+        "iconv-lite": "~0.4.24",
+        "on-finished": "~2.4.1",
+        "qs": "~6.14.0",
+        "raw-body": "~2.5.3",
+        "type-is": "~1.6.18",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8",
+        "npm": "1.2.8000 || >= 1.4.16"
+      }
+    },
+    "node_modules/bytes": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+      "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/call-bind-apply-helpers": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/call-bound": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+      "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "get-intrinsic": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/content-disposition": {
+      "version": "0.5.4",
+      "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
+      "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+      "license": "MIT",
+      "dependencies": {
+        "safe-buffer": "5.2.1"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/content-type": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+      "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/cookie": {
+      "version": "0.7.2",
+      "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+      "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/cookie-signature": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
+      "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
+      "license": "MIT"
+    },
+    "node_modules/debug": {
+      "version": "2.6.9",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+      "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+      "license": "MIT",
+      "dependencies": {
+        "ms": "2.0.0"
+      }
+    },
+    "node_modules/depd": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+      "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/destroy": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
+      "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8",
+        "npm": "1.2.8000 || >= 1.4.16"
+      }
+    },
+    "node_modules/dunder-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/ee-first": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+      "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+      "license": "MIT"
+    },
+    "node_modules/encodeurl": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+      "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/es-define-property": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-errors": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-object-atoms": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+      "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/escape-html": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+      "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+      "license": "MIT"
+    },
+    "node_modules/etag": {
+      "version": "1.8.1",
+      "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+      "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/express": {
+      "version": "4.22.1",
+      "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
+      "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
+      "license": "MIT",
+      "dependencies": {
+        "accepts": "~1.3.8",
+        "array-flatten": "1.1.1",
+        "body-parser": "~1.20.3",
+        "content-disposition": "~0.5.4",
+        "content-type": "~1.0.4",
+        "cookie": "~0.7.1",
+        "cookie-signature": "~1.0.6",
+        "debug": "2.6.9",
+        "depd": "2.0.0",
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "etag": "~1.8.1",
+        "finalhandler": "~1.3.1",
+        "fresh": "~0.5.2",
+        "http-errors": "~2.0.0",
+        "merge-descriptors": "1.0.3",
+        "methods": "~1.1.2",
+        "on-finished": "~2.4.1",
+        "parseurl": "~1.3.3",
+        "path-to-regexp": "~0.1.12",
+        "proxy-addr": "~2.0.7",
+        "qs": "~6.14.0",
+        "range-parser": "~1.2.1",
+        "safe-buffer": "5.2.1",
+        "send": "~0.19.0",
+        "serve-static": "~1.16.2",
+        "setprototypeof": "1.2.0",
+        "statuses": "~2.0.1",
+        "type-is": "~1.6.18",
+        "utils-merge": "1.0.1",
+        "vary": "~1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.10.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/finalhandler": {
+      "version": "1.3.2",
+      "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
+      "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "2.6.9",
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "on-finished": "~2.4.1",
+        "parseurl": "~1.3.3",
+        "statuses": "~2.0.2",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/forwarded": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+      "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/fresh": {
+      "version": "0.5.2",
+      "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+      "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/function-bind": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-intrinsic": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "es-define-property": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.1.1",
+        "function-bind": "^1.1.2",
+        "get-proto": "^1.0.1",
+        "gopd": "^1.2.0",
+        "has-symbols": "^1.1.0",
+        "hasown": "^2.0.2",
+        "math-intrinsics": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+      "license": "MIT",
+      "dependencies": {
+        "dunder-proto": "^1.0.1",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/gopd": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-symbols": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/hasown": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+      "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+      "license": "MIT",
+      "dependencies": {
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/helmet": {
+      "version": "8.1.0",
+      "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
+      "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18.0.0"
+      }
+    },
+    "node_modules/http-errors": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+      "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+      "license": "MIT",
+      "dependencies": {
+        "depd": "~2.0.0",
+        "inherits": "~2.0.4",
+        "setprototypeof": "~1.2.0",
+        "statuses": "~2.0.2",
+        "toidentifier": "~1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/iconv-lite": {
+      "version": "0.4.24",
+      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+      "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+      "license": "MIT",
+      "dependencies": {
+        "safer-buffer": ">= 2.1.2 < 3"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/inherits": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+      "license": "ISC"
+    },
+    "node_modules/ipaddr.js": {
+      "version": "1.9.1",
+      "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+      "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/math-intrinsics": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/media-typer": {
+      "version": "0.3.0",
+      "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+      "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/merge-descriptors": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
+      "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/methods": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+      "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+      "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+      "license": "MIT",
+      "bin": {
+        "mime": "cli.js"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/mime-db": {
+      "version": "1.52.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime-types": {
+      "version": "2.1.35",
+      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+      "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-db": "1.52.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/ms": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+      "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+      "license": "MIT"
+    },
+    "node_modules/negotiator": {
+      "version": "0.6.3",
+      "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+      "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/object-inspect": {
+      "version": "1.13.4",
+      "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+      "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/on-finished": {
+      "version": "2.4.1",
+      "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+      "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+      "license": "MIT",
+      "dependencies": {
+        "ee-first": "1.1.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/parseurl": {
+      "version": "1.3.3",
+      "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+      "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/path-to-regexp": {
+      "version": "0.1.12",
+      "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
+      "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
+      "license": "MIT"
+    },
+    "node_modules/proxy-addr": {
+      "version": "2.0.7",
+      "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+      "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+      "license": "MIT",
+      "dependencies": {
+        "forwarded": "0.2.0",
+        "ipaddr.js": "1.9.1"
+      },
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/qs": {
+      "version": "6.14.2",
+      "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
+      "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "side-channel": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=0.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/range-parser": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+      "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/raw-body": {
+      "version": "2.5.3",
+      "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
+      "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "~3.1.2",
+        "http-errors": "~2.0.1",
+        "iconv-lite": "~0.4.24",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/safe-buffer": {
+      "version": "5.2.1",
+      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+      "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT"
+    },
+    "node_modules/safer-buffer": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+      "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+      "license": "MIT"
+    },
+    "node_modules/send": {
+      "version": "0.19.2",
+      "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
+      "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "2.6.9",
+        "depd": "2.0.0",
+        "destroy": "1.2.0",
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "etag": "~1.8.1",
+        "fresh": "~0.5.2",
+        "http-errors": "~2.0.1",
+        "mime": "1.6.0",
+        "ms": "2.1.3",
+        "on-finished": "~2.4.1",
+        "range-parser": "~1.2.1",
+        "statuses": "~2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/send/node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "license": "MIT"
+    },
+    "node_modules/serve-static": {
+      "version": "1.16.3",
+      "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
+      "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
+      "license": "MIT",
+      "dependencies": {
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "parseurl": "~1.3.3",
+        "send": "~0.19.1"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/setprototypeof": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+      "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+      "license": "ISC"
+    },
+    "node_modules/side-channel": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+      "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.3",
+        "side-channel-list": "^1.0.0",
+        "side-channel-map": "^1.0.1",
+        "side-channel-weakmap": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-list": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
+      "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-map": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+      "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-weakmap": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+      "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3",
+        "side-channel-map": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/statuses": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+      "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/toidentifier": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+      "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.6"
+      }
+    },
+    "node_modules/type-is": {
+      "version": "1.6.18",
+      "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+      "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+      "license": "MIT",
+      "dependencies": {
+        "media-typer": "0.3.0",
+        "mime-types": "~2.1.24"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/unpipe": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+      "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/utils-merge": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+      "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4.0"
+      }
+    },
+    "node_modules/vary": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+      "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    }
+  }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..956ce29
--- /dev/null
+++ b/package.json
@@ -0,0 +1,14 @@
+{
+  "name": "slack-dm-viewer",
+  "version": "1.0.0",
+  "description": "View Steve's Slack self-DMs / notes to self",
+  "main": "server.js",
+  "scripts": {
+    "start": "node server.js",
+    "dev": "node server.js"
+  },
+  "dependencies": {
+    "express": "^4.18.2",
+    "helmet": "^8.1.0"
+  }
+}
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..8cd0ca4
--- /dev/null
+++ b/server.js
@@ -0,0 +1,1251 @@
+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 = 'I3YusisdESUNdxtrmlb3QJeu9q8ODKJO';
+
+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 = 'xoxb-3958182050256-9852565284259-8kfXhJQWVsf515doMRI67AHQ';
+// dwmcp_bot token has users:read scope
+const SLACK_USERS_TOKEN = 'xoxb-3958182050256-9814366234406-HiR1ybK6HT8JViWDEpjXpmsq';
+
+const STEVE_USER_ID = 'U03TV3UC2V7';
+// Steve's user token for private channel access (#claude-to-steve)
+const SLACK_USER_TOKEN = 'xoxp-3958182050256-3947130410993-10509093793987-db03ca5d0c121d415a816178f4aac1a7';
+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">$2</a>')
+        .replace(/<(https?:\/\/[^>]+)>/g, '<a href="$1" target="_blank" rel="noopener">$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:I3YusisdESUNdxtrmlb3QJeu9q8ODKJO') } })
+          .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:I3YusisdESUNdxtrmlb3QJeu9q8ODKJO') }
+          });
+          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:I3YusisdESUNdxtrmlb3QJeu9q8ODKJO') }
+            });
+            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:I3YusisdESUNdxtrmlb3QJeu9q8ODKJO'),
+              '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:I3YusisdESUNdxtrmlb3QJeu9q8ODKJO') }
+          });
+          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:I3YusisdESUNdxtrmlb3QJeu9q8ODKJO'),
+              '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:I3YusisdESUNdxtrmlb3QJeu9q8ODKJO') }
+            });
+            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">{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}`);
+});

(oldest)  ·  back to Slack Dm Viewer  ·  snapshot: 1 file(s) changed, ~1 modified cabe9d5 →