package main import ( "encoding/json" "fmt" "io" "net/http" "net/url" "strconv" "strings" "time" ) const telegramPage = ` Hermes on Telegram
← Back to Hermes

Hermes on Telegram

Link this Keycloak account to a private Telegram chat. Messages will use the same isolated Hermes tenant as the WebUI.

Checking Telegram…

Codes expire after 10 minutes. Only direct messages are accepted; group messages are ignored.

` const privateFilesPage = ` Hermes Private Files
← Back to Hermes

Private files

Files created by Hermes in your isolated 10 GiB Home workspace.

Loading your workspace…

Files and folders Download folder
    Select a file
    Choose a file to preview it here.
    ` const bridgeCSS = ` #hermes-chat-shortcuts{position:fixed;right:18px;top:82px;z-index:9999;display:flex;gap:8px;align-items:center;font:600 13px system-ui,sans-serif} #hermes-chat-shortcuts a{padding:8px 12px;border-radius:999px;color:#fff;text-decoration:none;box-shadow:0 5px 20px #0005}#hermes-files-shortcut{background:#475569}#hermes-telegram-shortcut{background:#229ed9} .hermes-link-page{margin:0;min-height:100vh;display:grid;place-items:center;background:#0f172a;color:#e2e8f0;font:16px/1.5 system-ui,sans-serif} .hermes-link-card{width:min(620px,calc(100% - 40px));box-sizing:border-box;padding:32px;border:1px solid #334155;border-radius:18px;background:#111827;box-shadow:0 20px 60px #0006} .hermes-link-card h1{margin:.6rem 0}.hermes-back{color:#7dd3fc}.hermes-link-actions{display:flex;gap:12px;flex-wrap:wrap;margin:24px 0} .hermes-link-card button{border:0;border-radius:10px;padding:11px 16px;background:#229ed9;color:#fff;font-weight:700;cursor:pointer}.hermes-link-card button.secondary{background:#334155}.hermes-link-card button:disabled{cursor:not-allowed;opacity:.45} #telegram-result{padding:16px;border-radius:10px;background:#1e293b;overflow-wrap:anywhere}#telegram-result a{color:#7dd3fc}.hermes-fine-print{color:#94a3b8;font-size:13px} .hermes-files-page{margin:0;min-height:100vh;background:#0b1020;color:#e5e7eb;font:15px/1.5 system-ui,sans-serif}.hermes-files-shell{width:min(1500px,calc(100% - 36px));margin:auto;padding:28px 0}.hermes-files-header{display:flex;justify-content:space-between;gap:28px;align-items:end;border-bottom:1px solid #293249;padding-bottom:18px}.hermes-files-header h1{margin:.4rem 0 0}.hermes-files-header p{margin:.25rem 0;color:#9ca3af}.hermes-files-header label{display:grid;gap:6px;color:#9ca3af}.hermes-files-header select{min-width:260px;background:#151b2e;color:#e5e7eb;border:1px solid #39445f;border-radius:8px;padding:9px}.hermes-breadcrumbs{display:flex;gap:6px;flex-wrap:wrap;margin:18px 0}.hermes-breadcrumbs button{border:0;background:transparent;color:#7dd3fc;cursor:pointer;padding:4px}.hermes-files-grid{display:grid;grid-template-columns:minmax(280px,38%) 1fr;gap:18px}.hermes-files-grid>section{border:1px solid #293249;border-radius:12px;background:#11172a;min-height:65vh;overflow:hidden}.hermes-files-toolbar{min-height:42px;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 14px;border-bottom:1px solid #293249}.hermes-button{padding:6px 10px;border-radius:7px;background:#334155;color:#e5e7eb;text-decoration:none}.hermes-file-list{list-style:none;margin:0;padding:8px}.hermes-file-list button{width:100%;display:grid;grid-template-columns:1fr auto;gap:14px;text-align:left;border:0;border-radius:7px;padding:9px 10px;background:transparent;color:#e5e7eb;cursor:pointer}.hermes-file-list button:hover,.hermes-file-list button:focus{background:#202941}.hermes-file-meta{color:#8d98ad;font-size:12px}.hermes-file-viewer pre{box-sizing:border-box;margin:0;padding:18px;max-height:calc(65vh - 64px);overflow:auto;white-space:pre-wrap;overflow-wrap:anywhere;color:#d9e2f1;font:13px/1.55 ui-monospace,SFMono-Regular,Consolas,monospace}#files-status{color:#9ca3af}@media(max-width:800px){#hermes-chat-shortcuts{top:auto;bottom:112px}.hermes-files-header{display:block}.hermes-files-header label{margin-top:14px}.hermes-files-header select{width:100%;min-width:0}.hermes-files-grid{grid-template-columns:1fr}.hermes-files-grid>section{min-height:38vh}} ` const bridgeJS = `(() => { const page = document.querySelector('[data-telegram-page]'); const filesPage = document.querySelector('[data-files-page]'); const hideChatAdministration = () => { document.querySelectorAll('[data-panel="kanban"]').forEach((node) => { node.hidden = true; }); }; hideChatAdministration(); if (!page && !filesPage) { if (!document.getElementById('hermes-chat-shortcuts')) { const shortcuts = document.createElement('nav'); shortcuts.id = 'hermes-chat-shortcuts'; const match = location.pathname.match(/^\/session\/([^/]+)/); const files = document.createElement('a'); files.id = 'hermes-files-shortcut'; files.href = '/private-files' + (match ? '?session_id=' + encodeURIComponent(match[1]) : ''); files.textContent = 'Files'; files.setAttribute('aria-label', 'Browse private Hermes files'); const telegram = document.createElement('a'); telegram.id = 'hermes-telegram-shortcut'; telegram.href = '/telegram'; telegram.textContent = 'Telegram'; telegram.setAttribute('aria-label', 'Connect Hermes to Telegram'); shortcuts.append(files, telegram); document.body.appendChild(shortcuts); } return; } if (filesPage) { const params = new URLSearchParams(location.search); const sessionPicker = document.getElementById('files-session'); const list = document.getElementById('files-list'); const status = document.getElementById('files-status'); const breadcrumbs = document.getElementById('files-breadcrumbs'); const title = document.getElementById('file-title'); const content = document.getElementById('file-content'); const fileDownload = document.getElementById('file-download'); const folderDownload = document.getElementById('files-download-folder'); const back = document.getElementById('files-back'); let sessionId = params.get('session_id') || ''; let currentPath = '.'; const api = async (path) => { const response = await fetch(path, {cache:'no-store'}); let payload; try { payload = await response.json(); } catch (_) { payload = {}; } if (!response.ok) throw new Error(payload.error || payload.detail || ('Request failed (' + response.status + ')')); return payload; }; const endpoint = (kind, path) => '/api/' + kind + '?session_id=' + encodeURIComponent(sessionId) + '&path=' + encodeURIComponent(path || '.'); const renderBreadcrumbs = () => { breadcrumbs.replaceChildren(); const parts = currentPath === '.' ? [] : currentPath.split('/').filter(Boolean); const roots = [{label:'Home', path:'.'}]; let built = ''; parts.forEach((part) => { built = built ? built + '/' + part : part; roots.push({label:part, path:built}); }); roots.forEach((item, index) => { if (index) breadcrumbs.append(document.createTextNode(' / ')); const button = document.createElement('button'); button.type = 'button'; button.textContent = item.label; button.addEventListener('click', () => loadDirectory(item.path)); breadcrumbs.appendChild(button); }); }; const openFile = async (entry) => { status.textContent = 'Opening ' + entry.name + '…'; try { const payload = await api(endpoint('file', entry.path)); title.textContent = entry.path; content.textContent = payload.content || '[Empty file]'; fileDownload.href = endpoint('file/raw', entry.path); fileDownload.setAttribute('download', entry.name); fileDownload.hidden = false; status.textContent = entry.size == null ? 'File loaded.' : 'File loaded (' + entry.size.toLocaleString() + ' bytes).'; } catch (error) { status.textContent = error.message; } }; const loadDirectory = async (path) => { currentPath = path || '.'; status.textContent = 'Loading ' + (currentPath === '.' ? 'Home' : currentPath) + '…'; title.textContent = 'Select a file'; content.textContent = 'Choose a file to preview it here.'; fileDownload.hidden = true; renderBreadcrumbs(); folderDownload.href = endpoint('folder/download', currentPath); try { const payload = await api(endpoint('list', currentPath)); const entries = Array.isArray(payload.entries) ? payload.entries.slice() : []; entries.sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === 'dir' ? -1 : 1)); list.replaceChildren(); if (!entries.length) { const empty = document.createElement('li'); empty.textContent = 'This folder is empty.'; list.appendChild(empty); } entries.forEach((entry) => { const item = document.createElement('li'); const button = document.createElement('button'); button.type = 'button'; const name = document.createElement('span'); name.textContent = (entry.type === 'dir' ? '📁 ' : '📄 ') + entry.name; const meta = document.createElement('span'); meta.className = 'hermes-file-meta'; meta.textContent = entry.type === 'dir' ? 'folder' : ((entry.size || 0).toLocaleString() + ' B'); button.append(name, meta); button.addEventListener('click', () => entry.type === 'dir' ? loadDirectory(entry.path) : openFile(entry)); item.appendChild(button); list.appendChild(item); }); status.textContent = entries.length + (entries.length === 1 ? ' item' : ' items') + ' in ' + (currentPath === '.' ? 'Home' : currentPath) + '.'; } catch (error) { list.replaceChildren(); status.textContent = error.message; } }; const bootFiles = async () => { try { const payload = await api('/api/sessions?sidebar_source=webui&exclude_hidden=1'); const sessions = (payload.sessions || []).filter((item) => item && item.session_id && !item.read_only); if (!sessions.length) throw new Error('Start a chat first so Hermes can attach the private Home workspace.'); if (!sessions.some((item) => item.session_id === sessionId)) sessionId = sessions[0].session_id; sessionPicker.replaceChildren(); sessions.forEach((item) => { const option = document.createElement('option'); option.value = item.session_id; option.textContent = item.title || item.session_id; option.selected = item.session_id === sessionId; sessionPicker.appendChild(option); }); sessionPicker.addEventListener('change', () => { sessionId = sessionPicker.value; back.href = '/session/' + encodeURIComponent(sessionId); loadDirectory('.'); }); back.href = '/session/' + encodeURIComponent(sessionId); await loadDirectory('.'); } catch (error) { status.textContent = error.message; } }; bootFiles(); return; } const status = document.getElementById('telegram-status'); const result = document.getElementById('telegram-result'); const linkButton = document.getElementById('telegram-link'); const unlinkButton = document.getElementById('telegram-unlink'); const action = async (path) => { const response = await fetch(path, {method:'POST',headers:{'Content-Type':'application/json','X-Hermes-Action':'telegram-link'},body:'{}'}); const payload = await response.json(); if (!response.ok) throw new Error(payload.error || 'Request failed'); return payload; }; const refresh = async () => { try { const response = await fetch('/api/telegram/status', {cache:'no-store'}); const payload = await response.json(); if (!payload.configured) { status.textContent = 'Telegram is not active yet: the operator must add the BotFather bot token before account links can be created.'; linkButton.hidden = true; unlinkButton.hidden = true; return; } linkButton.hidden = false; linkButton.disabled = false; status.textContent = payload.linked ? 'Telegram is linked to this private account.' : 'Telegram is ready to link.'; unlinkButton.hidden = !payload.linked; } catch (_) { status.textContent = 'Telegram status is temporarily unavailable.'; } }; linkButton.addEventListener('click', async () => { try { const payload = await action('/api/telegram/link'); result.hidden = false; result.replaceChildren(); const text = document.createElement('p'); text.textContent = 'Send /link ' + payload.code + ' to the Hermes bot. This code expires at ' + new Date(payload.expires_at).toLocaleTimeString() + '.'; result.appendChild(text); if (payload.deep_link) { const anchor = document.createElement('a'); anchor.href = payload.deep_link; anchor.rel = 'noopener noreferrer'; anchor.textContent = 'Open Telegram and link now'; result.appendChild(anchor); } } catch (error) { status.textContent = error.message; } }); unlinkButton.addEventListener('click', async () => { try { await action('/api/telegram/unlink'); result.hidden = true; await refresh(); } catch (error) { status.textContent = error.message; } }); refresh(); })();` func writeJSON(writer http.ResponseWriter, status int, value any) { writer.Header().Set("Content-Type", "application/json") writer.Header().Set("Cache-Control", "no-store") writer.WriteHeader(status) _ = json.NewEncoder(writer).Encode(value) } func validTelegramAction(request *http.Request) bool { return request.Header.Get("X-Hermes-Action") == "telegram-link" && strings.HasPrefix(request.Header.Get("Content-Type"), "application/json") } func (router *tenantRouter) serveTelegramWeb(writer http.ResponseWriter, request *http.Request, subject string) bool { switch request.URL.Path { case "/hermes-chat-bridge.css": if request.Method != http.MethodGet { http.Error(writer, "method not allowed", http.StatusMethodNotAllowed) return true } writer.Header().Set("Content-Type", "text/css; charset=utf-8") writer.Header().Set("Cache-Control", "public, max-age=3600") _, _ = io.WriteString(writer, bridgeCSS) return true case "/hermes-chat-bridge.js": if request.Method != http.MethodGet { http.Error(writer, "method not allowed", http.StatusMethodNotAllowed) return true } writer.Header().Set("Content-Type", "application/javascript; charset=utf-8") writer.Header().Set("Cache-Control", "public, max-age=3600") _, _ = io.WriteString(writer, bridgeJS) return true case "/telegram": if request.Method != http.MethodGet { http.Error(writer, "method not allowed", http.StatusMethodNotAllowed) return true } writer.Header().Set("Content-Type", "text/html; charset=utf-8") writer.Header().Set("Cache-Control", "no-store") writer.Header().Set("Content-Security-Policy", "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'self'") _, _ = io.WriteString(writer, telegramPage) return true case "/private-files": if request.Method != http.MethodGet { http.Error(writer, "method not allowed", http.StatusMethodNotAllowed) return true } writer.Header().Set("Content-Type", "text/html; charset=utf-8") writer.Header().Set("Cache-Control", "no-store") writer.Header().Set("Content-Security-Policy", "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' data:; base-uri 'none'; form-action 'self'") _, _ = io.WriteString(writer, privateFilesPage) return true case "/api/telegram/status": if request.Method != http.MethodGet { writeJSON(writer, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) return true } linked, err := router.telegramLinked(subject) if err != nil { writeJSON(writer, http.StatusServiceUnavailable, map[string]string{"error": err.Error()}) return true } username := "" if router.telegram != nil { username = router.telegram.username() } writeJSON(writer, http.StatusOK, map[string]any{ "configured": router.telegram != nil, "linked": linked, "bot_username": username, }) return true case "/api/telegram/link": if request.Method != http.MethodPost || !validTelegramAction(request) { writeJSON(writer, http.StatusForbidden, map[string]string{"error": "same-origin action required"}) return true } if router.telegram == nil { writeJSON(writer, http.StatusServiceUnavailable, map[string]string{"error": "Telegram bot token is not configured"}) return true } code, expires, err := router.createLink(subject) if err != nil { writeJSON(writer, http.StatusServiceUnavailable, map[string]string{"error": err.Error()}) return true } username := router.telegram.username() deepLink := "" if username != "" { deepLink = fmt.Sprintf("https://t.me/%s?start=%s", url.PathEscape(username), url.QueryEscape(code)) } writeJSON(writer, http.StatusOK, map[string]any{ "code": code, "expires_at": expires.Format(time.RFC3339), "deep_link": deepLink, }) return true case "/api/telegram/unlink": if request.Method != http.MethodPost || !validTelegramAction(request) { writeJSON(writer, http.StatusForbidden, map[string]string{"error": "same-origin action required"}) return true } if err := router.unlinkTelegram(subject); err != nil { writeJSON(writer, http.StatusServiceUnavailable, map[string]string{"error": err.Error()}) return true } writeJSON(writer, http.StatusOK, map[string]bool{"unlinked": true}) return true default: return false } } func injectChatBridge(response *http.Response) error { if !strings.Contains(response.Header.Get("Content-Type"), "text/html") { return nil } body, err := io.ReadAll(response.Body) if err != nil { return err } _ = response.Body.Close() content := string(body) if !strings.Contains(content, "hermes-chat-bridge.js") { content = strings.Replace(content, "", ``, 1) content = strings.Replace(content, "", ``, 1) } response.Body = io.NopCloser(strings.NewReader(content)) response.ContentLength = int64(len(content)) response.Header.Set("Content-Length", strconv.Itoa(len(content))) response.Header.Set("Cache-Control", "no-store") response.Header.Del("ETag") return nil }