393 lines
11 KiB
Go

package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"sync"
"time"
"unicode/utf8"
)
type telegramConfig struct {
BotToken string
RelayKey string
}
type telegramBot struct {
config telegramConfig
router *tenantRouter
apiBase string
client *http.Client
agentClient *http.Client
mu sync.RWMutex
botUsername string
workLimit chan struct{}
slotLocks map[int]*sync.Mutex
slotLocksMux sync.Mutex
}
type telegramUpdate struct {
UpdateID int64 `json:"update_id"`
Message *telegramMessage `json:"message"`
}
type telegramMessage struct {
MessageID int64 `json:"message_id"`
From *telegramUser `json:"from"`
Chat telegramChat `json:"chat"`
Text string `json:"text"`
}
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{
config: config,
router: router,
apiBase: "https://api.telegram.org/bot" + config.BotToken,
client: &http.Client{Timeout: 70 * time.Second},
agentClient: &http.Client{Timeout: 15 * time.Minute},
workLimit: make(chan struct{}, 4),
slotLocks: map[int]*sync.Mutex{},
}
}
func (bot *telegramBot) username() string {
bot.mu.RLock()
defer bot.mu.RUnlock()
return bot.botUsername
}
func (bot *telegramBot) setUsername(username string) {
bot.mu.Lock()
defer bot.mu.Unlock()
bot.botUsername = strings.TrimPrefix(strings.TrimSpace(username), "@")
}
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 {
OK bool `json:"ok"`
Result json.RawMessage `json:"result"`
}
if response.StatusCode != http.StatusOK || json.Unmarshal(body, &envelope) != nil || !envelope.OK {
return fmt.Errorf("Telegram API returned status %d", response.StatusCode)
}
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 {
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 {
bot.setUsername(me.Username)
break
}
time.Sleep(10 * time.Second)
}
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 {
time.Sleep(3 * time.Second)
continue
}
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)
}
}
}
func commandParts(text string) (string, []string) {
fields := strings.Fields(strings.TrimSpace(text))
if len(fields) == 0 || !strings.HasPrefix(fields[0], "/") {
return "", nil
}
command := strings.TrimPrefix(strings.ToLower(fields[0]), "/")
command, _, _ = strings.Cut(command, "@")
return command, fields[1:]
}
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 {
_ = bot.sendText(message.Chat.ID, "Sign in to chat.hermes.bstein.dev, open Telegram, and create a one-time link code.")
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" {
_ = bot.sendText(message.Chat.ID, "Send any text to chat with Hermes. Use /unlink to disconnect this Telegram account. Model and intensity controls are available in Hermes WebUI.")
return
}
slot, linked := bot.router.telegramSlot(userID)
if !linked {
_ = bot.sendText(message.Chat.ID, "This Telegram account is not linked. Sign in to chat.hermes.bstein.dev and open Telegram to connect it.")
return
}
text := strings.TrimSpace(message.Text)
if text == "" {
_ = bot.sendText(message.Chat.ID, "Text messages are supported now; attachment support will be added separately.")
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()
_ = bot.sendAction(message.Chat.ID, "typing")
reply, err := bot.askTenant(slot, text, update.UpdateID)
if err != nil {
_ = bot.sendText(message.Chat.ID, "Hermes could not answer right now. Please try again shortly.")
return
}
_ = bot.sendText(message.Chat.ID, reply)
}
func (bot *telegramBot) askTenant(slot int, text string, updateID int64) (string, error) {
if bot.router.backendAPIURL == nil {
return "", errors.New("tenant API unavailable")
}
payload, _ := json.Marshal(map[string]any{
"input": text,
"conversation": "telegram",
"store": true,
})
ctx, cancel := context.WithTimeout(context.Background(), 14*time.Minute)
defer cancel()
request, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
bot.router.backendAPIURL(slot)+"/v1/responses",
bytes.NewReader(payload),
)
if err != nil {
return "", err
}
request.Header.Set("Authorization", "Bearer "+bot.config.RelayKey)
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-Hermes-Session-Key", "telegram")
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()
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()
delete(router.state.Telegram, router.identityHash("telegram", userID))
return router.saveLocked()
}