atlas-iac/services/hermes/router/telegram_topics.go
2026-08-17 14:39:21 +00:00

179 lines
5.0 KiB
Go

package main
import (
"crypto/sha256"
"encoding/hex"
"errors"
"sort"
"strconv"
"strings"
"unicode"
"unicode/utf8"
)
const (
telegramDefaultTopic = "General"
telegramTopicIdleTTL = 7 * 24 * 60 * 60
)
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 {
return telegramTopicConversationGeneration(topicID, 0)
}
func telegramTopicConversationGeneration(topicID string, generation int64) string {
if topicID == "" || topicID == "general" {
// Preserve the original named conversation so existing Telegram context
// survives rollout into topic-aware routing.
if generation == 0 {
return "telegram"
}
topicID = "general"
}
conversation := "telegram-topic-" + topicID
if generation > 0 {
conversation += "-g" + strconv.FormatInt(generation, 10)
}
return conversation
}
func topicExpired(topic telegramTopic, now int64) bool {
return topic.LastUsed > 0 && now > topic.LastUsed && now-topic.LastUsed > telegramTopicIdleTTL
}
func touchTopic(topic telegramTopic, now int64) telegramTopic {
if topicExpired(topic, now) {
topic.Generation++
}
topic.LastUsed = now
return topic
}
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: telegramTopicConversationGeneration(topicID, state.Topics[topicID].Generation),
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
topic := state.Topics[topicID]
topic.Label = label
topic = touchTopic(topic, router.now().Unix())
state.Topics[topicID] = topic
router.state.TelegramTopics[identity] = state
if err := router.saveLocked(); err != nil {
return activeTelegramTopic{}, err
}
return activeTelegramTopic{
Conversation: telegramTopicConversationGeneration(topicID, topic.Generation),
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 = touchTopic(topic, router.now().Unix())
state.Topics[topicID] = topic
router.state.TelegramTopics[identity] = state
if err := router.saveLocked(); err != nil {
return activeTelegramTopic{}, err
}
return activeTelegramTopic{
Conversation: telegramTopicConversationGeneration(topicID, topic.Generation),
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
}