2026-08-08 17:59:45 -03:00
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
const telegramPage = ` < ! doctype html >
< html lang = "en" >
< head >
< meta charset = "utf-8" >
< meta name = "viewport" content = "width=device-width,initial-scale=1" >
< title > Hermes on Telegram < / title >
2026-08-13 12:39:11 -03:00
< link rel = "stylesheet" href = "/hermes-chat-bridge.css?v=20260813-telegram-readiness-v1" >
2026-08-08 17:59:45 -03:00
< / head >
< body class = "hermes-link-page" >
< main class = "hermes-link-card" data - telegram - page >
< a class = "hermes-back" href = "/" > ← Back to Hermes < / a >
< h1 > Hermes on Telegram < / h1 >
2026-08-12 23:08:21 -03:00
< p > The operator configures one shared Hermes bot . Link your own Telegram account once so direct messages use this Keycloak account ' s isolated Hermes tenant . < / p >
2026-08-15 21:48:30 -03:00
< p > After linking , you can send text or photos for analysis . Images Hermes creates or revises are returned directly in the same private chat . < / p >
2026-08-13 12:39:11 -03:00
< p > < strong > Account - link commands go only to the private chat with the Hermes bot — not to Hermes WebUI and not to BotFather . < / strong > < / p >
2026-08-08 17:59:45 -03:00
< p id = "telegram-status" > Checking Telegram … < / p >
< div class = "hermes-link-actions" >
< button id = "telegram-link" type = "button" > Create one - time link < / button >
< button id = "telegram-unlink" class = "secondary" type = "button" > Unlink Telegram < / button >
< / div >
< section id = "telegram-result" hidden > < / section >
2026-08-13 04:31:26 -03:00
< section id = "telegram-operator-setup" class = "hermes-operator-setup" hidden >
< h2 > Activate the shared bot ( operator , once ) < / h2 >
< ol >
< li > < a class = "hermes-button primary" href = "https://t.me/BotFather" target = "_blank" rel = "noopener noreferrer" > Open @ BotFather < / a > and send < code > / newbot < / code > . < / li >
< li > Choose the bot ' s display name and a username ending in < code > bot < / code > , then copy the token BotFather returns . < / li >
2026-08-13 04:52:04 -03:00
< li > On a trusted administrator computer with the Vault CLI , sign in to < a href = "https://secret.bstein.dev" target = "_blank" rel = "noopener noreferrer" > Atlas Vault < / a > and store the token at < code > kv / atlas / hermes / chat - telegram < / code > under < code > bot_token < / code > . < / li >
2026-08-13 04:31:26 -03:00
< / ol >
2026-08-13 04:52:04 -03:00
< pre > export VAULT_ADDR = https : //secret.bstein.dev
vault login - method = oidc
vault kv patch - mount = kv atlas / hermes / chat - telegram bot_token = ' & lt ; BOTFATHER_TOKEN & gt ; ' < / pre >
< p > < code > vault kv patch < / code > changes only < code > bot_token < / code > , so it preserves the existing < code > relay_key < / code > . < strong > Do not paste the token into Hermes , a browser form , Git , or a message . < / strong > < / p >
< p > After the token is saved , ask the Hermes operator to apply the tracked router rollout . Then return here : the account - link button will appear and each Keycloak user can privately link their own Telegram account . < / p >
2026-08-13 04:31:26 -03:00
< / section >
2026-08-08 17:59:45 -03:00
< p class = "hermes-fine-print" > Codes expire after 10 minutes . Only direct messages are accepted ; group messages are ignored . < / p >
< / main >
2026-08-13 12:39:11 -03:00
< script src = "/hermes-chat-bridge.js?v=20260813-telegram-readiness-v1" defer > < / script >
2026-08-09 12:55:20 -03:00
< / body >
< / html > `
const privateFilesPage = ` < ! doctype html >
< html lang = "en" >
< head >
< meta charset = "utf-8" >
< meta name = "viewport" content = "width=device-width,initial-scale=1" >
< title > Hermes Private Files < / title >
2026-08-13 04:52:04 -03:00
< link rel = "stylesheet" href = "/hermes-chat-bridge.css?v=20260813-telegram-operator-v2" >
2026-08-09 12:55:20 -03:00
< / head >
< body class = "hermes-files-page" >
< main class = "hermes-files-shell" data - files - page >
< header class = "hermes-files-header" >
< div >
< a id = "files-back" class = "hermes-back" href = "/" > ← Back to Hermes < / a >
< h1 > Private files < / h1 >
< p > Files created by Hermes in your isolated 10 GiB Home workspace . < / p >
< / div >
< label > Conversation < select id = "files-session" > < / select > < / label >
< / header >
< nav id = "files-breadcrumbs" class = "hermes-breadcrumbs" aria - label = "Folder path" > < / nav >
< p id = "files-status" > Loading your workspace … < / p >
< div class = "hermes-files-grid" >
< section >
< div class = "hermes-files-toolbar" >
< strong > Files and folders < / strong >
< a id = "files-download-folder" class = "hermes-button" href = "#" > Download folder < / a >
< / div >
< ul id = "files-list" class = "hermes-file-list" > < / ul >
< / section >
< section class = "hermes-file-viewer" >
< div class = "hermes-files-toolbar" >
< strong id = "file-title" > Select a file < / strong >
< a id = "file-download" class = "hermes-button" href = "#" hidden > Download < / a >
< / div >
< pre id = "file-content" > Choose a file to preview it here . < / pre >
< / section >
< / div >
< / main >
2026-08-13 04:52:04 -03:00
< script src = "/hermes-chat-bridge.js?v=20260813-telegram-operator-v2" defer > < / script >
2026-08-08 17:59:45 -03:00
< / body >
< / html > `
const bridgeCSS = `
hermes: declutter chat to a chat-only surface
Extend the chat router HTML-injection bridge so tenants see a clean
chat + conversation-history experience instead of the full agent
cockpit. hideChatAdministration now hides the rail buttons and panels
for logs, insights, memory, skills, workspaces, todos, tasks and
profiles (alongside the existing Kanban), and bridgeCSS hides the same
[data-panel] targets pre-JS to avoid a flash. Also hide the redundant
legacy settings model dropdown (#settingsModel) via bridgeCSS while
keeping the composer routing chip. Chat, history/sessions, new-chat,
composer and the model chip are untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-18 06:55:43 -03:00
[ 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 }
2026-08-12 23:08:21 -03:00
# hermes - files - sidebar , # hermes - telegram - sidebar { display : flex ; align - items : center }
2026-08-08 17:59:45 -03:00
. hermes - link - page { margin : 0 ; min - height : 100 vh ; display : grid ; place - items : center ; background : # 0 f172a ; color : # e2e8f0 ; font : 16 px / 1.5 system - ui , sans - serif }
. hermes - link - card { width : min ( 620 px , calc ( 100 % - 40 px ) ) ; box - sizing : border - box ; padding : 32 px ; border : 1 px solid # 334155 ; border - radius : 18 px ; background : # 111827 ; box - shadow : 0 20 px 60 px # 0006 }
. hermes - link - card h1 { margin : .6 rem 0 } . hermes - back { color : # 7 dd3fc } . hermes - link - actions { display : flex ; gap : 12 px ; flex - wrap : wrap ; margin : 24 px 0 }
2026-08-09 02:42:11 -03:00
. hermes - link - card button { border : 0 ; border - radius : 10 px ; padding : 11 px 16 px ; background : # 229 ed9 ; color : # fff ; font - weight : 700 ; cursor : pointer } . hermes - link - card button . secondary { background : # 334155 } . hermes - link - card button : disabled { cursor : not - allowed ; opacity : .45 }
2026-08-13 04:31:26 -03:00
# telegram - result { padding : 16 px ; border - radius : 10 px ; background : # 1e293 b ; overflow - wrap : anywhere } # telegram - result a { color : # 7 dd3fc } . hermes - fine - print { color : # 94 a3b8 ; font - size : 13 px } . hermes - operator - setup { margin : 24 px 0 ; padding : 18 px ; border : 1 px solid # 334155 ; border - radius : 12 px ; background : # 0b1 220 } . hermes - operator - setup h2 { margin : 0 0 12 px ; font - size : 19 px } . hermes - operator - setup li { margin : 10 px 0 } . hermes - operator - setup code , . hermes - operator - setup pre { font - family : ui - monospace , SFMono - Regular , Consolas , monospace } . hermes - operator - setup pre { padding : 12 px ; border - radius : 8 px ; background : # 020617 ; color : # cbd5e1 ; white - space : pre - wrap ; overflow - wrap : anywhere } . hermes - button { display : inline - block ; padding : 8 px 12 px ; border - radius : 8 px ; background : # 334155 ; color : # e5e7eb ; text - decoration : none } . hermes - button . primary { background : # 229 ed9 ; color : # fff ; font - weight : 700 }
2026-08-12 23:08:21 -03:00
. hermes - files - page { margin : 0 ; min - height : 100 vh ; background : # 0b10 20 ; color : # e5e7eb ; font : 15 px / 1.5 system - ui , sans - serif } . hermes - files - shell { width : min ( 1500 px , calc ( 100 % - 36 px ) ) ; margin : auto ; padding : 28 px 0 } . hermes - files - header { display : flex ; justify - content : space - between ; gap : 28 px ; align - items : end ; border - bottom : 1 px solid # 293249 ; padding - bottom : 18 px } . hermes - files - header h1 { margin : .4 rem 0 0 } . hermes - files - header p { margin : .25 rem 0 ; color : # 9 ca3af } . hermes - files - header label { display : grid ; gap : 6 px ; color : # 9 ca3af } . hermes - files - header select { min - width : 260 px ; background : # 151 b2e ; color : # e5e7eb ; border : 1 px solid # 39445 f ; border - radius : 8 px ; padding : 9 px } . hermes - breadcrumbs { display : flex ; gap : 6 px ; flex - wrap : wrap ; margin : 18 px 0 } . hermes - breadcrumbs button { border : 0 ; background : transparent ; color : # 7 dd3fc ; cursor : pointer ; padding : 4 px } . hermes - files - grid { display : grid ; grid - template - columns : minmax ( 280 px , 38 % ) 1 fr ; gap : 18 px } . hermes - files - grid > section { border : 1 px solid # 293249 ; border - radius : 12 px ; background : # 11172 a ; min - height : 65 vh ; overflow : hidden } . hermes - files - toolbar { min - height : 42 px ; display : flex ; align - items : center ; justify - content : space - between ; gap : 12 px ; padding : 10 px 14 px ; border - bottom : 1 px solid # 293249 } . hermes - button { padding : 6 px 10 px ; border - radius : 7 px ; background : # 334155 ; color : # e5e7eb ; text - decoration : none } . hermes - file - list { list - style : none ; margin : 0 ; padding : 8 px } . hermes - file - list button { width : 100 % ; display : grid ; grid - template - columns : 1 fr auto ; gap : 14 px ; text - align : left ; border : 0 ; border - radius : 7 px ; padding : 9 px 10 px ; background : transparent ; color : # e5e7eb ; cursor : pointer } . hermes - file - list button : hover , . hermes - file - list button : focus { background : # 202941 } . hermes - file - meta { color : # 8 d98ad ; font - size : 12 px } . hermes - file - viewer pre { box - sizing : border - box ; margin : 0 ; padding : 18 px ; max - height : calc ( 65 vh - 64 px ) ; overflow : auto ; white - space : pre - wrap ; overflow - wrap : anywhere ; color : # d9e2f1 ; font : 13 px / 1.55 ui - monospace , SFMono - Regular , Consolas , monospace } # files - status { color : # 9 ca3af } @ media ( max - width : 800 px ) { . hermes - files - header { display : block } . hermes - files - header label { margin - top : 14 px } . hermes - files - header select { width : 100 % ; min - width : 0 } . hermes - files - grid { grid - template - columns : 1 fr } . hermes - files - grid > section { min - height : 38 vh } }
2026-08-08 17:59:45 -03:00
`
const bridgeJS = ` ( ( ) = > {
const page = document . querySelector ( ' [ data - telegram - page ] ' ) ;
2026-08-09 12:55:20 -03:00
const filesPage = document . querySelector ( ' [ data - files - page ] ' ) ;
hermes: declutter chat to a chat-only surface
Extend the chat router HTML-injection bridge so tenants see a clean
chat + conversation-history experience instead of the full agent
cockpit. hideChatAdministration now hides the rail buttons and panels
for logs, insights, memory, skills, workspaces, todos, tasks and
profiles (alongside the existing Kanban), and bridgeCSS hides the same
[data-panel] targets pre-JS to avoid a flash. Also hide the redundant
legacy settings model dropdown (#settingsModel) via bridgeCSS while
keeping the composer routing chip. Chat, history/sessions, new-chat,
composer and the model chip are untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-18 06:55:43 -03:00
const chatAdministrationPanels = [ ' kanban ',' logs ',' insights ',' memory ',' skills ',' workspaces ',' todos ',' tasks ',' profiles ' ] ;
2026-08-09 12:55:20 -03:00
const hideChatAdministration = ( ) = > {
hermes: declutter chat to a chat-only surface
Extend the chat router HTML-injection bridge so tenants see a clean
chat + conversation-history experience instead of the full agent
cockpit. hideChatAdministration now hides the rail buttons and panels
for logs, insights, memory, skills, workspaces, todos, tasks and
profiles (alongside the existing Kanban), and bridgeCSS hides the same
[data-panel] targets pre-JS to avoid a flash. Also hide the redundant
legacy settings model dropdown (#settingsModel) via bridgeCSS while
keeping the composer routing chip. Chat, history/sessions, new-chat,
composer and the model chip are untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-18 06:55:43 -03:00
chatAdministrationPanels . forEach ( ( panel ) = > {
document . querySelectorAll ( ' [ data - panel = "' + panel + '" ] ' ) . forEach ( ( node ) = > { node . hidden = true ; } ) ;
} ) ;
2026-08-09 12:55:20 -03:00
} ;
hideChatAdministration ( ) ;
if ( ! page && ! filesPage ) {
2026-08-12 23:08:21 -03:00
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 = ( ) = > {
2026-08-09 12:55:20 -03:00
const match = location . pathname . match ( / ^ \ / session \ / ( [ ^ / ] + ) / ) ;
2026-08-12 23:08:21 -03:00
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 ; }
} ) ;
}
2026-08-13 04:31:26 -03:00
if ( files ) labelLink ( files , ' hermes - files - sidebar ' , ' / private - files ' + ( match ? ' ? session_id = ' + encodeURIComponent ( match [ 1 ] ) : ' ' ) , ' Files ' , ' Browse private Hermes files ' ) ;
2026-08-12 23:08:21 -03:00
let telegram = document . getElementById ( ' hermes - telegram - sidebar ' ) ;
if ( ! telegram ) {
2026-08-13 04:31:26 -03:00
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 = ' < svg width = "20" height = "20" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke - width = "1.5" stroke - linecap = "round" stroke - linejoin = "round" aria - hidden = "true" > < path d = "M22 2 9.5 14.5" / > < path d = "m22 2-7 20-4-9-9-4Z" / > < / svg > ' ;
telegram . addEventListener ( ' click ' , ( ) = > { location . assign ( ' / telegram ' ) ; } ) ;
const anchor = rail . querySelector ( ' [ data - panel = "logs" ] ' ) || rail . querySelector ( ' . rail - spacer ' ) ;
rail . insertBefore ( telegram , anchor || null ) ;
2026-08-12 23:08:21 -03:00
}
} ;
let scheduled = false ;
const observer = new MutationObserver ( ( ) = > {
if ( scheduled ) return ;
scheduled = true ;
hermes: declutter chat to a chat-only surface
Extend the chat router HTML-injection bridge so tenants see a clean
chat + conversation-history experience instead of the full agent
cockpit. hideChatAdministration now hides the rail buttons and panels
for logs, insights, memory, skills, workspaces, todos, tasks and
profiles (alongside the existing Kanban), and bridgeCSS hides the same
[data-panel] targets pre-JS to avoid a flash. Also hide the redundant
legacy settings model dropdown (#settingsModel) via bridgeCSS while
keeping the composer routing chip. Chat, history/sessions, new-chat,
composer and the model chip are untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-18 06:55:43 -03:00
requestAnimationFrame ( ( ) = > { scheduled = false ; hideChatAdministration ( ) ; installSidebarLinks ( ) ; } ) ;
2026-08-12 23:08:21 -03:00
} ) ;
installSidebarLinks ( ) ;
observer . observe ( document . body , { childList : true , subtree : true } ) ;
2026-08-08 17:59:45 -03:00
return ;
}
2026-08-09 12:55:20 -03:00
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 ;
}
2026-08-08 17:59:45 -03:00
const status = document . getElementById ( ' telegram - status ' ) ;
const result = document . getElementById ( ' telegram - result ' ) ;
const linkButton = document . getElementById ( ' telegram - link ' ) ;
const unlinkButton = document . getElementById ( ' telegram - unlink ' ) ;
2026-08-13 04:31:26 -03:00
const operatorSetup = document . getElementById ( ' telegram - operator - setup ' ) ;
2026-08-08 17:59:45 -03:00
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 ) {
2026-08-09 02:42:11 -03:00
status . textContent = ' Telegram is not active yet : the operator must add the BotFather bot token before account links can be created . ' ;
linkButton . hidden = true ;
2026-08-08 17:59:45 -03:00
unlinkButton . hidden = true ;
2026-08-13 04:31:26 -03:00
operatorSetup . hidden = false ;
2026-08-08 17:59:45 -03:00
return ;
}
2026-08-13 12:39:11 -03:00
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 ;
}
2026-08-13 04:31:26 -03:00
operatorSetup . hidden = true ;
2026-08-09 02:42:11 -03:00
linkButton . hidden = false ;
linkButton . disabled = false ;
2026-08-13 12:39:11 -03:00
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 + '.' ;
2026-08-08 17:59:45 -03:00
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' ) ;
2026-08-13 12:39:11 -03:00
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 ( ) + '.' ;
2026-08-08 17:59:45 -03:00
result . appendChild ( text ) ;
if ( payload . deep_link ) {
const anchor = document . createElement ( 'a' ) ;
anchor . href = payload . deep_link ;
anchor . rel = ' noopener noreferrer ' ;
2026-08-13 12:39:11 -03:00
anchor . target = ' _blank ' ;
anchor . textContent = ' Open ' + botName + ' in Telegram and link this account ' ;
2026-08-08 17:59:45 -03:00
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
2026-08-17 14:39:21 +00:00
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
2026-08-08 17:59:45 -03:00
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
2026-08-09 12:55:20 -03:00
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
2026-08-08 17:59:45 -03:00
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 := ""
2026-08-13 12:39:11 -03:00
ready := false
lastError := ""
2026-08-08 17:59:45 -03:00
if router . telegram != nil {
2026-08-13 12:39:11 -03:00
username , ready , lastError = router . telegram . status ( )
2026-08-08 17:59:45 -03:00
}
writeJSON ( writer , http . StatusOK , map [ string ] any {
"configured" : router . telegram != nil ,
2026-08-13 12:39:11 -03:00
"ready" : ready ,
2026-08-08 17:59:45 -03:00
"linked" : linked ,
"bot_username" : username ,
2026-08-13 12:39:11 -03:00
"error" : lastError ,
2026-08-08 17:59:45 -03:00
} )
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
}
2026-08-13 12:39:11 -03:00
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
}
2026-08-08 17:59:45 -03:00
code , expires , err := router . createLink ( subject )
if err != nil {
writeJSON ( writer , http . StatusServiceUnavailable , map [ string ] string { "error" : err . Error ( ) } )
return true
}
2026-08-13 12:39:11 -03:00
deepLink := fmt . Sprintf ( "https://t.me/%s?start=%s" , url . PathEscape ( username ) , url . QueryEscape ( code ) )
2026-08-08 17:59:45 -03:00
writeJSON ( writer , http . StatusOK , map [ string ] any {
2026-08-13 12:39:11 -03:00
"code" : code ,
"expires_at" : expires . Format ( time . RFC3339 ) ,
"deep_link" : deepLink ,
"bot_username" : username ,
2026-08-08 17:59:45 -03:00
} )
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" ) {
2026-08-13 12:39:11 -03:00
content = strings . Replace ( content , "</head>" , ` <link rel="stylesheet" href="/hermes-chat-bridge.css?v=20260813-telegram-readiness-v1"></head> ` , 1 )
content = strings . Replace ( content , "</body>" , ` <script src="/hermes-chat-bridge.js?v=20260813-telegram-readiness-v1" defer></script></body> ` , 1 )
2026-08-08 17:59:45 -03:00
}
2026-08-17 14:39:21 +00:00
if ! strings . Contains ( content , "hermes-session-continuity.js" ) {
fix(hermes): poll the session contract the chat tenants actually serve
Returning to chat.hermes.bstein.dev after a Keycloak logout/login showed
"This session is unavailable to this account. Start a new chat." even
though the session was intact and owned by the same subject.
The banner comes from the continuity fallback the router injects into
every chat page. It polled `/api/sessions/<id>` and
`/api/sessions/<id>/messages` — routes that belong to the Hermes agent
dashboard (added by scripts/patch_web_session_activity.py, applied only
in agent-deployment.yaml). The router proxies browser traffic to the
tenant Hermes WebUI instead, whose only session read is
`GET /api/session?session_id=<id>`; the dashboard paths are unrouted
there, so server.py answered its generic 404 for every poll and the
fallback reported a false ownership failure.
The script runs only on a full document load of `/session/<id>`, which is
exactly what the OIDC round-trip produces when oauth2-proxy returns the
browser to `rd=/session/<id>` — hence the "only after relogin" symptom.
Poll the WebUI contract instead, and let its own answers decide what the
banner claims: 409 `session_profile_mismatch` is the single response that
means the session is outside this account's active scope, 404 now means
the conversation is no longer stored, and 401/403 still re-enter OIDC.
The steady-state poll drops to one request and backs off to 3s/15s now
that it reaches a real endpoint on the tenant Raspberry Pi.
`boundSessionSnapshot` follows the same move: it caps the WebUI envelope
`{"session": {..., "messages": [...]}}`, relaying every other session key
verbatim rather than re-serializing a fixed struct that would silently
drop metadata the banner depends on.
Isolation is unchanged and now covered: the router still resolves the
slot from the salted Keycloak subject, overwrites any client-supplied
X-Hermes-Tenant-Identity, and forwards only the two tenant cookies.
Tests: relogin keeps a stable slot and resolves the durable session; a
second subject replaying the owner's session id, WebUI cookie and a
forged tenant header gets 404 from its own backend and never reaches the
owner's; the legacy dashboard paths are pinned as permanent 404s against
a stub of the deployed WebUI dispatch.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 18:14:55 +00:00
content = strings . Replace ( content , "</body>" , ` <script src="/hermes-session-continuity.js?v=20260820-webui-session-contract" defer></script></body> ` , 1 )
2026-08-17 14:39:21 +00:00
}
2026-08-08 17:59:45 -03:00
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
}