← back to Slack Dm Viewer
server.js
1252 lines
const express = require('express');
const helmet = require('helmet');
const https = require('https');
const path = require('path');
const fs = require('fs');
const app = express();
// Security headers via helmet (added 2026-05-04 overnight YOLO loop)
app.use(helmet({ contentSecurityPolicy: false }));
const PORT = process.env.PORT || 7204;
// --- Auth ---
const AUTH_USER = 'admin';
const AUTH_PASS = process.env.GEORGE_BASIC_AUTH_PASS || '';
function basicAuth(req, res, next) {
// Skip auth for health endpoint
if (req.path === '/health') return next();
const auth = req.headers.authorization;
if (!auth || !auth.startsWith('Basic ')) {
res.setHeader('WWW-Authenticate', 'Basic realm="Slacky"');
return res.status(401).send('Authentication required');
}
const decoded = Buffer.from(auth.split(' ')[1], 'base64').toString();
const [user, pass] = decoded.split(':');
if (user === AUTH_USER && pass === AUTH_PASS) return next();
res.setHeader('WWW-Authenticate', 'Basic realm="Slacky"');
return res.status(401).send('Invalid credentials');
}
app.use(express.json({ limit: '50mb' }));
app.use(basicAuth);
// --- Slack API config ---
// designerbot token has im:history scope
const SLACK_BOT_TOKEN = '${SLACK_BOT_TOKEN}';
// dwmcp_bot token has users:read scope
const SLACK_USERS_TOKEN = '${SLACK_BOT_TOKEN}';
const STEVE_USER_ID = 'U03TV3UC2V7';
// Steve's user token for private channel access (#claude-to-steve)
const SLACK_USER_TOKEN = '${SLACK_USER_TOKEN}';
const CLAUDE_STEVE_CHANNEL = 'C09SW7VQ0RK';
// Persistent config
const CONFIG_FILE = path.join(__dirname, 'config.json');
function loadConfig() {
try {
if (fs.existsSync(CONFIG_FILE)) return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
} catch (e) {}
return { channelId: null, cachedMessages: [] };
}
function saveConfig(cfg) {
fs.writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2));
}
// --- Slack API helpers ---
function slackApi(method, params, token) {
return new Promise((resolve, reject) => {
const qs = new URLSearchParams(params).toString();
const reqPath = `/api/${method}${qs ? '?' + qs : ''}`;
const options = {
hostname: 'slack.com',
path: reqPath,
method: 'GET',
headers: {
'Authorization': `Bearer ${token || SLACK_BOT_TOKEN}`,
'Content-Type': 'application/json'
}
};
const req = https.request(options, res => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
try { resolve(JSON.parse(data)); }
catch (e) { reject(new Error('Invalid JSON from Slack')); }
});
});
req.on('error', reject);
req.setTimeout(15000, () => { req.destroy(); reject(new Error('Slack API timeout')); });
req.end();
});
}
function slackApiPost(method, body, token) {
return new Promise((resolve, reject) => {
const postData = JSON.stringify(body);
const options = {
hostname: 'slack.com',
path: `/api/${method}`,
method: 'POST',
headers: {
'Authorization': `Bearer ${token || SLACK_BOT_TOKEN}`,
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(postData)
}
};
const req = https.request(options, res => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
try { resolve(JSON.parse(data)); }
catch (e) { reject(new Error('Invalid JSON from Slack')); }
});
});
req.on('error', reject);
req.setTimeout(15000, () => { req.destroy(); reject(new Error('Slack API timeout')); });
req.write(postData);
req.end();
});
}
// --- API Endpoints ---
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'healthy', service: 'slacky', port: PORT, uptime: process.uptime() });
});
// Get config
app.get('/api/config', (req, res) => {
const cfg = loadConfig();
res.json({ channelId: cfg.channelId, hasCachedMessages: (cfg.cachedMessages || []).length > 0 });
});
// Set channel ID
app.post('/api/config/channel', (req, res) => {
const { channelId } = req.body;
if (!channelId) return res.status(400).json({ error: 'channelId required' });
const cfg = loadConfig();
cfg.channelId = channelId;
saveConfig(cfg);
res.json({ ok: true, channelId });
});
// Auto-detect Steve's self-DM channel
app.post('/api/detect-channel', async (req, res) => {
try {
// Try conversations.list with im type using the users token
const result = await slackApi('conversations.list', { types: 'im', limit: '200' }, SLACK_USERS_TOKEN);
if (result.ok && result.channels) {
const selfDm = result.channels.find(c => c.user === STEVE_USER_ID);
if (selfDm) {
const cfg = loadConfig();
cfg.channelId = selfDm.id;
saveConfig(cfg);
return res.json({ ok: true, channelId: selfDm.id, method: 'auto-detect' });
}
// If no self-DM, return all DM channels for manual selection
return res.json({ ok: false, error: 'Self-DM not found', channels: result.channels });
}
// If missing_scope, try conversations.open as fallback
if (result.error === 'missing_scope') {
return res.json({
ok: false,
error: 'Bot token lacks im:read scope. Please enter your self-DM channel ID manually.',
hint: 'In Slack, right-click on your self-DM chat, Copy Link. The channel ID is the last part of the URL (starts with D).'
});
}
res.json({ ok: false, error: result.error || 'Unknown error' });
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
// Fetch messages from Slack
app.get('/api/messages', async (req, res) => {
const cfg = loadConfig();
const channelId = req.query.channel || cfg.channelId;
if (!channelId) {
// Return cached messages if available
if (cfg.cachedMessages && cfg.cachedMessages.length > 0) {
return res.json({ ok: true, messages: cfg.cachedMessages, source: 'cache' });
}
return res.status(400).json({ ok: false, error: 'No channel ID configured. Set it via /api/config/channel or use the setup page.' });
}
try {
let allMessages = [];
let cursor = undefined;
let pages = 0;
const maxPages = 20; // Limit to prevent runaway
do {
const params = { channel: channelId, limit: '200' };
if (cursor) params.cursor = cursor;
const result = await slackApi('conversations.history', params, SLACK_BOT_TOKEN);
if (!result.ok) {
// If we get not_in_channel, try joining first
if (result.error === 'not_in_channel' || result.error === 'channel_not_found') {
return res.status(400).json({
ok: false,
error: `Cannot access channel ${channelId}: ${result.error}. The bot must be added to this DM channel. Try the other token or upload JSON export.`,
slackError: result.error
});
}
return res.status(400).json({ ok: false, error: result.error });
}
// Filter to only Steve's messages (self-DMs from Steve to Steve)
const steveMessages = result.messages.filter(m =>
m.user === STEVE_USER_ID && m.subtype !== 'channel_join' && m.subtype !== 'channel_leave'
);
allMessages = allMessages.concat(steveMessages);
cursor = result.response_metadata && result.response_metadata.next_cursor;
pages++;
} while (cursor && pages < maxPages);
// Cache messages
cfg.cachedMessages = allMessages;
cfg.channelId = channelId;
cfg.lastFetch = new Date().toISOString();
saveConfig(cfg);
res.json({ ok: true, messages: allMessages, count: allMessages.length, source: 'slack-api', pages });
} catch (e) {
// Return cached on error
if (cfg.cachedMessages && cfg.cachedMessages.length > 0) {
return res.json({ ok: true, messages: cfg.cachedMessages, source: 'cache-fallback', error: e.message });
}
res.status(500).json({ ok: false, error: e.message });
}
});
// Upload JSON export
app.post('/api/upload', (req, res) => {
try {
let messages = req.body.messages || req.body;
if (!Array.isArray(messages)) {
// Try to parse as Slack export format (array of messages)
if (typeof messages === 'string') {
messages = JSON.parse(messages);
}
if (!Array.isArray(messages)) {
return res.status(400).json({ ok: false, error: 'Expected an array of messages' });
}
}
// Filter to Steve's messages only
const steveMessages = messages.filter(m => {
// Slack export format has user field or user_profile
const userId = m.user || (m.user_profile && m.user_profile.real_name);
return userId === STEVE_USER_ID ||
(typeof userId === 'string' && userId.toLowerCase().includes('steve')) ||
m.subtype === undefined; // In self-DM, all messages are from Steve
});
const cfg = loadConfig();
cfg.cachedMessages = steveMessages.length > 0 ? steveMessages : messages;
cfg.lastUpload = new Date().toISOString();
saveConfig(cfg);
res.json({ ok: true, count: cfg.cachedMessages.length, source: 'upload' });
} catch (e) {
res.status(400).json({ ok: false, error: e.message });
}
});
// Get cached messages
app.get('/api/cached', (req, res) => {
const cfg = loadConfig();
res.json({
ok: true,
messages: cfg.cachedMessages || [],
count: (cfg.cachedMessages || []).length,
channelId: cfg.channelId,
lastFetch: cfg.lastFetch,
lastUpload: cfg.lastUpload
});
});
// Clear cache
app.delete('/api/cached', (req, res) => {
const cfg = loadConfig();
cfg.cachedMessages = [];
saveConfig(cfg);
res.json({ ok: true });
});
// Serve frontend
app.get('/', (req, res) => {
res.send(getHTML());
});
// --- Frontend ---
function getHTML() {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Steve's Notes to Self | Slacky</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #0f1117;
color: #e0e0e0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
min-height: 100vh;
}
/* Agent Avatar */
.agent-avatar { width: 56px; height: 56px; border-radius: 50%; border: 2px solid #4A154B; object-fit: cover; }
.agent-avatar-fallback { width: 56px; height: 56px; border-radius: 50%; border: 2px solid #4A154B; background: #4A154B; color: #fff; font-size: 22px; font-weight: 700; display: none; align-items: center; justify-content: center; }
/* Header */
.header {
background: #1a1d27;
border-bottom: 1px solid #2d3041;
padding: 16px 24px;
display: flex;
align-items: center;
justify-content: space-between;
position: sticky;
top: 0;
z-index: 100;
}
.header-left {
display: flex;
align-items: center;
gap: 12px;
}
.header-icon {
width: 36px;
height: 36px;
background: linear-gradient(135deg, #4a154b, #611f69);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
}
.header h1 {
font-size: 18px;
font-weight: 600;
color: #fff;
}
.header .subtitle {
font-size: 12px;
color: #8b8fa3;
margin-top: 2px;
}
.header-right {
display: flex;
align-items: center;
gap: 12px;
}
.status-badge {
padding: 4px 10px;
border-radius: 12px;
font-size: 11px;
font-weight: 500;
}
.status-connected { background: #1a3a2a; color: #4ade80; }
.status-offline { background: #3a1a1a; color: #f87171; }
.status-cached { background: #3a2a1a; color: #fbbf24; }
/* Controls */
.controls {
background: #1a1d27;
border-bottom: 1px solid #2d3041;
padding: 12px 24px;
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.search-box {
flex: 1;
min-width: 200px;
position: relative;
}
.search-box input {
width: 100%;
padding: 8px 12px 8px 36px;
background: #0f1117;
border: 1px solid #2d3041;
border-radius: 6px;
color: #e0e0e0;
font-size: 14px;
outline: none;
transition: border-color 0.2s;
}
.search-box input:focus {
border-color: #611f69;
}
.search-box input::placeholder {
color: #555;
}
.search-icon {
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
color: #555;
font-size: 14px;
}
.btn {
padding: 8px 16px;
border-radius: 6px;
border: none;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.btn-primary {
background: linear-gradient(135deg, #4a154b, #611f69);
color: #fff;
}
.btn-primary:hover { background: linear-gradient(135deg, #611f69, #7c2d7e); }
.btn-secondary {
background: #2d3041;
color: #e0e0e0;
}
.btn-secondary:hover { background: #3d4055; }
.btn-danger {
background: #3a1a1a;
color: #f87171;
}
.btn-danger:hover { background: #4a2020; }
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.msg-count {
font-size: 12px;
color: #8b8fa3;
white-space: nowrap;
}
/* Main content */
.main {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
/* Setup panel */
.setup-panel {
background: #1a1d27;
border: 1px solid #2d3041;
border-radius: 12px;
padding: 24px;
margin-bottom: 20px;
}
.setup-panel h2 {
font-size: 16px;
margin-bottom: 12px;
color: #fff;
}
.setup-panel p {
font-size: 13px;
color: #8b8fa3;
margin-bottom: 16px;
line-height: 1.5;
}
.setup-row {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.setup-row input {
flex: 1;
padding: 8px 12px;
background: #0f1117;
border: 1px solid #2d3041;
border-radius: 6px;
color: #e0e0e0;
font-size: 14px;
outline: none;
}
.setup-row input:focus { border-color: #611f69; }
.setup-or {
text-align: center;
color: #555;
font-size: 12px;
margin: 16px 0;
position: relative;
}
.setup-or::before, .setup-or::after {
content: '';
position: absolute;
top: 50%;
width: 40%;
height: 1px;
background: #2d3041;
}
.setup-or::before { left: 0; }
.setup-or::after { right: 0; }
.upload-zone {
border: 2px dashed #2d3041;
border-radius: 8px;
padding: 24px;
text-align: center;
cursor: pointer;
transition: all 0.2s;
}
.upload-zone:hover { border-color: #611f69; background: rgba(97, 31, 105, 0.05); }
.upload-zone.dragover { border-color: #611f69; background: rgba(97, 31, 105, 0.1); }
.upload-icon { font-size: 32px; margin-bottom: 8px; }
.upload-text { font-size: 13px; color: #8b8fa3; }
.upload-text strong { color: #e0e0e0; }
/* Messages */
.messages-container {
display: flex;
flex-direction: column;
gap: 4px;
}
/* Date separator */
.date-separator {
display: flex;
align-items: center;
gap: 12px;
margin: 16px 0 8px;
}
.date-separator .line {
flex: 1;
height: 1px;
background: #2d3041;
}
.date-separator .date-label {
font-size: 12px;
font-weight: 600;
color: #8b8fa3;
background: #0f1117;
padding: 2px 12px;
border-radius: 12px;
border: 1px solid #2d3041;
white-space: nowrap;
}
/* Chat bubble */
.message {
display: flex;
gap: 10px;
padding: 6px 16px;
border-radius: 4px;
transition: background 0.15s;
}
.message:hover {
background: rgba(255,255,255,0.02);
}
.message.highlight {
background: rgba(97, 31, 105, 0.15);
border-left: 3px solid #611f69;
}
.avatar {
width: 36px;
height: 36px;
border-radius: 6px;
background: linear-gradient(135deg, #2563eb, #1d4ed8);
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 700;
color: #fff;
flex-shrink: 0;
margin-top: 2px;
}
.message-content {
flex: 1;
min-width: 0;
}
.message-header {
display: flex;
align-items: baseline;
gap: 8px;
margin-bottom: 2px;
}
.sender-name {
font-size: 14px;
font-weight: 700;
color: #fff;
}
.message-time {
font-size: 11px;
color: #555;
}
.message-text {
font-size: 14px;
line-height: 1.5;
color: #d4d4d8;
word-break: break-word;
}
.message-text a {
color: #60a5fa;
text-decoration: none;
}
.message-text a:hover { text-decoration: underline; }
.message-text code {
background: #2d3041;
padding: 1px 4px;
border-radius: 3px;
font-family: 'SF Mono', Monaco, Consolas, monospace;
font-size: 12px;
color: #f0abfc;
}
.message-text pre {
background: #1a1d27;
border: 1px solid #2d3041;
border-radius: 6px;
padding: 12px;
margin: 8px 0;
overflow-x: auto;
font-family: 'SF Mono', Monaco, Consolas, monospace;
font-size: 12px;
color: #d4d4d8;
}
.message-text blockquote {
border-left: 3px solid #4a154b;
padding-left: 12px;
margin: 4px 0;
color: #8b8fa3;
}
/* Files/attachments */
.attachment {
margin-top: 6px;
padding: 8px 12px;
background: #1a1d27;
border: 1px solid #2d3041;
border-left: 3px solid #611f69;
border-radius: 4px;
font-size: 13px;
}
.attachment-title {
color: #60a5fa;
font-weight: 500;
margin-bottom: 4px;
}
.attachment-text {
color: #8b8fa3;
font-size: 12px;
}
.attachment img {
max-width: 300px;
max-height: 200px;
border-radius: 4px;
margin-top: 6px;
}
/* Loading / Empty */
.empty-state {
text-align: center;
padding: 60px 20px;
color: #555;
}
.empty-state .icon { font-size: 48px; margin-bottom: 16px; }
.empty-state h3 { font-size: 18px; color: #8b8fa3; margin-bottom: 8px; }
.empty-state p { font-size: 13px; }
.loading {
text-align: center;
padding: 40px;
color: #8b8fa3;
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid #2d3041;
border-top-color: #611f69;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin: 0 auto 12px;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* Toast */
.toast {
position: fixed;
bottom: 20px;
right: 20px;
padding: 12px 20px;
border-radius: 8px;
font-size: 13px;
z-index: 1000;
transition: opacity 0.3s;
max-width: 400px;
}
.toast-success { background: #1a3a2a; color: #4ade80; border: 1px solid #22543d; }
.toast-error { background: #3a1a1a; color: #f87171; border: 1px solid #7f1d1d; }
.toast-info { background: #1a2a3a; color: #60a5fa; border: 1px solid #1e3a5f; }
/* Responsive */
@media (max-width: 640px) {
.header { padding: 12px 16px; }
.controls { padding: 8px 16px; }
.main { padding: 12px; }
.message { padding: 6px 8px; }
.avatar { width: 28px; height: 28px; font-size: 11px; }
}
/* Hidden file input */
#fileInput { display: none; }
</style>
</head>
<body>
<div id="root"></div>
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
<script crossorigin src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script type="text/babel">
const { useState, useEffect, useRef, useMemo, useCallback } = React;
// --- Helpers ---
function formatTimestamp(ts) {
if (!ts) return '';
const d = new Date(typeof ts === 'string' && ts.includes('.') ? parseFloat(ts) * 1000 :
typeof ts === 'number' ? ts * 1000 : ts);
if (isNaN(d.getTime())) return ts;
return d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
}
function formatDate(ts) {
if (!ts) return '';
const d = new Date(typeof ts === 'string' && ts.includes('.') ? parseFloat(ts) * 1000 :
typeof ts === 'number' ? ts * 1000 : ts);
if (isNaN(d.getTime())) return '';
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
if (d.toDateString() === today.toDateString()) return 'Today';
if (d.toDateString() === yesterday.toDateString()) return 'Yesterday';
return d.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' });
}
function getDateKey(ts) {
if (!ts) return 'unknown';
const d = new Date(typeof ts === 'string' && ts.includes('.') ? parseFloat(ts) * 1000 :
typeof ts === 'number' ? ts * 1000 : ts);
if (isNaN(d.getTime())) return 'unknown';
return d.toDateString();
}
function formatMessageText(text) {
if (!text) return '';
// Convert Slack mrkdwn to HTML
let html = text
// Escape HTML
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
// URLs
.replace(/<(https?:\/\/[^|>]+)\|([^>]+)>/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$2</a>')
.replace(/<(https?:\/\/[^>]+)>/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>')
// Bold
.replace(/\*([^*]+)\*/g, '<strong>$1</strong>')
// Italic
.replace(/_([^_]+)_/g, '<em>$1</em>')
// Strikethrough
.replace(/~([^~]+)~/g, '<del>$1</del>')
// Inline code
.replace(/\`([^\`]+)\`/g, '<code>$1</code>')
// Code blocks
.replace(/\`\`\`([\\s\\S]*?)\`\`\`/g, '<pre>$1</pre>')
// Blockquote
.replace(/^> (.+)$/gm, '<blockquote>$1</blockquote>')
// Newlines
.replace(/\\n/g, '<br>');
return html;
}
// --- Toast Component ---
function Toast({ message, type, onClose }) {
useEffect(() => {
const timer = setTimeout(onClose, 4000);
return () => clearTimeout(timer);
}, [onClose]);
return (
<div className={"toast toast-" + type}>
{message}
</div>
);
}
// --- Main App ---
function App() {
const [messages, setMessages] = useState([]);
const [loading, setLoading] = useState(false);
const [search, setSearch] = useState('');
const [channelId, setChannelId] = useState('');
const [channelInput, setChannelInput] = useState('');
const [showSetup, setShowSetup] = useState(true);
const [source, setSource] = useState('');
const [toast, setToast] = useState(null);
const fileInputRef = useRef(null);
const showToast = useCallback((message, type = 'info') => {
setToast({ message, type });
}, []);
// Load config on mount
useEffect(() => {
fetch('/api/config', { headers: { 'Authorization': 'Basic ' + btoa('admin:' + '') } })
.then(r => r.json())
.then(cfg => {
if (cfg.channelId) {
setChannelId(cfg.channelId);
setChannelInput(cfg.channelId);
setShowSetup(false);
// Load cached messages
loadMessages(cfg.channelId);
}
})
.catch(() => {});
}, []);
const loadMessages = useCallback(async (chId) => {
setLoading(true);
try {
const params = chId ? '?channel=' + chId : '';
const res = await fetch('/api/messages' + params, {
headers: { 'Authorization': 'Basic ' + btoa('admin:' + '') }
});
const data = await res.json();
if (data.ok) {
setMessages(data.messages || []);
setSource(data.source || 'unknown');
setShowSetup(false);
showToast('Loaded ' + (data.messages || []).length + ' messages from ' + (data.source || 'Slack'), 'success');
} else {
// Try cached
const cachedRes = await fetch('/api/cached', {
headers: { 'Authorization': 'Basic ' + btoa('admin:' + '') }
});
const cachedData = await cachedRes.json();
if (cachedData.messages && cachedData.messages.length > 0) {
setMessages(cachedData.messages);
setSource('cache');
setShowSetup(false);
showToast('Loaded ' + cachedData.messages.length + ' cached messages. API error: ' + data.error, 'info');
} else {
showToast(data.error || 'Failed to load messages', 'error');
}
}
} catch (e) {
showToast('Error: ' + e.message, 'error');
} finally {
setLoading(false);
}
}, [showToast]);
const handleSetChannel = useCallback(async () => {
if (!channelInput.trim()) return;
try {
const res = await fetch('/api/config/channel', {
method: 'POST',
headers: {
'Authorization': 'Basic ' + btoa('admin:' + ''),
'Content-Type': 'application/json'
},
body: JSON.stringify({ channelId: channelInput.trim() })
});
const data = await res.json();
if (data.ok) {
setChannelId(channelInput.trim());
showToast('Channel ID set. Loading messages...', 'success');
loadMessages(channelInput.trim());
}
} catch (e) {
showToast('Error: ' + e.message, 'error');
}
}, [channelInput, loadMessages, showToast]);
const handleAutoDetect = useCallback(async () => {
setLoading(true);
try {
const res = await fetch('/api/detect-channel', {
method: 'POST',
headers: { 'Authorization': 'Basic ' + btoa('admin:' + '') }
});
const data = await res.json();
if (data.ok) {
setChannelId(data.channelId);
setChannelInput(data.channelId);
showToast('Auto-detected channel: ' + data.channelId, 'success');
loadMessages(data.channelId);
} else {
showToast(data.hint || data.error || 'Auto-detect failed', 'error');
}
} catch (e) {
showToast('Error: ' + e.message, 'error');
} finally {
setLoading(false);
}
}, [loadMessages, showToast]);
const handleFileUpload = useCallback(async (e) => {
const file = e.target.files[0];
if (!file) return;
setLoading(true);
try {
const text = await file.text();
let parsed;
try {
parsed = JSON.parse(text);
} catch {
showToast('Invalid JSON file', 'error');
setLoading(false);
return;
}
const res = await fetch('/api/upload', {
method: 'POST',
headers: {
'Authorization': 'Basic ' + btoa('admin:' + ''),
'Content-Type': 'application/json'
},
body: JSON.stringify({ messages: parsed })
});
const data = await res.json();
if (data.ok) {
showToast('Uploaded ' + data.count + ' messages', 'success');
// Reload from cache
const cachedRes = await fetch('/api/cached', {
headers: { 'Authorization': 'Basic ' + btoa('admin:' + '') }
});
const cachedData = await cachedRes.json();
setMessages(cachedData.messages || []);
setSource('upload');
setShowSetup(false);
} else {
showToast(data.error || 'Upload failed', 'error');
}
} catch (e) {
showToast('Error: ' + e.message, 'error');
} finally {
setLoading(false);
e.target.value = '';
}
}, [showToast]);
const handleDrop = useCallback((e) => {
e.preventDefault();
e.stopPropagation();
const file = e.dataTransfer.files[0];
if (file && file.name.endsWith('.json')) {
// Simulate file input change
const dt = new DataTransfer();
dt.items.add(file);
fileInputRef.current.files = dt.files;
fileInputRef.current.dispatchEvent(new Event('change', { bubbles: true }));
}
}, []);
// Filter messages by search
const filteredMessages = useMemo(() => {
if (!search.trim()) return messages;
const q = search.toLowerCase();
return messages.filter(m => {
const text = (m.text || '').toLowerCase();
return text.includes(q);
});
}, [messages, search]);
// Group messages by date
const groupedMessages = useMemo(() => {
const groups = {};
// Messages from Slack API come newest first, we want to display newest first too
const sorted = [...filteredMessages].sort((a, b) => {
const tsA = parseFloat(a.ts || 0);
const tsB = parseFloat(b.ts || 0);
return tsB - tsA;
});
sorted.forEach(m => {
const key = getDateKey(m.ts);
if (!groups[key]) groups[key] = { date: m.ts, messages: [] };
groups[key].messages.push(m);
});
return Object.values(groups);
}, [filteredMessages]);
const statusType = source === 'slack-api' ? 'connected' :
source === 'upload' || source === 'cache' ? 'cached' : 'offline';
const statusText = source === 'slack-api' ? 'Live from Slack' :
source === 'upload' ? 'From Upload' :
source === 'cache' ? 'Cached' :
source === 'cache-fallback' ? 'Cached (API Error)' : 'Not Connected';
return (
<div>
{/* Header */}
<div className="header">
<div className="header-left">
<img className="agent-avatar" src="http://45.61.58.125/agent-avatars/the-stager.png" onError="this.style.display='none';this.nextElementSibling.style.display='flex'" />
<div className="agent-avatar-fallback">S</div>
<div className="header-icon">S</div>
<div>
<h1>Steve's Notes to Self</h1>
<div className="subtitle">Slacky</div>
</div>
</div>
<div className="header-right">
<span className={"status-badge status-" + statusType}>{statusText}</span>
<button className="btn btn-secondary" onClick={() => setShowSetup(!showSetup)}>
{showSetup ? 'Hide Setup' : 'Setup'}
</button>
</div>
</div>
{/* Controls */}
<div className="controls">
<div className="search-box">
<span className="search-icon">🔍</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">📤</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 > Import/Export > 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">💬</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">🔎</div>
<h3>No Results</h3>
<p>No messages match "{search}"</p>
</div>
)}
{/* Messages */}
{!loading && filteredMessages.length > 0 && (
<div className="messages-container">
{groupedMessages.map((group, gi) => (
<div key={gi}>
<div className="date-separator">
<div className="line"></div>
<span className="date-label">{formatDate(group.date)}</span>
<div className="line"></div>
</div>
{group.messages.map((msg, mi) => (
<div
key={msg.ts || mi}
className={"message" + (search && msg.text && msg.text.toLowerCase().includes(search.toLowerCase()) ? ' highlight' : '')}
>
<div className="avatar">SA</div>
<div className="message-content">
<div className="message-header">
<span className="sender-name">Steve Abrams</span>
<span className="message-time">{formatTimestamp(msg.ts)}</span>
</div>
<div
className="message-text"
dangerouslySetInnerHTML={{ __html: formatMessageText(msg.text) }}
/>
{/* Attachments */}
{msg.attachments && msg.attachments.map((att, ai) => (
<div key={ai} className="attachment">
{att.title && <div className="attachment-title">{att.title}</div>}
{att.text && <div className="attachment-text">{att.text}</div>}
{att.fallback && !att.title && !att.text && <div className="attachment-text">{att.fallback}</div>}
{att.image_url && <img src={att.image_url} alt="attachment" />}
{att.thumb_url && !att.image_url && <img src={att.thumb_url} alt="attachment" />}
</div>
))}
{/* Files */}
{msg.files && msg.files.map((file, fi) => (
<div key={fi} className="attachment">
<div className="attachment-title">
{file.url_private ? (
<a href={file.url_private} target="_blank" rel="noopener noreferrer">{file.name || file.title || 'File'}</a>
) : (
file.name || file.title || 'File'
)}
</div>
{file.mimetype && file.mimetype.startsWith('image/') && file.url_private && (
<img src={file.url_private} alt={file.name} />
)}
</div>
))}
</div>
</div>
))}
</div>
))}
</div>
)}
</div>
{/* Toast */}
{toast && (
<Toast
message={toast.message}
type={toast.type}
onClose={() => setToast(null)}
/>
)}
</div>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>`;
}
// --- Claude-Steve Channel Viewer ---
// Fetch messages from #claude-to-steve (Steve's messages only, no bots)
app.get('/api/claude-steve/messages', async (req, res) => {
try {
let allMessages = [];
let cursor = undefined;
let pages = 0;
const maxPages = 50;
do {
const params = { channel: CLAUDE_STEVE_CHANNEL, limit: '200' };
if (cursor) params.cursor = cursor;
const result = await slackApi('conversations.history', params, SLACK_USER_TOKEN);
if (!result.ok) {
return res.status(400).json({ ok: false, error: result.error });
}
const steveMessages = result.messages.filter(m =>
m.user === STEVE_USER_ID && !m.subtype && !m.bot_id
);
allMessages = allMessages.concat(steveMessages);
cursor = result.response_metadata && result.response_metadata.next_cursor;
pages++;
} while (cursor && pages < maxPages);
allMessages.sort((a, b) => parseFloat(b.ts) - parseFloat(a.ts));
res.json({ ok: true, messages: allMessages, count: allMessages.length, pages });
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
// Serve Claude-Steve viewer
app.get('/claude-steve', (req, res) => {
res.send(getClaudeSteveHTML());
});
// Claude-Steve viewer frontend (inline)
function getClaudeSteveHTML() {
return fs.readFileSync(path.join(__dirname, 'claude-steve.html'), 'utf8');
}
// --- Start ---
app.listen(PORT, '0.0.0.0', () => {
console.log(`Slacky running on http://0.0.0.0:${PORT}`);
console.log(`Claude-Steve viewer: http://45.61.58.125:${PORT}/claude-steve`);
console.log(`Dashboard: http://45.61.58.125:${PORT}`);
});