package bot import ( "context" "fmt" "log" "strings" "time" xchart "ops-assistant/internal/chart" "ops-assistant/internal/core/ops" "ops-assistant/internal/service" "ops-assistant/models" tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" "gorm.io/gorm" ) // DefaultUserID 统一用户ID,使所有平台共享同一份账本 const DefaultUserID int64 = 1 type TGBot struct { api *tgbotapi.BotAPI finance *service.FinanceService db *gorm.DB opsSvc *ops.Service } func NewTGBot(db *gorm.DB, token string, finance *service.FinanceService, opsSvc *ops.Service) (*TGBot, error) { bot, err := tgbotapi.NewBotAPI(token) if err != nil { return nil, err } return &TGBot{api: bot, finance: finance, db: db, opsSvc: opsSvc}, nil } func (b *TGBot) Start(ctx context.Context) { u := tgbotapi.NewUpdate(0) u.Timeout = 60 updates := b.api.GetUpdatesChan(u) log.Println("🚀 Telegram Bot 已启动") for { select { case <-ctx.Done(): log.Println("⏳ Telegram Bot 正在停止...") b.api.StopReceivingUpdates() return case update, ok := <-updates: if !ok { return } if update.Message == nil || update.Message.Text == "" { continue } eventID := fmt.Sprintf("tg:%d", update.UpdateID) if b.isDuplicate(eventID) { continue } log.Printf("📩 inbound platform=telegram event=%s chat=%d user=%d text=%q", eventID, update.Message.Chat.ID, update.Message.From.ID, strings.TrimSpace(update.Message.Text)) b.handleMessage(update.Message) } } } func (b *TGBot) 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 = ?", "telegram", eventID).First(&existed).Error; err == nil { return true } _ = b.db.Create(&models.MessageDedup{Platform: "telegram", EventID: eventID, ProcessedAt: time.Now()}).Error return false } func (b *TGBot) handleMessage(msg *tgbotapi.Message) { text := msg.Text chatID := msg.Chat.ID if b.opsSvc != nil { if handled, out := b.opsSvc.Handle(DefaultUserID, text); handled { m := tgbotapi.NewMessage(chatID, out) if _, err := b.api.Send(m); err != nil { log.Printf("发送OPS消息失败 chat=%d: %v", chatID, err) } return } } var reply string switch { case text == "/start": reply = "🛠️ 欢迎使用 Ops-Assistant!\n\n直接发送消费描述即可记账,例如:\n• 午饭 25元\n• 打车 ¥30\n• 买咖啡15块\n\n命令:\n/list - 查看最近记录\n/today - 今日汇总\n/chart - 本月图表\n/help - 帮助" case text == "/help": reply = "📖 使用说明:\n\n直接发送带金额的文本即可自动记账。\n系统会自动识别金额和消费分类。\n\n支持格式:\n• 午饭 25元\n• ¥30 打车\n• 买水果15块\n\n命令:\n/list - 最近10条记录\n/today - 今日汇总\n/chart - 本月消费图表\n/week - 近7天每日趋势\n/start - 欢迎信息" case text == "/today": today := time.Now().Format("2006-01-02") items, err := b.finance.GetTransactionsByDate(DefaultUserID, today) if err != nil { reply = "❌ 查询失败" } else if len(items) == 0 { reply = fmt.Sprintf("📭 %s 暂无消费记录", today) } else { 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)) reply = sb.String() } case text == "/chart": b.sendMonthlyChart(chatID) return case text == "/week": b.sendWeeklyChart(chatID) return case text == "/list": items, err := b.finance.GetTransactions(DefaultUserID, 10) if err != nil { reply = "❌ 查询失败" } else if len(items) == 0 { reply = "📭 暂无记录" } else { 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())) } reply = sb.String() } case strings.HasPrefix(text, "/"): reply = "❓ 未知命令,输入 /help 查看帮助" default: amount, category, err := b.finance.AddTransaction(DefaultUserID, text) if err != nil { reply = "❌ 记账失败,请稍后重试" log.Printf("记账失败: %v", err) } else if amount == 0 { reply = "📍 没看到金额,这笔花了多少钱?" } else { amountYuan := float64(amount) / 100.0 reply = fmt.Sprintf("✅ 已记入【%s】:%.2f元\n📝 备注:%s", category, amountYuan, text) } } m := tgbotapi.NewMessage(chatID, reply) if _, err := b.api.Send(m); err != nil { log.Printf("发送消息失败 chat=%d: %v", chatID, err) } } func (b *TGBot) sendMonthlyChart(chatID int64) { now := time.Now() dateFrom := now.Format("2006-01") + "-01" dateTo := now.Format("2006-01-02") title := fmt.Sprintf("%d年%d月消费分类", now.Year(), now.Month()) stats, err := b.finance.GetCategoryStats(DefaultUserID, dateFrom, dateTo) if err != nil || len(stats) == 0 { m := tgbotapi.NewMessage(chatID, "📭 本月暂无消费数据") b.api.Send(m) return } imgData, err := xchart.GeneratePieChart(stats, title) if err != nil { log.Printf("生成饼图失败: %v", err) m := tgbotapi.NewMessage(chatID, "❌ 图表生成失败") b.api.Send(m) return } var total int64 var totalCount int for _, s := range stats { total += s.Total totalCount += s.Count } caption := fmt.Sprintf("📊 %s\n💰 共 %d 笔,合计 %.2f 元", title, totalCount, float64(total)/100.0) photo := tgbotapi.NewPhoto(chatID, tgbotapi.FileBytes{Name: "chart.png", Bytes: imgData}) photo.Caption = caption if _, err := b.api.Send(photo); err != nil { log.Printf("发送图表失败 chat=%d: %v", chatID, err) } } func (b *TGBot) sendWeeklyChart(chatID int64) { now := time.Now() dateFrom := now.AddDate(0, 0, -6).Format("2006-01-02") dateTo := now.Format("2006-01-02") title := fmt.Sprintf("近7天消费趋势 (%s ~ %s)", dateFrom[5:], dateTo[5:]) stats, err := b.finance.GetDailyStats(DefaultUserID, dateFrom, dateTo) if err != nil || len(stats) == 0 { m := tgbotapi.NewMessage(chatID, "📭 近7天暂无消费数据") b.api.Send(m) return } imgData, err := xchart.GenerateBarChart(stats, title) if err != nil { log.Printf("生成柱状图失败: %v", err) m := tgbotapi.NewMessage(chatID, "❌ 图表生成失败") b.api.Send(m) return } var total int64 var totalCount int for _, s := range stats { total += s.Total totalCount += s.Count } caption := fmt.Sprintf("📈 %s\n💰 共 %d 笔,合计 %.2f 元", title, totalCount, float64(total)/100.0) photo := tgbotapi.NewPhoto(chatID, tgbotapi.FileBytes{Name: "chart.png", Bytes: imgData}) photo.Caption = caption if _, err := b.api.Send(photo); err != nil { log.Printf("发送图表失败 chat=%d: %v", chatID, err) } }