Files
ops-assistant/internal/qq/qq.go
2026-03-19 21:23:28 +08:00

475 lines
14 KiB
Go
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package qq
import (
"context"
"fmt"
"log"
"path"
"regexp"
"strconv"
"strings"
"time"
"ops-assistant/internal/core/ops"
"ops-assistant/internal/service"
"ops-assistant/internal/core/ai"
"ops-assistant/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"
"github.com/tidwall/gjson"
"gorm.io/gorm"
)
// DefaultUserID 统一用户ID使所有平台共享同一份账本
const DefaultUserID int64 = 1
type QQBot struct {
api openapi.OpenAPI
finance *service.FinanceService
credentials *token.QQBotCredentials
db *gorm.DB
opsSvc *ops.Service
aiClient *ai.Client
aiAutoReload time.Time
}
func NewQQBot(db *gorm.DB, appID string, secret string, finance *service.FinanceService, opsSvc *ops.Service) *QQBot {
return &QQBot{
db: db,
finance: finance,
opsSvc: opsSvc,
credentials: &token.QQBotCredentials{
AppID: appID,
AppSecret: secret,
},
aiClient: buildAIClient(db),
aiAutoReload: time.Now(),
}
}
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 ""
}
// 先交给 opsSvc 处理命令
if b.opsSvc != nil {
if handled, out := b.opsSvc.Handle(DefaultUserID, text); handled {
if strings.HasPrefix(text, "/cpa ") || text == "/cpa" || strings.HasPrefix(text, "/cf ") || strings.HasPrefix(text, "/mail ") {
jobID := parseJobID(out)
if jobID > 0 {
go b.waitAndPushJobResult(userID, jobID, text)
}
}
return out
}
}
// 规则兜底:常见自然语映射到标准命令
if b.opsSvc != nil {
norm := strings.ReplaceAll(strings.ReplaceAll(strings.TrimSpace(text), " ", ""), " ", "")
if (strings.Contains(norm, "域名") || strings.Contains(norm, "站点")) && !strings.Contains(norm, "解析") && !strings.Contains(strings.ToLower(norm), "dns") {
cmd := "/cf zones"
if handled, out := b.opsSvc.Handle(DefaultUserID, cmd); handled {
jobID := parseJobID(out)
if jobID > 0 {
go b.waitAndPushJobResult(userID, jobID, cmd)
}
return out
}
}
switch norm {
case "cpa状态", "CPA状态", "cpaStatus", "cpastatus":
cmd := "/cpa status"
if handled, out := b.opsSvc.Handle(DefaultUserID, cmd); handled {
jobID := parseJobID(out)
if jobID > 0 {
go b.waitAndPushJobResult(userID, jobID, cmd)
}
return out
}
case "功能", "菜单", "帮助", "help", "Help", "HELP", "你能做什么", "你会什么":
cmd := "/help"
if handled, out := b.opsSvc.Handle(DefaultUserID, cmd); handled {
return out
}
case "cf状态", "cf配置", "cf配置状态", "cloudflare状态", "cloudflare配置":
cmd := "/cf status"
if handled, out := b.opsSvc.Handle(DefaultUserID, cmd); handled {
jobID := parseJobID(out)
if jobID > 0 {
go b.waitAndPushJobResult(userID, jobID, cmd)
}
return out
}
case "cf域名", "cf账号域名", "cfzones", "cf zones", "cloudflare域名", "cloudflare站点", "站点列表", "域名列表", "我的域名", "域名清单":
cmd := "/cf zones"
if handled, out := b.opsSvc.Handle(DefaultUserID, cmd); handled {
jobID := parseJobID(out)
if jobID > 0 {
go b.waitAndPushJobResult(userID, jobID, cmd)
}
return out
}
case "cf解析", "cf记录", "解析记录", "dns记录", "dns列表", "列解析", "列记录":
return "❌ 缺少 zone_id请用/cf dns list <zone_id>"
case "cfworkers", "cf workers", "workers列表", "workers list", "列workers":
cmd := "/cf workers list"
if handled, out := b.opsSvc.Handle(DefaultUserID, cmd); handled {
jobID := parseJobID(out)
if jobID > 0 {
go b.waitAndPushJobResult(userID, jobID, cmd)
}
return out
}
}
}
// 非命令:尝试 AI 翻译 -> 标准命令
if time.Since(b.aiAutoReload) > 3*time.Second {
b.aiClient = buildAIClient(b.db)
b.aiAutoReload = time.Now()
}
if b.aiClient != nil {
if cmd, err := b.aiClient.Suggest(text); err == nil {
cmd = strings.TrimSpace(cmd)
if cmd != "" && cmd != "FAIL" && strings.HasPrefix(cmd, "/") {
// 仅翻译成命令,交给模块处理(不回译文)
if b.opsSvc != nil {
if handled, out := b.opsSvc.Handle(DefaultUserID, cmd); handled {
if strings.HasPrefix(cmd, "/cpa ") || cmd == "/cpa" || strings.HasPrefix(cmd, "/cf ") || strings.HasPrefix(cmd, "/mail ") {
jobID := parseJobID(out)
if jobID > 0 {
go b.waitAndPushJobResult(userID, jobID, cmd)
}
}
return out
}
}
return "❌ 无法识别,请使用标准命令"
}
return "❌ 无法识别,请使用标准命令"
} else {
if strings.Contains(err.Error(), "rate limited") {
return "⚠️ AI 服务繁忙,请稍后再试或使用标准命令"
}
return "❌ 无法识别,请使用标准命令"
}
}
// 仍然无法处理
return "❌ 无法识别,请使用标准命令"
}
func parseJobID(out string) uint {
re := regexp.MustCompile(`job=(\d+)`)
m := re.FindStringSubmatch(out)
if len(m) < 2 {
return 0
}
n, _ := strconv.Atoi(m[1])
if n <= 0 {
return 0
}
return uint(n)
}
func (b *QQBot) waitAndPushJobResult(userID string, jobID uint, cmd string) {
if b.db == nil {
return
}
var job models.OpsJob
for i := 0; i < 15; i++ { // 最多等 ~30s
time.Sleep(2 * time.Second)
if err := b.db.First(&job, jobID).Error; err != nil {
continue
}
if job.Status == "pending" || job.Status == "running" {
continue
}
break
}
if job.ID == 0 || job.Status == "pending" || job.Status == "running" {
return
}
msg := formatJobResult(b.db, jobID, cmd)
if strings.TrimSpace(msg) == "" {
return
}
_, err := b.api.PostC2CMessage(context.Background(), userID, dto.MessageToCreate{Content: msg})
if err != nil {
log.Printf("QQ 推送任务结果失败: %v", err)
}
}
func formatJobResult(db *gorm.DB, jobID uint, cmd string) string {
var job models.OpsJob
if err := db.First(&job, jobID).Error; err != nil {
return ""
}
if job.Runbook == "cpa_status" {
return formatCPAStatusResult(db, jobID, job.Status)
}
if job.Runbook == "cpa_usage_backup" {
return formatCPAUsageBackupResult(db, jobID)
}
if job.Runbook == "cf_zones" {
return formatCFZonesResult(db, jobID, cmd)
}
if job.Runbook == "cf_workers_list" {
return formatCFWorkersResult(db, jobID, cmd)
}
return fmt.Sprintf("📦 %s 结果:%s (job=%d)", strings.TrimSpace(cmd), job.Status, jobID)
}
func formatCFZonesResult(db *gorm.DB, jobID uint, cmd string) string {
var steps []models.OpsJobStep
_ = db.Where("job_id = ?", jobID).Order("id asc").Find(&steps).Error
var raw string
for _, st := range steps {
if st.StepID == "list_zones" {
raw = strings.TrimSpace(st.StdoutTail)
break
}
}
if raw == "" {
return fmt.Sprintf("📦 %s 结果success (job=%d)", strings.TrimSpace(cmd), jobID)
}
arr := gjson.Get(raw, "zones").Array()
if len(arr) == 0 {
arr = gjson.Get(raw, "result").Array()
}
if len(arr) == 0 {
return fmt.Sprintf("📦 %s 结果success (job=%d)\n(no zones)", strings.TrimSpace(cmd), jobID)
}
lines := make([]string, 0, len(arr)+2)
lines = append(lines, fmt.Sprintf("✅ %s 完成 (job=%d)", strings.TrimSpace(cmd), jobID))
limit := len(arr)
if limit > 50 {
limit = 50
}
for i := 0; i < limit; i++ {
name := arr[i].Get("name").String()
id := arr[i].Get("id").String()
if name == "" && id == "" {
continue
}
if id != "" {
lines = append(lines, fmt.Sprintf("- %s (%s)", name, id))
} else {
lines = append(lines, fmt.Sprintf("- %s", name))
}
}
if len(arr) > limit {
lines = append(lines, fmt.Sprintf("... 共 %d 个,已展示前 %d 个", len(arr), limit))
}
return strings.Join(lines, "\n")
}
func formatCFWorkersResult(db *gorm.DB, jobID uint, cmd string) string {
var steps []models.OpsJobStep
_ = db.Where("job_id = ?", jobID).Order("id asc").Find(&steps).Error
var raw string
for _, st := range steps {
if st.StepID == "list_workers" {
raw = strings.TrimSpace(st.StdoutTail)
break
}
}
if raw == "" {
return fmt.Sprintf("📦 %s 结果success (job=%d)", strings.TrimSpace(cmd), jobID)
}
arr := gjson.Get(raw, "workers").Array()
if len(arr) == 0 {
arr = gjson.Get(raw, "result").Array()
}
lines := make([]string, 0, len(arr)+2)
lines = append(lines, fmt.Sprintf("✅ %s 完成 (job=%d)", strings.TrimSpace(cmd), jobID))
if len(arr) == 0 {
lines = append(lines, "(no workers)")
return strings.Join(lines, "\n")
}
limit := len(arr)
if limit > 50 {
limit = 50
}
for i := 0; i < limit; i++ {
name := arr[i].Get("id").String()
if name == "" {
name = arr[i].Get("name").String()
}
if name == "" {
continue
}
lines = append(lines, fmt.Sprintf("- %s", name))
}
if len(arr) > limit {
lines = append(lines, fmt.Sprintf("... 共 %d 个,已展示前 %d 个", len(arr), limit))
}
return strings.Join(lines, "\n")
}
func formatCPAUsageBackupResult(db *gorm.DB, jobID uint) string {
var steps []models.OpsJobStep
_ = db.Where("job_id = ?", jobID).Order("id asc").Find(&steps).Error
var raw string
for _, st := range steps {
if st.StepID == "export_and_package" {
raw = strings.TrimSpace(st.StdoutTail)
break
}
}
if raw == "" {
return fmt.Sprintf("✅ /cpa usage backup 执行成功job=%d", jobID)
}
backup := ""
for _, line := range strings.Split(raw, "\n") {
if strings.HasPrefix(line, "backup=") {
backup = strings.TrimSpace(strings.TrimPrefix(line, "backup="))
break
}
}
if backup == "" {
return fmt.Sprintf("✅ /cpa usage backup 执行成功job=%d", jobID)
}
file := path.Base(backup)
return fmt.Sprintf("✅ /cpa usage backup 执行成功job=%d\n📦 备份文件:%s\n📁 路径:%s", jobID, file, path.Dir(backup)+"/")
}
func formatCPAStatusResult(db *gorm.DB, jobID uint, status string) string {
var steps []models.OpsJobStep
_ = db.Where("job_id = ?", jobID).Order("id asc").Find(&steps).Error
var svc, usage string
for _, st := range steps {
if st.StepID == "service_status" {
svc = strings.TrimSpace(st.StdoutTail)
}
if st.StepID == "usage_snapshot" {
usage = strings.TrimSpace(st.StdoutTail)
}
}
tr := gjson.Get(usage, "usage.total_requests").String()
tt := gjson.Get(usage, "usage.total_tokens").String()
if tr == "" {
tr = "-"
}
if tt == "" {
tt = "-"
}
return fmt.Sprintf("✅ /cpa status 完成 (job=%d)\nservice=%s\nrequests=%s\ntokens=%s", jobID, svc, tr, tt)
}
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
}
}