239 lines
7.4 KiB
Go
239 lines
7.4 KiB
Go
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
|
||
}
|
||
}
|