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

The operator configures one shared Hermes bot. Link your own Telegram account once so direct messages use this Keycloak account's isolated Hermes tenant.

After linking, you can send text or photos for analysis. Images Hermes creates or revises are returned directly in the same private chat.

Account-link commands go only to the private chat with the Hermes bot—not to Hermes WebUI and not to BotFather.

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 = ` [data-panel="kanban"],[data-panel="logs"],[data-panel="insights"],[data-panel="memory"],[data-panel="skills"],[data-panel="workspaces"],[data-panel="todos"],[data-panel="tasks"],[data-panel="profiles"]{display:none!important} #settingsModel,label:has(#settingsModel),.settings-row:has(#settingsModel){display:none!important} #hermes-files-sidebar,#hermes-telegram-sidebar{display:flex;align-items:center} .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-operator-setup{margin:24px 0;padding:18px;border:1px solid #334155;border-radius:12px;background:#0b1220}.hermes-operator-setup h2{margin:0 0 12px;font-size:19px}.hermes-operator-setup li{margin:10px 0}.hermes-operator-setup code,.hermes-operator-setup pre{font-family:ui-monospace,SFMono-Regular,Consolas,monospace}.hermes-operator-setup pre{padding:12px;border-radius:8px;background:#020617;color:#cbd5e1;white-space:pre-wrap;overflow-wrap:anywhere}.hermes-button{display:inline-block;padding:8px 12px;border-radius:8px;background:#334155;color:#e5e7eb;text-decoration:none}.hermes-button.primary{background:#229ed9;color:#fff;font-weight:700} .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-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 chatAdministrationPanels = ['kanban','logs','insights','memory','skills','workspaces','todos','tasks','profiles']; const hideChatAdministration = () => { chatAdministrationPanels.forEach((panel) => { document.querySelectorAll('[data-panel="' + panel + '"]').forEach((node) => { node.hidden = true; }); }); }; hideChatAdministration(); if (!page && !filesPage) { const labelLink = (link, id, href, label, ariaLabel) => { if (link.id !== id) link.id = id; if (link.getAttribute('href') !== href) link.setAttribute('href', href); if (link.getAttribute('aria-label') !== ariaLabel) link.setAttribute('aria-label', ariaLabel); const walker = document.createTreeWalker(link, NodeFilter.SHOW_TEXT); let textNode = walker.nextNode(); let replaced = false; while (textNode) { if (textNode.textContent.trim()) { const current = textNode.textContent.trim(); if (current.toLowerCase() !== label.toLowerCase()) { textNode.textContent = textNode.textContent.replace(current, label); } replaced = true; break; } textNode = walker.nextNode(); } if (!replaced) link.append(document.createTextNode(label)); }; const installSidebarLinks = () => { const match = location.pathname.match(/^\/session\/([^/]+)/); let files = document.getElementById('hermes-files-sidebar'); if (!files) { files = Array.from(document.querySelectorAll('a[href]')).find((link) => { try { return new URL(link.href, location.href).pathname === '/files'; } catch (_) { return false; } }); } if (files) labelLink(files, 'hermes-files-sidebar', '/private-files' + (match ? '?session_id=' + encodeURIComponent(match[1]) : ''), 'Files', 'Browse private Hermes files'); let telegram = document.getElementById('hermes-telegram-sidebar'); if (!telegram) { const rail = document.querySelector('.rail'); if (!rail) return; telegram = document.createElement('button'); telegram.type = 'button'; telegram.id = 'hermes-telegram-sidebar'; telegram.className = 'rail-btn nav-tab has-tooltip'; telegram.setAttribute('data-tooltip', 'Telegram'); telegram.setAttribute('aria-label', 'Connect this account to the shared Hermes Telegram bot'); telegram.innerHTML = ''; telegram.addEventListener('click', () => { location.assign('/telegram'); }); const anchor = rail.querySelector('[data-panel="logs"]') || rail.querySelector('.rail-spacer'); rail.insertBefore(telegram, anchor || null); } }; let scheduled = false; const observer = new MutationObserver(() => { if (scheduled) return; scheduled = true; requestAnimationFrame(() => { scheduled = false; hideChatAdministration(); installSidebarLinks(); }); }); installSidebarLinks(); observer.observe(document.body, {childList:true, subtree:true}); 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 operatorSetup = document.getElementById('telegram-operator-setup'); 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; operatorSetup.hidden = false; return; } if (!payload.ready) { status.textContent = payload.error || 'The shared Telegram bot token is not accepted by Telegram. The operator must update the token and roll out the router.'; linkButton.hidden = true; unlinkButton.hidden = true; operatorSetup.hidden = false; return; } operatorSetup.hidden = true; linkButton.hidden = false; linkButton.disabled = false; const botName = payload.bot_username ? '@' + payload.bot_username : 'the Hermes bot'; status.textContent = payload.linked ? 'Telegram is linked to this private account through ' + botName + '.' : 'Telegram is ready. Link this account with ' + botName + '.'; 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'); const botName = payload.bot_username ? '@' + payload.bot_username : 'the Hermes bot'; text.textContent = 'In a private Telegram chat with ' + botName + ', send /link ' + payload.code + '. Do not send it to BotFather or paste it into Hermes WebUI. 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.target = '_blank'; anchor.textContent = 'Open ' + botName + ' in Telegram and link this account'; 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 "/hermes-session-continuity.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, sessionContinuityJS) 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 := "" ready := false lastError := "" if router.telegram != nil { username, ready, lastError = router.telegram.status() } writeJSON(writer, http.StatusOK, map[string]any{ "configured": router.telegram != nil, "ready": ready, "linked": linked, "bot_username": username, "error": lastError, }) 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 } username, ready, _ := router.telegram.status() if !ready || username == "" { writeJSON(writer, http.StatusServiceUnavailable, map[string]string{"error": "The configured Telegram bot is not active. Ask the operator to update its BotFather token and roll out the router."}) return true } code, expires, err := router.createLink(subject) if err != nil { writeJSON(writer, http.StatusServiceUnavailable, map[string]string{"error": err.Error()}) return true } 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, "bot_username": username, }) 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) } if !strings.Contains(content, "hermes-session-continuity.js") { 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 }