179 lines
5.0 KiB
Go
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
|
|
}
|