2026-08-08 17:59:45 -03:00
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
2026-08-13 13:05:17 -03:00
"log"
2026-08-08 17:59:45 -03:00
"net/http"
"net/url"
"os"
"strconv"
"strings"
"sync"
"time"
"unicode/utf8"
)
type telegramConfig struct {
BotToken string
RelayKey string
}
type telegramBot struct {
2026-08-17 14:39:21 +00:00
config telegramConfig
router * tenantRouter
apiBase string
fileBase string
client * http . Client
mediaClient * http . Client
agentClient * http . Client
mu sync . RWMutex
botUsername string
ready bool
lastError string
workLimit chan struct { }
activityEvery time . Duration
slotLocks map [ int ] * sync . Mutex
slotLocksMux sync . Mutex
2026-08-08 17:59:45 -03:00
}
type telegramUpdate struct {
UpdateID int64 ` json:"update_id" `
Message * telegramMessage ` json:"message" `
}
type telegramMessage struct {
2026-08-15 21:48:30 -03:00
MessageID int64 ` json:"message_id" `
From * telegramUser ` json:"from" `
Chat telegramChat ` json:"chat" `
Text string ` json:"text" `
Caption string ` json:"caption" `
Photo [ ] telegramPhotoSize ` json:"photo" `
}
type telegramPhotoSize struct {
FileID string ` json:"file_id" `
FileSize int64 ` json:"file_size" `
Width int ` json:"width" `
Height int ` json:"height" `
2026-08-08 17:59:45 -03:00
}
type telegramUser struct {
ID int64 ` json:"id" `
}
type telegramChat struct {
ID int64 ` json:"id" `
Type string ` json:"type" `
}
func readTelegramConfig ( path string ) ( telegramConfig , error ) {
if path == "" {
path = "/vault/secrets/telegram-config"
}
content , err := os . ReadFile ( path )
if err != nil {
return telegramConfig { } , err
}
config := telegramConfig { }
for _ , line := range strings . Split ( string ( content ) , "\n" ) {
key , value , found := strings . Cut ( strings . TrimSpace ( line ) , "=" )
if ! found {
continue
}
switch strings . TrimSpace ( key ) {
case "bot_token" :
config . BotToken = strings . TrimSpace ( value )
case "relay_key" :
config . RelayKey = strings . TrimSpace ( value )
}
}
return config , nil
}
func newTelegramBot ( config telegramConfig , router * tenantRouter ) * telegramBot {
return & telegramBot {
2026-08-17 14:39:21 +00:00
config : config ,
router : router ,
apiBase : "https://api.telegram.org/bot" + config . BotToken ,
fileBase : "https://api.telegram.org/file/bot" + config . BotToken ,
client : & http . Client { Timeout : 70 * time . Second } ,
mediaClient : newTenantMediaClient ( ) ,
agentClient : & http . Client { Timeout : 15 * time . Minute } ,
workLimit : make ( chan struct { } , 4 ) ,
activityEvery : 4 * time . Second ,
slotLocks : map [ int ] * sync . Mutex { } ,
2026-08-08 17:59:45 -03:00
}
}
2026-08-13 12:39:11 -03:00
func ( bot * telegramBot ) status ( ) ( string , bool , string ) {
2026-08-08 17:59:45 -03:00
bot . mu . RLock ( )
defer bot . mu . RUnlock ( )
2026-08-13 12:39:11 -03:00
return bot . botUsername , bot . ready , bot . lastError
2026-08-08 17:59:45 -03:00
}
2026-08-13 13:05:17 -03:00
func ( bot * telegramBot ) setStatus ( username string , ready bool , lastError string ) bool {
2026-08-08 17:59:45 -03:00
bot . mu . Lock ( )
defer bot . mu . Unlock ( )
2026-08-13 13:05:17 -03:00
previousUsername := bot . botUsername
previousReady := bot . ready
previousError := bot . lastError
2026-08-13 12:39:11 -03:00
if username != "" {
bot . botUsername = strings . TrimPrefix ( strings . TrimSpace ( username ) , "@" )
}
bot . ready = ready
bot . lastError = strings . TrimSpace ( lastError )
2026-08-13 13:05:17 -03:00
return previousUsername != bot . botUsername || previousReady != bot . ready || previousError != bot . lastError
2026-08-08 17:59:45 -03:00
}
func ( bot * telegramBot ) slotLock ( slot int ) * sync . Mutex {
bot . slotLocksMux . Lock ( )
defer bot . slotLocksMux . Unlock ( )
if bot . slotLocks [ slot ] == nil {
bot . slotLocks [ slot ] = & sync . Mutex { }
}
return bot . slotLocks [ slot ]
}
func ( bot * telegramBot ) call ( ctx context . Context , method string , values url . Values , result any ) error {
request , err := http . NewRequestWithContext (
ctx ,
http . MethodPost ,
bot . apiBase + "/" + method ,
strings . NewReader ( values . Encode ( ) ) ,
)
if err != nil {
return errors . New ( "create Telegram request" )
}
request . Header . Set ( "Content-Type" , "application/x-www-form-urlencoded" )
response , err := bot . client . Do ( request )
if err != nil {
return errors . New ( "Telegram API unavailable" )
}
defer response . Body . Close ( )
body , err := io . ReadAll ( io . LimitReader ( response . Body , 2 << 20 ) )
if err != nil {
return errors . New ( "read Telegram response" )
}
var envelope struct {
2026-08-13 13:05:17 -03:00
OK bool ` json:"ok" `
Result json . RawMessage ` json:"result" `
Description string ` json:"description" `
ErrorCode int ` json:"error_code" `
2026-08-08 17:59:45 -03:00
}
2026-08-13 13:05:17 -03:00
if err := json . Unmarshal ( body , & envelope ) ; err != nil {
return fmt . Errorf ( "Telegram API returned undecodable status %d" , response . StatusCode )
}
if response . StatusCode != http . StatusOK || ! envelope . OK {
description := strings . TrimSpace ( envelope . Description )
if description == "" {
description = http . StatusText ( response . StatusCode )
}
return fmt . Errorf ( "Telegram API error %d: %s" , envelope . ErrorCode , description )
2026-08-08 17:59:45 -03:00
}
if result != nil && len ( envelope . Result ) > 0 {
if err := json . Unmarshal ( envelope . Result , result ) ; err != nil {
return errors . New ( "decode Telegram response" )
}
}
return nil
}
func ( bot * telegramBot ) run ( ) {
for {
2026-08-13 12:39:11 -03:00
for {
ctx , cancel := context . WithTimeout ( context . Background ( ) , 15 * time . Second )
var me struct {
Username string ` json:"username" `
}
err := bot . call ( ctx , "getMe" , url . Values { } , & me )
cancel ( )
if err == nil {
2026-08-13 13:05:17 -03:00
ctx , cancel = context . WithTimeout ( context . Background ( ) , 15 * time . Second )
err = bot . call ( ctx , "deleteWebhook" , url . Values {
"drop_pending_updates" : { "false" } ,
} , nil )
cancel ( )
if err == nil {
if bot . setStatus ( me . Username , true , "" ) {
log . Printf ( "Telegram bot ready as @%s" , strings . TrimPrefix ( me . Username , "@" ) )
}
break
}
}
if bot . setStatus ( "" , false , "Telegram rejected the configured bot token or is temporarily unavailable." ) {
log . Printf ( "Telegram bot readiness check failed: %v" , err )
2026-08-13 12:39:11 -03:00
}
time . Sleep ( 10 * time . Second )
2026-08-08 17:59:45 -03:00
}
2026-08-13 12:39:11 -03:00
for {
offset := bot . router . telegramOffset ( )
ctx , cancel := context . WithTimeout ( context . Background ( ) , 65 * time . Second )
values := url . Values {
"offset" : { strconv . FormatInt ( offset , 10 ) } ,
"timeout" : { "50" } ,
"allowed_updates" : { ` ["message"] ` } ,
}
var updates [ ] telegramUpdate
err := bot . call ( ctx , "getUpdates" , values , & updates )
cancel ( )
if err != nil {
2026-08-13 13:05:17 -03:00
if bot . setStatus ( "" , false , "Hermes lost contact with the configured Telegram bot." ) {
log . Printf ( "Telegram update polling failed: %v" , err )
}
2026-08-13 12:39:11 -03:00
time . Sleep ( 3 * time . Second )
break
}
for _ , update := range updates {
_ = bot . router . setTelegramOffset ( update . UpdateID + 1 )
bot . workLimit <- struct { } { }
go func ( update telegramUpdate ) {
defer func ( ) { <- bot . workLimit } ( )
bot . handleUpdate ( update )
} ( update )
}
2026-08-08 17:59:45 -03:00
}
}
}
func ( bot * telegramBot ) handleUpdate ( update telegramUpdate ) {
message := update . Message
if message == nil || message . From == nil || message . Chat . Type != "private" || message . Chat . ID != message . From . ID {
return
}
userID := strconv . FormatInt ( message . From . ID , 10 )
command , args := commandParts ( message . Text )
if command == "start" || command == "link" {
if len ( args ) == 0 {
2026-08-21 06:43:18 +00:00
_ = bot . sendText ( message . Chat . ID , "Sign in to chat.bstein.dev, open Telegram, and create a one-time link code." )
2026-08-08 17:59:45 -03:00
return
}
if _ , err := bot . router . consumeLink ( userID , args [ 0 ] ) ; err != nil {
_ = bot . sendText ( message . Chat . ID , "That link code is invalid or expired. Create a new one in Hermes WebUI." )
return
}
_ = bot . sendText ( message . Chat . ID , "Telegram is linked to your private Hermes account. Send a message whenever you are ready." )
return
}
if command == "unlink" {
if err := bot . router . unlinkTelegramUser ( userID ) ; err != nil {
_ = bot . sendText ( message . Chat . ID , "I could not unlink Telegram right now. Try again shortly." )
return
}
_ = bot . sendText ( message . Chat . ID , "Telegram has been unlinked from Hermes." )
return
}
if command == "help" {
2026-08-17 14:39:21 +00:00
_ = bot . sendText ( message . Chat . ID , "Send text or a photo to chat with Hermes. Photos are available for analysis on the message that carries them. Image edits can reuse the latest image Hermes generated; inbound Telegram photos are not stored as editable artifacts. Use /topic <name> to create or switch a durable topic, /topics to list topics, or /unlink to disconnect this Telegram account. Model and intensity controls are available in Hermes WebUI." )
2026-08-08 17:59:45 -03:00
return
}
slot , linked := bot . router . telegramSlot ( userID )
if ! linked {
2026-08-21 06:43:18 +00:00
_ = bot . sendText ( message . Chat . ID , "This Telegram account is not linked. Sign in to chat.bstein.dev and open Telegram to connect it." )
2026-08-08 17:59:45 -03:00
return
}
2026-08-16 15:59:09 -03:00
if command == "topic" {
if len ( args ) == 0 {
active := bot . router . activeTelegramTopic ( userID )
_ = bot . sendText ( message . Chat . ID , "Current topic: " + active . Label + ". Use /topic <name> to switch or create one." )
return
}
active , err := bot . router . selectTelegramTopic ( userID , strings . Join ( args , " " ) )
if err != nil {
_ = bot . sendText ( message . Chat . ID , "That topic name is invalid. Use a short descriptive name, up to 48 characters." )
return
}
_ = bot . sendText ( message . Chat . ID , "Switched to topic: " + active . Label + ". Messages here keep their own bounded context." )
return
}
if command == "topics" {
active := bot . router . activeTelegramTopic ( userID )
lines := [ ] string { "Telegram topics (current: " + active . Label + "):" }
for _ , topic := range bot . router . telegramTopics ( userID ) {
lines = append ( lines , "• " + topic . Label )
}
lines = append ( lines , "Use /topic <name> to switch or create one." )
_ = bot . sendText ( message . Chat . ID , strings . Join ( lines , "\n" ) )
return
}
2026-08-08 17:59:45 -03:00
text := strings . TrimSpace ( message . Text )
2026-08-15 21:48:30 -03:00
hasPhoto := len ( message . Photo ) > 0
if hasPhoto {
text = strings . TrimSpace ( message . Caption )
if text == "" {
text = "Describe this image and answer any question it implies."
}
}
2026-08-08 17:59:45 -03:00
if text == "" {
2026-08-15 21:48:30 -03:00
_ = bot . sendText ( message . Chat . ID , "Hermes currently accepts text and image attachments. Please send other file types through Hermes WebUI." )
2026-08-08 17:59:45 -03:00
return
}
if utf8 . RuneCountInString ( text ) > 12000 {
_ = bot . sendText ( message . Chat . ID , "That message is too long. Please split it into smaller parts." )
return
}
lock := bot . slotLock ( slot )
lock . Lock ( )
defer lock . Unlock ( )
2026-08-16 15:59:09 -03:00
topic , err := bot . router . touchTelegramTopic ( userID )
if err != nil {
_ = bot . sendText ( message . Chat . ID , "Hermes could not save the active topic right now. Please try again shortly." )
return
}
2026-08-17 14:39:21 +00:00
text = telegramInputPrompt ( text , hasPhoto )
stopActivity := bot . startTelegramActivity ( message . Chat . ID )
defer stopActivity ( )
2026-08-15 21:48:30 -03:00
var reply string
if hasPhoto {
media , fetchErr := bot . fetchTelegramPhoto ( message . Photo )
if fetchErr != nil {
_ = bot . sendText ( message . Chat . ID , "Hermes could not safely read that image. Please try a smaller JPEG, PNG, or WebP image." )
return
}
defer os . Remove ( media . Path )
2026-08-16 15:59:09 -03:00
reply , err = bot . askTenantImage ( slot , text , media , update . UpdateID , topic )
2026-08-15 21:48:30 -03:00
} else {
2026-08-16 15:59:09 -03:00
reply , err = bot . askTenant ( slot , text , update . UpdateID , topic )
2026-08-15 21:48:30 -03:00
}
2026-08-08 17:59:45 -03:00
if err != nil {
_ = bot . sendText ( message . Chat . ID , "Hermes could not answer right now. Please try again shortly." )
return
}
2026-08-15 00:34:08 -03:00
_ = bot . sendReply ( message . Chat . ID , slot , reply )
2026-08-08 17:59:45 -03:00
}
2026-08-16 15:59:09 -03:00
func ( bot * telegramBot ) askTenant ( slot int , text string , updateID int64 , topic activeTelegramTopic ) ( string , error ) {
2026-08-08 17:59:45 -03:00
if bot . router . backendAPIURL == nil {
return "" , errors . New ( "tenant API unavailable" )
}
payload , _ := json . Marshal ( map [ string ] any {
"input" : text ,
2026-08-16 15:59:09 -03:00
"conversation" : topic . Conversation ,
2026-08-08 17:59:45 -03:00
"store" : true ,
2026-08-16 15:59:09 -03:00
"truncation" : "auto" ,
2026-08-08 17:59:45 -03:00
} )
2026-08-16 15:59:09 -03:00
return bot . askTenantRequest ( slot , bytes . NewReader ( payload ) , updateID , topic )
2026-08-15 21:48:30 -03:00
}
2026-08-16 15:59:09 -03:00
func ( bot * telegramBot ) askTenantRequest ( slot int , bodyReader io . Reader , updateID int64 , topic activeTelegramTopic ) ( string , error ) {
2026-08-15 21:48:30 -03:00
if bot . router . backendAPIURL == nil {
return "" , errors . New ( "tenant API unavailable" )
}
2026-08-08 17:59:45 -03:00
ctx , cancel := context . WithTimeout ( context . Background ( ) , 14 * time . Minute )
defer cancel ( )
request , err := http . NewRequestWithContext (
ctx ,
http . MethodPost ,
bot . router . backendAPIURL ( slot ) + "/v1/responses" ,
2026-08-15 21:48:30 -03:00
bodyReader ,
2026-08-08 17:59:45 -03:00
)
if err != nil {
return "" , err
}
request . Header . Set ( "Authorization" , "Bearer " + bot . config . RelayKey )
request . Header . Set ( "Content-Type" , "application/json" )
2026-08-16 15:59:09 -03:00
request . Header . Set ( "X-Hermes-Session-Key" , topic . Conversation )
request . Header . Set ( "X-Hermes-Conversation-Platform" , "telegram" )
request . Header . Set ( "X-Hermes-Conversation-Title" , "Telegram · " + topic . Label )
2026-08-08 17:59:45 -03:00
request . Header . Set ( "Idempotency-Key" , "telegram-" + strconv . FormatInt ( updateID , 10 ) )
response , err := bot . agentClient . Do ( request )
if err != nil {
return "" , errors . New ( "tenant request failed" )
}
defer response . Body . Close ( )
body , err := io . ReadAll ( io . LimitReader ( response . Body , 8 << 20 ) )
if err != nil || response . StatusCode != http . StatusOK {
return "" , fmt . Errorf ( "tenant returned status %d" , response . StatusCode )
}
var parsed struct {
Output [ ] struct {
Type string ` json:"type" `
Role string ` json:"role" `
Content [ ] struct {
Type string ` json:"type" `
Text string ` json:"text" `
} ` json:"content" `
} ` json:"output" `
}
if err := json . Unmarshal ( body , & parsed ) ; err != nil {
return "" , errors . New ( "decode tenant response" )
}
var parts [ ] string
for _ , item := range parsed . Output {
if item . Type != "message" || item . Role != "assistant" {
continue
}
for _ , content := range item . Content {
if ( content . Type == "output_text" || content . Type == "text" ) && strings . TrimSpace ( content . Text ) != "" {
parts = append ( parts , strings . TrimSpace ( content . Text ) )
}
}
}
answer := strings . Join ( parts , "\n\n" )
if answer == "" {
return "" , errors . New ( "tenant returned no assistant text" )
}
return answer , nil
}
func ( bot * telegramBot ) sendAction ( chatID int64 , action string ) error {
ctx , cancel := context . WithTimeout ( context . Background ( ) , 10 * time . Second )
defer cancel ( )
2026-08-17 14:39:21 +00:00
return bot . sendActionContext ( ctx , chatID , action )
}
func ( bot * telegramBot ) sendActionContext ( ctx context . Context , chatID int64 , action string ) error {
2026-08-08 17:59:45 -03:00
return bot . call ( ctx , "sendChatAction" , url . Values {
"chat_id" : { strconv . FormatInt ( chatID , 10 ) } ,
"action" : { action } ,
} , nil )
}
func splitTelegramText ( text string ) [ ] string {
runes := [ ] rune ( strings . TrimSpace ( text ) )
if len ( runes ) == 0 {
return [ ] string { "Hermes completed the request without a text response." }
}
const limit = 3900
var chunks [ ] string
for len ( runes ) > limit {
cut := limit
for index := limit ; index > limit - 500 ; index -- {
if runes [ index - 1 ] == '\n' || runes [ index - 1 ] == ' ' {
cut = index
break
}
}
chunks = append ( chunks , strings . TrimSpace ( string ( runes [ : cut ] ) ) )
runes = runes [ cut : ]
}
if tail := strings . TrimSpace ( string ( runes ) ) ; tail != "" {
chunks = append ( chunks , tail )
}
return chunks
}
func ( bot * telegramBot ) sendText ( chatID int64 , text string ) error {
for _ , chunk := range splitTelegramText ( text ) {
ctx , cancel := context . WithTimeout ( context . Background ( ) , 15 * time . Second )
err := bot . call ( ctx , "sendMessage" , url . Values {
"chat_id" : { strconv . FormatInt ( chatID , 10 ) } ,
"text" : { chunk } ,
"disable_web_page_preview" : { "true" } ,
} , nil )
cancel ( )
if err != nil {
return err
}
}
return nil
}
func ( router * tenantRouter ) telegramOffset ( ) int64 {
router . mu . Lock ( )
defer router . mu . Unlock ( )
return router . state . TelegramOffset
}
func ( router * tenantRouter ) setTelegramOffset ( offset int64 ) error {
router . mu . Lock ( )
defer router . mu . Unlock ( )
if offset <= router . state . TelegramOffset {
return nil
}
router . state . TelegramOffset = offset
return router . saveLocked ( )
}
func ( router * tenantRouter ) unlinkTelegramUser ( userID string ) error {
router . mu . Lock ( )
defer router . mu . Unlock ( )
2026-08-16 15:59:09 -03:00
identity := router . identityHash ( "telegram" , userID )
delete ( router . state . Telegram , identity )
delete ( router . state . TelegramTopics , identity )
2026-08-08 17:59:45 -03:00
return router . saveLocked ( )
}