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 = `
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 ] ' ) ;
const hideChatAdministration = ( ) = > {
document . querySelectorAll ( ' [ data - panel = "kanban" ] ' ) . forEach ( ( node ) = > { node . hidden = true ; } ) ;
} ;
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 ;
requestAnimationFrame ( ( ) = > { scheduled = false ; installSidebarLinks ( ) ; } ) ;
} ) ;
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
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
}
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
}