feat: xiaji-go v1.0.0 - 智能记账机器人

- Telegram Bot + QQ Bot (WebSocket) 双平台支持
- 150+ 预设分类关键词,jieba 智能分词
- Web 管理后台(记录查看/删除/CSV导出)
- 金额精确存储(分/int64)
- 版本信息嵌入(编译时注入)
- Docker 支持
- 优雅关闭(context + signal)
This commit is contained in:
2026-02-15 06:40:04 +08:00
commit 0c1a4f06f7
18 changed files with 1719 additions and 0 deletions

212
internal/qq/qq.go Normal file
View File

@@ -0,0 +1,212 @@
package qq
import (
"context"
"fmt"
"hash/fnv"
"log"
"strings"
"time"
"xiaji-go/internal/service"
"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"
)
type QQBot struct {
api openapi.OpenAPI
finance *service.FinanceService
credentials *token.QQBotCredentials
}
func NewQQBot(appID string, secret string, finance *service.FinanceService) *QQBot {
return &QQBot{
finance: finance,
credentials: &token.QQBotCredentials{
AppID: appID,
AppSecret: secret,
},
}
}
// hashUserID 将 QQ 的字符串用户标识转为 int64
func hashUserID(authorID string) int64 {
h := fnv.New64a()
h.Write([]byte(authorID))
return int64(h.Sum64())
}
func (b *QQBot) Start(ctx context.Context) {
// 创建 token source 并启动自动刷新
tokenSource := token.NewQQBotTokenSource(b.credentials)
if err := token.StartRefreshAccessToken(ctx, tokenSource); err != nil {
log.Printf("❌ QQ Bot Token 刷新失败: %v", err)
return
}
// 初始化 OpenAPI
b.api = botgo.NewOpenAPI(b.credentials.AppID, tokenSource).WithTimeout(5 * time.Second)
// 注册事件处理器
_ = event.RegisterHandlers(
b.groupATMessageHandler(),
b.c2cMessageHandler(),
b.channelATMessageHandler(),
)
// 获取 WebSocket 接入信息
wsInfo, err := b.api.WS(ctx, nil, "")
if err != nil {
log.Printf("❌ QQ Bot 获取 WS 信息失败: %v", err)
return
}
// 设置 intents: 群聊和C2C (1<<25) + 公域消息 (1<<30)
intent := dto.Intent(1<<25 | 1<<30)
log.Printf("🚀 QQ Bot 已启动 (WebSocket, shards=%d)", wsInfo.Shards)
// 启动 session manager (阻塞)
if err := botgo.NewSessionManager().Start(wsInfo, tokenSource, &intent); err != nil {
log.Printf("❌ QQ Bot WebSocket 断开: %v", err)
}
}
// isCommand 判断是否匹配命令关键词
func isCommand(text string, keywords ...string) bool {
for _, kw := range keywords {
if text == kw {
return true
}
}
return false
}
// processAndReply 通用记账处理
func (b *QQBot) processAndReply(userID string, content string) string {
uid := hashUserID(userID)
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" +
"• 帮助 — 本帮助信息"
case isCommand(text, "查看", "记录", "列表", "list", "/list", "最近"):
items, err := b.finance.GetTransactions(uid, 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(uid, 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()
}
amount, category, err := b.finance.AddTransaction(uid, 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)
}
// channelATMessageHandler 频道@机器人消息
func (b *QQBot) channelATMessageHandler() event.ATMessageEventHandler {
return func(ev *dto.WSPayload, data *dto.WSATMessageData) error {
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
}
}
// groupATMessageHandler 群@机器人消息
func (b *QQBot) groupATMessageHandler() event.GroupATMessageEventHandler {
return func(ev *dto.WSPayload, data *dto.WSGroupATMessageData) error {
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
}
}
// c2cMessageHandler C2C 私聊消息
func (b *QQBot) c2cMessageHandler() event.C2CMessageEventHandler {
return func(ev *dto.WSPayload, data *dto.WSC2CMessageData) error {
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
}
}