Files
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

309 lines
8.4 KiB
Go

package qq
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
"time"
"ops-assistant/models"
"github.com/tencent-connect/botgo/dto"
)
type subRemindItem struct {
ID string `json:"id"`
Name string `json:"name"`
Category string `json:"category"`
Account string `json:"account"`
Amount float64 `json:"amount"`
Currency string `json:"currency"`
DueDate string `json:"due_date"`
CycleUnit string `json:"cycle_unit"`
CycleValue int `json:"cycle_value"`
Remind []int `json:"remind_days"`
}
type subListResp struct {
Success bool `json:"success"`
Data []subRemindItem `json:"data"`
Error string `json:"error"`
}
type subNotifySettings struct {
APIBase string
Token string
NotifyQQUserID string
NotifyGroupID string
NotifyWeComWebhook string
NotifyTime string
}
func (b *QQBot) StartSubScheduler(ctx context.Context, hour, minute int) {
if b.db == nil {
return
}
go func() {
b.trySendTestOnce(ctx)
for {
next := nextRunTime(hour, minute)
t := time.NewTimer(time.Until(next))
select {
case <-ctx.Done():
t.Stop()
return
case <-t.C:
b.runSubNotifyOnce(ctx)
}
}
}()
}
func (b *QQBot) trySendTestOnce(ctx context.Context) {
if b.db == nil {
log.Printf("[sub_notify] test skip: db nil")
return
}
var s models.AppSetting
if err := b.db.Where("key = ?", "sub_notify_test_once").First(&s).Error; err != nil {
log.Printf("[sub_notify] test skip: setting not found")
return
}
if strings.TrimSpace(s.Value) != "1" {
log.Printf("[sub_notify] test skip: flag=%s", strings.TrimSpace(s.Value))
return
}
cfg, err := b.loadSubNotifySettings()
if err != nil {
log.Printf("[sub_notify] test skip: load settings err=%v", err)
return
}
if strings.TrimSpace(cfg.NotifyWeComWebhook) == "" && strings.TrimSpace(cfg.NotifyQQUserID) == "" && strings.TrimSpace(cfg.NotifyGroupID) == "" {
log.Printf("[sub_notify] test skip: notify target empty")
return
}
for i := 0; i < 10; i++ {
if b.api != nil {
break
}
select {
case <-ctx.Done():
log.Printf("[sub_notify] test skip: ctx done")
return
case <-time.After(1 * time.Second):
}
}
if b.api == nil {
log.Printf("[sub_notify] test skip: api nil")
return
}
msg := fmt.Sprintf("✅ 订阅提醒测试 %s", time.Now().Format("01-02 15:04"))
if strings.TrimSpace(cfg.NotifyWeComWebhook) != "" {
if err := sendWeCom(cfg.NotifyWeComWebhook, msg); err == nil {
s.Value = "0"
_ = b.db.Save(&s).Error
log.Printf("[sub_notify] test send ok wecom")
} else {
log.Printf("[sub_notify] test send failed wecom err=%v", err)
}
return
}
if strings.TrimSpace(cfg.NotifyGroupID) != "" {
if err := b.sendGroup(cfg.NotifyGroupID, msg); err == nil {
s.Value = "0"
_ = b.db.Save(&s).Error
log.Printf("[sub_notify] test send ok group=%s", cfg.NotifyGroupID)
} else {
log.Printf("[sub_notify] test send failed group=%s err=%v", cfg.NotifyGroupID, err)
}
return
}
if err := b.sendC2C(cfg.NotifyQQUserID, msg); err == nil {
s.Value = "0"
_ = b.db.Save(&s).Error
log.Printf("[sub_notify] test send ok user=%s", cfg.NotifyQQUserID)
} else {
log.Printf("[sub_notify] test send failed user=%s err=%v", cfg.NotifyQQUserID, err)
}
}
func nextRunTime(hour, minute int) time.Time {
now := time.Now()
loc := now.Location()
next := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, loc)
if !next.After(now) {
next = next.Add(24 * time.Hour)
}
return next
}
func (b *QQBot) runSubNotifyOnce(ctx context.Context) {
cfg, err := b.loadSubNotifySettings()
if err != nil {
return
}
if strings.TrimSpace(cfg.NotifyWeComWebhook) == "" && strings.TrimSpace(cfg.NotifyQQUserID) == "" && strings.TrimSpace(cfg.NotifyGroupID) == "" {
return
}
url := fmt.Sprintf("%s/api/subscriptions?user_id=1&channel=qq", strings.TrimRight(cfg.APIBase, "/"))
body, status, err := doSubRequest("GET", url, nil, cfg.Token)
if err != nil || status >= 400 {
return
}
var out subListResp
if err := json.Unmarshal([]byte(body), &out); err != nil || !out.Success {
return
}
if len(out.Data) == 0 {
return
}
now := time.Now()
today := now.Format("2006-01-02")
for _, it := range out.Data {
if it.DueDate == "" {
continue
}
due, err := time.ParseInLocation("2006-01-02", it.DueDate, now.Location())
if err != nil {
continue
}
daysLeft := int(due.Sub(time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())).Hours() / 24)
if !shouldRemind(it.Remind, daysLeft) {
continue
}
targetID := cfg.NotifyQQUserID
if strings.TrimSpace(cfg.NotifyWeComWebhook) != "" {
targetID = "wecom"
} else if strings.TrimSpace(cfg.NotifyGroupID) != "" {
targetID = cfg.NotifyGroupID
}
if b.hasNotified("qqbot_official", targetID, it.ID, today, daysLeft) {
continue
}
msg := formatSubReminder(it, daysLeft)
if msg == "" {
continue
}
if strings.TrimSpace(cfg.NotifyWeComWebhook) != "" {
_ = sendWeCom(cfg.NotifyWeComWebhook, msg)
} else if strings.TrimSpace(cfg.NotifyGroupID) != "" {
_ = b.sendGroup(cfg.NotifyGroupID, msg)
} else {
_ = b.sendC2C(cfg.NotifyQQUserID, msg)
}
b.markNotified("qqbot_official", targetID, it.ID, today, daysLeft)
}
}
func (b *QQBot) loadSubNotifySettings() (*subNotifySettings, error) {
if b.db == nil {
return nil, fmt.Errorf("db 未初始化")
}
get := func(key string) string {
var s models.AppSetting
if err := b.db.Where("key = ?", key).First(&s).Error; err == nil {
return strings.TrimSpace(s.Value)
}
return ""
}
cfg := &subNotifySettings{
APIBase: get("sub_api_base"),
Token: get("sub_api_token"),
NotifyQQUserID: get("sub_notify_qq_user_id"),
NotifyGroupID: get("sub_notify_group_id"),
NotifyWeComWebhook: get("sub_notify_wecom_webhook"),
NotifyTime: get("sub_notify_time"),
}
if cfg.APIBase == "" {
return nil, fmt.Errorf("sub_api_base 未配置")
}
if cfg.Token == "" {
return nil, fmt.Errorf("sub_api_token 未配置")
}
return cfg, nil
}
func shouldRemind(remind []int, daysLeft int) bool {
for _, d := range remind {
if d == daysLeft {
return true
}
}
return false
}
func (b *QQBot) hasNotified(platform, userID, subID, date string, daysLeft int) bool {
var row models.SubNotifyLog
if err := b.db.Where("platform=? AND external_user_id=? AND sub_id=? AND notify_date=? AND days_left=?", platform, userID, subID, date, daysLeft).First(&row).Error; err == nil {
return true
}
return false
}
func (b *QQBot) markNotified(platform, userID, subID, date string, daysLeft int) {
_ = b.db.Create(&models.SubNotifyLog{Platform: platform, ExternalUserID: userID, SubID: subID, NotifyDate: date, DaysLeft: daysLeft, CreatedAt: time.Now()}).Error
}
func formatSubReminder(it subRemindItem, daysLeft int) string {
prefix := "⏰"
if daysLeft < 0 {
prefix = "⚠️"
}
return fmt.Sprintf("%s 订阅提醒:%s | 分类:%s | 到期:%s | 剩余:%d天 | 金额:%.2f %s", prefix, it.Name, it.Category, it.DueDate, daysLeft, it.Amount, it.Currency)
}
func (b *QQBot) sendC2C(userID, msg string) error {
if b.api == nil {
log.Printf("[sub_notify] skip send: api nil")
return nil
}
_, err := b.api.PostC2CMessage(context.Background(), userID, dto.MessageToCreate{Content: msg})
if err != nil {
log.Printf("[sub_notify] send failed user=%s err=%v", userID, err)
return err
}
log.Printf("[sub_notify] send ok user=%s", userID)
return nil
}
func (b *QQBot) sendGroup(groupID, msg string) error {
if b.api == nil {
log.Printf("[sub_notify] skip send group: api nil")
return nil
}
_, err := b.api.PostGroupMessage(context.Background(), groupID, dto.MessageToCreate{Content: msg})
if err != nil {
log.Printf("[sub_notify] send failed group=%s err=%v", groupID, err)
return err
}
log.Printf("[sub_notify] send ok group=%s", groupID)
return nil
}
func sendWeCom(webhook, msg string) error {
payload := map[string]any{
"msgtype": "text",
"text": map[string]any{"content": msg},
}
body, _ := json.Marshal(payload)
req, err := http.NewRequest(http.MethodPost, webhook, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("wecom status %d", resp.StatusCode)
}
return nil
}