atlas-iac/services/hermes/router/telegram_topics.go
2026-08-16 15:59:09 -03:00

140 lines
4.1 KiB
Go

package main
import (
"crypto/sha256"
"encoding/hex"
"errors"
"sort"
"strings"
"unicode"
"unicode/utf8"
)
const telegramDefaultTopic = "General"
type activeTelegramTopic struct {
Conversation string
Label string
}
func normalizeTelegramTopicLabel(raw string) (string, error) {
label := strings.Join(strings.Fields(strings.TrimSpace(raw)), " ")
if label == "" {
return "", errors.New("topic name is empty")
}
if utf8.RuneCountInString(label) > 48 {
return "", errors.New("topic name is too long")
}
for _, character := range label {
if unicode.IsControl(character) {
return "", errors.New("topic name contains control characters")
}
}
return label, nil
}
func telegramTopicID(label string) string {
if strings.EqualFold(strings.TrimSpace(label), telegramDefaultTopic) {
return "general"
}
digest := sha256.Sum256([]byte(strings.ToLower(strings.TrimSpace(label))))
return hex.EncodeToString(digest[:8])
}
func telegramTopicConversation(topicID string) string {
if topicID == "" || topicID == "general" {
// Preserve the original named conversation so existing Telegram context
// survives rollout into topic-aware routing.
return "telegram"
}
return "telegram-topic-" + topicID
}
func (router *tenantRouter) activeTelegramTopic(userID string) activeTelegramTopic {
identity := router.identityHash("telegram", userID)
router.mu.Lock()
defer router.mu.Unlock()
state := router.state.TelegramTopics[identity]
topicID := state.Active
if topicID == "" {
topicID = "general"
}
label := telegramDefaultTopic
if topic, found := state.Topics[topicID]; found && strings.TrimSpace(topic.Label) != "" {
label = topic.Label
}
return activeTelegramTopic{Conversation: telegramTopicConversation(topicID), Label: label}
}
func (router *tenantRouter) selectTelegramTopic(userID, rawLabel string) (activeTelegramTopic, error) {
label, err := normalizeTelegramTopicLabel(rawLabel)
if err != nil {
return activeTelegramTopic{}, err
}
identity := router.identityHash("telegram", userID)
topicID := telegramTopicID(label)
if topicID == "general" {
label = telegramDefaultTopic
}
router.mu.Lock()
defer router.mu.Unlock()
state := router.state.TelegramTopics[identity]
if state.Topics == nil {
state.Topics = map[string]telegramTopic{}
}
state.Active = topicID
state.Topics[topicID] = telegramTopic{Label: label, LastUsed: router.now().Unix()}
router.state.TelegramTopics[identity] = state
if err := router.saveLocked(); err != nil {
return activeTelegramTopic{}, err
}
return activeTelegramTopic{Conversation: telegramTopicConversation(topicID), Label: label}, nil
}
func (router *tenantRouter) touchTelegramTopic(userID string) (activeTelegramTopic, error) {
identity := router.identityHash("telegram", userID)
router.mu.Lock()
defer router.mu.Unlock()
state := router.state.TelegramTopics[identity]
if state.Topics == nil {
state.Topics = map[string]telegramTopic{}
}
topicID := state.Active
if topicID == "" {
topicID = "general"
state.Active = topicID
}
topic := state.Topics[topicID]
if strings.TrimSpace(topic.Label) == "" {
topic.Label = telegramDefaultTopic
}
topic.LastUsed = router.now().Unix()
state.Topics[topicID] = topic
router.state.TelegramTopics[identity] = state
if err := router.saveLocked(); err != nil {
return activeTelegramTopic{}, err
}
return activeTelegramTopic{Conversation: telegramTopicConversation(topicID), Label: topic.Label}, nil
}
func (router *tenantRouter) telegramTopics(userID string) []telegramTopic {
identity := router.identityHash("telegram", userID)
router.mu.Lock()
defer router.mu.Unlock()
state := router.state.TelegramTopics[identity]
topics := make([]telegramTopic, 0, len(state.Topics)+1)
for _, topic := range state.Topics {
topics = append(topics, topic)
}
if _, found := state.Topics["general"]; !found {
topics = append(topics, telegramTopic{Label: telegramDefaultTopic})
}
sort.SliceStable(topics, func(left, right int) bool {
if topics[left].LastUsed == topics[right].LastUsed {
return strings.ToLower(topics[left].Label) < strings.ToLower(topics[right].Label)
}
return topics[left].LastUsed > topics[right].LastUsed
})
return topics
}