feat: channels/audit UI unify, apply flow hardening, bump v1.1.12
This commit is contained in:
@@ -9,8 +9,10 @@ import (
|
||||
|
||||
xchart "xiaji-go/internal/chart"
|
||||
"xiaji-go/internal/service"
|
||||
"xiaji-go/models"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// DefaultUserID 统一用户ID,使所有平台共享同一份账本
|
||||
@@ -19,14 +21,15 @@ const DefaultUserID int64 = 1
|
||||
type TGBot struct {
|
||||
api *tgbotapi.BotAPI
|
||||
finance *service.FinanceService
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewTGBot(token string, finance *service.FinanceService) (*TGBot, error) {
|
||||
func NewTGBot(db *gorm.DB, token string, finance *service.FinanceService) (*TGBot, error) {
|
||||
bot, err := tgbotapi.NewBotAPI(token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &TGBot{api: bot, finance: finance}, nil
|
||||
return &TGBot{api: bot, finance: finance, db: db}, nil
|
||||
}
|
||||
|
||||
func (b *TGBot) Start(ctx context.Context) {
|
||||
@@ -49,11 +52,29 @@ func (b *TGBot) Start(ctx context.Context) {
|
||||
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
|
||||
@@ -113,7 +134,6 @@ func (b *TGBot) handleMessage(msg *tgbotapi.Message) {
|
||||
reply = "❓ 未知命令,输入 /help 查看帮助"
|
||||
|
||||
default:
|
||||
// 记账逻辑
|
||||
amount, category, err := b.finance.AddTransaction(DefaultUserID, text)
|
||||
if err != nil {
|
||||
reply = "❌ 记账失败,请稍后重试"
|
||||
@@ -132,7 +152,6 @@ func (b *TGBot) handleMessage(msg *tgbotapi.Message) {
|
||||
}
|
||||
}
|
||||
|
||||
// sendMonthlyChart 发送本月分类饼图
|
||||
func (b *TGBot) sendMonthlyChart(chatID int64) {
|
||||
now := time.Now()
|
||||
dateFrom := now.Format("2006-01") + "-01"
|
||||
@@ -154,7 +173,6 @@ func (b *TGBot) sendMonthlyChart(chatID int64) {
|
||||
return
|
||||
}
|
||||
|
||||
// 计算总计文字
|
||||
var total int64
|
||||
var totalCount int
|
||||
for _, s := range stats {
|
||||
@@ -170,7 +188,6 @@ func (b *TGBot) sendMonthlyChart(chatID int64) {
|
||||
}
|
||||
}
|
||||
|
||||
// sendWeeklyChart 发送近7天每日消费柱状图
|
||||
func (b *TGBot) sendWeeklyChart(chatID int64) {
|
||||
now := time.Now()
|
||||
dateFrom := now.AddDate(0, 0, -6).Format("2006-01-02")
|
||||
@@ -192,7 +209,6 @@ func (b *TGBot) sendWeeklyChart(chatID int64) {
|
||||
return
|
||||
}
|
||||
|
||||
// 总计
|
||||
var total int64
|
||||
var totalCount int
|
||||
for _, s := range stats {
|
||||
|
||||
394
internal/channel/channel.go
Normal file
394
internal/channel/channel.go
Normal file
@@ -0,0 +1,394 @@
|
||||
package channel
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"xiaji-go/config"
|
||||
"xiaji-go/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type UnifiedMessage struct {
|
||||
Platform string `json:"platform"`
|
||||
EventID string `json:"event_id"`
|
||||
ChatID string `json:"chat_id"`
|
||||
UserID string `json:"user_id"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
var secretCipher *cipherContext
|
||||
|
||||
type cipherContext struct {
|
||||
aead cipher.AEAD
|
||||
}
|
||||
|
||||
func InitSecretCipher(key string) error {
|
||||
k := deriveKey32(key)
|
||||
block, err := aes.NewCipher(k)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
secretCipher = &cipherContext{aead: aead}
|
||||
return nil
|
||||
}
|
||||
|
||||
func deriveKey32(s string) []byte {
|
||||
b := []byte(s)
|
||||
out := make([]byte, 32)
|
||||
if len(b) >= 32 {
|
||||
copy(out, b[:32])
|
||||
return out
|
||||
}
|
||||
copy(out, b)
|
||||
for i := len(b); i < 32; i++ {
|
||||
out[i] = byte((i * 131) % 251)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func encryptString(plain string) (string, error) {
|
||||
if secretCipher == nil {
|
||||
return plain, errors.New("cipher not initialized")
|
||||
}
|
||||
nonce := make([]byte, secretCipher.aead.NonceSize())
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
ciphertext := secretCipher.aead.Seal(nil, nonce, []byte(plain), nil)
|
||||
buf := append(nonce, ciphertext...)
|
||||
return "enc:v1:" + base64.StdEncoding.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
func decryptString(raw string) (string, error) {
|
||||
if !strings.HasPrefix(raw, "enc:v1:") {
|
||||
return raw, nil
|
||||
}
|
||||
if secretCipher == nil {
|
||||
return "", errors.New("cipher not initialized")
|
||||
}
|
||||
data, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(raw, "enc:v1:"))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
ns := secretCipher.aead.NonceSize()
|
||||
if len(data) <= ns {
|
||||
return "", errors.New("invalid ciphertext")
|
||||
}
|
||||
nonce := data[:ns]
|
||||
ct := data[ns:]
|
||||
pt, err := secretCipher.aead.Open(nil, nonce, ct, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(pt), nil
|
||||
}
|
||||
|
||||
func maybeDecrypt(raw string) string {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return raw
|
||||
}
|
||||
pt, err := decryptString(raw)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
return pt
|
||||
}
|
||||
|
||||
func MaybeDecryptPublic(raw string) string {
|
||||
return maybeDecrypt(raw)
|
||||
}
|
||||
|
||||
func EncryptSecretJSON(raw string) string {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return raw
|
||||
}
|
||||
if strings.HasPrefix(raw, "enc:v1:") {
|
||||
return raw
|
||||
}
|
||||
if secretCipher == nil {
|
||||
return raw
|
||||
}
|
||||
enc, err := encryptString(raw)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
return enc
|
||||
}
|
||||
|
||||
type telegramSecret struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
type qqSecret struct {
|
||||
AppID string `json:"appid"`
|
||||
Secret string `json:"secret"`
|
||||
}
|
||||
|
||||
type feishuSecret struct {
|
||||
AppID string `json:"app_id"`
|
||||
AppSecret string `json:"app_secret"`
|
||||
VerificationToken string `json:"verification_token"`
|
||||
EncryptKey string `json:"encrypt_key"`
|
||||
}
|
||||
|
||||
func parseJSON(raw string, out any) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return
|
||||
}
|
||||
_ = json.Unmarshal([]byte(raw), out)
|
||||
}
|
||||
|
||||
// ApplyChannelConfig 从数据库渠道配置覆盖运行时配置(优先级:DB > YAML)
|
||||
func ApplyChannelConfig(db *gorm.DB, cfg *config.Config) error {
|
||||
var rows []models.ChannelConfig
|
||||
if err := db.Find(&rows).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
switch row.Platform {
|
||||
case "telegram":
|
||||
sec := telegramSecret{}
|
||||
parseJSON(maybeDecrypt(row.SecretJSON), &sec)
|
||||
cfg.Telegram.Enabled = row.Enabled
|
||||
if strings.TrimSpace(sec.Token) != "" {
|
||||
cfg.Telegram.Token = strings.TrimSpace(sec.Token)
|
||||
}
|
||||
case "qqbot_official":
|
||||
sec := qqSecret{}
|
||||
parseJSON(maybeDecrypt(row.SecretJSON), &sec)
|
||||
cfg.QQBot.Enabled = row.Enabled
|
||||
if strings.TrimSpace(sec.AppID) != "" {
|
||||
cfg.QQBot.AppID = strings.TrimSpace(sec.AppID)
|
||||
}
|
||||
if strings.TrimSpace(sec.Secret) != "" {
|
||||
cfg.QQBot.Secret = strings.TrimSpace(sec.Secret)
|
||||
}
|
||||
case "feishu":
|
||||
sec := feishuSecret{}
|
||||
parseJSON(maybeDecrypt(row.SecretJSON), &sec)
|
||||
cfg.Feishu.Enabled = row.Enabled
|
||||
if strings.TrimSpace(sec.AppID) != "" {
|
||||
cfg.Feishu.AppID = strings.TrimSpace(sec.AppID)
|
||||
}
|
||||
if strings.TrimSpace(sec.AppSecret) != "" {
|
||||
cfg.Feishu.AppSecret = strings.TrimSpace(sec.AppSecret)
|
||||
}
|
||||
if strings.TrimSpace(sec.VerificationToken) != "" {
|
||||
cfg.Feishu.VerificationToken = strings.TrimSpace(sec.VerificationToken)
|
||||
}
|
||||
if strings.TrimSpace(sec.EncryptKey) != "" {
|
||||
cfg.Feishu.EncryptKey = strings.TrimSpace(sec.EncryptKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func httpClient() *http.Client {
|
||||
return &http.Client{Timeout: 8 * time.Second}
|
||||
}
|
||||
|
||||
func TestChannelConnectivity(ctx context.Context, row models.ChannelConfig) (status, detail string) {
|
||||
if !row.Enabled {
|
||||
return "disabled", "渠道未启用"
|
||||
}
|
||||
switch row.Platform {
|
||||
case "telegram":
|
||||
sec := telegramSecret{}
|
||||
parseJSON(maybeDecrypt(row.SecretJSON), &sec)
|
||||
if strings.TrimSpace(sec.Token) == "" {
|
||||
return "error", "telegram token 为空"
|
||||
}
|
||||
url := fmt.Sprintf("https://api.telegram.org/bot%s/getMe", strings.TrimSpace(sec.Token))
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
resp, err := httpClient().Do(req)
|
||||
if err != nil {
|
||||
return "error", err.Error()
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
if resp.StatusCode != 200 || !strings.Contains(string(body), `"ok":true`) {
|
||||
return "error", fmt.Sprintf("telegram getMe失败: http=%d", resp.StatusCode)
|
||||
}
|
||||
return "ok", "telegram getMe 成功"
|
||||
|
||||
case "qqbot_official":
|
||||
sec := qqSecret{}
|
||||
parseJSON(maybeDecrypt(row.SecretJSON), &sec)
|
||||
if strings.TrimSpace(sec.AppID) == "" || strings.TrimSpace(sec.Secret) == "" {
|
||||
return "error", "qq appid/secret 为空"
|
||||
}
|
||||
payload, _ := json.Marshal(map[string]string{"appId": strings.TrimSpace(sec.AppID), "clientSecret": strings.TrimSpace(sec.Secret)})
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, "https://bots.qq.com/app/getAppAccessToken", bytes.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := httpClient().Do(req)
|
||||
if err != nil {
|
||||
return "error", err.Error()
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
if resp.StatusCode != 200 || !strings.Contains(string(body), "access_token") {
|
||||
return "error", fmt.Sprintf("qq access token 获取失败: http=%d", resp.StatusCode)
|
||||
}
|
||||
return "ok", "qq access token 获取成功"
|
||||
|
||||
case "feishu":
|
||||
sec := feishuSecret{}
|
||||
parseJSON(maybeDecrypt(row.SecretJSON), &sec)
|
||||
if strings.TrimSpace(sec.AppID) == "" || strings.TrimSpace(sec.AppSecret) == "" {
|
||||
return "error", "feishu app_id/app_secret 为空"
|
||||
}
|
||||
tk, err := GetFeishuTenantToken(ctx, strings.TrimSpace(sec.AppID), strings.TrimSpace(sec.AppSecret))
|
||||
if err != nil || strings.TrimSpace(tk) == "" {
|
||||
if err == nil {
|
||||
err = fmt.Errorf("token 为空")
|
||||
}
|
||||
return "error", err.Error()
|
||||
}
|
||||
return "ok", "feishu tenant_access_token 获取成功"
|
||||
default:
|
||||
return "error", "未知平台"
|
||||
}
|
||||
}
|
||||
|
||||
func ParseFeishuInbound(body []byte, verificationToken string) (*UnifiedMessage, string, error) {
|
||||
// url_verification
|
||||
var verifyReq struct {
|
||||
Type string `json:"type"`
|
||||
Challenge string `json:"challenge"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &verifyReq); err == nil && verifyReq.Type == "url_verification" {
|
||||
if strings.TrimSpace(verificationToken) != "" && verifyReq.Token != verificationToken {
|
||||
return nil, "", fmt.Errorf("verification token mismatch")
|
||||
}
|
||||
return nil, verifyReq.Challenge, nil
|
||||
}
|
||||
|
||||
var event struct {
|
||||
Header struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
} `json:"header"`
|
||||
Event struct {
|
||||
Sender struct {
|
||||
SenderID struct {
|
||||
OpenID string `json:"open_id"`
|
||||
} `json:"sender_id"`
|
||||
} `json:"sender"`
|
||||
Message struct {
|
||||
MessageID string `json:"message_id"`
|
||||
ChatID string `json:"chat_id"`
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
} `json:"event"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &event); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
if event.Header.EventType != "im.message.receive_v1" {
|
||||
return nil, "", nil
|
||||
}
|
||||
|
||||
eventID := strings.TrimSpace(event.Header.EventID)
|
||||
if eventID == "" {
|
||||
eventID = strings.TrimSpace(event.Event.Message.MessageID)
|
||||
}
|
||||
if eventID == "" {
|
||||
return nil, "", fmt.Errorf("missing event id")
|
||||
}
|
||||
|
||||
var content struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(event.Event.Message.Content), &content)
|
||||
text := strings.TrimSpace(content.Text)
|
||||
if text == "" {
|
||||
return nil, "", nil
|
||||
}
|
||||
|
||||
return &UnifiedMessage{
|
||||
Platform: "feishu",
|
||||
EventID: eventID,
|
||||
ChatID: strings.TrimSpace(event.Event.Message.ChatID),
|
||||
UserID: strings.TrimSpace(event.Event.Sender.SenderID.OpenID),
|
||||
Text: text,
|
||||
}, "", nil
|
||||
}
|
||||
|
||||
func GetFeishuTenantToken(ctx context.Context, appID, appSecret string) (string, error) {
|
||||
payload, _ := json.Marshal(map[string]string{"app_id": appID, "app_secret": appSecret})
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal", bytes.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := httpClient().Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 8192))
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("http=%d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var out struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
TenantAccessToken string `json:"tenant_access_token"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if out.Code != 0 || strings.TrimSpace(out.TenantAccessToken) == "" {
|
||||
if out.Msg == "" {
|
||||
out.Msg = "获取token失败"
|
||||
}
|
||||
return "", fmt.Errorf(out.Msg)
|
||||
}
|
||||
return out.TenantAccessToken, nil
|
||||
}
|
||||
|
||||
func SendFeishuText(ctx context.Context, tenantToken, receiveID, text string) error {
|
||||
contentBytes, _ := json.Marshal(map[string]string{"text": text})
|
||||
payload, _ := json.Marshal(map[string]string{
|
||||
"receive_id": receiveID,
|
||||
"msg_type": "text",
|
||||
"content": string(contentBytes),
|
||||
})
|
||||
url := "https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=open_id"
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+tenantToken)
|
||||
resp, err := httpClient().Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 8192))
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("http=%d", resp.StatusCode)
|
||||
}
|
||||
if !strings.Contains(string(body), `"code":0`) {
|
||||
return fmt.Errorf("feishu send failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
130
internal/feishu/feishu.go
Normal file
130
internal/feishu/feishu.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package feishu
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"xiaji-go/internal/channel"
|
||||
"xiaji-go/internal/service"
|
||||
"xiaji-go/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// DefaultUserID 统一用户ID,使所有平台共享同一份账本
|
||||
const DefaultUserID int64 = 1
|
||||
|
||||
type Bot struct {
|
||||
db *gorm.DB
|
||||
finance *service.FinanceService
|
||||
appID string
|
||||
appSecret string
|
||||
verificationToken string
|
||||
encryptKey string
|
||||
}
|
||||
|
||||
func NewBot(db *gorm.DB, finance *service.FinanceService, appID, appSecret, verificationToken, encryptKey string) *Bot {
|
||||
return &Bot{
|
||||
db: db,
|
||||
finance: finance,
|
||||
appID: appID,
|
||||
appSecret: appSecret,
|
||||
verificationToken: verificationToken,
|
||||
encryptKey: encryptKey,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bot) Start(ctx context.Context) {
|
||||
log.Printf("🚀 Feishu Bot 已启用 app_id=%s", maskID(b.appID))
|
||||
<-ctx.Done()
|
||||
log.Printf("⏳ Feishu Bot 已停止")
|
||||
}
|
||||
|
||||
func (b *Bot) RegisterRoutes(r *gin.Engine) {
|
||||
r.POST("/webhook/feishu", b.handleWebhook)
|
||||
}
|
||||
|
||||
func (b *Bot) handleWebhook(c *gin.Context) {
|
||||
body, err := io.ReadAll(io.LimitReader(c.Request.Body, 1<<20))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
|
||||
return
|
||||
}
|
||||
|
||||
// 统一走 channel 包解析,便于后续扩展验签/解密
|
||||
msg, verifyChallenge, err := channel.ParseFeishuInbound(body, b.verificationToken)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if verifyChallenge != "" {
|
||||
c.JSON(http.StatusOK, gin.H{"challenge": verifyChallenge})
|
||||
return
|
||||
}
|
||||
if msg == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0})
|
||||
return
|
||||
}
|
||||
|
||||
// 幂等去重
|
||||
var existed models.MessageDedup
|
||||
if err := b.db.Where("platform = ? AND event_id = ?", "feishu", msg.EventID).First(&existed).Error; err == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0})
|
||||
return
|
||||
}
|
||||
_ = b.db.Create(&models.MessageDedup{Platform: "feishu", EventID: msg.EventID, ProcessedAt: time.Now()}).Error
|
||||
|
||||
reply := b.handleText(msg.Text)
|
||||
if reply != "" && msg.UserID != "" {
|
||||
tk, err := channel.GetFeishuTenantToken(c.Request.Context(), b.appID, b.appSecret)
|
||||
if err == nil {
|
||||
_ = channel.SendFeishuText(c.Request.Context(), tk, msg.UserID, reply)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0})
|
||||
}
|
||||
|
||||
func (b *Bot) handleText(text string) string {
|
||||
trim := strings.TrimSpace(text)
|
||||
switch trim {
|
||||
case "帮助", "help", "/help", "菜单", "功能", "/start":
|
||||
return "🦞 虾记记账\n\n直接发送消费描述即可记账:\n• 午饭 25元\n• 打车 ¥30\n\n📋 命令:记录/查看、今日/今天、统计"
|
||||
case "查看", "记录", "列表", "最近":
|
||||
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()
|
||||
}
|
||||
|
||||
amount, category, err := b.finance.AddTransaction(DefaultUserID, trim)
|
||||
if err != nil {
|
||||
return "❌ 记账失败,请稍后重试"
|
||||
}
|
||||
if amount == 0 {
|
||||
return "📍 没看到金额,这笔花了多少钱?"
|
||||
}
|
||||
return fmt.Sprintf("✅ 已记入【%s】:%.2f元\n📝 备注:%s", category, float64(amount)/100.0, trim)
|
||||
}
|
||||
|
||||
func maskID(s string) string {
|
||||
if len(s) <= 6 {
|
||||
return "***"
|
||||
}
|
||||
return s[:3] + "***" + s[len(s)-3:]
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"xiaji-go/internal/service"
|
||||
"xiaji-go/models"
|
||||
|
||||
"github.com/tencent-connect/botgo"
|
||||
"github.com/tencent-connect/botgo/dto"
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
"github.com/tencent-connect/botgo/event"
|
||||
"github.com/tencent-connect/botgo/openapi"
|
||||
"github.com/tencent-connect/botgo/token"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// DefaultUserID 统一用户ID,使所有平台共享同一份账本
|
||||
@@ -24,10 +26,12 @@ type QQBot struct {
|
||||
api openapi.OpenAPI
|
||||
finance *service.FinanceService
|
||||
credentials *token.QQBotCredentials
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewQQBot(appID string, secret string, finance *service.FinanceService) *QQBot {
|
||||
func NewQQBot(db *gorm.DB, appID string, secret string, finance *service.FinanceService) *QQBot {
|
||||
return &QQBot{
|
||||
db: db,
|
||||
finance: finance,
|
||||
credentials: &token.QQBotCredentials{
|
||||
AppID: appID,
|
||||
@@ -37,42 +41,35 @@ func NewQQBot(appID string, secret string, finance *service.FinanceService) *QQB
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -82,7 +79,18 @@ func isCommand(text string, keywords ...string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// processAndReply 通用记账处理
|
||||
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 == "" {
|
||||
@@ -91,7 +99,6 @@ func (b *QQBot) processAndReply(userID string, content string) string {
|
||||
|
||||
today := time.Now().Format("2006-01-02")
|
||||
|
||||
// 命令处理
|
||||
switch {
|
||||
case isCommand(text, "帮助", "help", "/help", "/start", "菜单", "功能"):
|
||||
return "🦞 虾记记账\n\n" +
|
||||
@@ -173,17 +180,18 @@ func (b *QQBot) processAndReply(userID string, content string) string {
|
||||
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 {
|
||||
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,
|
||||
})
|
||||
_, err := b.api.PostMessage(context.Background(), data.ChannelID, &dto.MessageToCreate{MsgID: data.ID, Content: reply})
|
||||
if err != nil {
|
||||
log.Printf("QQ频道消息发送失败: %v", err)
|
||||
}
|
||||
@@ -191,17 +199,18 @@ func (b *QQBot) channelATMessageHandler() event.ATMessageEventHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// groupATMessageHandler 群@机器人消息
|
||||
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,
|
||||
})
|
||||
_, err := b.api.PostGroupMessage(context.Background(), data.GroupID, dto.MessageToCreate{MsgID: data.ID, Content: reply})
|
||||
if err != nil {
|
||||
log.Printf("QQ群消息发送失败: %v", err)
|
||||
}
|
||||
@@ -209,17 +218,18 @@ func (b *QQBot) groupATMessageHandler() event.GroupATMessageEventHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// c2cMessageHandler C2C 私聊消息
|
||||
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,
|
||||
})
|
||||
_, err := b.api.PostC2CMessage(context.Background(), data.Author.ID, dto.MessageToCreate{MsgID: data.ID, Content: reply})
|
||||
if err != nil {
|
||||
log.Printf("QQ私聊消息发送失败: %v", err)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user