Files
ops-assistant/internal/qq/qq.go
T
openclaw 81939825d1 feat: 模块化命令框架 + runbook 严格断言与查询结果回传
- core/module: 命令模板、门控(feature flag/confirm/dry-run)、ecode 错误标签、ResultFormatter
- runbook executor: http.get/http.post/assert.json(expect_success 严格断言)、目标主机管理、任务落库、最终 stdout 直接回传
- cf 模块: /cf dns list 支持域名自动解析 zone_id;dns list/zones/workers list 格式化回传;API success:false 判真失败
- 全部 CF/CPA runbook 接入严格断言,杜绝假阳性 success
- 新增 cpa/cfdav/mail/sub 模块与 runbooks;QQ 订阅到期提醒调度
- Web 后台: 模块设置(CF/CPA/cfdav/AI)、OPS 任务、渠道管理、审计页
2026-09-13 07:42:17 +08:00

480 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 ""
}
// sub 引导式新增流程(优先级高于模块命令)
if reply, handled := b.handleSubFlow(userID, text); handled {
return reply
}
// 先交给 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 ") || strings.HasPrefix(text, "/cfdav ") || text == "/cfdav" {
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
}
}