update: tg bot

This commit is contained in:
ctwj
2025-09-22 07:58:06 +08:00
parent 091be5ef70
commit 6fa9036705
2 changed files with 326 additions and 25 deletions

View File

@@ -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
}

View File

@@ -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 ||
@@ -549,10 +551,17 @@ func (s *TelegramBotServiceImpl) handleMessage(message *tgbotapi.Message) {
if isForward {
utils.Info("[TELEGRAM:MESSAGE] 跳过自动回复,转发消息 from ChatID=%d", chatID)
} else {
// 检查消息是否包含换行符
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
}