package qq import ( "context" "fmt" "log" "strings" "time" "xiaji-go/internal/service" "xiaji-go/models" "github.com/tencent-connect/botgo" "github.com/tencent-connect/botgo/dto" "github.com/tencent-connect/botgo/dto/message" "github.com/tencent-connect/botgo/event" "github.com/tencent-connect/botgo/openapi" "github.com/tencent-connect/botgo/token" "gorm.io/gorm" ) // DefaultUserID 统一用户ID,使所有平台共享同一份账本 const DefaultUserID int64 = 1 type QQBot struct { api openapi.OpenAPI finance *service.FinanceService credentials *token.QQBotCredentials db *gorm.DB } func NewQQBot(db *gorm.DB, appID string, secret string, finance *service.FinanceService) *QQBot { return &QQBot{ db: db, finance: finance, credentials: &token.QQBotCredentials{ AppID: appID, AppSecret: secret, }, } } func (b *QQBot) Start(ctx context.Context) { tokenSource := token.NewQQBotTokenSource(b.credentials) if err := token.StartRefreshAccessToken(ctx, tokenSource); err != nil { log.Printf("❌ QQ Bot Token 刷新失败: %v", err) return } b.api = botgo.NewOpenAPI(b.credentials.AppID, tokenSource).WithTimeout(5 * time.Second) _ = event.RegisterHandlers( b.groupATMessageHandler(), b.c2cMessageHandler(), b.channelATMessageHandler(), ) wsInfo, err := b.api.WS(ctx, nil, "") if err != nil { log.Printf("❌ QQ Bot 获取 WS 信息失败: %v", err) return } intent := dto.Intent(1<<25 | 1<<30) log.Printf("🚀 QQ Bot 已启动 (WebSocket, shards=%d)", wsInfo.Shards) if err := botgo.NewSessionManager().Start(wsInfo, tokenSource, &intent); err != nil { log.Printf("❌ QQ Bot WebSocket 断开: %v", err) } } func isCommand(text string, keywords ...string) bool { for _, kw := range keywords { if text == kw { return true } } return false } func (b *QQBot) isDuplicate(eventID string) bool { if b.db == nil || strings.TrimSpace(eventID) == "" { return false } var existed models.MessageDedup if err := b.db.Where("platform = ? AND event_id = ?", "qqbot_official", eventID).First(&existed).Error; err == nil { return true } _ = b.db.Create(&models.MessageDedup{Platform: "qqbot_official", EventID: eventID, ProcessedAt: time.Now()}).Error return false } func (b *QQBot) processAndReply(userID string, content string) string { text := strings.TrimSpace(message.ETLInput(content)) if text == "" { return "" } today := time.Now().Format("2006-01-02") switch { case isCommand(text, "帮助", "help", "/help", "/start", "菜单", "功能"): return "🦞 虾记记账\n\n" + "直接发送消费描述即可记账:\n" + "• 午饭 25元\n" + "• 打车 ¥30\n" + "• 买咖啡15块\n\n" + "📋 命令列表:\n" + "• 记录/查看 — 最近10条\n" + "• 今日/今天 — 今日汇总\n" + "• 统计/报表 — 本月分类统计\n" + "• 帮助 — 本帮助信息" case isCommand(text, "查看", "记录", "列表", "list", "/list", "最近"): items, err := b.finance.GetTransactions(DefaultUserID, 10) if err != nil { return "❌ 查询失败" } if len(items) == 0 { return "📭 暂无记录" } var sb strings.Builder sb.WriteString("📋 最近记录:\n\n") for _, item := range items { sb.WriteString(fmt.Sprintf("• [%s] %s:%.2f元\n", item.Date, item.Category, item.AmountYuan())) } return sb.String() case isCommand(text, "今日", "今天", "today"): items, err := b.finance.GetTransactionsByDate(DefaultUserID, today) if err != nil { return "❌ 查询失败" } if len(items) == 0 { return fmt.Sprintf("📭 %s 暂无消费记录", today) } var sb strings.Builder var total int64 sb.WriteString(fmt.Sprintf("📊 今日(%s)消费:\n\n", today)) for _, item := range items { sb.WriteString(fmt.Sprintf("• %s:%.2f元\n", item.Category, item.AmountYuan())) total += item.Amount } sb.WriteString(fmt.Sprintf("\n💰 共 %d 笔,合计 %.2f 元", len(items), float64(total)/100.0)) return sb.String() case isCommand(text, "统计", "报表", "图表", "chart", "/chart"): now := time.Now() dateFrom := now.Format("2006-01") + "-01" dateTo := now.Format("2006-01-02") stats, err := b.finance.GetCategoryStats(DefaultUserID, dateFrom, dateTo) if err != nil || len(stats) == 0 { return fmt.Sprintf("📭 %d年%d月暂无消费数据", now.Year(), now.Month()) } var sb strings.Builder var grandTotal int64 var grandCount int sb.WriteString(fmt.Sprintf("📊 %d年%d月消费统计:\n\n", now.Year(), now.Month())) for _, s := range stats { yuan := float64(s.Total) / 100.0 sb.WriteString(fmt.Sprintf("• %s:%.2f元(%d笔)\n", s.Category, yuan, s.Count)) grandTotal += s.Total grandCount += s.Count } sb.WriteString(fmt.Sprintf("\n💰 共 %d 笔,合计 %.2f 元", grandCount, float64(grandTotal)/100.0)) return sb.String() } amount, category, err := b.finance.AddTransaction(DefaultUserID, text) if err != nil { log.Printf("QQ记账失败 user=%s: %v", userID, err) return "❌ 记账失败,请稍后重试" } if amount == 0 { return "📍 没看到金额,这笔花了多少钱?" } amountYuan := float64(amount) / 100.0 return fmt.Sprintf("✅ 已记入【%s】:%.2f元\n📝 备注:%s", category, amountYuan, text) } func (b *QQBot) channelATMessageHandler() event.ATMessageEventHandler { return func(ev *dto.WSPayload, data *dto.WSATMessageData) error { eventID := "qq:channel:" + strings.TrimSpace(data.ID) if b.isDuplicate(eventID) { return nil } log.Printf("📩 inbound platform=qqbot_official event=%s chat=%s user=%s text=%q", eventID, data.ChannelID, data.Author.ID, strings.TrimSpace(message.ETLInput(data.Content))) reply := b.processAndReply(data.Author.ID, data.Content) if reply == "" { return nil } _, err := b.api.PostMessage(context.Background(), data.ChannelID, &dto.MessageToCreate{MsgID: data.ID, Content: reply}) if err != nil { log.Printf("QQ频道消息发送失败: %v", err) } return nil } } func (b *QQBot) groupATMessageHandler() event.GroupATMessageEventHandler { return func(ev *dto.WSPayload, data *dto.WSGroupATMessageData) error { eventID := "qq:group:" + strings.TrimSpace(data.ID) if b.isDuplicate(eventID) { return nil } log.Printf("📩 inbound platform=qqbot_official event=%s chat=%s user=%s text=%q", eventID, data.GroupID, data.Author.ID, strings.TrimSpace(message.ETLInput(data.Content))) reply := b.processAndReply(data.Author.ID, data.Content) if reply == "" { return nil } _, err := b.api.PostGroupMessage(context.Background(), data.GroupID, dto.MessageToCreate{MsgID: data.ID, Content: reply}) if err != nil { log.Printf("QQ群消息发送失败: %v", err) } return nil } } func (b *QQBot) c2cMessageHandler() event.C2CMessageEventHandler { return func(ev *dto.WSPayload, data *dto.WSC2CMessageData) error { eventID := "qq:c2c:" + strings.TrimSpace(data.ID) if b.isDuplicate(eventID) { return nil } log.Printf("📩 inbound platform=qqbot_official event=%s chat=%s user=%s text=%q", eventID, data.Author.ID, data.Author.ID, strings.TrimSpace(message.ETLInput(data.Content))) reply := b.processAndReply(data.Author.ID, data.Content) if reply == "" { return nil } _, err := b.api.PostC2CMessage(context.Background(), data.Author.ID, dto.MessageToCreate{MsgID: data.ID, Content: reply}) if err != nil { log.Printf("QQ私聊消息发送失败: %v", err) } return nil } }