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 }