diff --git a/db/repo/resource_repository.go b/db/repo/resource_repository.go index 2cdf57a..6286875 100644 --- a/db/repo/resource_repository.go +++ b/db/repo/resource_repository.go @@ -42,6 +42,7 @@ type ResourceRepository interface { MarkAsSyncedToMeilisearch(ids []uint) error MarkAllAsUnsyncedToMeilisearch() error FindAllWithPagination(page, limit int) ([]entity.Resource, int64, error) + GetRandomResourceWithFilters(categoryFilter, tagFilter string, isPushSavedInfo bool) (*entity.Resource, error) } // ResourceRepositoryImpl Resource的Repository实现 @@ -613,3 +614,47 @@ func (r *ResourceRepositoryImpl) FindAllWithPagination(page, limit int) ([]entit err := db.Offset(offset).Limit(limit).Find(&resources).Error return resources, total, err } + +// GetRandomResourceWithFilters 使用 PostgreSQL RANDOM() 功能随机获取一个符合条件的资源 +func (r *ResourceRepositoryImpl) GetRandomResourceWithFilters(categoryFilter, tagFilter string, isPushSavedInfo bool) (*entity.Resource, error) { + // 构建查询条件 + query := r.db.Model(&entity.Resource{}).Preload("Category").Preload("Pan").Preload("Tags") + + // 基础条件:有效且公开的资源 + query = query.Where("is_valid = ? AND is_public = ?", true, true) + + // 根据分类过滤 + if categoryFilter != "" { + // 查找分类ID + var categoryEntity entity.Category + if err := r.db.Where("name ILIKE ?", "%"+categoryFilter+"%").First(&categoryEntity).Error; err == nil { + query = query.Where("category_id = ?", categoryEntity.ID) + } + } + + // 根据标签过滤 + if tagFilter != "" { + // 查找标签ID + var tagEntity entity.Tag + if err := r.db.Where("name ILIKE ?", "%"+tagFilter+"%").First(&tagEntity).Error; err == nil { + // 通过中间表查找包含该标签的资源 + query = query.Joins("JOIN resource_tags ON resources.id = resource_tags.resource_id"). + Where("resource_tags.tag_id = ?", tagEntity.ID) + } + } + + // // 根据是否只推送已转存资源过滤 + // if isPushSavedInfo { + // query = query.Where("save_url IS NOT NULL AND save_url != '' AND TRIM(save_url) != ''") + // } + + // 使用 PostgreSQL 的 RANDOM() 进行随机排序,并限制为1个结果 + var resource entity.Resource + err := query.Order("RANDOM()").Limit(1).First(&resource).Error + + if err != nil { + return nil, err + } + + return &resource, nil +} diff --git a/services/telegram_bot_service.go b/services/telegram_bot_service.go index 974c3af..3817128 100644 --- a/services/telegram_bot_service.go +++ b/services/telegram_bot_service.go @@ -8,6 +8,7 @@ import ( "strconv" "strings" "time" + "unicode/utf8" "github.com/ctwj/urldb/db/entity" "github.com/ctwj/urldb/db/repo" @@ -28,6 +29,7 @@ type TelegramBotService interface { ValidateApiKeyWithProxy(apiKey string, proxyEnabled bool, proxyType, proxyHost string, proxyPort int, proxyUsername, proxyPassword string) (bool, map[string]interface{}, error) GetBotUsername() string SendMessage(chatID int64, text string) error + SendMessageWithFormat(chatID int64, text string, parseMode string) error DeleteMessage(chatID int64, messageID int) error RegisterChannel(chatID int64, chatName, chatType string) error IsChannelRegistered(chatID int64) bool @@ -540,7 +542,7 @@ func (s *TelegramBotServiceImpl) handleMessage(message *tgbotapi.Message) { return } - // 默认自动回复(只对正常消息,不对转发消息) + // 默认自动回复(只对正常消息,不对转发消息,且消息没有换行) if s.config.AutoReplyEnabled { // 检查是否是转发消息 isForward := message.ForwardFrom != nil || @@ -550,8 +552,15 @@ func (s *TelegramBotServiceImpl) handleMessage(message *tgbotapi.Message) { if isForward { utils.Info("[TELEGRAM:MESSAGE] 跳过自动回复,转发消息 from ChatID=%d", chatID) } else { - utils.Info("[TELEGRAM:MESSAGE] 发送自动回复 to ChatID=%d (AutoReplyEnabled=%v)", chatID, s.config.AutoReplyEnabled) - s.sendReply(message, s.config.AutoReplyTemplate) + // 检查消息是否包含换行符 + hasNewLine := strings.Contains(text, "\n") || strings.Contains(text, "\r") + + if hasNewLine { + utils.Info("[TELEGRAM:MESSAGE] 跳过自动回复,消息包含换行 from ChatID=%d", chatID) + } else { + utils.Info("[TELEGRAM:MESSAGE] 发送自动回复 to ChatID=%d (AutoReplyEnabled=%v)", chatID, s.config.AutoReplyEnabled) + s.sendReply(message, s.config.AutoReplyTemplate) + } } } else { utils.Info("[TELEGRAM:MESSAGE] 跳过自动回复 to ChatID=%d (AutoReplyEnabled=%v)", chatID, s.config.AutoReplyEnabled) @@ -763,7 +772,7 @@ func (s *TelegramBotServiceImpl) sendReplyWithAutoDelete(message *tgbotapi.Messa } msg := tgbotapi.NewMessage(message.Chat.ID, text) - msg.ParseMode = "Markdown" + msg.ParseMode = "MarkdownV2" msg.ReplyToMessageID = message.MessageID utils.Debug("[TELEGRAM:MESSAGE] 发送Markdown版本消息: %s", text[:min(100, len(text))]) @@ -991,8 +1000,8 @@ func (s *TelegramBotServiceImpl) pushToChannel(channel entity.TelegramChannel) { // 2. 构建推送消息 message := s.buildPushMessage(channel, resources) - // 3. 发送消息(推送消息不自动删除) - err := s.SendMessage(channel.ChatID, message) + // 3. 发送消息(推送消息不自动删除,使用 Markdown 格式) + err := s.SendMessageWithFormat(channel.ChatID, message, "MarkdownV2") if err != nil { utils.Error("[TELEGRAM:PUSH:ERROR] 推送失败到频道 %s (%d): %v", channel.ChatName, channel.ChatID, err) return @@ -1010,24 +1019,66 @@ func (s *TelegramBotServiceImpl) pushToChannel(channel entity.TelegramChannel) { // findResourcesForChannel 查找适合频道的资源 func (s *TelegramBotServiceImpl) findResourcesForChannel(channel entity.TelegramChannel) []interface{} { - // 这里需要实现根据频道配置过滤资源 - // 暂时返回空数组,实际实现中需要查询资源数据库 + utils.Info("[TELEGRAM:PUSH] 开始为频道 %s (%d) 查找资源", channel.ChatName, channel.ChatID) + + params := map[string]interface{}{"category": "", "tag": ""} + + if channel.ContentCategories != "" { + categories := strings.Split(channel.ContentCategories, ",") + for i, category := range categories { + categories[i] = strings.TrimSpace(category) + } + params["category"] = categories[0] + } + + if channel.ContentTags != "" { + tags := strings.Split(channel.ContentTags, ",") + for i, tag := range tags { + tags[i] = strings.TrimSpace(tag) + } + params["tag"] = tags[0] + } + + // 尝试使用 PostgreSQL 的随机功能 + defer func() { + if r := recover(); r != nil { + utils.Warn("[TELEGRAM:PUSH] 随机查询失败,回退到传统方法: %v", r) + } + }() + + randomResource, err := s.resourceRepo.GetRandomResourceWithFilters(params["category"].(string), params["tag"].(string), channel.IsPushSavedInfo) + if err == nil && randomResource != nil { + utils.Info("[TELEGRAM:PUSH] 成功获取随机资源: %s", randomResource.Title) + return []interface{}{randomResource} + } + return []interface{}{} } // buildPushMessage 构建推送消息 func (s *TelegramBotServiceImpl) buildPushMessage(channel entity.TelegramChannel, resources []interface{}) string { - message := fmt.Sprintf("📢 **%s**\n\n", channel.ChatName) + resource := resources[0].(*entity.Resource) - if len(resources) == 0 { - message += "暂无新内容推送" - } else { - message += fmt.Sprintf("🆕 发现 %d 个新资源:\n\n", len(resources)) - // 这里需要格式化资源列表 - message += "*详细资源列表请查看网站*" + message := fmt.Sprintf("🆕 %s\n\n", s.cleanResourceText(resource.Title)) + + if resource.Description != "" { + message += fmt.Sprintf("📝 %s\n\n", s.cleanResourceText(resource.Description)) } - message += fmt.Sprintf("\n\n⏰ 下次推送: %d 分钟后", channel.PushFrequency) + // 添加标签 + if len(resource.Tags) > 0 { + message += "\n🏷️ " + for i, tag := range resource.Tags { + if i > 0 { + message += " " + } + message += fmt.Sprintf("#%s", tag.Name) + } + message += "\n" + } + + // 添加资源信息 + message += fmt.Sprintf("\n💡 评论区评论 (【%s】%s) 即可获取资源,括号内名称点击可复制📋\n", resource.Key, resource.Title) return message } @@ -1040,28 +1091,79 @@ func (s *TelegramBotServiceImpl) GetBotUsername() string { return "" } -// SendMessage 发送消息 +// SendMessage 发送消息(默认使用 MarkdownV2 格式) func (s *TelegramBotServiceImpl) SendMessage(chatID int64, text string) error { + return s.SendMessageWithFormat(chatID, text, "MarkdownV2") +} + +// SendMessageWithFormat 发送消息,支持指定格式 +func (s *TelegramBotServiceImpl) SendMessageWithFormat(chatID int64, text string, parseMode string) error { if s.bot == nil { return fmt.Errorf("Bot 未初始化") } - // 清理消息文本,确保UTF-8编码 - text = s.cleanMessageText(text) + // 根据格式选择不同的文本清理方法 + var cleanedText string + switch parseMode { + case "Markdown", "MarkdownV2": + cleanedText = s.cleanMessageText(text) + case "HTML": + cleanedText = s.cleanMessageTextForPlain(text) // HTML 格式暂时使用纯文本清理 + default: // 纯文本或其他格式 + cleanedText = s.cleanMessageTextForPlain(text) + parseMode = "" // Telegram API 中空字符串表示纯文本 + } - msg := tgbotapi.NewMessage(chatID, text) - msg.ParseMode = "Markdown" + msg := tgbotapi.NewMessage(chatID, cleanedText) + msg.ParseMode = parseMode + + // 检测并添加代码实体(只在 Markdown 格式下) + if parseMode == "Markdown" || parseMode == "MarkdownV2" { + entities := s.parseCodeEntities(text, cleanedText) + if len(entities) > 0 { + msg.Entities = entities + utils.Info("[TELEGRAM:MESSAGE] 为消息添加了 %d 个代码实体", len(entities)) + } + } _, err := s.bot.Send(msg) if err != nil { - // 如果是UTF-8编码错误或Markdown错误,尝试发送纯文本版本 - if strings.Contains(err.Error(), "UTF-8") || strings.Contains(err.Error(), "Bad Request") { - utils.Info("[TELEGRAM:PUSH] 尝试发送纯文本版本...") + utils.Error("[TELEGRAM:MESSAGE:ERROR] 发送消息失败 (格式: %s): %v", parseMode, err) + // 如果是格式错误,尝试发送纯文本版本 + if strings.Contains(err.Error(), "parse") || strings.Contains(err.Error(), "Bad Request") { + utils.Info("[TELEGRAM:MESSAGE] 尝试发送纯文本版本...") msg.ParseMode = "" msg.Text = s.cleanMessageTextForPlain(text) + msg.Entities = nil // 纯文本模式下不使用实体 _, err = s.bot.Send(msg) } - } + s.bot.Send("*bold text*\n" + +"_italic \n" + +"__underline__\n" + +"~strikethrough~\n" + +"||spoiler||\n" + +"*bold _italic bold ~italic bold strikethrough ||italic bold strikethrough spoiler||~ __underline italic bold___ bold*\n" + +"[inline URL](http://www.example.com/)\n" + +"[inline mention of a user](tg://user?id=123456789)\n" + +"![👍](tg://emoji?id=5368324170671202286)\n" + +"`inline fixed-width code`\n" + +"```\n" + +"pre-formatted fixed-width code block\n" + +"```\n" + +"```python\n" + +"pre-formatted fixed-width code block written in the Python programming language\n" + +"```\n" + +">Block quotation started\n" + +">Block quotation continued\n" + +">Block quotation continued\n" + +">Block quotation continued\n" + +">The last line of the block quotation\n" + +"**>The expandable block quotation started right after the previous block quotation\n" + +">It is separated from the previous block quotation by an empty bold entity\n" + +">Expandable block quotation continued\n" + +">Hidden by default part of the expandable block quotation started\n" + +">Expandable block quotation continued\n" + +">The last line of the expandable block quotation with the expandability mark||") return err } @@ -1461,3 +1563,157 @@ func (s *TelegramBotServiceImpl) CleanupDuplicateChannels() error { utils.Info("[TELEGRAM:CLEANUP:SUCCESS] 成功清理重复的频道记录") return nil } + +// parseCodeEntities 解析消息中的代码实体 +func (s *TelegramBotServiceImpl) parseCodeEntities(originalText string, cleanedText string) []tgbotapi.MessageEntity { + var entities []tgbotapi.MessageEntity + + // 定义开始和结束标记 + startMarker := "评论区评论 (" + endMarker := ") 即可获取资源" + + // 在原始文本中查找标记 + start := strings.Index(originalText, startMarker) + if start == -1 { + return entities + } + + // 计算代码块的开始位置(在开始标记之后) + codeStart := start + len(startMarker) + + // 查找结束标记 + end := strings.Index(originalText[codeStart:], endMarker) + if end == -1 { + return entities + } + + // 计算代码块的结束位置 + codeEnd := codeStart + end + + // 确保代码内容不为空 + if codeEnd <= codeStart { + return entities + } + + // 获取原始代码内容 + originalCodeContent := originalText[codeStart:codeEnd] + + // 在清理后的文本中查找相同的代码内容,计算新的偏移量 + cleanedStart := strings.Index(cleanedText, originalCodeContent) + if cleanedStart == -1 { + // 如果找不到完全匹配的内容,使用精确偏移计算 + cleanedStart = s.findPreciseOffset(originalText, cleanedText, codeStart) + } + + // 验证清理后偏移量是否有效 + if cleanedStart < 0 || cleanedStart >= len(cleanedText) { + utils.Warn("[TELEGRAM:MESSAGE] 无法计算有效的实体偏移量") + return entities + } + + // 安全地获取清理后的代码内容(确保不超出字符串边界) + cleanedEnd := cleanedStart + len(originalCodeContent) + if cleanedEnd > len(cleanedText) { + cleanedEnd = len(cleanedText) + } + cleanedCodeContent := cleanedText[cleanedStart:cleanedEnd] + + // 确保清理后的代码内容不为空 + if strings.TrimSpace(cleanedCodeContent) == "" { + return entities + } + + // 创建代码实体,使用 UTF-8 字符计数 + codeEntity := tgbotapi.MessageEntity{ + Type: "code", + Offset: utf8.RuneCountInString(cleanedText[:cleanedStart]), // 使用 UTF-8 字符计数 + Length: utf8.RuneCountInString(cleanedCodeContent), // 使用 UTF-8 字符计数 + } + + entities = append(entities, codeEntity) + + utils.Info("[TELEGRAM:MESSAGE] 检测到代码实体: 原始位置=%d-%d, 清理后位置=%d-%d", + codeStart, codeEnd, cleanedStart, cleanedEnd) + utils.Info("[TELEGRAM:MESSAGE] 原始代码内容: %s", originalCodeContent) + utils.Info("[TELEGRAM:MESSAGE] 清理后代码内容: %s", cleanedCodeContent) + utils.Info("[TELEGRAM:MESSAGE] 实体偏移量: %d, 长度: %d", codeEntity.Offset, codeEntity.Length) + + return entities +} + +// findPreciseOffset 通过字符级别的精确匹配计算清理后文本中的偏移量 +func (s *TelegramBotServiceImpl) findPreciseOffset(originalText string, cleanedText string, originalOffset int) int { + // 获取原始文本中指定位置前后的上下文 + contextSize := 50 + originalContext := originalText[max(0, originalOffset-contextSize):min(len(originalText), originalOffset+contextSize)] + + // 在清理后的文本中查找相似的上下文 + bestMatch := -1 + maxSimilarity := 0.0 + + for i := 0; i <= len(cleanedText)-len(originalContext); i++ { + candidate := cleanedText[i:min(len(cleanedText), i+len(originalContext))] + similarity := s.calculateSimilarity(originalContext, candidate) + if similarity > maxSimilarity { + maxSimilarity = similarity + bestMatch = i + (originalOffset - max(0, originalOffset-contextSize)) + } + } + + // 如果相似度足够高,返回最佳匹配 + if maxSimilarity > 0.7 { + return max(0, min(len(cleanedText)-1, bestMatch)) + } + + // 回退到比例估算 + return s.calculateCleanedOffset(originalText, cleanedText, originalOffset) +} + +// calculateSimilarity 计算两个字符串的相似度 +func (s *TelegramBotServiceImpl) calculateSimilarity(s1, s2 string) float64 { + if len(s1) == 0 || len(s2) == 0 { + return 0 + } + + // 简单字符匹配相似度 + matches := 0 + minLen := min(len(s1), len(s2)) + + for i := 0; i < minLen; i++ { + if s1[i] == s2[i] { + matches++ + } + } + + return float64(matches) / float64(minLen) +} + +// calculateCleanedOffset 计算清理后文本中的偏移量(比例估算) +func (s *TelegramBotServiceImpl) calculateCleanedOffset(originalText string, cleanedText string, originalOffset int) int { + // 计算清理后文本中对应位置的近似偏移量 + // 这种方法通过比较字符比例来估算位置 + if len(originalText) == 0 { + return 0 + } + + originalRatio := float64(originalOffset) / float64(len(originalText)) + estimatedOffset := int(float64(len(cleanedText)) * originalRatio) + + // 确保偏移量在有效范围内 + if estimatedOffset < 0 { + estimatedOffset = 0 + } + if estimatedOffset >= len(cleanedText) { + estimatedOffset = len(cleanedText) - 1 + } + + return estimatedOffset +} + +// 辅助函数:返回两个数中的较大值 +func max(a, b int) int { + if a > b { + return a + } + return b +}