- 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 任务、渠道管理、审计页
324 lines
8.5 KiB
Go
324 lines
8.5 KiB
Go
package qq
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"regexp"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"ops-assistant/models"
|
||
)
|
||
|
||
type subSessionPayload struct {
|
||
Step string `json:"step"`
|
||
Temp map[string]string `json:"temp"`
|
||
}
|
||
|
||
type subAPISettings struct {
|
||
APIBase string
|
||
Token string
|
||
}
|
||
|
||
const (
|
||
subStepName = "WAIT_NAME"
|
||
subStepCategory = "WAIT_CATEGORY"
|
||
subStepAcct = "WAIT_ACCOUNT"
|
||
subStepAmount = "WAIT_AMOUNT"
|
||
subStepCurrency = "WAIT_CURRENCY"
|
||
subStepDate = "WAIT_DATE"
|
||
subStepCycleUnit = "WAIT_CYCLE_UNIT"
|
||
subStepCycleValue = "WAIT_CYCLE_VALUE"
|
||
)
|
||
|
||
var dateRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
|
||
|
||
func (b *QQBot) handleSubFlow(userID string, text string) (string, bool) {
|
||
raw := strings.TrimSpace(text)
|
||
if !strings.HasPrefix(raw, "/sub") {
|
||
if raw == "/cancel" {
|
||
if b.hasSubSession(userID) {
|
||
b.clearSubSession(userID)
|
||
return "✅ 已取消订阅新增流程", true
|
||
}
|
||
return "", false
|
||
}
|
||
if !b.hasSubSession(userID) {
|
||
return "", false
|
||
}
|
||
} else {
|
||
if raw == "/sub" || strings.HasPrefix(raw, "/sub help") {
|
||
if b.opsSvc != nil {
|
||
if handled, out := b.opsSvc.Handle(DefaultUserID, raw); handled {
|
||
return out, true
|
||
}
|
||
}
|
||
return "订阅模块\n- /sub add\n- /sub list\n- /sub report\n- /sub report category\n- /sub report cycle\n- /sub report due <7|30|overdue>\n- /sub del <id>\n- /sub cancel(或 /cancel)取消新增流程", true
|
||
}
|
||
if raw == "/sub cancel" {
|
||
if b.hasSubSession(userID) {
|
||
b.clearSubSession(userID)
|
||
return "✅ 已取消订阅新增流程", true
|
||
}
|
||
return "", true
|
||
}
|
||
if raw == "/sub add" || strings.HasPrefix(raw, "/sub add ") {
|
||
b.setSubSession(userID, &subSessionPayload{Step: subStepName, Temp: map[string]string{}})
|
||
return "1️⃣ 输入订阅名称(例如:Netflix)", true
|
||
}
|
||
return "", false
|
||
}
|
||
|
||
sess := b.getSubSession(userID)
|
||
if sess == nil {
|
||
return "", false
|
||
}
|
||
msg := raw
|
||
if msg == "" {
|
||
return "", true
|
||
}
|
||
if sess.Temp == nil {
|
||
sess.Temp = map[string]string{}
|
||
}
|
||
|
||
switch sess.Step {
|
||
case subStepName:
|
||
sess.Temp["name"] = msg
|
||
sess.Step = subStepCategory
|
||
b.setSubSession(userID, sess)
|
||
return "2️⃣ 输入分类(例如:通信/视频/游戏/工具/其他,可 /skip=未分类)", true
|
||
case subStepCategory:
|
||
if msg == "/skip" {
|
||
sess.Temp["category"] = "未分类"
|
||
} else {
|
||
sess.Temp["category"] = msg
|
||
}
|
||
if strings.TrimSpace(sess.Temp["category"]) == "" {
|
||
return "❌ 分类不能为空,请重新输入(可 /skip)", true
|
||
}
|
||
sess.Step = subStepAcct
|
||
b.setSubSession(userID, sess)
|
||
return "3️⃣ 输入账号/备注(可选,回复 /skip 跳过)", true
|
||
case subStepAcct:
|
||
if msg == "/skip" {
|
||
sess.Temp["account"] = ""
|
||
} else {
|
||
sess.Temp["account"] = msg
|
||
}
|
||
sess.Step = subStepAmount
|
||
b.setSubSession(userID, sess)
|
||
return "4️⃣ 输入金额(数字,例如 9.99)", true
|
||
case subStepAmount:
|
||
amt, err := strconv.ParseFloat(msg, 64)
|
||
if err != nil || amt < 0 {
|
||
return "❌ 金额格式错误,请输入数字(例如 9.99)", true
|
||
}
|
||
sess.Temp["amount"] = fmt.Sprintf("%.2f", amt)
|
||
sess.Step = subStepCurrency
|
||
b.setSubSession(userID, sess)
|
||
return "5️⃣ 输入币种(CNY/USD/EUR/GBP/HKD/JPY/MYR/SGD,或 /skip 默认 CNY)", true
|
||
case subStepCurrency:
|
||
cur := strings.ToUpper(msg)
|
||
if msg == "/skip" {
|
||
cur = "CNY"
|
||
}
|
||
if !isAllowedCurrency(cur) {
|
||
return "❌ 币种不支持,请输入 CNY/USD/EUR/GBP/HKD/JPY/MYR/SGD", true
|
||
}
|
||
sess.Temp["currency"] = cur
|
||
sess.Step = subStepDate
|
||
b.setSubSession(userID, sess)
|
||
return "6️⃣ 输入到期日(YYYY-MM-DD)", true
|
||
case subStepDate:
|
||
if !dateRe.MatchString(msg) {
|
||
return "❌ 日期格式错误,请用 YYYY-MM-DD", true
|
||
}
|
||
sess.Temp["due_date"] = msg
|
||
sess.Step = subStepCycleUnit
|
||
b.setSubSession(userID, sess)
|
||
return "7️⃣ 输入周期单位(day/month/year)", true
|
||
case subStepCycleUnit:
|
||
unit := strings.ToLower(msg)
|
||
if !isAllowedCycleUnit(unit) {
|
||
return "❌ 周期单位不支持,请输入 day/month/year", true
|
||
}
|
||
sess.Temp["cycle_unit"] = unit
|
||
sess.Step = subStepCycleValue
|
||
b.setSubSession(userID, sess)
|
||
return "8️⃣ 输入周期数量(数字)", true
|
||
case subStepCycleValue:
|
||
cv, err := strconv.Atoi(msg)
|
||
if err != nil || cv <= 0 {
|
||
return "❌ 周期数量必须是正整数", true
|
||
}
|
||
sess.Temp["cycle_value"] = strconv.Itoa(cv)
|
||
b.clearSubSession(userID)
|
||
return b.subCreate(sess)
|
||
default:
|
||
b.clearSubSession(userID)
|
||
return "❌ 会话已重置,请重新发送 /sub add", true
|
||
}
|
||
}
|
||
|
||
func (b *QQBot) subCreate(sess *subSessionPayload) (string, bool) {
|
||
cfg, err := b.loadSubSettings()
|
||
if err != nil {
|
||
return "❌ 未配置 sub_api_base/sub_api_token", true
|
||
}
|
||
payload := map[string]any{
|
||
"user_id": "1",
|
||
"channel": "qq",
|
||
"name": sess.Temp["name"],
|
||
"category": sess.Temp["category"],
|
||
"account": sess.Temp["account"],
|
||
"amount": toFloat(sess.Temp["amount"]),
|
||
"currency": sess.Temp["currency"],
|
||
"due_date": sess.Temp["due_date"],
|
||
"cycle_unit": sess.Temp["cycle_unit"],
|
||
"cycle_value": toInt(sess.Temp["cycle_value"]),
|
||
}
|
||
body, _ := json.Marshal(payload)
|
||
url := strings.TrimRight(cfg.APIBase, "/") + "/api/subscriptions"
|
||
resp, status, err := doSubRequest(http.MethodPost, url, body, cfg.Token)
|
||
if err != nil {
|
||
return "❌ 创建失败: " + err.Error(), true
|
||
}
|
||
if status >= 400 {
|
||
return fmt.Sprintf("❌ 创建失败: http %d %s", status, strings.TrimSpace(resp)), true
|
||
}
|
||
return "✅ 添加成功", true
|
||
}
|
||
|
||
func (b *QQBot) loadSubSettings() (*subAPISettings, error) {
|
||
if b.db == nil {
|
||
return nil, fmt.Errorf("db nil")
|
||
}
|
||
var base models.AppSetting
|
||
if err := b.db.Where("key = ?", "sub_api_base").First(&base).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
var token models.AppSetting
|
||
if err := b.db.Where("key = ?", "sub_api_token").First(&token).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
apiBase := strings.TrimSpace(base.Value)
|
||
apiToken := strings.TrimSpace(token.Value)
|
||
if apiBase == "" || apiToken == "" {
|
||
return nil, fmt.Errorf("empty settings")
|
||
}
|
||
return &subAPISettings{APIBase: apiBase, Token: apiToken}, nil
|
||
}
|
||
|
||
func (b *QQBot) hasSubSession(userID string) bool {
|
||
if b.db == nil {
|
||
return false
|
||
}
|
||
var st models.SubSession
|
||
if err := b.db.Where("user_id = ? AND platform = ?", userID, "qqbot_official").First(&st).Error; err != nil {
|
||
return false
|
||
}
|
||
return strings.TrimSpace(st.Step) != ""
|
||
}
|
||
|
||
func (b *QQBot) getSubSession(userID string) *subSessionPayload {
|
||
if b.db == nil {
|
||
return nil
|
||
}
|
||
var st models.SubSession
|
||
if err := b.db.Where("user_id = ? AND platform = ?", userID, "qqbot_official").First(&st).Error; err != nil {
|
||
return nil
|
||
}
|
||
var payload subSessionPayload
|
||
if err := json.Unmarshal([]byte(st.TempJSON), &payload.Temp); err != nil {
|
||
payload.Temp = map[string]string{}
|
||
}
|
||
payload.Step = st.Step
|
||
return &payload
|
||
}
|
||
|
||
func (b *QQBot) setSubSession(userID string, sess *subSessionPayload) {
|
||
if b.db == nil {
|
||
return
|
||
}
|
||
tmp, _ := json.Marshal(sess.Temp)
|
||
var row models.SubSession
|
||
if err := b.db.Where("user_id = ? AND platform = ?", userID, "qqbot_official").First(&row).Error; err == nil {
|
||
row.Step = sess.Step
|
||
row.TempJSON = string(tmp)
|
||
_ = b.db.Save(&row).Error
|
||
return
|
||
}
|
||
_ = b.db.Create(&models.SubSession{
|
||
UserID: userID,
|
||
Platform: "qqbot_official",
|
||
Step: sess.Step,
|
||
TempJSON: string(tmp),
|
||
}).Error
|
||
}
|
||
|
||
func (b *QQBot) clearSubSession(userID string) {
|
||
if b.db == nil {
|
||
return
|
||
}
|
||
_ = b.db.Where("user_id = ? AND platform = ?", userID, "qqbot_official").Delete(&models.SubSession{}).Error
|
||
}
|
||
|
||
func doSubRequest(method, url string, body []byte, token string) (string, int, error) {
|
||
req, err := http.NewRequest(method, url, bytes.NewReader(body))
|
||
if err != nil {
|
||
return "", 0, err
|
||
}
|
||
if token != "" {
|
||
req.Header.Set("Authorization", "Bearer "+token)
|
||
}
|
||
if body != nil {
|
||
req.Header.Set("Content-Type", "application/json")
|
||
}
|
||
client := &http.Client{Timeout: 15 * time.Second}
|
||
resp, err := client.Do(req)
|
||
if err != nil {
|
||
return "", 0, err
|
||
}
|
||
defer resp.Body.Close()
|
||
data, _ := io.ReadAll(resp.Body)
|
||
return string(data), resp.StatusCode, nil
|
||
}
|
||
|
||
func toInt(s string) int {
|
||
if s == "" {
|
||
return 0
|
||
}
|
||
v, _ := strconv.Atoi(s)
|
||
return v
|
||
}
|
||
|
||
func toFloat(s string) float64 {
|
||
if s == "" {
|
||
return 0
|
||
}
|
||
v, _ := strconv.ParseFloat(s, 64)
|
||
return v
|
||
}
|
||
|
||
func isAllowedCurrency(c string) bool {
|
||
switch strings.ToUpper(c) {
|
||
case "CNY", "USD", "EUR", "GBP", "HKD", "JPY", "MYR", "SGD":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func isAllowedCycleUnit(u string) bool {
|
||
switch strings.ToLower(u) {
|
||
case "day", "month", "year":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|