diff --git a/abineundo/console_windows.go b/abineundo/console_windows.go index 0f79d3662c..42a401a72c 100644 --- a/abineundo/console_windows.go +++ b/abineundo/console_windows.go @@ -29,7 +29,7 @@ func setConsoleTitle(title string) (err error) { if err != nil { return } - r1, _, e1 := syscall.Syscall(procSetConsoleTitle.Addr(), 1, uintptr(unsafe.Pointer(p0)), 0, 0) + r1, _, e1 := syscall.SyscallN(procSetConsoleTitle.Addr(), uintptr(unsafe.Pointer(p0))) if r1 == 0 { err = errnoErr(e1) } @@ -46,9 +46,8 @@ func init() { if debugMode { logrus.Warnf("调试模式下忽略控制台模式获取失败: %v", err) return // 调试模式下直接返回,跳过后续配置 - } else { - panic(err) // 非调试模式下 panic } + panic(err) // 非调试模式下 panic } mode &^= windows.ENABLE_QUICK_EDIT_MODE // 禁用快速编辑模式 diff --git a/main.go b/main.go index 4ee2794a68..dda495ddd6 100644 --- a/main.go +++ b/main.go @@ -7,6 +7,7 @@ import ( "encoding/json" "flag" "fmt" + "io" "math/rand" "os" "runtime" @@ -43,7 +44,8 @@ import ( _ "github.com/FloatTech/ZeroBot-Plugin/plugin/atri" // ATRI词库 - _ "github.com/FloatTech/ZeroBot-Plugin/plugin/manager" // 群管 + _ "github.com/FloatTech/ZeroBot-Plugin/plugin/manager" // 群管 + _ "github.com/FloatTech/ZeroBot-Plugin/plugin/servicemenu" // 服务菜单(列表/用法/主题/重载) _ "github.com/FloatTech/zbputils/job" // 定时指令触发器 @@ -236,9 +238,9 @@ func init() { // 直接写死 AccessToken 时,请更改下面第二个参数 token := flag.String("t", "", "Set AccessToken of WSClient.") // 直接写死 URL 时,请更改下面第二个参数 - url := flag.String("u", "ws://127.0.0.1:6700", "Set Url of WSClient.") + url := flag.String("u", "ws://192.168.1.244:6700", "Set Url of WSClient.") // 默认昵称 - adana := flag.String("n", "椛椛", "Set default nickname.") + adana := flag.String("n", "亚托莉", "Set default nickname.") prefix := flag.String("p", "/", "Set command prefix.") runcfg := flag.String("c", "", "Run from config file.") save := flag.String("s", "", "Save default config to file and exit.") @@ -275,6 +277,22 @@ func init() { // sus = append(sus, 12345678) // sus = append(sus, 87654321) + // 从 data/superusers.txt 读取超级用户 QQ,每行一个,支持 # 注释 + if f, err := os.Open("data/superusers.txt"); err == nil { + data, err := io.ReadAll(f) + f.Close() + if err == nil { + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(strings.TrimPrefix(line, "#")) + if i, err := strconv.ParseInt(line, 10, 64); err == nil { + sus = append(sus, i) + } + } + } + } else { + logrus.Infoln("[main] 未找到 data/superusers.txt, 请创建并写入你的QQ号以启用管理命令") + } + // 启用 webui // go webctrl.RunGui(*g) @@ -332,6 +350,45 @@ func main() { rand.Seed(time.Now().UnixNano()) //nolint: staticcheck } message.SetForceBase64File(config.ForceBase64File) + + // 跳过 github 原始资源站, 直接用镜像, 避免启动时每个文件等 1 分钟超时 + file.SkipOriginal = true + + // 修复 ZeroBot CommandRule 在 @机器人 + 命令 场景下的匹配 bug: + // CommandRule 只检查 Message[0].Type == "text", + // 但 @机器人 时 Message[0] 是 "at" 段,Message[1] 才是 text, + // 导致所有 /全局沉默 /启用 等命令永远匹配不上。 + // 在 FirstPriority(0) 把 text 段提到最前面,让 CommandRule 正常工作。 + zero.OnMessage(func(ctx *zero.Ctx) bool { + if len(ctx.Event.Message) < 2 { + return false + } + if ctx.Event.Message[0].Type == "text" { + return false + } + textIdx := -1 + for i, seg := range ctx.Event.Message { + if seg.Type == "text" { + textIdx = i + break + } + } + if textIdx <= 0 { + return false + } + text := strings.TrimLeft(ctx.Event.Message[textIdx].Data["text"], " ") + if !strings.HasPrefix(text, zero.BotConfig.CommandPrefix) { + return false + } + // 把第一个 text 段移到最前面 + segs := make(message.Message, 0, len(ctx.Event.Message)) + segs = append(segs, ctx.Event.Message[textIdx]) + segs = append(segs, ctx.Event.Message[:textIdx]...) + segs = append(segs, ctx.Event.Message[textIdx+1:]...) + ctx.Event.Message = segs + return false // 不拦截,让后续 matcher 继续匹配 + }).FirstPriority().Handle(func(_ *zero.Ctx) {}) + // 帮助 zero.OnFullMatchGroup([]string{"help", "/help", ".help", "菜单"}, zero.OnlyToMe).SetBlock(true). Handle(func(ctx *zero.Ctx) { diff --git a/plugin/aichat/main.go b/plugin/aichat/main.go index 93d93a4aee..e4df914210 100644 --- a/plugin/aichat/main.go +++ b/plugin/aichat/main.go @@ -47,10 +47,11 @@ var ( ) func init() { - en.OnMessage(chat.EnsureConfig, func(ctx *zero.Ctx) bool { + // 优先级设为 10,低于控制命令(SecondPriority=1), + // 避免 /全局禁用、/启用 等管理命令被 AI 聊天抢先拦截 + aim := en.OnMessage(chat.EnsureConfig, func(ctx *zero.Ctx) bool { stor, ok := ctx.State[zero.StateKeyPrefixKeep+"aichatcfg_stor__"].(chat.Storage) if !ok { - logrus.Warnln("ERROR: cannot get stor") return false } mp := ctx.State[control.StateKeySyncxState].(*syncx.Map[string, any]) @@ -70,7 +71,9 @@ func init() { ctx.Block() } return true - }).SetBlock(false).Handle(func(ctx *zero.Ctx) { + }).SetBlock(false) + (*zero.Matcher)(aim).SetPriority(10) + aim.Handle(func(ctx *zero.Ctx) { gid := ctx.Event.GroupID if gid == 0 { gid = -ctx.Event.UserID diff --git a/plugin/aifalse/hardware_other.go b/plugin/aifalse/hardware_other.go new file mode 100644 index 0000000000..d40febde1b --- /dev/null +++ b/plugin/aifalse/hardware_other.go @@ -0,0 +1,76 @@ +//go:build !windows + +package aifalse + +import ( + "context" + "os/exec" + "regexp" + "strings" + "time" + + "github.com/sirupsen/logrus" +) + +func init() { + wmiGPUInfo = func() []gpuInfo { + // Linux/macOS: 用 lspci 列出 VGA/3D 设备 + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + out, err := exec.CommandContext(ctx, "lspci").CombinedOutput() + if err != nil { + logrus.Debugf("[aifalse] lspci 失败: %v", err) + return nil + } + + vgaRe := regexp.MustCompile(`VGA compatible controller:\s*(.*)`) + displayRe := regexp.MustCompile(`Display controller:\s*(.*)`) + + var result []gpuInfo + for _, line := range strings.Split(string(out), "\n") { + var name string + if m := vgaRe.FindStringSubmatch(line); len(m) > 1 { + name = m[1] + } else if m := displayRe.FindStringSubmatch(line); len(m) > 1 { + name = m[1] + } + if name == "" { + continue + } + name = strings.TrimSpace(name) + result = append(result, gpuInfo{ + Name: name, + Vendor: detectVendor(name), + }) + } + if len(result) > 0 { + logrus.Debugf("[aifalse] lspci 识别到 %d 个 GPU", len(result)) + } + return result + } +} + +// queryHWiNFO 非 Windows 平台无 HWiNFO。 +func queryHWiNFO() (temps, fans, powers []*status) { return nil, nil, nil } + +// lhmSensor 非 Windows 平台不采集(LibreHardwareMonitor 仅支持 Windows), +// 但字段需与 hardware_windows.go 保持一致,供跨平台代码(tempstate)编译通过。 +type lhmSensor struct { + Name string + SensorType string // Temperature / Fan / Power / Load / Clock / Voltage / Control + Parent string + Value float64 +} + +// queryLibreHardwareMonitor 非 Windows 平台永远返回 nil。 +func queryLibreHardwareMonitor() []lhmSensor { return nil } + +// queryLibreHardwareMonitorHTTP 非 Windows 平台永远返回 nil。 +func queryLibreHardwareMonitorHTTP() []lhmSensor { return nil } + +// mergeLHMSensors 非 Windows 平台占位。 +func mergeLHMSensors(_, _ []lhmSensor) []lhmSensor { return nil } + +// lhmSensorDisplayName 非 Windows 平台占位。 +func lhmSensorDisplayName(_ lhmSensor) string { return "" } diff --git a/plugin/aifalse/hardware_windows.go b/plugin/aifalse/hardware_windows.go new file mode 100644 index 0000000000..cd92e18718 --- /dev/null +++ b/plugin/aifalse/hardware_windows.go @@ -0,0 +1,441 @@ +//go:build windows + +package aifalse + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os/exec" + "strconv" + "strings" + "time" + + "github.com/sirupsen/logrus" +) + +func init() { + wmiGPUInfo = func() []gpuInfo { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // PowerShell: Get-CimInstance Win32_VideoController 返回名称/显存/驱动 + // 过滤掉虚拟显卡(远程桌面、虚拟机模拟器、虚拟显示适配器等) + filter := `'virtual|indirect|basic|spacedesk|mumu|mirabox|gameviewer|idd|remote|display adapter|Microsoft Basic'` + psCmd := `Get-CimInstance Win32_VideoController | ` + + fmt.Sprintf(`Where-Object { $_.Name -notmatch %s } | `, filter) + + `Select-Object Name, AdapterRAM, DriverVersion | ` + + `ConvertTo-Json -Compress` + cmd := exec.CommandContext(ctx, "powershell", "-NoProfile", "-Command", psCmd) + out, err := cmd.CombinedOutput() + if err != nil { + logrus.Warnf("[aifalse] PowerShell Get-CimInstance 失败: %v, 输出: %s", err, string(out)) + return pnpFallbackGPU() + } + + return parsePowerShellGPU(out) + } +} + +// parsePowerShellGPU 解析 PowerShell ConvertTo-Json 的输出。 +// PowerShell 在只有 1 个结果时返回单个 JSON 对象,多个时返回数组,需要统一处理。 +func parsePowerShellGPU(out []byte) []gpuInfo { + trimmed := strings.TrimSpace(string(out)) + if trimmed == "" || trimmed == "null" { + return nil + } + + var entries []struct { + Name string `json:"Name"` + AdapterRAM interface{} `json:"AdapterRAM"` + DriverVersion string `json:"DriverVersion"` + } + + // 先尝试解析为数组 + if err := json.Unmarshal(out, &entries); err != nil { + // 可能是单个对象,再试一次 + var single struct { + Name string `json:"Name"` + AdapterRAM interface{} `json:"AdapterRAM"` + DriverVersion string `json:"DriverVersion"` + } + if err2 := json.Unmarshal(out, &single); err2 != nil { + logrus.Warnf("[aifalse] PowerShell GPU JSON 解析失败: %v / %v, raw=%s", err, err2, trimmed) + return nil + } + if single.Name == "" { + return nil + } + entries = []struct { + Name string `json:"Name"` + AdapterRAM interface{} `json:"AdapterRAM"` + DriverVersion string `json:"DriverVersion"` + }{single} + } + + result := make([]gpuInfo, 0, len(entries)) + for _, e := range entries { + if e.Name == "" { + continue + } + vendor := detectVendor(e.Name) + memMiB := parseRAM(e.AdapterRAM) + result = append(result, gpuInfo{ + Name: e.Name, + Vendor: vendor, + MemTotal: memMiB, + }) + } + return result +} + +// pnpFallbackGPU 在 Get-CimInstance 也失败时,用 Get-PnpDevice -Class Display 兜底。 +// PnP 设备没有显存信息,但至少能列出显卡名称。 +func pnpFallbackGPU() []gpuInfo { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + filter := `'virtual|indirect|basic|spacedesk|mumu|mirabox|gameviewer|idd|remote|display adapter|Microsoft Basic'` + psCmd := `Get-PnpDevice -Class Display | ` + + fmt.Sprintf(`Where-Object { $_.FriendlyName -and $_.Status -eq 'OK' -and $_.FriendlyName -notmatch %s } | `, filter) + + `Select-Object FriendlyName | ` + + `ConvertTo-Json -Compress` + cmd := exec.CommandContext(ctx, "powershell", "-NoProfile", "-Command", psCmd) + out, err := cmd.CombinedOutput() + if err != nil { + logrus.Warnf("[aifalse] PowerShell Get-PnpDevice 也失败: %v", err) + return nil + } + + var items []struct { + Name string `json:"FriendlyName"` + } + trimmed := strings.TrimSpace(string(out)) + if trimmed == "" || trimmed == "null" { + return nil + } + if err := json.Unmarshal(out, &items); err != nil { + var single struct { + Name string `json:"FriendlyName"` + } + if err2 := json.Unmarshal(out, &single); err2 != nil { + logrus.Warnf("[aifalse] PnP JSON 解析失败: %v / %v", err, err2) + return nil + } + if single.Name == "" { + return nil + } + items = []struct { + Name string `json:"FriendlyName"` + }{single} + } + + result := make([]gpuInfo, 0, len(items)) + for _, item := range items { + result = append(result, gpuInfo{ + Name: item.Name, + Vendor: detectVendor(item.Name), + }) + } + return result +} + +// parseRAM 解析 PowerShell 返回的 AdapterRAM。 +// CimInstance 的 AdapterRAM 是 uint64,但 JSON 序列化后可能是 number 或 null。 +func parseRAM(v interface{}) float64 { + switch val := v.(type) { + case float64: + return val / (1024 * 1024) + case int64: + return float64(val) / (1024 * 1024) + case int: + return float64(val) / (1024 * 1024) + case nil: + return 0 + } + return 0 +} + +// hwiSensor 对应 HWiNFO64 注册表 HKLM\SOFTWARE\HWiNFO64\VSB 里的传感器条目。 +// HWiNFO 在后台运行时(HWInfoSever 进程)会持续把传感器数据写入注册表, +// 这是最简单最稳定的读取方式 —— 不需要额外装 LibreHardwareMonitor。 +type hwiSensor struct { + Name string `json:"Name"` // 如 "CPU Package" + Label string `json:"Label"` // 分组标签,如 "CPU [#0]: Intel Core i7-4170" + Value float64 `json:"Value"` // 裸数值 + Unit string `json:"Unit"` // "°C" / "RPM" / "W" +} + +// queryHWiNFO 从 HWiNFO64 注册表读取温度/风扇/功耗传感器数据。 +// 返回的三个切片按类型分好类:温度 / 风扇 / 功耗。 +// HWiNFO 没装或没在运行时返回 nil, nil, nil(正常情况,不报错)。 +func queryHWiNFO() (temps, fans, powers []*status) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + // 尝试三个可能的注册表路径: + // 1. HKLM\SOFTWARE\HWiNFO64\VSB (64 位 HWiNFO 在 64 位系统) + // 2. HKLM\SOFTWARE\WOW6432Node\HWiNFO64\VSB (32 位 HWiNFO 在 64 位系统) + // 3. HKLM\SOFTWARE\HWiNFO32\VSB (32 位 HWiNFO 在 32 位系统) + psCmd := `$keys = @('HKLM:\SOFTWARE\HWiNFO64\VSB','HKLM:\SOFTWARE\WOW6432Node\HWiNFO64\VSB','HKLM:\SOFTWARE\HWiNFO32\VSB'); ` + + `foreach($k in $keys){ if(Test-Path $k){ $p = Get-ItemProperty $k; ` + + `$sensors = $p.PSObject.Properties.Name | Where-Object { $_ -match '^Sensor\d+$' }; ` + + `$result = @(); foreach($sn in $sensors){ $idx = $sn -replace '^Sensor',''; ` + + `$v = $p."Value$idx"; if($v -match '°C|RPM|W'){ ` + + `if($v -match '([\d.]+)\s*(°C|RPM|W)'){ $result += [PSCustomObject]@{ ` + + `Name=$p."Sensor$idx"; Label=$p."Label$idx"; Value=[double]$Matches[1]; Unit=$Matches[2] } } } }; ` + + `if($result.Count -gt 0){ $result | ConvertTo-Json -Compress; break } } }` + + cmd := exec.CommandContext(ctx, "powershell", "-NoProfile", "-Command", psCmd) + out, err := cmd.CombinedOutput() + if err != nil { + return nil, nil, nil + } + trimmed := strings.TrimSpace(string(out)) + if trimmed == "" || trimmed == "null" { + return nil, nil, nil + } + + var sensors []hwiSensor + if err := json.Unmarshal(out, &sensors); err != nil { + var single hwiSensor + if err2 := json.Unmarshal(out, &single); err2 != nil { + logrus.Debugf("[aifalse] HWiNFO 注册表 JSON 解析失败: %v / %v", err, err2) + return nil, nil, nil + } + sensors = []hwiSensor{single} + } + + for _, s := range sensors { + if s.Name == "" || s.Value <= 0 { + continue + } + name := s.Name + if s.Label != "" { + // Label 格式如 "CPU [#0]: Intel Core i7-4170",去掉 CPU 名称冗余 + name = s.Label + " · " + s.Name + } + switch s.Unit { + case "°C": + temps = append(temps, &status{ + name: name, + text: []string{fmt.Sprintf("%.1f°C", s.Value)}, + precent: 0, + }) + case "RPM": + fans = append(fans, &status{ + name: name, + text: []string{fmt.Sprintf("%.0f RPM", s.Value)}, + precent: 0, + }) + case "W": + powers = append(powers, &status{ + name: name + " 功耗", + text: []string{fmt.Sprintf("%.1f W", s.Value)}, + precent: 0, + }) + } + } + if len(temps)+len(fans)+len(powers) > 0 { + logrus.Infof("[aifalse] HWiNFO 注册表: 温度 %d, 风扇 %d, 功耗 %d", + len(temps), len(fans), len(powers)) + } + return temps, fans, powers +} + +// lhmSensor 对应 LibreHardwareMonitor WMI 中 root/LibreHardwareMonitor/Sensor 的字段。 +// LHM 是 HWiNFO 的开源免费替代方案,如果你没装 HWiNFO 也可以装 LHM。 +type lhmSensor struct { + Name string `json:"Name"` + SensorType string `json:"SensorType"` // Temperature / Fan / Power / Load / Clock / Voltage / Control + Parent string `json:"Parent"` // 所属硬件(如 "Supermicro X11" / "Intel Core i7-8700K") + Value float64 `json:"Value"` +} + +// queryLibreHardwareMonitor 通过 WMI 查询 LibreHardwareMonitor 暴露的传感器数据。 +// 如果服务器上没装 LHM 或没在运行,返回空切片(不报错)。 +// LHM 需要后台运行(可以关闭 UI 但不能退出进程),WMI namespace 才会注册。 +func queryLibreHardwareMonitor() []lhmSensor { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + // Get-CimInstance -ClassName Sensor -Namespace root/LibreHardwareMonitor + // 只取我们关心的类型,减少数据量 + psCmd := `Get-CimInstance -ClassName Sensor -Namespace root/LibreHardwareMonitor -ErrorAction SilentlyContinue | ` + + `Where-Object { $_.SensorType -in 'Temperature','Fan','Power' -and $_.Value -ne $null } | ` + + `Select-Object Name, SensorType, Parent, Value | ` + + `ConvertTo-Json -Compress` + cmd := exec.CommandContext(ctx, "powershell", "-NoProfile", "-Command", psCmd) + out, err := cmd.CombinedOutput() + if err != nil { + // LHM 没装或没运行 —— 正常情况,不打警告 + return nil + } + trimmed := strings.TrimSpace(string(out)) + if trimmed == "" || trimmed == "null" { + return nil + } + + var sensors []lhmSensor + if err := json.Unmarshal(out, &sensors); err != nil { + // 可能是单个对象 + var single lhmSensor + if err2 := json.Unmarshal(out, &single); err2 != nil { + logrus.Debugf("[aifalse] LHM WMI JSON 解析失败: %v / %v", err, err2) + return nil + } + sensors = []lhmSensor{single} + } + + // 过滤无效值 + result := make([]lhmSensor, 0, len(sensors)) + for _, s := range sensors { + if s.Name == "" || s.Value <= 0 { + continue + } + result = append(result, s) + } + return result +} + +// lhmHTTPNode 是 LibreHardwareMonitor HTTP REST API /data.json 的节点结构。 +// 整个 JSON 是一棵递归树,传感器节点的 Value 字段是带单位的字符串,需要解析。 +// 例: {"Text":"CPU Package","Type":"Temperature","Value":"53.0 °C","Children":[]} +type lhmHTTPNode struct { + ID int `json:"id"` + Text string `json:"Text"` + Type string `json:"Type"` // Temperature / Fan / Power / Load / Clock / Voltage / Data / Control / SmallData / Factor / Level / Throughput + SensorID string `json:"SensorId"` // 只有传感器节点才有 + Value string `json:"Value"` // 字符串,带单位:"53.0 °C" / "2700 RPM" / "23.4 W" + RawValue string `json:"RawValue"` // 字符串,数值+单位 + Children []lhmHTTPNode `json:"Children"` +} + +// queryLibreHardwareMonitorHTTP 通过 LHM 的 HTTP REST API (默认 http://127.0.0.1:8085/data.json) 获取传感器数据。 +// 与 WMI 查询并行执行,谁有数据用谁。 +func queryLibreHardwareMonitorHTTP() []lhmSensor { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, "GET", "http://127.0.0.1:8085/data.json", nil) + if err != nil { + return nil + } + + client := &http.Client{Timeout: 3 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil // HTTP server 没开 —— 正常情况 + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil + } + + var root lhmHTTPNode + if err := json.Unmarshal(body, &root); err != nil { + logrus.Debugf("[aifalse] LHM HTTP JSON 解析失败: %v", err) + return nil + } + + // 递归收集传感器 + var result []lhmSensor + var walk func(nodes []lhmHTTPNode, parent string) + walk = func(nodes []lhmHTTPNode, parent string) { + for _, n := range nodes { + // 传感器节点的判断:有 SensorId 且有 Type + if n.SensorID != "" && n.Type != "" { + // 只收集我们关心的类型 + if n.Type != "Temperature" && n.Type != "Fan" && n.Type != "Power" { + continue + } + // 解析 Value 字符串 "53.0 °C" / "2700 RPM" / "23.4 W" -> float64 + val := parseLHMHTTPValue(n.Value) + if val <= 0 { + continue // 过滤无效值 + } + result = append(result, lhmSensor{ + Name: n.Text, + SensorType: n.Type, + Parent: parent, + Value: val, + }) + } else if n.Text != "" { + // 硬件节点(分组节点),更新 parent 继续递归 + walk(n.Children, n.Text) + continue + } + walk(n.Children, parent) + } + } + walk(root.Children, "") + + if len(result) == 0 { + return nil + } + logrus.Infof("[aifalse] LHM HTTP REST 返回 %d 个传感器", len(result)) + return result +} + +// parseLHMHTTPValue 从 LHM HTTP /data.json 的 Value 字符串中提取数字。 +// 格式: "53.0 °C" / "2700 RPM" / "23.4 W" / "NaN %" 等 +func parseLHMHTTPValue(s string) float64 { + s = strings.TrimSpace(s) + if s == "" || strings.Contains(s, "NaN") || strings.Contains(s, "-") { + return 0 + } + // 去掉单位部分:取空格前的数字 + parts := strings.Fields(s) + if len(parts) == 0 { + return 0 + } + val, err := strconv.ParseFloat(parts[0], 64) + if err != nil { + return 0 + } + return val +} + +// mergeLHMSensors 合并 WMI 和 HTTP REST 两个数据源的结果。 +// 用 Name+SensorType+Parent 做 key 去重,保留第一个非空值。 +func mergeLHMSensors(wmi, http []lhmSensor) []lhmSensor { + if len(wmi) == 0 && len(http) == 0 { + return nil + } + seen := make(map[string]struct{}) + var merged []lhmSensor + add := func(s lhmSensor) { + k := s.Name + "|" + s.SensorType + "|" + s.Parent + if _, ok := seen[k]; ok { + return + } + seen[k] = struct{}{} + merged = append(merged, s) + } + for _, s := range wmi { + add(s) + } + for _, s := range http { + add(s) + } + return merged +} + +// lhmSensorDisplayName 从 LHM 传感器对象生成对用户友好的显示名。 +// LHM 的 Name 类似 "CPU Package" / "CPU Core #1" / "Fan #1" / "CPU Package" (Power) +// Parent 类似 "Intel(R) Core(TM) i7-8700K" 或主板名 +func lhmSensorDisplayName(s lhmSensor) string { + name := strings.TrimSpace(s.Name) + // 对于 Power 类型,标注清楚 + if s.SensorType == "Power" { + return name + " 功耗" + } + return name +} diff --git a/plugin/aifalse/lhm_autosetup.go b/plugin/aifalse/lhm_autosetup.go new file mode 100644 index 0000000000..90f179c950 --- /dev/null +++ b/plugin/aifalse/lhm_autosetup.go @@ -0,0 +1,378 @@ +//go:build windows + +package aifalse + +import ( + "archive/zip" + "context" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/pkg/errors" + "github.com/sirupsen/logrus" +) + +// lhmAutoSetup 负责在 Windows 上自动配置 LibreHardwareMonitor 后台进程。 +// 首次运行时自动下载 LHM portable zip、写 config、后台静默启动。 +// 后续启动检测进程是否存在,不存在则重启。 +// +// 设计原则: +// - 完全自动,用户零感知(不需要手动装任何东西) +// - 网络不通或权限不足时优雅降级,bot 仍能跑(只是没温度/风扇/功耗数据) +// - 下载失败 3 次后放弃,下次再试 +func lhmAutoSetup() { + lhmDir := filepath.Join("data", "aifalse", "librehardwaremonitor") + exePath := filepath.Join(lhmDir, "LibreHardwareMonitor.exe") + configPath := filepath.Join(lhmDir, "LibreHardwareMonitor.config") + + // 1. 先检查 HTTP 服务器是否已经可达 + if probeLHMHTTP() { + logrus.Debugln("[aifalse] LHM HTTP 已在运行,跳过自动配置") + return + } + + // 2. 检查本地是否已有 LHM + if _, err := os.Stat(exePath); os.IsNotExist(err) { + logrus.Infoln("[aifalse] 未找到 LHM,开始自动下载配置...") + if err := downloadAndSetupLHM(lhmDir, configPath); err != nil { + logrus.Warnf("[aifalse] LHM 自动配置失败(不影响使用): %v", err) + return + } + } + + // 3. 确保 config 开启了 HTTP server + if err := ensureLHMConfig(configPath); err != nil { + logrus.Warnf("[aifalse] LHM config 写入失败: %v", err) + } + + // 4. 后台静默启动 LHM + if err := startLHMBackground(exePath); err != nil { + logrus.Warnf("[aifalse] LHM 启动失败(可能需要管理员权限): %v", err) + return + } + + // 5. 等一下让 LHM 初始化 HTTP server + for i := 0; i < 20; i++ { + time.Sleep(500 * time.Millisecond) + if probeLHMHTTP() { + logrus.Infof("[aifalse] LHM 自动配置成功!HTTP server 已就绪") + return + } + } + logrus.Warnln("[aifalse] LHM 启动后 HTTP server 未就绪,可能需要管理员权限运行 bot") +} + +// probeLHMHTTP 检测 http://127.0.0.1:8085/data.json 是否可达。 +func probeLHMHTTP() bool { + ctx, cancel := context.WithTimeout(context.Background(), 1500*time.Millisecond) + defer cancel() + req, _ := http.NewRequestWithContext(ctx, "GET", "http://127.0.0.1:8085/data.json", nil) + client := &http.Client{Timeout: 1500 * time.Millisecond} + resp, err := client.Do(req) + if err != nil { + return false + } + resp.Body.Close() + return resp.StatusCode == 200 +} + +// downloadAndSetupLHM 从 GitHub release 下载 LHM portable zip 并解压到 lhmDir。 +func downloadAndSetupLHM(lhmDir, configPath string) error { + if err := os.MkdirAll(lhmDir, 0o755); err != nil { + return errors.Wrap(err, "创建目录失败") + } + + // 先试 winget(如果有的话) + if tryWingetInstall() { + logrus.Infoln("[aifalse] winget 安装 LHM 成功") + return copyLHMFromWinget(lhmDir, configPath) + } + + // winget 不行,直接下载 zip + urls := []string{ + // .NET Framework 4.7.2 版本(兼容性好,Windows 10+ 默认有) + "https://github.com/LibreHardwareMonitor/LibreHardwareMonitor/releases/latest/download/LibreHardwareMonitor.zip", + } + + var lastErr error + for _, url := range urls { + logrus.Infof("[aifalse] 下载 LHM: %s", url) + tmpZip := filepath.Join(lhmDir, "_lhm_download.zip") + if err := downloadFile(url, tmpZip); err != nil { + lastErr = err + logrus.Debugf("[aifalse] 下载失败: %v", err) + continue + } + if err := extractZip(tmpZip, lhmDir); err != nil { + lastErr = err + os.Remove(tmpZip) + continue + } + os.Remove(tmpZip) + // 解压后可能多了一层目录(librehardwaremonitor/xxx/LibreHardwareMonitor.exe) + if exe := findLHMExe(lhmDir); exe != "" { + // 如果 exe 在子目录,把文件移到 lhmDir 根目录 + if exe != filepath.Join(lhmDir, "LibreHardwareMonitor.exe") { + if err := moveFiles(filepath.Dir(exe), lhmDir); err != nil { + logrus.Debugf("[aifalse] 移动 LHM 文件失败: %v", err) + } + } + return nil + } + lastErr = errors.Errorf("解压后没找到 LibreHardwareMonitor.exe") + } + return lastErr +} + +// tryWingetInstall 尝试用 winget 安装 LHM(如果系统有 winget 的话)。 +func tryWingetInstall() bool { + _, err := exec.LookPath("winget") + if err != nil { + return false // 没装 winget + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + // 静默安装,接受协议 + cmd := exec.CommandContext(ctx, "winget", "install", "--id=LibreHardwareMonitor.LibreHardwareMonitor", + "--silent", "--accept-source-agreements", "--accept-package-agreements") + cmd.Stdout = io.Discard + cmd.Stderr = io.Discard + if err := cmd.Run(); err != nil { + logrus.Debugf("[aifalse] winget 安装 LHM 失败: %v", err) + return false + } + return true +} + +// copyLHMFromWinget winget 安装后,把 LHM 文件从安装目录复制到 lhmDir。 +func copyLHMFromWinget(lhmDir, _ string) error { + // winget 安装路径通常在 Program Files 或 Users\...\AppData\Local\Microsoft\WinGet\Packages + // 简化处理:搜索常见位置 + locations := []string{ + filepath.Join(os.Getenv("LOCALAPPDATA"), "Microsoft", "WinGet", "Packages"), + filepath.Join(os.Getenv("ProgramFiles"), "LibreHardwareMonitor"), + filepath.Join(os.Getenv("ProgramFiles(x86)"), "LibreHardwareMonitor"), + } + for _, loc := range locations { + exe := findLHMExe(loc) + if exe != "" { + return copyFiles(filepath.Dir(exe), lhmDir) + } + } + return errors.Errorf("winget 安装了但找不到 exe") +} + +// findLHMExe 在 rootDir 递归搜索 LibreHardwareMonitor.exe。 +func findLHMExe(rootDir string) string { + var found string + // 回调自身吞掉所有错误(读不到的条目直接跳过),Walk 的返回值无意义 + _ = filepath.Walk(rootDir, func(path string, info os.FileInfo, err error) error { + if err != nil || found != "" { + return nil + } + if !info.IsDir() && strings.EqualFold(info.Name(), "LibreHardwareMonitor.exe") { + found = path + } + return nil + }) + return found +} + +// downloadFile 下载 URL 到 dstPath。 +func downloadFile(url, dstPath string) error { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return errors.Errorf("HTTP %d", resp.StatusCode) + } + out, err := os.Create(dstPath) + if err != nil { + return err + } + defer out.Close() + _, err = io.Copy(out, resp.Body) + return err +} + +// extractZip 解压 zip 文件到 destDir(扁平化结构)。 +func extractZip(zipPath, destDir string) error { + r, err := zip.OpenReader(zipPath) + if err != nil { + return err + } + defer r.Close() + for _, f := range r.File { + // 跳过目录 + if f.FileInfo().IsDir() { + continue + } + // 安全:防止 zip slip(不允许解压到 destDir 外面) + target := filepath.Join(destDir, f.Name) + if !strings.HasPrefix(filepath.Clean(target), filepath.Clean(destDir)) { + continue + } + // 创建父目录 + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + continue + } + // 解压文件 + src, err := f.Open() + if err != nil { + continue + } + dst, err := os.Create(target) + if err != nil { + src.Close() + continue + } + _, _ = io.Copy(dst, src) // 复制失败的条目直接跳过(best-effort 解压) + src.Close() + dst.Close() + } + return nil +} + +// copyFiles 把 srcDir 下的所有文件复制到 dstDir。 +func copyFiles(srcDir, dstDir string) error { + return filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil + } + if info.IsDir() { + return nil + } + rel, _ := filepath.Rel(srcDir, path) + target := filepath.Join(dstDir, rel) + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return nil + } + return os.WriteFile(target, data, 0o644) + }) +} + +// moveFiles 把 srcDir 下的所有文件移到 dstDir。 +func moveFiles(srcDir, dstDir string) error { + return filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil + } + if info.IsDir() { + return nil + } + rel, _ := filepath.Rel(srcDir, path) + target := filepath.Join(dstDir, rel) + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return nil + } + if err := os.Rename(path, target); err != nil { + logrus.Debugf("[aifalse] 移动文件失败 %s -> %s: %v", path, target, err) + } + return nil + }) +} + +// ensureLHMConfig 确保 config 文件里开启了 HTTP server。 +func ensureLHMConfig(configPath string) error { + if _, err := os.Stat(configPath); err != nil { + // config 不存在,创建一个最小可用的 + config := ` + + + + + + +` + return os.WriteFile(configPath, []byte(config), 0o644) + } + + data, err := os.ReadFile(configPath) + if err != nil { + return err + } + content := string(data) + + // 已经有 runWebServerMenuItem=true 就不动 + if strings.Contains(content, `key="runWebServerMenuItem" value="true"`) { + return nil + } + + // 把已有的 runWebServerMenuItem 改成 true + if strings.Contains(content, `key="runWebServerMenuItem"`) { + content = strings.ReplaceAll(content, + `key="runWebServerMenuItem" value="false"`, + `key="runWebServerMenuItem" value="true"`) + } else { + // 在 前插入 + insert := ` + + +` + content = strings.Replace(content, "", insert+" ", 1) + } + + // 确保 listenerIp 是 127.0.0.1(避免 "?" 通配符问题) + content = strings.ReplaceAll(content, `key="listenerIp" value="?"`, `key="listenerIp" value="127.0.0.1"`) + + return os.WriteFile(configPath, []byte(content), 0o644) +} + +// startLHMBackground 后台静默启动 LHM。 +// 用 START /MIN 让窗口最小化到托盘(LHM 会自动缩到系统托盘)。 +func startLHMBackground(exePath string) error { + // 检查是否已经在运行 + if isProcessRunning("LibreHardwareMonitor") { + logrus.Debugln("[aifalse] LHM 已在运行") + return nil + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // 用 cmd /c START /MIN 启动,隐藏主窗口 + cmd := exec.CommandContext(ctx, "cmd", "/c", "start", "/min", filepath.Base(exePath)) + cmd.Dir = filepath.Dir(exePath) + cmd.Stdout = io.Discard + cmd.Stderr = io.Discard + + if err := cmd.Start(); err != nil { + return errors.Wrap(err, "启动失败") + } + // 不 Wait——让它自己跑 + logrus.Infof("[aifalse] LHM 后台启动中: %s", exePath) + return nil +} + +// isProcessRunning 检查指定进程是否在运行。 +func isProcessRunning(name string) bool { + _, err := exec.LookPath("tasklist") + if err != nil { + return false + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "tasklist", "/FI", "IMAGENAME eq "+name+".exe", "/FO", "CSV") + out, err := cmd.Output() + if err != nil { + return false + } + return strings.Contains(string(out), name+".exe") +} diff --git a/plugin/aifalse/lhm_autosetup_other.go b/plugin/aifalse/lhm_autosetup_other.go new file mode 100644 index 0000000000..60b88c0ef2 --- /dev/null +++ b/plugin/aifalse/lhm_autosetup_other.go @@ -0,0 +1,6 @@ +//go:build !windows + +package aifalse + +// lhmAutoSetup Linux/macOS 不需要自动配置 LHM——gopsutil 直接读 sysfs 拿真实温度。 +func lhmAutoSetup() {} diff --git a/plugin/aifalse/main.go b/plugin/aifalse/main.go index e9037d9876..63f9ce558c 100644 --- a/plugin/aifalse/main.go +++ b/plugin/aifalse/main.go @@ -3,21 +3,24 @@ package aifalse import ( "bytes" - "errors" + "context" + "fmt" "image" "image/color" + "io" "math" + "math/rand" + "net/http" + "os" + "os/exec" + "path/filepath" "runtime" "strconv" "strings" "sync" - "sync/atomic" "time" - "unsafe" - "github.com/FloatTech/AnimeAPI/bilibili" "github.com/FloatTech/floatbox/file" - "github.com/FloatTech/floatbox/web" "github.com/FloatTech/gg" "github.com/FloatTech/gg/factory" ctrl "github.com/FloatTech/zbpctrl" @@ -25,10 +28,12 @@ import ( "github.com/FloatTech/zbputils/ctxext" "github.com/FloatTech/zbputils/img/text" "github.com/disintegration/imaging" + "github.com/pkg/errors" "github.com/shirou/gopsutil/v4/cpu" "github.com/shirou/gopsutil/v4/disk" "github.com/shirou/gopsutil/v4/host" "github.com/shirou/gopsutil/v4/mem" + "github.com/shirou/gopsutil/v4/sensors" "github.com/sirupsen/logrus" "golang.org/x/text/cases" "golang.org/x/text/language" @@ -39,27 +44,96 @@ import ( "github.com/wdvxdr1123/ZeroBot/message" ) +// wmiGPUInfo 运行时初始化: +// - Windows: 通过 PowerShell Get-CimInstance / Get-PnpDevice 查询所有品牌显卡名称和显存 +// - Linux/macOS: 通过 lspci 查询 VGA/3D 控制器 +// 变量名沿用旧名(原本打算用 WMI 但该库在 Windows Server 上不可用,改用 PowerShell) +var ( + wmiGPUInfo func() []gpuInfo +) + +type gpuInfo struct { + Name string + Vendor string // "NVIDIA" / "Intel" / "AMD" / "Unknown" + MemTotal float64 // MiB, 0 = unknown +} + +// detectVendor 从显卡名称字符串中识别厂商(全平台通用)。 +func detectVendor(name string) string { + lower := strings.ToLower(name) + switch { + case strings.Contains(lower, "nvidia"), strings.Contains(lower, "geforce"), + strings.Contains(lower, "quadro"), strings.Contains(lower, "tesla"), + strings.Contains(lower, "rtx"), strings.Contains(lower, "gtx"), + strings.Contains(lower, "titan"): + return "NVIDIA" + case strings.Contains(lower, "amd"), strings.Contains(lower, "radeon"), + strings.Contains(lower, "firepro"), strings.Contains(lower, "vega"), + strings.Contains(lower, "ryzen"): + return "AMD" + case strings.Contains(lower, "intel"), strings.Contains(lower, "iris"), + strings.Contains(lower, "uhd"), strings.Contains(lower, "arc"), + strings.Contains(lower, "hd graphics"): + return "Intel" + } + return "Unknown" +} + const ( - backgroundURL = "https://pic.re/image" - referer = "https://weibo.com/" + canvasW = 1280 + cardw = canvasW - 70 - 70 // 两侧各 70 边距 + topPad = 70 + titleH = 250 + basicH = 380 + gap = 40 + bottomPad = 70 ) var ( boottime = time.Now() - bgdata *[]byte - bgcount uintptr isday bool lightcolor = [3][4]uint8{{255, 70, 0, 255}, {255, 165, 0, 255}, {145, 240, 145, 255}} darkcolor = [3][4]uint8{{215, 50, 0, 255}, {205, 135, 0, 255}, {115, 200, 115, 255}} + + // 后台 CPU 采样器:避免每次自检都阻塞 1 秒等 cpu.Percent + cpuMu sync.RWMutex + cpuOverall float64 + cpuModel string + cpuMhz float64 + cpuCore int + cpuThread int + + // 资源缓存:头像按 uid 缓存,字体全局加载一次,背景读取本地文件 + avatarCache sync.Map // int64 -> []byte + fontMu sync.Mutex + fontBytes []byte + bgMu sync.Mutex + bgImage image.Image + + // 带超时的 HTTP 客户端:floatbox/web.NewDefaultClient 和 http.DefaultClient 均无超时, + // 头像下载在网络异常时会挂死导致自检迟迟不出图,这里统一设置 10s 超时。 + httpClient = &http.Client{ + Timeout: 10 * time.Second, + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + }, + } + + // 数据目录(data/aifalse/),背景图等本地资源存放于此 + dataFolder string ) func init() { // 插件主体 + startCPUSampler() + engine := control.AutoRegister(&ctrl.Options[*zero.Ctx]{ - DisableOnDefault: false, - Brief: "自检, 全局限速", + DisableOnDefault: false, + Brief: "自检, 全局限速", + PrivateDataFolder: "aifalse", Help: "- 查询计算机当前活跃度: [检查身体 | 自检 | 启动自检 | 系统状态]\n" + "- 设置默认限速为每 m [分钟 | 秒] n 次触发", }) + dataFolder = engine.DataFolder() c, ok := control.Lookup("aifalse") if !ok { panic("register aifalse error") @@ -144,64 +218,132 @@ func init() { // 插件主体 }) } +// startCPUSampler 启动一个常驻 goroutine 周期性采样 CPU 占用, +// 这样自检时无需再阻塞 1 秒等待 cpu.Percent 返回,显著加快生成速度。 +func startCPUSampler() { + info, err := cpu.Info() + if err == nil && len(info) > 0 { + core, _ := cpu.Counts(false) + thread, _ := cpu.Counts(true) + cpuMu.Lock() + cpuModel = strings.TrimSpace(info[0].ModelName) + cpuMhz = info[0].Mhz + cpuCore = core + cpuThread = thread + cpuMu.Unlock() + } + go func() { + for { + p, err := cpu.Percent(time.Second, false) + if err == nil && len(p) > 0 { + cpuMu.Lock() + cpuOverall = p[0] + cpuMu.Unlock() + } + time.Sleep(time.Second) + } + }() +} + func drawstatus(m *ctrl.Control[*zero.Ctx], uid int64, botname string, botrunstatus string) (sendimg image.Image, err error) { - diskstate, err := diskstate() + // 并行采集较慢的硬件数据(磁盘、nvidia-smi、温度/风扇/功耗传感器), + // 同时主流程读取已缓存/快速的资源(字体、头像、背景、内存)。 + gather := &sync.WaitGroup{} + gather.Add(4) + var ( + disks []*status + moreinfos []*status + gpus []*status + temps []*status + fans []*status + powers []*status + eDisk error + eMore error + ) + go func() { defer gather.Done(); disks, eDisk = diskstate() }() + go func() { defer gather.Done(); moreinfos, eMore = moreinfo(m) }() + go func() { defer gather.Done(); gpus = gpustate() }() + // tempstate 不返回 error(失败时返回 nil 切片),所以不需要 eTemp + go func() { defer gather.Done(); temps, fans, powers = tempstate() }() + + basics, err := basicstate() if err != nil { return } - diskcardh := 40 + (20+50)*len(diskstate) + 40 - 20 - - moreinfo, err := moreinfo(m) + back := loadBackground() // 自带兜底背景图,不会失败 + avatarbuf, _ := loadAvatar(uid) + fontbyte, err := getFont() if err != nil { return } - moreinfocardh := 30 + (20+32*72/96)*len(moreinfo) + 30 - 20 - basicstate, err := basicstate() - if err != nil { + gather.Wait() + if eDisk != nil { + err = eDisk + return + } + if eMore != nil { + err = eMore return } + // GPU / 温度为可选信息:取不到就跳过对应卡片 + if len(disks) == 0 { + disks = []*status{{name: "/", text: []string{"无可用磁盘"}}} + } - dldata := (*[]byte)(atomic.LoadPointer((*unsafe.Pointer)(unsafe.Pointer(&bgdata)))) - if dldata == (*[]byte)(nil) || uintptr(time.Since(boottime).Hours()/24) >= atomic.LoadUintptr(&bgcount) { - url, err1 := bilibili.GetRealURL(backgroundURL) - if err1 != nil { - return nil, err1 - } - data, err1 := web.RequestDataWith(web.NewDefaultClient(), url, "", referer, "", nil) - if err1 != nil { - return nil, err1 + var avatarf *factory.Factory + if len(avatarbuf) > 0 { + avatar, _, derr := image.Decode(bytes.NewReader(avatarbuf)) + if derr == nil { + avatarf = factory.Size(avatar, 200, 200) } - atomic.AddUintptr(&bgcount, 1) - atomic.StorePointer((*unsafe.Pointer)(unsafe.Pointer(&bgdata)), unsafe.Pointer(&data)) - dldata = &data } - data := *dldata - back, _, err := image.Decode(bytes.NewReader(data)) - if err != nil { - return + // 计算各卡片高度与纵向位置 + diskH := barCardH(len(disks)) + moreH := infoCardH(len(moreinfos)) + var gpuH, tempH, fanH, powerH int + if len(gpus) > 0 { + // GPU 统一用 info 卡渲染(左名称右详情), + // 比 bar 卡更适合展示 Intel/AMD 只有基础信息的场景, + // 也避免了无利用率数据时显示假的 0% 进度条 + gpuH = infoCardH(len(gpus)) } - - data, err = web.GetData("https://q4.qlogo.cn/g?b=qq&nk=" + strconv.FormatInt(uid, 10) + "&s=640") - if err != nil { - return + if len(temps) > 0 { + tempH = infoCardH(len(temps)) } - avatar, _, err := image.Decode(bytes.NewReader(data)) - if err != nil { - return + if len(fans) > 0 { + fanH = infoCardH(len(fans)) } - avatarf := factory.Size(avatar, 200, 200) - - fontbyte, err := file.GetLazyData(text.GlowSansFontFile, control.Md5File, true) - if err != nil { - return + if len(powers) > 0 { + powerH = infoCardH(len(powers)) } - canvas := gg.NewContext(1280, 70+250+40+380+diskcardh+40+moreinfocardh+40+70) - - bh, bw, ch, cw := float64(back.Bounds().Dy()), float64(back.Bounds().Dx()), float64(canvas.H()), float64(canvas.W()) - + titleTop := topPad + basicTop := titleTop + titleH + gap + diskTop := basicTop + basicH + gap + y := diskTop + diskH + advance := func(h int) { + if h > 0 { + y += gap + h + } + } + advance(gpuH) + gpuTop := y - gpuH + advance(tempH) + tempTop := y - tempH + advance(fanH) + fanTop := y - fanH + advance(powerH) + powerTop := y - powerH + advance(moreH) + moreTop := y - moreH + footerTop := y + gap + totalH := footerTop + bottomPad + + canvas := gg.NewContext(canvasW, totalH) + cw, ch := float64(canvas.W()), float64(canvas.H()) + bh, bw := float64(back.Bounds().Dy()), float64(back.Bounds().Dx()) if bh/bw < ch/cw { back = factory.Size(back, int(bw*ch/bh), int(bh*ch/bh)).Image() canvas.DrawImageAnchored(back, canvas.W()/2, canvas.H()/2, 0.5, 0.5) @@ -209,338 +351,331 @@ func drawstatus(m *ctrl.Control[*zero.Ctx], uid int64, botname string, botrunsta back = factory.Size(back, int(bw*cw/bw), int(bh*cw/bw)).Image() canvas.DrawImage(back, 0, 0) } - var blurback image.Image - bwg := &sync.WaitGroup{} - bwg.Add(1) - go func() { - defer bwg.Done() - blurback = imaging.Blur(canvas.Image(), 8) - }() - + blurback := imaging.Blur(canvas.Image(), 8) if !isday { canvas.SetRGBA255(0, 0, 0, 50) canvas.DrawRectangle(0, 0, cw, ch) canvas.Fill() } - wg := &sync.WaitGroup{} - wg.Add(5) - - cardw := canvas.W() - 70 - 70 - - titlecardh := 250 - basiccardh := 380 - - var titleimg, basicimg, diskimg, moreinfoimg, shadowimg image.Image - go func() { - defer wg.Done() - titlecard := gg.NewContext(cardw, titlecardh) - bwg.Wait() - - titlecard.DrawRoundedRectangle(1, 1, float64(titlecard.W()-1*2), float64(titlecardh-1*2), 16) - titlecard.ClipPreserve() - titlecard.DrawImage(blurback, -70, -70) - titlecard.SetColor(colorswitch(140)) - titlecard.FillPreserve() + // 标题卡片 + titleCard := newCard(titleTop, titleH, blurback) + if avatarf != nil { + titleCard.DrawImage(avatarf.Circle(0).Image(), (titleH-avatarf.H())/2, (titleH-avatarf.H())/2) + } + if err = titleCard.ParseFontFace(fontbyte, 72); err != nil { + return + } + fw, _ := titleCard.MeasureString(botname) + titleCard.SetColor(fontcolorswitch()) + titleCard.DrawStringAnchored(botname, float64(titleH)+fw/2, float64(titleH)*0.5/2, 0.5, 0.5) + if err = titleCard.ParseFontFace(fontbyte, 24); err != nil { + return + } + titleCard.SetColor(fontcolorswitch()) + titleCard.NewSubPath() + titleCard.MoveTo(float64(titleH), float64(titleH)/2) + titleCard.LineTo(float64(titleCard.W()-titleH), float64(titleH)/2) + titleCard.Stroke() + fw, _ = titleCard.MeasureString(botrunstatus) + titleCard.DrawStringAnchored(botrunstatus, float64(titleH)+fw/2, float64(titleH)*(0.5+0.25/2), 0.5, 0.5) + brt, err := botruntime() + if err != nil { + return + } + fw, _ = titleCard.MeasureString(brt) + titleCard.DrawStringAnchored(brt, float64(titleH)+fw/2, float64(titleH)*(0.5+0.5/2), 0.5, 0.5) + bs, err := botstatus() + if err != nil { + return + } + fw, _ = titleCard.MeasureString(bs) + titleCard.DrawStringAnchored(bs, float64(titleH)+fw/2, float64(titleH)*(0.5+0.75/2), 0.5, 0.5) - titlecard.SetLineWidth(3) - titlecard.SetColor(colorswitch(100)) - titlecard.ResetClip() - titlecard.Stroke() + // 基础状态卡片(CPU / RAM / SWAP 圆环) + basicCard := newCard(basicTop, basicH, blurback) + if err = renderBasicCard(basicCard, basics[:], fontbyte); err != nil { + return + } - titlecard.DrawImage(avatarf.Circle(0).Image(), (titlecardh-avatarf.H())/2, (titlecardh-avatarf.H())/2) + // 磁盘卡片 + diskCard := newCard(diskTop, diskH, blurback) + if err = renderBarCard(diskCard, disks, fontbyte); err != nil { + return + } - err = titlecard.ParseFontFace(fontbyte, 72) - if err != nil { + // GPU 状态卡片(可选)—— info 卡样式(左名称右详情) + var gpuCard *gg.Context + if gpuH > 0 { + gpuCard = newCard(gpuTop, gpuH, blurback) + if err = renderInfoCard(gpuCard, gpus, fontbyte); err != nil { return } - fw, _ := titlecard.MeasureString(botname) - - titlecard.SetColor(fontcolorswitch()) - - titlecard.DrawStringAnchored(botname, float64(titlecardh)+fw/2, float64(titlecardh)*0.5/2, 0.5, 0.5) + } - err = titlecard.ParseFontFace(fontbyte, 24) - if err != nil { + // 硬件温度卡片(可选) + var tempCard *gg.Context + if tempH > 0 { + tempCard = newCard(tempTop, tempH, blurback) + if err = renderInfoCard(tempCard, temps, fontbyte); err != nil { return } - titlecard.SetColor(fontcolorswitch()) - - titlecard.NewSubPath() - titlecard.MoveTo(float64(titlecardh), float64(titlecardh)/2) - titlecard.LineTo(float64(titlecard.W()-titlecardh), float64(titlecardh)/2) - titlecard.Stroke() - - fw, _ = titlecard.MeasureString(botrunstatus) - - titlecard.DrawStringAnchored(botrunstatus, float64(titlecardh)+fw/2, float64(titlecardh)*(0.5+0.25/2), 0.5, 0.5) + } - brt, err := botruntime() - if err != nil { + // 风扇转速卡片(可选,需要 LibreHardwareMonitor) + var fanCard *gg.Context + if fanH > 0 { + fanCard = newCard(fanTop, fanH, blurback) + if err = renderInfoCard(fanCard, fans, fontbyte); err != nil { return } - fw, _ = titlecard.MeasureString(brt) - - titlecard.DrawStringAnchored(brt, float64(titlecardh)+fw/2, float64(titlecardh)*(0.5+0.5/2), 0.5, 0.5) + } - bs, err := botstatus() - if err != nil { + // 功耗卡片(可选,需要 LibreHardwareMonitor) + var powerCard *gg.Context + if powerH > 0 { + powerCard = newCard(powerTop, powerH, blurback) + if err = renderInfoCard(powerCard, powers, fontbyte); err != nil { return } - fw, _ = titlecard.MeasureString(bs) + } - titlecard.DrawStringAnchored(bs, float64(titlecardh)+fw/2, float64(titlecardh)*(0.5+0.75/2), 0.5, 0.5) - titleimg = titlecard.Image() - }() - go func() { - defer wg.Done() - basiccard := gg.NewContext(cardw, basiccardh) - bwg.Wait() - - basiccard.DrawRoundedRectangle(1, 1, float64(basiccard.W()-1*2), float64(basiccardh-1*2), 16) - basiccard.ClipPreserve() - basiccard.DrawImage(blurback, -70, -70-titlecardh-40) - basiccard.SetColor(colorswitch(140)) - basiccard.FillPreserve() - - basiccard.SetLineWidth(3) - basiccard.SetColor(colorswitch(100)) - basiccard.ResetClip() - basiccard.Stroke() - - bslen := len(basicstate) - for i, v := range basicstate { - offset := float64(i) * ((float64(basiccard.W())-200*float64(bslen))/float64(bslen+1) + 200) - - basiccard.SetRGBA255(57, 57, 57, 255) - if isday { - basiccard.SetRGBA255(235, 235, 235, 255) - } - basiccard.DrawCircle((float64(basiccard.W())-200*float64(bslen))/float64(bslen+1)+200/2+offset, 20+200/2, 100) - basiccard.Fill() + // 详细信息卡片 + moreCard := newCard(moreTop, moreH, blurback) + if err = renderInfoCard(moreCard, moreinfos, fontbyte); err != nil { + return + } - colors := darkcolor - if isday { - colors = lightcolor - } + // 卡片阴影 + shadow := gg.NewContext(canvas.W(), canvas.H()) + shadow.SetRGBA255(0, 0, 0, 100) + shadow.SetLineWidth(12) + shadow.DrawRoundedRectangle(float64(70), float64(titleTop), float64(cardw), float64(titleH), 16) + shadow.Stroke() + shadow.DrawRoundedRectangle(float64(70), float64(basicTop), float64(cardw), float64(basicH), 16) + shadow.Stroke() + shadow.DrawRoundedRectangle(float64(70), float64(diskTop), float64(cardw), float64(diskH), 16) + shadow.Stroke() + if gpuH > 0 { + shadow.DrawRoundedRectangle(float64(70), float64(gpuTop), float64(cardw), float64(gpuH), 16) + shadow.Stroke() + } + if tempH > 0 { + shadow.DrawRoundedRectangle(float64(70), float64(tempTop), float64(cardw), float64(tempH), 16) + shadow.Stroke() + } + if fanH > 0 { + shadow.DrawRoundedRectangle(float64(70), float64(fanTop), float64(cardw), float64(fanH), 16) + shadow.Stroke() + } + if powerH > 0 { + shadow.DrawRoundedRectangle(float64(70), float64(powerTop), float64(cardw), float64(powerH), 16) + shadow.Stroke() + } + shadow.DrawRoundedRectangle(float64(70), float64(moreTop), float64(cardw), float64(moreH), 16) + shadow.Stroke() + canvas.DrawImage(imaging.Blur(shadow.Image(), 24), 0, 0) + canvas.DrawImage(titleCard.Image(), 70, titleTop) + canvas.DrawImage(basicCard.Image(), 70, basicTop) + canvas.DrawImage(diskCard.Image(), 70, diskTop) + if gpuH > 0 { + canvas.DrawImage(gpuCard.Image(), 70, gpuTop) + } + if tempH > 0 { + canvas.DrawImage(tempCard.Image(), 70, tempTop) + } + if fanH > 0 { + canvas.DrawImage(fanCard.Image(), 70, fanTop) + } + if powerH > 0 { + canvas.DrawImage(powerCard.Image(), 70, powerTop) + } + canvas.DrawImage(moreCard.Image(), 70, moreTop) - switch { - case v.precent > 90: - basiccard.SetColor(slice2color(colors[0])) - case v.precent > 70: - basiccard.SetColor(slice2color(colors[1])) - default: - basiccard.SetColor(slice2color(colors[2])) - } + if err = canvas.ParseFontFace(fontbyte, 28); err != nil { + return + } + canvas.SetRGBA255(0, 0, 0, 255) + canvas.DrawStringAnchored("Created By ZeroBot-Plugin "+banner.Version, float64(canvas.W())/2+3, float64(canvas.H())-float64(bottomPad)/2+3, 0.5, 0.5) + canvas.SetRGBA255(255, 255, 255, 255) + canvas.DrawStringAnchored("Created By ZeroBot-Plugin "+banner.Version, float64(canvas.W())/2, float64(canvas.H())-float64(bottomPad)/2, 0.5, 0.5) - basiccard.NewSubPath() - basiccard.MoveTo((float64(basiccard.W())-200*float64(bslen))/float64(bslen+1)+200/2+offset, 20+200/2) - basiccard.DrawEllipticalArc((float64(basiccard.W())-200*float64(bslen))/float64(bslen+1)+200/2+offset, 20+200/2, 100, 100, -0.5*math.Pi, -0.5*math.Pi+2*v.precent*0.01*math.Pi) - basiccard.Fill() + sendimg = canvas.Image() + return +} - basiccard.SetColor(colorswitch(255)) - basiccard.DrawCircle((float64(basiccard.W())-200*float64(bslen))/float64(bslen+1)+200/2+offset, 20+200/2, 80) - basiccard.Fill() +// newCard 创建一张带毛玻璃背景与描边的卡片画布。 +func newCard(topY, h int, blurback image.Image) *gg.Context { + c := gg.NewContext(cardw, h) + c.DrawRoundedRectangle(1, 1, float64(c.W()-2), float64(h-2), 16) + c.ClipPreserve() + c.DrawImage(blurback, -70, -topY) + c.SetColor(colorswitch(140)) + c.FillPreserve() + c.SetLineWidth(3) + c.SetColor(colorswitch(100)) + c.ResetClip() + c.Stroke() + return c +} - err = basiccard.ParseFontFace(fontbyte, 42) - if err != nil { - return - } +// renderBasicCard 渲染 CPU / RAM / SWAP 圆环卡片。 +func renderBasicCard(card *gg.Context, state []*status, fontbyte []byte) error { + bslen := len(state) + for i, v := range state { + offset := float64(i) * ((float64(card.W())-200*float64(bslen))/float64(bslen+1) + 200) + cx := (float64(card.W())-200*float64(bslen))/float64(bslen+1) + 200/2 + offset + cy := float64(20 + 200/2) + + card.SetRGBA255(57, 57, 57, 255) + if isday { + card.SetRGBA255(235, 235, 235, 255) + } + card.DrawCircle(cx, cy, 100) + card.Fill() - basiccard.SetRGBA255(213, 213, 213, 255) - basiccard.DrawStringAnchored(strconv.FormatFloat(v.precent, 'f', 0, 64)+"%", (float64(basiccard.W())-200*float64(bslen))/float64(bslen+1)+200/2+offset, 20+200/2, 0.5, 0.5) + colors := darkcolor + if isday { + colors = lightcolor + } + switch { + case v.precent > 90: + card.SetColor(slice2color(colors[0])) + case v.precent > 70: + card.SetColor(slice2color(colors[1])) + default: + card.SetColor(slice2color(colors[2])) + } + card.NewSubPath() + card.MoveTo(cx, cy) + card.DrawEllipticalArc(cx, cy, 100, 100, -0.5*math.Pi, -0.5*math.Pi+2*v.precent*0.01*math.Pi) + card.Fill() - basiccard.SetColor(fontcolorswitch()) + card.SetColor(colorswitch(255)) + card.DrawCircle(cx, cy, 80) + card.Fill() - _, fw := basiccard.MeasureString(v.name) - basiccard.DrawStringAnchored(v.name, (float64(basiccard.W())-200*float64(bslen))/float64(bslen+1)+200/2+offset, 20+200+15+basiccard.FontHeight()/2, 0.5, 0.5) + if err := card.ParseFontFace(fontbyte, 42); err != nil { + return err + } + card.SetRGBA255(213, 213, 213, 255) + card.DrawStringAnchored(strconv.FormatFloat(v.precent, 'f', 0, 64)+"%", cx, cy, 0.5, 0.5) - err = basiccard.ParseFontFace(fontbyte, 20) - if err != nil { - return - } - basiccard.SetColor(fontcolorswitch()) + card.SetColor(fontcolorswitch()) + _, fw := card.MeasureString(v.name) + card.DrawStringAnchored(v.name, cx, 20+200+15+card.FontHeight()/2, 0.5, 0.5) - textoffsety := basiccard.FontHeight() + 10 - for k, s := range v.text { - basiccard.DrawStringAnchored(s, (float64(basiccard.W())-200*float64(bslen))/float64(bslen+1)+200/2+offset, 20+200+15+fw+15+basiccard.FontHeight()/2+float64(k)*textoffsety, 0.5, 0.5) - } + if err := card.ParseFontFace(fontbyte, 20); err != nil { + return err } - basicimg = basiccard.Image() - }() - go func() { - defer wg.Done() - diskcard := gg.NewContext(cardw, diskcardh) - bwg.Wait() - - diskcard.DrawRoundedRectangle(1, 1, float64(diskcard.W()-1*2), float64(diskcardh-1*2), 16) - diskcard.ClipPreserve() - diskcard.DrawImage(blurback, -70, -70-titlecardh-40-basiccardh-40) - diskcard.SetColor(colorswitch(140)) - diskcard.FillPreserve() - - diskcard.SetLineWidth(3) - diskcard.SetColor(colorswitch(100)) - diskcard.ResetClip() - diskcard.Stroke() - - err = diskcard.ParseFontFace(fontbyte, 32) - if err != nil { - return + card.SetColor(fontcolorswitch()) + textoffsety := card.FontHeight() + 10 + for k, s := range v.text { + card.DrawStringAnchored(s, cx, 20+200+15+fw+15+card.FontHeight()/2+float64(k)*textoffsety, 0.5, 0.5) + } + } + return nil +} + +// renderBarCard 渲染横向进度条卡片(磁盘 / GPU 复用)。 +func renderBarCard(card *gg.Context, state []*status, fontbyte []byte) error { + if err := card.ParseFontFace(fontbyte, 32); err != nil { + return err + } + dslen := len(state) + if dslen == 1 { + v := state[0] + card.SetRGBA255(57, 57, 57, 255) + if isday { + card.SetRGBA255(192, 192, 192, 255) + } + card.DrawRoundedRectangle(40, 40, float64(card.W())-40-100, 50, 12) + card.ClipPreserve() + card.Fill() + + colors := darkcolor + if isday { + colors = lightcolor } + switch { + case v.precent > 90: + card.SetColor(slice2color(colors[0])) + case v.precent > 70: + card.SetColor(slice2color(colors[1])) + default: + card.SetColor(slice2color(colors[2])) + } + card.DrawRoundedRectangle(40, 40, (float64(card.W())-40-100)*v.precent*0.01, 50, 12) + card.Fill() + card.ResetClip() + + card.SetColor(fontcolorswitch()) + fw, _ := card.MeasureString(v.name) + fw1, _ := card.MeasureString(v.text[0]) + card.DrawStringAnchored(v.name, 40+10+fw/2, 40+50/2, 0.5, 0.5) + card.DrawStringAnchored(v.text[0], (float64(card.W())-100-10)-fw1/2, 40+50/2, 0.5, 0.5) + card.DrawStringAnchored(strconv.FormatFloat(v.precent, 'f', 0, 64)+"%", float64(card.W())-100/2, 40+50/2, 0.5, 0.5) + } else { + for i, v := range state { + offset := float64(i)*(50+20) - 20 + barY := 40 + (float64(card.H()-40*2)-50*float64(dslen))/float64(dslen-1) + offset - dslen := len(diskstate) - if dslen == 1 { - diskcard.SetRGBA255(57, 57, 57, 255) + card.SetRGBA255(57, 57, 57, 255) if isday { - diskcard.SetRGBA255(192, 192, 192, 255) + card.SetRGBA255(192, 192, 192, 255) } - diskcard.DrawRoundedRectangle(40, 40, float64(diskcard.W())-40-100, 50, 12) - diskcard.ClipPreserve() - diskcard.Fill() + card.DrawRoundedRectangle(40, barY, float64(card.W())-40-100, 50, 12) + card.ClipPreserve() + card.Fill() colors := darkcolor if isday { colors = lightcolor } - switch { - case diskstate[0].precent > 90: - diskcard.SetColor(slice2color(colors[0])) - case diskstate[0].precent > 70: - diskcard.SetColor(slice2color(colors[1])) + case v.precent > 90: + card.SetColor(slice2color(colors[0])) + case v.precent > 70: + card.SetColor(slice2color(colors[1])) default: - diskcard.SetColor(slice2color(colors[2])) - } - - diskcard.DrawRoundedRectangle(40, 40, (float64(diskcard.W())-40-100)*diskstate[0].precent*0.01, 50, 12) - diskcard.Fill() - diskcard.ResetClip() - - diskcard.SetColor(fontcolorswitch()) - - fw, _ := diskcard.MeasureString(diskstate[0].name) - fw1, _ := diskcard.MeasureString(diskstate[0].text[0]) - - diskcard.DrawStringAnchored(diskstate[0].name, 40+10+fw/2, 40+50/2, 0.5, 0.5) - diskcard.DrawStringAnchored(diskstate[0].text[0], (float64(diskcard.W())-100-10)-fw1/2, 40+50/2, 0.5, 0.5) - diskcard.DrawStringAnchored(strconv.FormatFloat(diskstate[0].precent, 'f', 0, 64)+"%", float64(diskcard.W())-100/2, 40+50/2, 0.5, 0.5) - } else { - for i, v := range diskstate { - offset := float64(i)*(50+20) - 20 - - diskcard.SetRGBA255(57, 57, 57, 255) - if isday { - diskcard.SetRGBA255(192, 192, 192, 255) - } - - diskcard.DrawRoundedRectangle(40, 40+(float64(diskcardh-40*2)-50*float64(dslen))/float64(dslen-1)+offset, float64(diskcard.W())-40-100, 50, 12) - diskcard.ClipPreserve() - diskcard.Fill() - - colors := darkcolor - if isday { - colors = lightcolor - } - - switch { - case v.precent > 90: - diskcard.SetColor(slice2color(colors[0])) - case v.precent > 70: - diskcard.SetColor(slice2color(colors[1])) - default: - diskcard.SetColor(slice2color(colors[2])) - } - - diskcard.DrawRoundedRectangle(40, 40+(float64(diskcardh-40*2)-50*float64(dslen))/float64(dslen-1)+offset, (float64(diskcard.W())-40-100)*v.precent*0.01, 50, 12) - diskcard.Fill() - diskcard.ResetClip() - - diskcard.SetColor(fontcolorswitch()) - - fw, _ := diskcard.MeasureString(v.name) - fw1, _ := diskcard.MeasureString(v.text[0]) - - diskcard.DrawStringAnchored(v.name, 40+10+fw/2, 40+(float64(diskcardh-40*2)-50*float64(dslen))/float64(dslen-1)+50/2+offset, 0.5, 0.5) - diskcard.DrawStringAnchored(v.text[0], (float64(diskcard.W())-100-10)-fw1/2, 40+(float64(diskcardh-40*2)-50*float64(dslen))/float64(dslen-1)+50/2+offset, 0.5, 0.5) - diskcard.DrawStringAnchored(strconv.FormatFloat(v.precent, 'f', 0, 64)+"%", float64(diskcard.W())-100/2, 40+(float64(diskcardh-40*2)-50*float64(dslen))/float64(dslen-1)+50/2+offset, 0.5, 0.5) + card.SetColor(slice2color(colors[2])) } + card.DrawRoundedRectangle(40, barY, (float64(card.W())-40-100)*v.precent*0.01, 50, 12) + card.Fill() + card.ResetClip() + + card.SetColor(fontcolorswitch()) + fw, _ := card.MeasureString(v.name) + fw1, _ := card.MeasureString(v.text[0]) + card.DrawStringAnchored(v.name, 40+10+fw/2, barY+50/2, 0.5, 0.5) + card.DrawStringAnchored(v.text[0], (float64(card.W())-100-10)-fw1/2, barY+50/2, 0.5, 0.5) + card.DrawStringAnchored(strconv.FormatFloat(v.precent, 'f', 0, 64)+"%", float64(card.W())-100/2, barY+50/2, 0.5, 0.5) } - diskimg = diskcard.Image() - }() - go func() { - defer wg.Done() - moreinfocard := gg.NewContext(cardw, moreinfocardh) - bwg.Wait() - - moreinfocard.DrawRoundedRectangle(1, 1, float64(moreinfocard.W()-1*2), float64(moreinfocard.H()-1*2), 16) - moreinfocard.ClipPreserve() - moreinfocard.DrawImage(blurback, -70, -70-titlecardh-40-basiccardh-40-diskcardh-40) - moreinfocard.SetColor(colorswitch(140)) - moreinfocard.FillPreserve() - - moreinfocard.SetLineWidth(3) - moreinfocard.SetColor(colorswitch(100)) - moreinfocard.ResetClip() - moreinfocard.Stroke() - - err = moreinfocard.ParseFontFace(fontbyte, 32) - if err != nil { - return - } - - milen := len(moreinfo) - for i, v := range moreinfo { - offset := float64(i)*(20+moreinfocard.FontHeight()) - 20 - - moreinfocard.SetColor(fontcolorswitch()) - - fw, _ := moreinfocard.MeasureString(v.name) - fw1, _ := moreinfocard.MeasureString(v.text[0]) - - moreinfocard.DrawStringAnchored(v.name, 20+fw/2, 30+(float64(moreinfocardh-30*2)-moreinfocard.FontHeight()*float64(milen))/float64(milen-1)+moreinfocard.FontHeight()/2+offset, 0.5, 0.5) - moreinfocard.DrawStringAnchored(v.text[0], float64(moreinfocard.W())-20-fw1/2, 30+(float64(moreinfocardh-30*2)-moreinfocard.FontHeight()*float64(milen))/float64(milen-1)+moreinfocard.FontHeight()/2+offset, 0.5, 0.5) - } - moreinfoimg = moreinfocard.Image() - }() - go func() { - defer wg.Done() - shadow := gg.NewContext(canvas.W(), canvas.H()) - shadow.SetRGBA255(0, 0, 0, 100) - shadow.SetLineWidth(12) - shadow.DrawRoundedRectangle(70, 70, float64(cardw), float64(titlecardh), 16) - shadow.Stroke() - shadow.DrawRoundedRectangle(70, float64(70+titlecardh+40), float64(cardw), float64(basiccardh), 16) - shadow.Stroke() - shadow.DrawRoundedRectangle(70, float64(70+titlecardh+40+basiccardh+40), float64(cardw), float64(diskcardh), 16) - shadow.Stroke() - shadow.DrawRoundedRectangle(70, float64(70+titlecardh+40+basiccardh+40+diskcardh+40), float64(cardw), float64(moreinfocardh), 16) - shadow.Stroke() - shadowimg = imaging.Blur(shadow.Image(), 24) - }() - - wg.Wait() - if shadowimg == nil || titleimg == nil || basicimg == nil || diskimg == nil || moreinfoimg == nil { - err = errors.New("图片渲染失败") - return } - canvas.DrawImage(shadowimg, 0, 0) - canvas.DrawImage(titleimg, 70, 70) - canvas.DrawImage(basicimg, 70, 70+titlecardh+40) - canvas.DrawImage(diskimg, 70, 70+titlecardh+40+basiccardh+40) - canvas.DrawImage(moreinfoimg, 70, 70+titlecardh+40+basiccardh+40+diskcardh+40) + return nil +} - err = canvas.ParseFontFace(fontbyte, 28) - if err != nil { - return +// renderInfoCard 渲染左侧名称 / 右侧数值的信息卡片(详细信息 / 温度复用)。 +func renderInfoCard(card *gg.Context, state []*status, fontbyte []byte) error { + if err := card.ParseFontFace(fontbyte, 32); err != nil { + return err } - canvas.SetRGBA255(0, 0, 0, 255) - canvas.DrawStringAnchored("Created By ZeroBot-Plugin "+banner.Version, float64(canvas.W())/2+3, float64(canvas.H())-70/2+3, 0.5, 0.5) - canvas.SetRGBA255(255, 255, 255, 255) - canvas.DrawStringAnchored("Created By ZeroBot-Plugin "+banner.Version, float64(canvas.W())/2, float64(canvas.H())-70/2, 0.5, 0.5) - - sendimg = canvas.Image() - return + milen := len(state) + for i, v := range state { + offset := float64(i)*(20+card.FontHeight()) - 20 + card.SetColor(fontcolorswitch()) + fw, _ := card.MeasureString(v.name) + fw1, _ := card.MeasureString(v.text[0]) + var y float64 + if milen == 1 { + y = float64(card.H()) / 2 + } else { + y = 30 + (float64(card.H()-30*2)-card.FontHeight()*float64(milen))/float64(milen-1) + card.FontHeight()/2 + offset + } + card.DrawStringAnchored(v.name, 20+fw/2, y, 0.5, 0.5) + card.DrawStringAnchored(v.text[0], float64(card.W())-20-fw1/2, y, 0.5, 0.5) + } + return nil } func botruntime() (string, error) { @@ -583,27 +718,20 @@ type status struct { } func basicstate() (stateinfo [3]*status, err error) { - percent, err := cpu.Percent(time.Second, true) - if err != nil { - return - } - cpuinfo, err := cpu.Info() - if err != nil { - return - } - cpucore, err := cpu.Counts(false) - if err != nil { - return - } - cputhread, err := cpu.Counts(true) - if err != nil { - return + cpuMu.RLock() + overall := cpuOverall + mhz := cpuMhz + core := cpuCore + thread := cpuThread + cpuMu.RUnlock() + + cores := strconv.Itoa(core) + "C" + strconv.Itoa(thread) + "T" + times := "未知" + if mhz > 0 { + times = "最大 " + strconv.FormatFloat(mhz/1000, 'f', 1, 64) + "Ghz" } - cores := strconv.Itoa(cpucore) + "C" + strconv.Itoa(cputhread) + "T" - times := "最大 " + strconv.FormatFloat(cpuinfo[0].Mhz/1000, 'f', 1, 64) + "Ghz" - stateinfo[0] = &status{ - precent: math.Round(percent[0]), + precent: math.Round(overall), name: "CPU", text: []string{cores, times}, } @@ -612,28 +740,28 @@ func basicstate() (stateinfo [3]*status, err error) { if err != nil { return } - total := "总共 " + storagefmt(float64(raminfo.Total)) - used := "已用 " + storagefmt(float64(raminfo.Used)) - free := "剩余 " + storagefmt(float64(raminfo.Free)) - stateinfo[1] = &status{ precent: math.Round(raminfo.UsedPercent), name: "RAM", - text: []string{total, used, free}, + text: []string{ + "总共 " + storagefmt(float64(raminfo.Total)), + "已用 " + storagefmt(float64(raminfo.Used)), + "剩余 " + storagefmt(float64(raminfo.Free)), + }, } swapinfo, err := mem.SwapMemory() if err != nil { return } - total = "总共 " + storagefmt(float64(swapinfo.Total)) - used = "已用 " + storagefmt(float64(swapinfo.Used)) - free = "剩余 " + storagefmt(float64(swapinfo.Free)) - stateinfo[2] = &status{ precent: math.Round(swapinfo.UsedPercent), name: "SWAP", - text: []string{total, used, free}, + text: []string{ + "总共 " + storagefmt(float64(swapinfo.Total)), + "已用 " + storagefmt(float64(swapinfo.Used)), + "剩余 " + storagefmt(float64(swapinfo.Free)), + }, } return } @@ -678,6 +806,415 @@ func diskstate() (stateinfo []*status, err error) { return stateinfo, nil } +// gpustate 采集 GPU 状态,合并 nvidia-smi(NVIDIA 详细数据)和全量显卡列表(所有品牌)。 +// 优先级:先拿全量列表(含 Intel/AMD/NVIDIA),再用 nvidia-smi 的详细数据覆盖匹配的 NVIDIA 卡。 +// 没有可用 GPU 时返回 nil,调用方跳过 GPU 卡片。 +func gpustate() []*status { + // 1. 先拿 nvidia-smi 的 NVIDIA 详细数据(利用率/温度/功耗) + nvidiaList, _ := nvidiaGPU() + logrus.Debugf("[aifalse] nvidia-smi 返回 %d 块", len(nvidiaList)) + + // 2. 再拿全量显卡列表(所有品牌,跨平台) + var allGPUs []gpuInfo + if wmiGPUInfo != nil { + allGPUs = wmiGPUInfo() + logrus.Debugf("[aifalse] 全量 GPU 列表 %d 个", len(allGPUs)) + } + stateinfo := make([]*status, 0, len(nvidiaList)+len(allGPUs)) + + // 3. 构建 NVIDIA 名称 → status 的映射,用于合并 + nvidiaByName := make(map[string]*status, len(nvidiaList)) + for _, s := range nvidiaList { + nvidiaByName[s.name] = s + } + + // 4. 按全量列表顺序输出,匹配到 NVIDIA 的用 nvidia-smi 详细数据覆盖 + usedNvidia := make(map[string]bool, len(nvidiaList)) + for _, g := range allGPUs { + vendor := g.Vendor + name := g.Name + + if vendor == "NVIDIA" { + // 尝试用 nvidia-smi 匹配(名称精确匹配或模糊包含) + var matched *status + if s, ok := nvidiaByName[name]; ok { + matched = s + } else { + // 模糊匹配:全量列表可能带 "NVIDIA Corporation" 前缀,nvidia-smi 返回纯净名 + for nName, nS := range nvidiaByName { + if strings.Contains(strings.ToLower(name), strings.ToLower(nName)) || + strings.Contains(strings.ToLower(nName), strings.ToLower(name)) { + matched = nS + break + } + } + } + if matched != nil { + stateinfo = append(stateinfo, matched) + usedNvidia[matched.name] = true + continue + } + // NVIDIA 但 nvidia-smi 没数据 —— 驱动未装或 WSL 环境 + stateinfo = append(stateinfo, basicGPUStatus(vendor, name, g.MemTotal, "NVIDIA 驱动未就绪,无详细监控数据")) + continue + } + + // Intel / AMD / Unknown —— 展示基础信息 + stateinfo = append(stateinfo, basicGPUStatus(vendor, name, g.MemTotal, "")) + } + + // 5. 全量列表可能漏掉某些 NVIDIA 卡(PowerShell 过滤等),确保 nvidia-smi 找到的也加上 + for nName, nS := range nvidiaByName { + if !usedNvidia[nName] { + stateinfo = append(stateinfo, nS) + } + } + + logrus.Infof("[aifalse] 最终 GPU 列表 %d 个 (nvidia-smi=%d, 全量=%d)", + len(stateinfo), len(nvidiaList), len(allGPUs)) + return stateinfo +} + +// basicGPUStatus 构造一张基础 GPU 状态卡(无利用率/温度等详细指标时使用)。 +func basicGPUStatus(vendor, name string, memMiB float64, extraNote string) *status { + displayName := name + if vendor != "Unknown" { + displayName = vendor + " " + name + } + var detail string + switch { + case memMiB > 0 && extraNote != "": + detail = "显存 " + strconv.FormatFloat(memMiB, 'f', 0, 64) + " MiB · " + extraNote + case memMiB > 0: + detail = "显存 " + strconv.FormatFloat(memMiB, 'f', 0, 64) + " MiB" + case extraNote != "": + detail = extraNote + default: + detail = "共享显存 · 温度见下方传感器" + } + return &status{ + precent: 0, // 无利用率数据 + name: displayName, + text: []string{detail}, + } +} + +// nvidiaGPU 通过 nvidia-smi 采集 NVIDIA GPU 状态。 +func nvidiaGPU() ([]*status, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, "nvidia-smi", + "--query-gpu=name,utilization.gpu,memory.used,memory.total,temperature.gpu,power.draw", + "--format=csv,noheader,nounits").Output() + if err != nil { + logrus.Warnln("[aifalse] nvidia-smi 执行失败:", err, "输出:", string(out)) + return nil, err + } + lines := strings.Split(strings.TrimSpace(string(out)), "\n") + stateinfo := make([]*status, 0, len(lines)) + for _, line := range lines { + if line == "" { + continue + } + fields := strings.Split(line, ",") + if len(fields) < 6 { + logrus.Warnf("[aifalse] nvidia-smi 字段数不足: %d, 行: %s", len(fields), line) + continue + } + for i := range fields { + fields[i] = strings.TrimSpace(fields[i]) + } + util, _ := strconv.ParseFloat(fields[1], 64) + memUsed, _ := strconv.ParseFloat(fields[2], 64) + memTotal, _ := strconv.ParseFloat(fields[3], 64) + temp, _ := strconv.ParseFloat(fields[4], 64) + power, _ := strconv.ParseFloat(fields[5], 64) + // info 卡样式下 precent 不单独显示,把利用率放进详情文本 + detail := fmt.Sprintf("%.0f%% · %s / %s MiB · %.0f°C · %.1f W", + util, + strconv.FormatFloat(memUsed, 'f', 0, 64), + strconv.FormatFloat(memTotal, 'f', 0, 64), + temp, power) + stateinfo = append(stateinfo, &status{ + precent: util, + name: fields[0], + text: []string{detail}, + }) + } + logrus.Infof("[aifalse] nvidiaGPU 解析完成: %d 块 GPU", len(stateinfo)) + return stateinfo, nil +} + +// tempstate 采集各硬件温度、风扇转速、CPU功耗。 +// 并行尝试三种途径(都给 3s 超时): +// 1. HWiNFO64 注册表 HKLM\SOFTWARE\HWiNFO64\VSB —— Windows 专属,最优先 +// 你服务器已经有 HWiNFO 后台在跑(HWInfoSever 进程),直接读注册表就行 +// 2. LibreHardwareMonitor WMI root/LibreHardwareMonitor/Sensor —— 开源免费备选 +// 3. gopsutil.SensorsTemperatures() —— 跨平台通用,Windows 上只能拿到 ACPI 热区 +// +// 如果 HWiNFO 或 LHM 提供了真实的 CPU 温度/风扇/功耗数据,会自动过滤掉 gopsutil 返回的 +// ACPI 主板热区(那些 25-30°C 的值很容易误导)。 +func tempstate() (temps []*status, fans []*status, powers []*status) { + // Windows 上异步触发 LHM 自动配置(下载 + 启动),不阻塞当前采集 + go lhmAutoSetup() + + type gopsutilResult struct { + ts []sensors.TemperatureStat + } + gopsCh := make(chan gopsutilResult, 1) + go func() { + ts, _ := sensors.SensorsTemperatures() + gopsCh <- gopsutilResult{ts} + }() + + lhmCh := make(chan []lhmSensor, 1) + go func() { + // WMI 和 HTTP REST API 并行跑,谁先返回数据就用谁,两个都有就合并 + wmiCh := make(chan []lhmSensor, 1) + httpCh := make(chan []lhmSensor, 1) + go func() { wmiCh <- queryLibreHardwareMonitor() }() + go func() { httpCh <- queryLibreHardwareMonitorHTTP() }() + wmi := <-wmiCh + http := <-httpCh + lhmCh <- mergeLHMSensors(wmi, http) + }() + + type hwiResult struct { + temps, fans, powers []*status + } + hwiCh := make(chan hwiResult, 1) + go func() { + t, f, p := queryHWiNFO() + hwiCh <- hwiResult{t, f, p} + }() + + // 并行收集,给 3s 总超时 + var gpTS []sensors.TemperatureStat + var lhmSensors []lhmSensor + var hwiTemps, hwiFans, hwiPowers []*status + timeout := time.After(3 * time.Second) + for i := 0; i < 3; i++ { + select { + case r := <-gopsCh: + gpTS = r.ts + case r := <-lhmCh: + lhmSensors = r + case r := <-hwiCh: + hwiTemps, hwiFans, hwiPowers = r.temps, r.fans, r.powers + case <-timeout: + logrus.Debugln("[aifalse] 温度采集超时") + } + } + // 兜底收剩余 + select { + case r := <-gopsCh: + if gpTS == nil { + gpTS = r.ts + } + default: + } + select { + case r := <-lhmCh: + if lhmSensors == nil { + lhmSensors = r + } + default: + } + select { + case r := <-hwiCh: + if hwiTemps == nil && hwiFans == nil && hwiPowers == nil { + hwiTemps, hwiFans, hwiPowers = r.temps, r.fans, r.powers + } + default: + } + + // === HWiNFO 优先 === + realSensorSource := false + if len(hwiTemps)+len(hwiFans)+len(hwiPowers) > 0 { + temps = append(temps, hwiTemps...) + fans = append(fans, hwiFans...) + powers = append(powers, hwiPowers...) + realSensorSource = true + logrus.Infof("[aifalse] HWiNFO: 温度 %d, 风扇 %d, 功耗 %d", + len(hwiTemps), len(hwiFans), len(hwiPowers)) + } + + // === LibreHardwareMonitor 次优先 === + if len(lhmSensors) > 0 { + logrus.Infof("[aifalse] LibreHardwareMonitor 返回 %d 个传感器", len(lhmSensors)) + for _, s := range lhmSensors { + switch s.SensorType { + case "Temperature": + temps = append(temps, &status{ + name: lhmSensorDisplayName(s), + text: []string{fmt.Sprintf("%.1f°C", s.Value)}, + precent: 0, + }) + realSensorSource = true + case "Fan": + fans = append(fans, &status{ + name: lhmSensorDisplayName(s), + text: []string{fmt.Sprintf("%.0f RPM", s.Value)}, + precent: 0, + }) + realSensorSource = true + case "Power": + powers = append(powers, &status{ + name: lhmSensorDisplayName(s), + text: []string{fmt.Sprintf("%.1f W", s.Value)}, + precent: 0, + }) + realSensorSource = true + } + } + } + + // === gopsutil 最后 === + if len(gpTS) > 0 { + if realSensorSource { + // 有真实传感器数据了,过滤掉 ACPI 主板热区(25-30°C 误导人) + gpTS = filterACPIThermalZones(gpTS) + } + temps = append(temps, cleanGopsutilTemps(gpTS)...) + } + + // 简化温度卡片:只保留 CPU Core 相关温度,去掉 Package/Average/Max/主板热区等 + temps = simplifyTemps(temps) + + logrus.Infof("[aifalse] 最终: 温度 %d, 风扇 %d, 功耗 %d (realSensorSource=%v)", + len(temps), len(fans), len(powers), realSensorSource) + return temps, fans, powers +} + +// simplifyTemps 只保留 CPU Core 相关温度,过滤掉: +// - CPU Package / CPU 封装温度(通常和 Core Max 接近,冗余) +// - Core Average / Core Max(Core #1/2/3/4 已经覆盖) +// - 主板温度 / ACPI 热区(没有真实传感器时的兜底,已经被上面过滤掉了) +// - GPU 温度(GPU 卡片自己已经显示了,冗余) +// +// 保留:CPU Core #1, CPU Core #2, CPU Core #3, CPU Core #4 ...(各核心温度) +// 如果没有 Core 温度(比如老 CPU 或 Linux),则退而保留 Package 或其他 CPU 温度。 +func simplifyTemps(temps []*status) []*status { + if len(temps) <= 1 { + return temps // 只有一个,不管它是什么都留着 + } + + // 第一遍:优先挑 "Core" 相关 + var coreOnly []*status + var cpuFallback []*status + for _, t := range temps { + name := strings.ToLower(strings.TrimSpace(t.name)) + switch { + case strings.Contains(name, "core") && !strings.Contains(name, "average") && !strings.Contains(name, "max"): + coreOnly = append(coreOnly, t) + case strings.Contains(name, "package"): + cpuFallback = append(cpuFallback, t) + case strings.Contains(name, "cpu"): + cpuFallback = append(cpuFallback, t) + } + } + + if len(coreOnly) > 0 { + return coreOnly + } + if len(cpuFallback) > 0 { + return cpuFallback + } + return temps // 什么都没匹配上,原样返回 +} + +// filterACPIThermalZones 过滤掉 gopsutil 返回的 ACPI ThermalZone 条目。 +// 当 HWiNFO / LHM 提供了真实的 CPU/GPU 温度时,ACPI 主板热区(通常 25-30°C)是多余的。 +func filterACPIThermalZones(ts []sensors.TemperatureStat) []sensors.TemperatureStat { + filtered := make([]sensors.TemperatureStat, 0, len(ts)) + for _, t := range ts { + key := strings.ToLower(strings.TrimSpace(t.SensorKey)) + if strings.Contains(key, "acpi") || strings.Contains(key, "thermalzone") { + continue // 跳过 ACPI 主板热区 + } + filtered = append(filtered, t) + } + if len(filtered) < len(ts) { + logrus.Debugf("[aifalse] 过滤掉 %d 个 ACPI 热区", len(ts)-len(filtered)) + } + return filtered +} + +// cleanGopsutilTemps 清洗 gopsutil 温度数据,ACPI ThermalZone 条目重命名。 +// 清洗后若出现同名条目(如多个 ACPI 热区都叫"主板温度"),自动加编号后缀。 +func cleanGopsutilTemps(ts []sensors.TemperatureStat) []*status { + var result []*status + // 第一步:清洗名称 + type rawEntry struct { + displayName string + temp float64 + } + cleaned := make([]rawEntry, 0, len(ts)) + seen := make(map[string]bool, len(ts)) + for _, t := range ts { + if t.Temperature <= 0 { + continue + } + key := strings.TrimSpace(t.SensorKey) + if key == "" || seen[key] { + continue + } + seen[key] = true + cleaned = append(cleaned, rawEntry{ + displayName: sanitizeSensorName(key, t.Temperature), + temp: t.Temperature, + }) + } + + // 第二步:处理同名冲突 → 加编号后缀 + nameCount := make(map[string]int) + for _, e := range cleaned { + nameCount[e.displayName]++ + } + nameIdx := make(map[string]int) + for _, e := range cleaned { + if nameCount[e.displayName] > 1 { + nameIdx[e.displayName]++ + result = append(result, &status{ + name: fmt.Sprintf("%s #%d", e.displayName, nameIdx[e.displayName]), + text: []string{fmt.Sprintf("%.1f°C", e.temp)}, + precent: 0, + }) + } else { + result = append(result, &status{ + name: e.displayName, + text: []string{fmt.Sprintf("%.1f°C", e.temp)}, + precent: 0, + }) + } + } + return result +} + +// sanitizeSensorName 把 gopsutil 的传感器 key(如 "ACPI\ThermalZone\TZ00_0")改成对用户友好的名字。 +func sanitizeSensorName(key string, temp float64) string { + // ACPI ThermalZone:主板环境温度,温度通常 25-40°C + if strings.Contains(key, "ACPI") || strings.Contains(key, "ThermalZone") || + strings.Contains(key, "TZ00") || strings.Contains(key, "TZ01") || + strings.Contains(key, "TZ02") { + if temp < 45 { + return "主板温度" + } + return "主板热区" + } + // hwmon 下的常见命名 + if strings.Contains(key, "coretemp") { + return "CPU " + key + } + if strings.Contains(key, "k10temp") { + return "AMD " + key + } + if strings.Contains(key, "nouveau") || strings.Contains(key, "nvrm") { + return "GPU " + key + } + return key +} + func moreinfo(m *ctrl.Control[*zero.Ctx]) (stateinfo []*status, err error) { var mems runtime.MemStats runtime.ReadMemStats(&mems) @@ -687,15 +1224,17 @@ func moreinfo(m *ctrl.Control[*zero.Ctx]) (stateinfo []*status, err error) { if err != nil { return } - cpuinfo, err := cpu.Info() - if err != nil { - return + cpuMu.RLock() + modelName := cpuModel + cpuMu.RUnlock() + if modelName == "" { + modelName = "未知" } count := len(m.Manager.M) stateinfo = []*status{ {name: "OS", text: []string{hostinfo.Platform}}, - {name: "CPU", text: []string{strings.TrimSpace(cpuinfo[0].ModelName)}}, + {name: "CPU", text: []string{modelName}}, {name: "Version", text: []string{hostinfo.PlatformVersion}}, {name: "Plugin", text: []string{"共 " + strconv.Itoa(count) + " 个"}}, {name: "Memory", text: []string{"已用 " + fmtmem}}, @@ -703,6 +1242,135 @@ func moreinfo(m *ctrl.Control[*zero.Ctx]) (stateinfo []*status, err error) { return } +// barCardH 返回横向进度条卡片的高度(磁盘 / GPU)。 +func barCardH(n int) int { + if n < 1 { + n = 1 + } + return 60 + 70*n +} + +// infoCardH 返回信息行卡片的高度(详细信息 / 温度)。 +func infoCardH(n int) int { + if n < 1 { + n = 1 + } + return 40 + 44*n +} + +// loadBackground 从本地 data/aifalse/ 目录随机挑选一张背景图。 +// 不再依赖网络下载,避免因图片服务不可用导致自检迟迟不出图。 +// 若目录下没有可用图片,返回兜底纯色背景。 +func loadBackground() image.Image { + bgMu.Lock() + defer bgMu.Unlock() + + imgs := listBackgroundImages() + if len(imgs) == 0 { + logrus.Warnln("[aifalse] data/aifalse/ 下没有找到背景图,使用兜底背景") + if bgImage != nil { + return bgImage + } + return fallbackBackground() + } + // 随机选一张 + path := imgs[rand.Intn(len(imgs))] + data, err := os.ReadFile(path) + if err != nil { + logrus.Warnln("[aifalse] 读取背景图失败:", err) + if bgImage != nil { + return bgImage + } + return fallbackBackground() + } + img, _, err := image.Decode(bytes.NewReader(data)) + if err != nil { + logrus.Warnln("[aifalse] 解码背景图失败:", path, err) + if bgImage != nil { + return bgImage + } + return fallbackBackground() + } + bgImage = img + return img +} + +// listBackgroundImages 列出 data/aifalse/ 下所有支持的图片文件。 +func listBackgroundImages() []string { + entries, err := os.ReadDir(dataFolder) + if err != nil { + return nil + } + exts := map[string]bool{ + ".jpg": true, + ".jpeg": true, + ".png": true, + ".gif": true, + ".webp": true, + ".bmp": true, + } + var imgs []string + for _, e := range entries { + if e.IsDir() { + continue + } + if exts[strings.ToLower(filepath.Ext(e.Name()))] { + imgs = append(imgs, filepath.Join(dataFolder, e.Name())) + } + } + return imgs +} + +// fallbackBackground 生成一张兜底的深色背景图。 +func fallbackBackground() image.Image { + img := image.NewNRGBA(image.Rect(0, 0, 1280, 720)) + for y := 0; y < 720; y++ { + for x := 0; x < 1280; x++ { + img.SetNRGBA(x, y, color.NRGBA{R: 25, G: 25, B: 35, A: 255}) + } + } + return img +} + +// loadAvatar 按 uid 缓存 QQ 头像。下载失败时返回空(调用方跳过头像绘制)。 +func loadAvatar(uid int64) ([]byte, error) { + if v, ok := avatarCache.Load(uid); ok { + if b, ok := v.([]byte); ok && len(b) > 0 { + return b, nil + } + } + url := "https://q4.qlogo.cn/g?b=qq&nk=" + strconv.FormatInt(uid, 10) + "&s=640" + resp, err := httpClient.Get(url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, errors.Errorf("头像状态码: %d", resp.StatusCode) + } + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + avatarCache.Store(uid, data) + return data, nil +} + +// getFont 全局只加载一次字体。 +func getFont() ([]byte, error) { + fontMu.Lock() + defer fontMu.Unlock() + if len(fontBytes) > 0 { + return fontBytes, nil + } + b, err := file.GetLazyData(text.GlowSansFontFile, control.Md5File, true) + if err != nil { + return nil, err + } + fontBytes = b + return b, nil +} + func colorswitch(a uint8) color.Color { if isday { return color.NRGBA{255, 255, 255, a} diff --git a/plugin/servicemenu/data/Font/FOT-Yuruka Std.ttf b/plugin/servicemenu/data/Font/FOT-Yuruka Std.ttf new file mode 100644 index 0000000000..26cb936795 Binary files /dev/null and b/plugin/servicemenu/data/Font/FOT-Yuruka Std.ttf differ diff --git a/plugin/servicemenu/data/Font/GlowSansSC-Normal-ExtraBold.ttf b/plugin/servicemenu/data/Font/GlowSansSC-Normal-ExtraBold.ttf new file mode 100644 index 0000000000..daa2413bda Binary files /dev/null and b/plugin/servicemenu/data/Font/GlowSansSC-Normal-ExtraBold.ttf differ diff --git a/plugin/servicemenu/data/Font/Impact.ttf b/plugin/servicemenu/data/Font/Impact.ttf new file mode 100644 index 0000000000..7b7956fd5b Binary files /dev/null and b/plugin/servicemenu/data/Font/Impact.ttf differ diff --git a/plugin/servicemenu/data/Font/Torus-Regular.otf b/plugin/servicemenu/data/Font/Torus-Regular.otf new file mode 100644 index 0000000000..d5fc09e17c Binary files /dev/null and b/plugin/servicemenu/data/Font/Torus-Regular.otf differ diff --git a/plugin/servicemenu/data/control/kanban.png b/plugin/servicemenu/data/control/kanban.png new file mode 100644 index 0000000000..f13e8dcba4 Binary files /dev/null and b/plugin/servicemenu/data/control/kanban.png differ diff --git a/plugin/servicemenu/data/control/plugins.db b/plugin/servicemenu/data/control/plugins.db new file mode 100644 index 0000000000..0b9fe3c551 Binary files /dev/null and b/plugin/servicemenu/data/control/plugins.db differ diff --git a/plugin/servicemenu/data/control/stor.spb b/plugin/servicemenu/data/control/stor.spb new file mode 100644 index 0000000000..15211e91b3 Binary files /dev/null and b/plugin/servicemenu/data/control/stor.spb differ diff --git a/plugin/servicemenu/data/control/zbpuwu.png b/plugin/servicemenu/data/control/zbpuwu.png new file mode 100644 index 0000000000..9b63b83258 Binary files /dev/null and b/plugin/servicemenu/data/control/zbpuwu.png differ diff --git a/plugin/servicemenu/main.go b/plugin/servicemenu/main.go new file mode 100644 index 0000000000..7ab61cc6a8 --- /dev/null +++ b/plugin/servicemenu/main.go @@ -0,0 +1,949 @@ +// Package servicemenu 服务菜单:自检图同款毛玻璃 UI + 主题切换 + 优雅重载 +package servicemenu + +import ( + "bytes" + "image" + "image/color" + "image/draw" + + // 注册jpg/gif解码器,背景图解码不依赖其他插件是否加载 + _ "image/gif" + _ "image/jpeg" + "image/png" + "math" + "math/rand" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + "time" + + "github.com/FloatTech/gg" + zbpctrl "github.com/FloatTech/zbpctrl" + "github.com/FloatTech/zbputils/control" + "github.com/disintegration/imaging" + "github.com/sirupsen/logrus" + zero "github.com/wdvxdr1123/ZeroBot" + "github.com/wdvxdr1123/ZeroBot/message" + + "github.com/FloatTech/ZeroBot-Plugin/kanban/banner" +) + +// theme 主题配色(iOS Liquid Glass:凸透镜放大 + 重磨砂 + 边缘折射 + 色散 + 奶白洗白) +type theme struct { + Name string + DisplayName string + BackgroundFrom [3]uint8 + BackgroundTo [3]uint8 + OverlayColor color.RGBA // 整图覆盖色(暗主题黑压暗、亮主题白提亮) + OverlayAlpha uint8 // 整图覆盖层 alpha (0-255) + Blur float64 // 背景模糊半径(磨砂强度,目标风格 26-34) + Magnify float64 // 凸透镜放大倍率(1.2-1.4,核心特征) + Refraction float64 // 边缘折射强度(像素) + Dispersion float64 // 色散强度(0-1),RGB 通道分离 + RimAlpha uint8 // 顶部轮廓光 alpha + BottomDark uint8 // 底部内阴影 alpha + Tint color.RGBA // 玻璃色调(奶白) + TintAlpha uint8 // 奶白洗白强度(55-76) + Saturate float64 // 饱和度提升(1.15-1.3) + TopLight uint8 // 顶部高光带 alpha + StrokeAlpha uint8 // 边缘细描边 alpha + Brightness float64 // 玻璃亮度 + TextMain color.RGBA + TextSec color.RGBA +} + +var themes = []theme{ + // 1. 暗夜玻璃 - 冷紫调(苹果风格:中心通透 + 边缘折射) + { + Name: "glass-dark", DisplayName: "暗夜玻璃", + BackgroundFrom: [3]uint8{55, 48, 72}, + BackgroundTo: [3]uint8{72, 60, 95}, + OverlayColor: color.RGBA{R: 0, G: 0, B: 0, A: 255}, + OverlayAlpha: 55, + Blur: 10, Magnify: 1.22, Refraction: 18, Dispersion: 0.12, + RimAlpha: 140, BottomDark: 65, + Tint: color.RGBA{R: 250, G: 248, B: 255, A: 255}, TintAlpha: 38, + Saturate: 1.3, TopLight: 55, StrokeAlpha: 60, Brightness: 1.1, + TextMain: color.RGBA{R: 255, G: 255, B: 255, A: 255}, + TextSec: color.RGBA{R: 255, G: 255, B: 255, A: 215}, + }, + // 2. 柔光玻璃 - 纯白奶感(目标风格) + { + Name: "glass-light", DisplayName: "柔光玻璃", + BackgroundFrom: [3]uint8{232, 228, 242}, + BackgroundTo: [3]uint8{218, 222, 238}, + OverlayColor: color.RGBA{R: 255, G: 255, B: 255, A: 255}, + OverlayAlpha: 25, + Blur: 10, Magnify: 1.22, Refraction: 18, Dispersion: 0.12, + RimAlpha: 140, BottomDark: 65, + Tint: color.RGBA{R: 255, G: 255, B: 255, A: 255}, TintAlpha: 46, + Saturate: 1.25, TopLight: 60, StrokeAlpha: 60, Brightness: 1.06, + TextMain: color.RGBA{R: 255, G: 255, B: 255, A: 255}, + TextSec: color.RGBA{R: 255, G: 255, B: 255, A: 215}, + }, + // 3. 霓虹赛博 - 青调奶白 + { + Name: "neon", DisplayName: "霓虹赛博", + BackgroundFrom: [3]uint8{20, 8, 35}, + BackgroundTo: [3]uint8{42, 14, 58}, + OverlayColor: color.RGBA{R: 0, G: 0, B: 0, A: 255}, + OverlayAlpha: 75, + Blur: 12, Magnify: 1.26, Refraction: 22, Dispersion: 0.15, + RimAlpha: 145, BottomDark: 70, + Tint: color.RGBA{R: 244, G: 255, B: 252, A: 255}, TintAlpha: 44, + Saturate: 1.38, TopLight: 65, StrokeAlpha: 70, Brightness: 1.12, + TextMain: color.RGBA{R: 255, G: 255, B: 255, A: 255}, + TextSec: color.RGBA{R: 240, G: 255, B: 250, A: 215}, + }, + // 4. 极简暖灰 - 暖米奶白 + { + Name: "minimal", DisplayName: "极简暖灰", + BackgroundFrom: [3]uint8{238, 236, 232}, + BackgroundTo: [3]uint8{228, 226, 220}, + OverlayColor: color.RGBA{R: 255, G: 255, B: 255, A: 255}, + OverlayAlpha: 35, + Blur: 9, Magnify: 1.2, Refraction: 16, Dispersion: 0.1, + RimAlpha: 135, BottomDark: 60, + Tint: color.RGBA{R: 255, G: 252, B: 246, A: 255}, TintAlpha: 42, + Saturate: 1.2, TopLight: 55, StrokeAlpha: 55, Brightness: 1.05, + TextMain: color.RGBA{R: 255, G: 255, B: 255, A: 255}, + TextSec: color.RGBA{R: 255, G: 253, B: 248, A: 215}, + }, +} + +var currentTheme = themes[0] + +// ===== 随机背景图(复用自检图 data/aifalse/ 目录)===== +const bgDataDir = "data/aifalse" + +var ( + bgListOnce sync.Once + bgFiles []string +) + +func listBgFiles() []string { + bgListOnce.Do(func() { + entries, err := os.ReadDir(bgDataDir) + if err != nil { + return + } + exts := map[string]bool{ + ".jpg": true, ".jpeg": true, ".png": true, + ".gif": true, ".webp": true, ".bmp": true, + } + for _, e := range entries { + if e.IsDir() { + continue + } + if exts[strings.ToLower(filepath.Ext(e.Name()))] { + bgFiles = append(bgFiles, filepath.Join(bgDataDir, e.Name())) + } + } + }) + return bgFiles +} + +func loadRandomBg() image.Image { + files := listBgFiles() + if len(files) == 0 { + return nil + } + path := files[rand.Intn(len(files))] + f, err := os.Open(path) + if err != nil { + return nil + } + defer f.Close() + img, _, err := image.Decode(f) + if err != nil { + logrus.Warn("[servicemenu] 解码背景图失败:", path, err) + return nil + } + return img +} + +// SetTheme 按名称切换当前主题(大小写不敏感),未找到返回 false。 +func SetTheme(name string) bool { + for _, t := range themes { + if strings.EqualFold(t.Name, name) { + currentTheme = t + return true + } + } + return false +} + +func getPlugins() []*zbpctrl.Control[*zero.Ctx] { + var plugins []*zbpctrl.Control[*zero.Ctx] + control.ForEachByPrio(func(_ int, m *zbpctrl.Control[*zero.Ctx]) bool { + plugins = append(plugins, m) + return true + }) + return plugins +} + +func init() { + // ===== 命令注册 ===== + + zero.OnCommandGroup([]string{"服务列表", "service_list"}, zero.OnlyToMe).SetBlock(true).FirstPriority(). + Handle(func(ctx *zero.Ctx) { + gid := ctx.Event.GroupID + if gid == 0 { + gid = -ctx.Event.UserID + } + page := 1 + // 从命令参数里取页码 + if raw := ctx.State["args"]; raw != nil { + if s, ok := raw.(string); ok && strings.TrimSpace(s) != "" { + page = atoi(strings.TrimSpace(s)) + } + } + img, err := renderServiceList(gid, page) + if err != nil { + ctx.SendChain(message.Text("渲染失败: ", err)) + return + } + ctx.SendChain(message.ImageBytes(img)) + }) + + zero.OnRegex(`^服务列表\s*(\d*)\s*$`).SetBlock(true).FirstPriority(). + Handle(func(ctx *zero.Ctx) { + gid := ctx.Event.GroupID + if gid == 0 { + gid = -ctx.Event.UserID + } + page := 1 + if m := ctx.State["regex_matched"].([]string); len(m) > 1 && m[1] != "" { + page = atoi(m[1]) + } + img, err := renderServiceList(gid, page) + if err != nil { + ctx.SendChain(message.Text("渲染失败: ", err)) + return + } + ctx.SendChain(message.ImageBytes(img)) + }) + + zero.OnRegex(`^(菜单用法|menuusage)\s+(\S+)$`).SetBlock(true).FirstPriority(). + Handle(func(ctx *zero.Ctx) { + name := strings.ToLower(ctx.State["regex_matched"].([]string)[2]) + m, ok := control.Lookup(name) + if !ok { + ctx.SendChain(message.Text("没有找到插件: ", name)) + return + } + img, err := renderUsageCard(m) + if err != nil { + ctx.SendChain(message.Text("渲染失败: ", err)) + return + } + ctx.SendChain(message.ImageBytes(img)) + }) + + zero.OnRegex(`^主题\s+(\S+)$`).SetBlock(true).FirstPriority(). + Handle(func(ctx *zero.Ctx) { + name := strings.ToLower(ctx.State["regex_matched"].([]string)[1]) + if SetTheme(name) { + ctx.SendChain(message.Text("主题已切换为: ", currentTheme.DisplayName)) + } else { + var names []string + for _, t := range themes { + names = append(names, t.Name+"("+t.DisplayName+")") + } + ctx.SendChain(message.Text("没有这个主题。可用: ", strings.Join(names, ", "))) + } + }) + + zero.OnFullMatchGroup([]string{"主题列表", "listtheme"}).SetBlock(true).FirstPriority(). + Handle(func(ctx *zero.Ctx) { + var sb strings.Builder + sb.WriteString("可用主题:\n") + for i, t := range themes { + marker := " " + if t.Name == currentTheme.Name { + marker = "★" + } + sb.WriteString(marker + " " + itoa(i+1) + ". " + t.Name + " - " + t.DisplayName + "\n") + } + sb.WriteString("\n用法: menu <编号> 切换 | 主题 切换") + ctx.SendChain(message.Text(sb.String())) + }) + + zero.OnRegex(`^menu\s+(\d+)$`).SetBlock(true).FirstPriority(). + Handle(func(ctx *zero.Ctx) { + n := atoi(ctx.State["regex_matched"].([]string)[1]) + if n < 1 || n > len(themes) { + var sb strings.Builder + sb.WriteString("主题编号无效。可用:\n") + for i, t := range themes { + sb.WriteString(itoa(i+1) + ". " + t.DisplayName) + if t.Name == currentTheme.Name { + sb.WriteString(" ★") + } + sb.WriteString("\n") + } + ctx.SendChain(message.Text(sb.String())) + return + } + currentTheme = themes[n-1] + ctx.SendChain(message.Text("🎨 UI 已切换为 [", itoa(n), "] ", currentTheme.DisplayName)) + }) + + zero.OnFullMatchGroup([]string{"重载全部", "reloadall", "重载bot"}, zero.SuperUserPermission).SetBlock(true).FirstPriority(). + Handle(func(ctx *zero.Ctx) { + ctx.SendChain(message.Text("收到重载命令,正在重启进程...")) + go gracefulRestart() + }) + + zero.OnRegex(`^重载\s+(\S+)$`, zero.SuperUserPermission).SetBlock(true).FirstPriority(). + Handle(func(ctx *zero.Ctx) { + name := strings.ToLower(ctx.State["regex_matched"].([]string)[1]) + _, ok := control.Lookup(name) + if !ok { + ctx.SendChain(message.Text("没找到插件: ", name)) + return + } + ctx.SendChain(message.Text("注意: Go 编译后的插件无法单独热重载,将重启整个进程...")) + go gracefulRestart() + }) +} + +func atoi(s string) int { + n := 0 + for _, c := range s { + if c >= '0' && c <= '9' { + n = n*10 + int(c-'0') + } + } + return n +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + neg := false + if n < 0 { + neg = true + n = -n + } + var buf [20]byte + i := len(buf) + for n > 0 { + i-- + buf[i] = byte('0' + n%10) + n /= 10 + } + if neg { + i-- + buf[i] = '-' + } + return string(buf[i:]) +} + +func gracefulRestart() { + logrus.Infoln("[servicemenu] 准备重启进程...") + time.Sleep(1 * time.Second) + exePath, err := os.Executable() + if err != nil { + logrus.Errorf("[servicemenu] 获取 exe 路径失败: %v", err) + os.Exit(0) + return + } + if runtime.GOOS == "windows" { + dir := exePath[:strings.LastIndex(exePath, `\`)] + cmd := exec.Command("cmd", "/c", "start", "/min", exePath) + cmd.Dir = dir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Start(); err != nil { + logrus.Errorf("[servicemenu] 启动新进程失败: %v", err) + } + } else { + cmd := exec.Command(exePath, os.Args[1:]...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + go func() { + if err := cmd.Run(); err != nil { + logrus.Errorf("[servicemenu] 重启进程失败: %v", err) + } + }() + } + os.Exit(0) +} + +// ================ 图片渲染(和自检图同款模式)================ + +const ( + cardPadding = 70 + cardMarginY = 20 + colGap = 32 + itemH = 120 + headerH = 200 + footerH = 60 + cardRadius = 18 + itemsPerRow = 2 // 两列:卡片更宽,字号可整体放大,无需放大图片即可看清 + pageCount = 2 // 固定分成 2 页 +) + +// toRGBA 把任意 image.Image 转成 *image.RGBA(imaging.Blur 返回 NRGBA,SubImage 需要 RGBA) +func toRGBA(src image.Image) *image.RGBA { + if rgba, ok := src.(*image.RGBA); ok { + return rgba + } + b := src.Bounds() + dst := image.NewRGBA(b) + draw.Draw(dst, b, src, b.Min, draw.Src) + return dst +} + +// renderServiceList 渲染服务列表(固定分 2 页,page 从 1 开始) +func renderServiceList(gid int64, page int) ([]byte, error) { + t := currentTheme + allPlugins := getPlugins() + total := len(allPlugins) + if total == 0 { + total = 1 + allPlugins = append(allPlugins, nil) // 占位,防止除 0 + } + + perPage := (total + pageCount - 1) / pageCount // 向上取整 + if page < 1 { + page = 1 + } + if page > pageCount { + page = pageCount + } + + start := (page - 1) * perPage + end := start + perPage + if end > total { + end = total + } + pagePlugins := allPlugins[start:end] + + canvasW := 1200 + rows := int(math.Ceil(float64(len(pagePlugins)) / float64(itemsPerRow))) + if rows < 1 { + rows = 1 + } + canvasH := cardPadding + headerH + rows*(itemH+cardMarginY) + footerH + cardPadding + 40 + + c := gg.NewContext(canvasW, canvasH) + bg := buildBackground(canvasW, canvasH, t) + c.DrawImage(bg, 0, 0) + + // 整图统一覆盖层(暗主题压暗、亮主题提亮) + if t.OverlayAlpha > 0 { + oc := t.OverlayColor + c.SetRGBA255(int(oc.R), int(oc.G), int(oc.B), int(t.OverlayAlpha)) + c.DrawRectangle(0, 0, float64(canvasW), float64(canvasH)) + c.Fill() + } + + // blurback 必须对"已画完 Overlay 的完整 canvas"做 blur + // 这样卡片内外色调 100% 一致,毛玻璃才真实(和自检图 aifalse 同做法) + blurback := toRGBA(imaging.Blur(c.Image(), t.Blur)) + + // 标题卡 + headerX, headerY := cardPadding, cardPadding + headerW := canvasW - cardPadding*2 + drawNewCard(c, headerX, headerY, headerW, headerH, blurback, t) + + c.SetColor(t.TextMain) + loadFont(c, "data/Font/GlowSansSC-Normal-ExtraBold.ttf", 48) + c.DrawString("ZeroBot-Plugin", float64(headerX+32), float64(headerY+60)) + + const secFont = "data/Font/regular-bold.ttf" + drawTextOutlined(c, "OneBot + ZeroBot + Golang", secFont, 20, float64(headerX+32), float64(headerY+92), t.TextSec) + drawTextOutlined(c, banner.Version+" · FloatTech", secFont, 18, float64(headerX+32), float64(headerY+120), t.TextSec) + + c.SetColor(t.TextMain) + loadFont(c, "data/Font/GlowSansSC-Normal-ExtraBold.ttf", 40) + rightText := "服务列表" + fw, _ := c.MeasureString(rightText) + c.DrawString(rightText, float64(canvasW-cardPadding-32)-fw, float64(headerY+60)) + + subRight := "Server List" + loadFont(c, secFont, 24) + fw2, _ := c.MeasureString(subRight) + drawTextOutlined(c, subRight, secFont, 24, float64(canvasW-cardPadding-32)-fw2, float64(headerY+96), t.TextSec) + + // 分隔线用矩形填充(gg 的 Stroke 会写入垃圾像素,已弃用) + c.SetRGBA255(128, 128, 128, 60) + c.DrawRectangle(float64(headerX+32), float64(headerY+headerH-41), float64(canvasW-cardPadding-32-(headerX+32)), 2) + c.Fill() + + infoText := "总插件 " + itoa(total) + " · 第 " + itoa(page) + "/" + itoa(pageCount) + " 页 · 主题 " + t.DisplayName + drawTextOutlined(c, infoText, secFont, 16, float64(headerX+32), float64(headerY+headerH-18), t.TextSec) + + // 插件卡片(当前页) + cardAreaTop := headerY + headerH + cardMarginY + for i, m := range pagePlugins { + if m == nil { + continue + } + col := i % itemsPerRow + row := i / itemsPerRow + cardW := (canvasW - cardPadding*2 - colGap*(itemsPerRow-1)) / itemsPerRow + x := float64(cardPadding) + float64(col)*float64(cardW+colGap) + cardTop := float64(cardAreaTop) + float64(row)*(itemH+cardMarginY) + enabled := m.IsEnabledIn(gid) + drawNewCard(c, int(x), int(cardTop), cardW, itemH, blurback, t) + drawPluginCardContent(c, int(x), int(cardTop), cardW, itemH, m.Service, m.Options.Brief, enabled, t) + } + + // Footer + footerY := canvasH - cardPadding + 10 + drawTextOutlined(c, "服务列表 [1/2] | menu <编号> | 主题 <名> | 重载全部 | 用法 <英文名>", secFont, 16, float64(cardPadding), float64(footerY), t.TextSec) + + return encodePNG(c.Image()) +} + +// ============ liquid-glass 核心算法移植(from huangj17/liquid-glass-react & shuding/liquid-glass)======== + +// smoothStep 平滑插值,对应 TS 版 smoothStep(a, b, t) +// 注意:必须用 clamp 实现(原 TS 版支持 a > b 的反向区间, +// 例如 smoothStep(1.5, -0.5, d) 表示 d<-0.5 时为 1、d>1.5 时为 0。 +// 若用 "t 1 { + x = 1 + } + return x * x * (3 - 2*x) +} + +// clampF 浮点 clamp +func clampF(v, lo, hi float64) float64 { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} + +// sampleBilinear 双线性插值采样(消除位移采样的颗粒感) +func sampleBilinear(bg *image.RGBA, rect image.Rectangle, sx, sy float64) color.RGBA { + gx := sx + float64(rect.Min.X) + gy := sy + float64(rect.Min.Y) + x0 := int(math.Floor(gx)) + y0 := int(math.Floor(gy)) + tx := gx - float64(x0) + ty := gy - float64(y0) + + x1, y1 := x0+1, y0+1 + W := bg.Bounds().Dx() + H := bg.Bounds().Dy() + clamp := func(v, hi int) int { + if v < 0 { + return 0 + } + if v > hi { + return hi + } + return v + } + x0 = clamp(x0, W-1) + x1 = clamp(x1, W-1) + y0 = clamp(y0, H-1) + y1 = clamp(y1, H-1) + + c00 := bg.At(x0, y0).(color.RGBA) + c10 := bg.At(x1, y0).(color.RGBA) + c01 := bg.At(x0, y1).(color.RGBA) + c11 := bg.At(x1, y1).(color.RGBA) + + lerp := func(a, b, tt float64) float64 { return a + (b-a)*tt } + rr := lerp(lerp(float64(c00.R), float64(c10.R), tx), lerp(float64(c01.R), float64(c11.R), tx), ty) + g := lerp(lerp(float64(c00.G), float64(c10.G), tx), lerp(float64(c01.G), float64(c11.G), tx), ty) + bb := lerp(lerp(float64(c00.B), float64(c10.B), tx), lerp(float64(c01.B), float64(c11.B), tx), ty) + return color.RGBA{R: uint8(rr + 0.5), G: uint8(g + 0.5), B: uint8(bb + 0.5), A: 255} +} + +// renderLiquidGlass 按 iOS Liquid Glass 原理渲染卡片区域(from forum.cocos.org/t/topic/171941): +// +// 凸透镜放大(核心) → 边缘折射 → RGB 色散 → 轮廓光 → 亮度/饱和 → 奶白洗白 +func renderLiquidGlass(blurback *image.RGBA, x, y, w, h int, t theme) *image.RGBA { + rect := image.Rect(x, y, x+w, y+h) + out := image.NewRGBA(image.Rect(0, 0, w, h)) + bw := float64(w) + bh := float64(h) + cx := bw / 2 + cy := bh / 2 + rad := float64(cardRadius) + // 边缘折射带宽度:随卡片高度缩放(苹果液态玻璃的折射集中在外环 15%-30%) + edgeW := clampF(bh*0.30, 14, 36) + + // 圆角矩形 SDF(内部为负、边缘为 0、外部为正) + sdf := func(fx, fy float64) float64 { + qx := math.Abs(fx-cx) - (bw/2 - rad) + qy := math.Abs(fy-cy) - (bh/2 - rad) + return math.Min(math.Max(qx, qy), 0) + math.Hypot(math.Max(qx, 0), math.Max(qy, 0)) - rad + } + + for py := 0; py < h; py++ { + for px := 0; px < w; px++ { + fx := float64(px) + 0.5 + fy := float64(py) + 0.5 + dEdge := sdf(fx, fy) + + // 圆角矩形 alpha:以名义边缘为中心的 1.6px 对称渐隐(经典 SDF AA)。 + // 过窄(1px 偏外)会锯齿,过宽(3.5px)半透明边缘带压在阴影上呈暗环毛边 + alphaF := smoothStep(0.8, -0.8, dEdge) + if alphaF <= 0 { + out.Set(px, py, color.RGBA{0, 0, 0, 0}) + continue + } + + // SDF 外法线(数值梯度):折射方向垂直于最近边缘,随形状/尺寸自适应 + nx := sdf(fx+1, fy) - sdf(fx-1, fy) + ny := sdf(fx, fy+1) - sdf(fx, fy-1) + if nl := math.Hypot(nx, ny); nl > 1e-6 { + nx /= nl + ny /= nl + } else { + nx, ny = 0, 0 + } + + // === 1. 边缘折射:越靠近边缘越向外取样(外围背景被"压"进边缘环) + // 最外 5px 收敛到 0:边界处与卡外背景无错位,避免重影拖痕接缝 === + mag := t.Refraction * smoothStep(-edgeW*1.5, -edgeW*0.2, dEdge) * smoothStep(2.0, -3.0, dEdge) + + // === 2. 凸透镜放大(核心):采样区缩小 = 背景被放大 === + sx := (fx+nx*mag-cx)/t.Magnify + cx + sy := (fy+ny*mag-cy)/t.Magnify + cy + + // === 3. RGB 色散:R 前移,B 后移 === + ab := t.Dispersion + r := float64(sampleBilinear(blurback, rect, sx+nx*mag*ab, sy+ny*mag*ab).R) + g := float64(sampleBilinear(blurback, rect, sx, sy).G) + b := float64(sampleBilinear(blurback, rect, sx-nx*mag*ab, sy-ny*mag*ab).B) + + // === 4. 轮廓光:SDF 细边缘带(~5px),顶部最亮、侧边次之(环境光反射)=== + band := smoothStep(-7, -1.5, dEdge) + wr := float64(t.RimAlpha) / 255.0 + spec := band * (0.35 + 0.65*clampF(-ny, 0, 1)) * wr + r = r*(1-spec) + 255*spec + g = g*(1-spec) + 255*spec + b = b*(1-spec) + 255*spec + + // === 5. 底部内阴影(玻璃厚度)=== + bd := band * clampF(ny, 0, 1) * float64(t.BottomDark) / 255.0 + r *= 1 - bd + g *= 1 - bd + b *= 1 - bd + + // === 6. 亮度 + 饱和度 === + r *= t.Brightness + g *= t.Brightness + b *= t.Brightness + gray := 0.2126*r + 0.7152*g + 0.0722*b + r = gray + (r-gray)*t.Saturate + g = gray + (g-gray)*t.Saturate + b = gray + (b-gray)*t.Saturate + + // === 7. 奶白洗白(液态玻璃 milk 质感)=== + ta := float64(t.TintAlpha) / 255.0 + r = r*(1-ta) + float64(t.Tint.R)*ta + g = g*(1-ta) + float64(t.Tint.G)*ta + b = b*(1-ta) + float64(t.Tint.B)*ta + + // === 8. 顶部高光带(SDF 实现,替代 gg Clip+LinearGradient—— + // 该库的渐变填充在圆弧段会写入黑色垃圾像素)=== + hh := bh * 0.4 + if fy < hh { + ttH := fy / hh + ha := float64(t.TopLight) + if ttH < 0.5 { + ha += (ha/3 - ha) * (ttH * 2) + } else { + ha = ha / 3 * (1 - (ttH-0.5)*2) + } + inside := smoothStep(0, -2.5, dEdge) // 贴圆角轮廓,向内 2.5px 过渡 + a := ha / 255.0 * inside + r = r*(1-a) + 255*a + g = g*(1-a) + 255*a + b = b*(1-a) + 255*a + } + + // === 9. 边缘细描边(SDF 带,替代 gg Stroke——该库的矢量描边 + // 在圆弧段会忽略样式写入黑色垃圾像素,即角部黑刺的元凶)=== + sa := float64(t.StrokeAlpha) / 255.0 * + smoothStep(-1.8, -1.1, dEdge) * smoothStep(0.5, -0.3, dEdge) + if sa > 0 { + r = r*(1-sa) + 255*sa + g = g*(1-sa) + 255*sa + b = b*(1-sa) + 255*sa + } + + // 输出必须预乘 alpha(image.RGBA 是预乘格式,否则边缘渐隐像素会被二次相乘变暗) + aF := 255 * alphaF + out.Set(px, py, color.RGBA{ + R: uint8(clampF(r*alphaF, 0, 255) + 0.5), + G: uint8(clampF(g*alphaF, 0, 255) + 0.5), + B: uint8(clampF(b*alphaF, 0, 255) + 0.5), + A: uint8(aF + 0.5), + }) + } + } + return out +} + +// shadowCache 阴影图缓存(按尺寸),避免每张卡片重复模糊 +var ( + shadowCacheMu sync.Mutex + shadowCache = map[image.Point]*image.RGBA{} +) + +// shadowPad 阴影图四边留白宽度(px)。绘制阴影时按此边距反向偏移贴回卡片位置。 +const shadowPad = 28 + +// getBlurredShadow 生成圆角矩形高斯模糊阴影图(四边各留 shadowPad 边距) +// 阴影矩形相对卡片左右内缩 3px、整体只向下偏移:避免模糊 halo 贴着卡片 +// 左右/圆角形成深色环带(浅色背景上呈黑刺/磨损感) +func getBlurredShadow(w, h, radius int) *image.RGBA { + const pad = shadowPad + key := image.Point{w, h} + shadowCacheMu.Lock() + cached, ok := shadowCache[key] + shadowCacheMu.Unlock() + if ok { + return cached + } + + sc := gg.NewContext(w+pad*2, h+pad*2) + sc.SetColor(color.RGBA{R: 15, G: 15, B: 35, A: 50}) + sc.DrawRoundedRectangle(float64(pad)+3, float64(pad), float64(w)-6, float64(h), float64(radius)) + sc.Fill() + blurred := toRGBA(imaging.Blur(sc.Image(), 12)) + + shadowCacheMu.Lock() + shadowCache[key] = blurred + shadowCacheMu.Unlock() + return blurred +} + +// drawNewCard 液态玻璃卡片(iOS Liquid Glass) +// 分层(从下到上): +// 1. 高斯模糊阴影(干净无角部残影) +// 2. 液态玻璃底(凸透镜放大 + 边缘折射 + 色散 + 轮廓光 + 顶部高光 + 细描边 + 奶白洗白) +// +// 注意:不要用 gg 的 Stroke/Clip+Gradient 画描边和高光——FloatTech/gg 的矢量 +// 描边与渐变填充在圆角弧段会忽略样式写入黑色垃圾像素(角部黑刺/黑弧的元凶), +// 两者均已改为 renderLiquidGlass 内部的 SDF 距离场实现。 +func drawNewCard(c *gg.Context, x, y, w, h int, blurback *image.RGBA, t theme) { + // === 1. 高斯模糊阴影(内缩+下移落影,无左右 halo)=== + shadow := getBlurredShadow(w, h, cardRadius) + c.DrawImage(shadow, x-shadowPad, y-shadowPad+8) + + // === 2. 液态玻璃底(含高光带与描边)=== + glass := renderLiquidGlass(blurback, x, y, w, h, t) + c.DrawImage(glass, x, y) +} + +// loadFont 加载字体到上下文,失败时告警(字体随插件打包,加载失败会退回默认 +// 字体导致排版错乱,打印日志便于排查部署环境的资源缺失)。 +func loadFont(c *gg.Context, path string, size float64) { + if err := c.LoadFontFace(path, size); err != nil { + logrus.Warnf("[servicemenu] 加载字体失败 %s: %v", path, err) + } +} + +// ellipsizeByWidth 按像素宽度截断文本(rune 安全),超宽以 "..." 结尾。 +// 旧的按字节 len(brief)>24 截断有两个问题:中文每字 3 字节导致约 8 个汉字就 +// 被省略(远没占满卡片可用宽度),且 brief[:23] 可能切断 UTF-8 多字节字符 +// 产生乱码。现改为实测渲染宽度,二分找最长前缀。 +func ellipsizeByWidth(c *gg.Context, text, fontPath string, size, maxW float64) string { + loadFont(c, fontPath, size) + if tw, _ := c.MeasureString(text); tw <= maxW { + return text + } + const ell = "..." + runes := []rune(text) + lo, hi := 0, len(runes) + for lo < hi { + mid := (lo + hi + 1) / 2 + if w, _ := c.MeasureString(string(runes[:mid]) + ell); w <= maxW { + lo = mid + } else { + hi = mid - 1 + } + } + return string(runes[:lo]) + ell +} + +// drawTextOutlined 带黑色描边绘制文字。FloatTech/gg 的 Stroke 在曲线 +// 字形上会写入垃圾像素(圆角黑弧同源 bug),因此用多向偏移暗色底绘 +// 模拟描边再绘主体:1px 实描边 + 1.8px 淡晕两层,保证白字在奶白玻璃上远看清晰。 +func drawTextOutlined(c *gg.Context, text, fontPath string, size float64, x, y float64, fill color.RGBA) { + loadFont(c, fontPath, size) + c.SetRGBA255(25, 28, 40, 170) + for _, d := range [...][2]float64{ + {-1, 0}, {1, 0}, {0, -1}, {0, 1}, + {-0.71, -0.71}, {0.71, -0.71}, {-0.71, 0.71}, {0.71, 0.71}, + } { + c.DrawString(text, x+d[0], y+d[1]) + } + c.SetRGBA255(25, 28, 40, 70) + for _, d := range [...][2]float64{ + {-1.8, 0}, {1.8, 0}, {0, -1.8}, {0, 1.8}, + {-1.3, -1.3}, {1.3, -1.3}, {-1.3, 1.3}, {1.3, 1.3}, + } { + c.DrawString(text, x+d[0], y+d[1]) + } + c.SetColor(fill) + c.DrawString(text, x, y) +} + +func drawPluginCardContent(c *gg.Context, x, y, w, h int, name, brief string, enabled bool, t theme) { + // 状态色条 + if enabled { + c.SetRGBA255(136, 178, 0, 255) + } else { + c.SetRGBA255(204, 51, 51, 255) + } + c.DrawRoundedRectangle(float64(x)+7, float64(y+24), float64(6), float64(h-48), 3) + c.Fill() + + const ( + nameFont = "data/Font/GlowSansSC-Normal-ExtraBold.ttf" + briefFont = "data/Font/regular-bold.ttf" + nameBaseline = 55 // 按 itemH=120 视觉居中:文本块上下各留约 30px + briefBaseline = 89 + ) + + // 插件名(黑色描边,远看清晰) + drawTextOutlined(c, name, nameFont, 32, float64(x)+30, float64(y+nameBaseline), t.TextMain) + + // Brief:按实际像素宽度省略,右侧留出状态徽章区(圆徽 28 + 边距 16 + 间隙 10) + availW := float64(w) - 30 - 54 + brief = ellipsizeByWidth(c, brief, briefFont, 22, availW) + drawTextOutlined(c, brief, briefFont, 22, float64(x)+30, float64(y+briefBaseline), t.TextSec) + + // 状态徽章:右侧圆点 + 矢量勾/叉(✓/✗ 在 GlowSansSC 无字形,DrawString + // 永远渲染不出来,改用直线段绘制图标,必然渲染且远看清晰) + const iconD = 28.0 + cx := float64(x+w) - 16 - iconD/2 + cy := float64(y) + float64(h)/2 + if enabled { + c.SetRGBA255(104, 166, 0, 240) + } else { + c.SetRGBA255(204, 51, 51, 240) + } + c.DrawCircle(cx, cy, iconD/2) + c.Fill() + + r := iconD / 2 + c.SetStrokeStyle(gg.NewSolidPattern(color.RGBA{R: 255, G: 255, B: 255, A: 255})) + c.SetLineWidth(3) + c.SetLineCap(gg.LineCapRound) + if enabled { + // 勾:短臂下探到中低点,再长臂扬到右上 + c.MoveTo(cx-0.42*r, cy+0.05*r) + c.LineTo(cx-0.10*r, cy+0.38*r) + c.LineTo(cx+0.45*r, cy-0.30*r) + c.Stroke() + } else { + // 叉:两条对角直线 + c.MoveTo(cx-0.30*r, cy-0.30*r) + c.LineTo(cx+0.30*r, cy+0.30*r) + c.MoveTo(cx+0.30*r, cy-0.30*r) + c.LineTo(cx-0.30*r, cy+0.30*r) + c.Stroke() + } +} + +// buildBackground 构建主题渐变 + 随机本地图(cover 模式) +func buildBackground(w, h int, t theme) *image.RGBA { + bg := gg.NewContext(w, h) + + fr, fg, fb := float64(t.BackgroundFrom[0]), float64(t.BackgroundFrom[1]), float64(t.BackgroundFrom[2]) + tr, tg, tb := float64(t.BackgroundTo[0]), float64(t.BackgroundTo[1]), float64(t.BackgroundTo[2]) + grad := gg.NewLinearGradient(0, 0, 0, float64(h)) + grad.AddColorStop(0, color.RGBA{R: uint8(fr), G: uint8(fg), B: uint8(fb), A: 255}) + grad.AddColorStop(1, color.RGBA{R: uint8(tr), G: uint8(tg), B: uint8(tb), A: 255}) + bg.SetFillStyle(grad) + bg.DrawRectangle(0, 0, float64(w), float64(h)) + bg.Fill() + + randomImg := loadRandomBg() + if randomImg != nil { + srcW := randomImg.Bounds().Dx() + srcH := randomImg.Bounds().Dy() + scale := math.Max(float64(w)/float64(srcW), float64(h)/float64(srcH)) + newW := int(float64(srcW) * scale) + newH := int(float64(srcH) * scale) + resized := imaging.Resize(randomImg, newW, newH, imaging.Box) + final := imaging.CropCenter(resized, w, h) + bg.DrawImage(final, 0, 0) + } + + return bg.Image().(*image.RGBA) +} + +func renderUsageCard(m *zbpctrl.Control[*zero.Ctx]) ([]byte, error) { + t := currentTheme + cardW := 900 + headerH := 110 + + help := m.Options.Help + if help == "" { + help = "该插件无帮助文档。" + } + lines := strings.Count(help, "\n") + 1 + lineH := 28 + bodyH := lines*lineH + 60 + cardH := cardPadding + headerH + bodyH + cardPadding + + c := gg.NewContext(cardW, cardH) + bg := buildBackground(cardW, cardH, t) + c.DrawImage(bg, 0, 0) + + if t.OverlayAlpha > 0 { + oc := t.OverlayColor + c.SetRGBA255(int(oc.R), int(oc.G), int(oc.B), int(t.OverlayAlpha)) + c.DrawRectangle(0, 0, float64(cardW), float64(cardH)) + c.Fill() + } + + blurback := toRGBA(imaging.Blur(c.Image(), t.Blur)) + + drawNewCard(c, cardPadding, cardPadding, cardW-cardPadding*2, headerH, blurback, t) + c.SetColor(t.TextMain) + loadFont(c, "data/Font/GlowSansSC-Normal-ExtraBold.ttf", 48) + c.DrawString(m.Service, float64(cardPadding+32), float64(cardPadding+60)) + + c.SetColor(t.TextSec) + loadFont(c, "data/Font/regular-bold.ttf", 18) + c.DrawString(m.Options.Brief, float64(cardPadding+32), float64(cardPadding+90)) + + bodyY := cardPadding + headerH + cardMarginY + drawNewCard(c, cardPadding, bodyY, cardW-cardPadding*2, bodyH, blurback, t) + c.SetColor(t.TextMain) + loadFont(c, "data/Font/regular-bold.ttf", 16) + y := float64(bodyY) + 36 + for _, line := range strings.Split(help, "\n") { + c.DrawString(line, float64(cardPadding+28), y) + y += float64(lineH) + } + return encodePNG(c.Image()) +} + +func encodePNG(img image.Image) ([]byte, error) { + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + return nil, err + } + return buf.Bytes(), nil +} diff --git a/plugin/servicemenu/render_test.go b/plugin/servicemenu/render_test.go new file mode 100644 index 0000000000..d0f98aeeec --- /dev/null +++ b/plugin/servicemenu/render_test.go @@ -0,0 +1,396 @@ +package servicemenu + +// 测试渲染:不走 bot,直接用真实渲染管线输出 PNG 供视觉验证 +// 运行: go test -run TestRenderListPNG ./plugin/servicemenu +// 注意: chdir 到项目根目录,保证 data/Font、data/aifalse 相对路径可用 + +import ( + "fmt" + "image" + "image/color" + "math" + "os" + "testing" + + "github.com/FloatTech/ZeroBot-Plugin/kanban/banner" + "github.com/FloatTech/gg" + "github.com/disintegration/imaging" +) + +func TestMain(m *testing.M) { + _ = os.Chdir("../..") // 项目根目录 + os.Exit(m.Run()) +} + +// newTestCanvas 构建带真实背景的画布 + 对应 blurback(与 renderServiceList 同流程) +func newTestCanvas(w, h int, tt theme) (*gg.Context, *image.RGBA) { + c := gg.NewContext(w, h) + bg := buildBackground(w, h, tt) + c.DrawImage(bg, 0, 0) + if tt.OverlayAlpha > 0 { + oc := tt.OverlayColor + c.SetRGBA255(int(oc.R), int(oc.G), int(oc.B), int(tt.OverlayAlpha)) + c.DrawRectangle(0, 0, float64(w), float64(h)) + c.Fill() + } + blurback := toRGBA(imaging.Blur(c.Image(), tt.Blur)) + return c, blurback +} + +// writePNG 输出画布到项目根目录 PNG +func writePNG(t *testing.T, c *gg.Context, out string) { + png, err := encodePNG(c.Image()) + if err != nil { + t.Fatalf("编码失败: %v", err) + } + if err := os.WriteFile(out, png, 0o644); err != nil { + t.Fatalf("写出失败: %v", err) + } + t.Logf("已输出 %s", out) +} + +type fakePlugin struct{ name, brief string } + +func renderTestList(t *testing.T, tt theme, out string) { + plugins := []fakePlugin{ + {"job", "定时指令触发器"}, {"antiabuse", "违禁词检测"}, {"chat", "基础反应, 群空调"}, + {"chatcount", "聊天时长统计"}, {"sleepmanage", "睡眠小助手"}, {"airecord", "群应用: AI声聊"}, + {"atri", "atri人格文本回复"}, {"manager", "群管插件"}, {"aiwife", "ai随机生成老婆"}, + // 长简介:验证按像素宽度省略(应占满可用宽度而非提前截断) + {"animetrace", "AnimeTrace 动画/Galgame 截图溯源,识别番剧出处与集数"}, + {"danbooru", "二次元图片标签查询,支持 endless 名片与同名搜索重定向"}, + {"event", "好友申请和群聊邀请审核事件处理,自动同意或拒绝入群请求"}, + } + currentTheme = tt + + // 与 renderServiceList 相同的布局常量 + canvasW := 1200 + rows := 6 // 12 个测试插件 / 两列 + canvasH := cardPadding + headerH + rows*(itemH+cardMarginY) + footerH + cardPadding + 40 + c, blurback := newTestCanvas(canvasW, canvasH, tt) + + // header 卡 + drawNewCard(c, cardPadding, cardPadding, canvasW-cardPadding*2, headerH, blurback, tt) + c.SetColor(tt.TextMain) + c.LoadFontFace("data/Font/GlowSansSC-Normal-ExtraBold.ttf", 48) + c.DrawString("ZeroBot-Plugin", float64(cardPadding+32), float64(cardPadding+60)) + drawTextOutlined(c, "OneBot + ZeroBot + Golang", "data/Font/regular-bold.ttf", 20, float64(cardPadding+32), float64(cardPadding+92), tt.TextSec) + drawTextOutlined(c, banner.Version+" · FloatTech", "data/Font/regular-bold.ttf", 18, float64(cardPadding+32), float64(cardPadding+120), tt.TextSec) + + // 插件卡片 + cardAreaTop := cardPadding + headerH + cardMarginY + cardW := (canvasW - cardPadding*2 - colGap*(itemsPerRow-1)) / itemsPerRow + for j, p := range plugins { + col := j % itemsPerRow + row := j / itemsPerRow + x := float64(cardPadding) + float64(col)*float64(cardW+colGap) + cardTop := float64(cardAreaTop) + float64(row)*(itemH+cardMarginY) + drawNewCard(c, int(x), int(cardTop), cardW, itemH, blurback, tt) + drawPluginCardContent(c, int(x), int(cardTop), cardW, itemH, p.name, p.brief, j%4 != 3, tt) + } + + writePNG(t, c, out) +} + +func TestRenderListPNG(t *testing.T) { + for _, tt := range themes { + renderTestList(t, tt, fmt.Sprintf("glass_real_%s.png", tt.Name)) + } +} + +// TestRenderCloseupPNG 单卡放大特写:检查边缘折射 / 四角 / 颗粒感 +func TestRenderCloseupPNG(t *testing.T) { + tt := themes[0] + currentTheme = tt + + c, blurback := newTestCanvas(760, 560, tt) + + // 一大一小两块玻璃 + drawNewCard(c, 60, 60, 640, 220, blurback, tt) + drawPluginCardContent(c, 60, 60, 640, 220, "liquid glass", "凸透镜放大 + 边缘折射 + 色散", true, tt) + + drawNewCard(c, 60, 330, 300, 120, blurback, tt) + drawPluginCardContent(c, 60, 330, 300, 120, "job", "定时指令触发器", true, tt) + + drawNewCard(c, 400, 330, 300, 120, blurback, tt) + drawPluginCardContent(c, 400, 330, 300, 120, "manager", "群管插件", false, tt) + + writePNG(t, c, "glass_closeup.png") +} + +// TestCropDebug 裁剪放大整表的卡片角部,定位黑刺瑕疵 +func TestCropDebug(t *testing.T) { + src, err := imaging.Open("glass_real_glass-dark.png") + if err != nil { + t.Skip("先跑 TestRenderListPNG") + } + // job 卡所在区域(含左右角 + 上下缘) + crop := imaging.Crop(src, image.Rect(50, 265, 470, 400)) + zoom := imaging.Resize(crop, 420*3, 135*3, imaging.NearestNeighbor) + _ = imaging.Save(zoom, "glass_crop.png") + t.Log("已输出 glass_crop.png") + + // 6x 特写:job 卡左上角 (70,290) 及左缘 + crop2 := imaging.Crop(src, image.Rect(40, 262, 150, 372)) + zoom2 := imaging.Resize(crop2, 110*6, 110*6, imaging.NearestNeighbor) + _ = imaging.Save(zoom2, "glass_corner6x.png") + t.Log("已输出 glass_corner6x.png") + + // 像素扫描:横穿 job 卡左缘(y=320),定位暗线位置 + for _, py := range []int{300, 320, 340} { + row := "" + for px := 60; px <= 84; px += 2 { + r, g, b, _ := src.At(px, py).RGBA() + row += fmt.Sprintf("x%d:%d,%d,%d ", px, r>>8, g>>8, b>>8) + } + t.Logf("y=%d %s", py, row) + } +} + +// TestDarkPixelForensics 逐像素取证:step=1 扫描左缘全线 + 分层隔离(玻璃/阴影/描边) +// 定位 1px 深色竖线与角部斑点来自哪一层 +func TestDarkPixelForensics(t *testing.T) { + tt := themes[0] + currentTheme = tt + + // 复现 renderTestList 的 job 卡布局 + canvasW := 1200 + rows := 3 + canvasH := cardPadding + headerH + rows*(itemH+cardMarginY) + footerH + cardPadding + 40 + c, blurback := newTestCanvas(canvasW, canvasH, tt) + _ = c + cardAreaTop := cardPadding + headerH + cardMarginY + cardW := (canvasW - cardPadding*2 - colGap*(itemsPerRow-1)) / itemsPerRow + x, y, w, h := cardPadding, cardAreaTop, cardW, itemH + t.Logf("job 卡: x=%d y=%d w=%d h=%d cardRadius=%d edgeW=%.1f", x, y, w, h, cardRadius, clampF(float64(h)*0.30, 14, 36)) + + // === 分层渲染 === + // L1: 仅玻璃 + glass := renderLiquidGlass(blurback, x, y, w, h, tt) + // L2: 仅阴影 + shadow := getBlurredShadow(w, h, cardRadius) + // L3: 完整卡片(画在副本 canvas 上) + c2, blurback2 := newTestCanvas(canvasW, canvasH, tt) + drawNewCard(c2, x, y, w, h, blurback2, tt) + + scanDark := func(label string, img *image.RGBA, ox, oy int) { + found := 0 + for py := y; py < y+60 && py-oy < img.Bounds().Dy(); py++ { + for px := x - 6; px <= x+8; px++ { + lx, ly := px-ox, py-oy + if lx < 0 || ly < 0 || lx >= img.Bounds().Dx() || ly >= img.Bounds().Dy() { + continue + } + r, g, b, a := img.At(lx, ly).RGBA() + r8, g8, b8, a8 := int(r>>8), int(g>>8), int(b>>8), int(a>>8) + if a8 > 100 && r8 < 90 && g8 < 90 { + t.Logf("[%s] 暗点 (%d,%d) RGB=%d,%d,%d A=%d", label, px, py, r8, g8, b8, a8) + found++ + if found > 12 { + return + } + } + } + } + if found == 0 { + t.Logf("[%s] 左缘 y+0..60 无暗点", label) + } + } + + // blurback 本身(对照组:证明背景没有暗点) + scanDark("blurback", blurback, 0, 0) + // 仅玻璃层 + scanDark("glass-only", glass, x, y) + // 阴影层(在其自身坐标系里扫) + scanDark("shadow-only", shadow, x-shadowPad, y-shadowPad) + // 完整卡片 + scanDark("full-card", toRGBA(c2.Image()), 0, 0) + + // === step=1 全线扫描:完整卡片左缘 x-6..x+8, y..y+60 全部输出 === + full := toRGBA(c2.Image()) + line := "" + for py := y; py < y+40; py++ { + r, g, b, _ := full.At(x+2, py).RGBA() + v := (int(r>>8) + int(g>>8) + int(b>>8)) / 3 + if v < 120 { + line += fmt.Sprintf(" (%d,%d:%d)", x+2, py, v) + } + } + t.Logf("x=%d 列上 40 行内暗像素(<120):%s", x+2, line) + + // === 玻璃层内部取证:对暗点反算采样坐标 === + cx, cy := float64(w)/2, float64(h)/2 + rad := float64(cardRadius) + edgeW := clampF(float64(h)*0.30, 14, 36) + sdf := func(fx, fy float64) float64 { + qx := math.Abs(fx-cx) - (float64(w)/2 - rad) + qy := math.Abs(fy-cy) - (float64(h)/2 - rad) + return math.Min(math.Max(qx, qy), 0) + math.Hypot(math.Max(qx, 0), math.Max(qy, 0)) - rad + } + for py := 0; py < 60; py++ { + for px := 0; px < 12; px++ { + r, g, b, a := glass.At(px, py).RGBA() + if a>>8 <= 100 || int(r>>8) >= 90 { + continue + } + fx, fy := float64(px)+0.5, float64(py)+0.5 + dE := sdf(fx, fy) + nx := sdf(fx+1, fy) - sdf(fx-1, fy) + ny := sdf(fx, fy+1) - sdf(fx, fy-1) + if nl := math.Hypot(nx, ny); nl > 1e-6 { + nx, ny = nx/nl, ny/nl + } + mag := tt.Refraction * smoothStep(-edgeW*1.5, -edgeW*0.2, dE) * smoothStep(2.0, -3.0, dE) + sx := (fx+nx*mag-cx)/tt.Magnify + cx + sy := (fy+ny*mag-cy)/tt.Magnify + cy + sr, sg, sb, _ := blurback.At(int(sx)+x, int(sy)+y).RGBA() + t.Logf("glass 暗点 local(%d,%d) dEdge=%.2f mag=%.1f n=(%.2f,%.2f) sample=(%.1f,%.1f)->canvas(%d,%d) 采样色=%d,%d,%d 输出=%d,%d,%d", + px, py, dE, mag, nx, ny, sx, sy, int(sx)+x, int(sy)+y, sr>>8, sg>>8, sb>>8, r>>8, g>>8, b>>8) + } + } + + // 输出放大图目视 + crop := imaging.Crop(full, image.Rect(x-12, y-12, x+60, y+72)) + z6 := imaging.Resize(crop, 72*6, 84*6, imaging.NearestNeighbor) + _ = imaging.Save(z6, "glass_forensic6x.png") + t.Log("已输出 glass_forensic6x.png") +} + +// TestGGBugProbe 最小复现:分别测试 gg 的描边(带 alpha 的 SolidPattern)与 +// 圆角矩形 Clip+渐变填充,是否会在路径附近写入垃圾像素(黑点/蓝点) +func TestGGBugProbe(t *testing.T) { + probe := func(label string, fn func(c *gg.Context)) { + c := gg.NewContext(240, 240) + c.SetRGBA255(200, 200, 200, 255) + c.DrawRectangle(0, 0, 240, 240) + c.Fill() + fn(c) + bad := 0 + for py := 0; py < 240; py++ { + for px := 0; px < 240; px++ { + r, g, b, a := c.Image().At(px, py).RGBA() + r8, g8, b8, a8 := int(r>>8), int(g>>8), int(b>>8), int(a>>8) + // 灰底 200 与白色系之外的颜色都算异常 + if a8 < 250 || (r8 < 150 && g8 < 150) || b8 > 240 && r8 < 100 { + if bad < 8 { + t.Logf("[%s] 异常像素 (%d,%d) RGB=%d,%d,%d A=%d", label, px, py, r8, g8, b8, a8) + } + bad++ + } + } + } + t.Logf("[%s] 异常像素总数: %d", label, bad) + } + + // A: 描边 - SetStrokeStyle(白色 A=60) + Stroke + probe("stroke-alpha60", func(c *gg.Context) { + c.SetLineWidth(1.2) + c.SetStrokeStyle(gg.NewSolidPattern(color.RGBA{R: 255, G: 255, B: 255, A: 60})) + c.DrawRoundedRectangle(20.5, 20.5, 199, 199, 16) + c.Stroke() + }) + + // B: 描边 - 不设样式(默认黑色描边,预期全黑一圈作为对照) + probe("stroke-default", func(c *gg.Context) { + c.SetLineWidth(1.2) + c.DrawRoundedRectangle(20.5, 20.5, 199, 199, 16) + c.Stroke() + }) + + // C: Clip 圆角矩形 + 线性渐变填充(复刻高光带,radius 16 vs 高度 96) + probe("clip-gradient", func(c *gg.Context) { + c.DrawRoundedRectangle(22, 21, 196, 96, 16) + c.Clip() + hl := gg.NewLinearGradient(0, 20, 0, 116) + hl.AddColorStop(0, color.RGBA{R: 255, G: 255, B: 255, A: 55}) + hl.AddColorStop(0.5, color.RGBA{R: 255, G: 255, B: 255, A: 18}) + hl.AddColorStop(1, color.RGBA{R: 255, G: 255, B: 255, A: 0}) + c.SetFillStyle(hl) + c.DrawRectangle(20, 20, 200, 96) + c.Fill() + c.ResetClip() + }) + + // D: Clip 圆角矩形 + 纯色填充(隔离渐变因素) + probe("clip-solid", func(c *gg.Context) { + c.DrawRoundedRectangle(22, 21, 196, 96, 16) + c.Clip() + c.SetRGBA255(255, 255, 255, 40) + c.DrawRectangle(20, 20, 200, 96) + c.Fill() + c.ResetClip() + }) +} + +// TestLayerDebug 分层隔离实验:定位黑刺来自哪一层(阴影/玻璃/描边高光) +func TestLayerDebug(t *testing.T) { + tt := themes[0] + currentTheme = tt + + const panelW, panelH = 480, 150 + c := gg.NewContext(panelW, panelH*4) + // 浅色平底背景(模拟浅色二次元图) + c.SetRGBA255(235, 220, 215, 255) + c.DrawRectangle(0, 0, float64(panelW), float64(panelH*4)) + c.Fill() + + // 先构建干净的 blurback(平底 + overlay + blur),避免把面板内容采进去 + flat := gg.NewContext(panelW, panelH*4) + flat.SetRGBA255(235, 220, 215, 255) + flat.DrawRectangle(0, 0, float64(panelW), float64(panelH*4)) + flat.Fill() + if tt.OverlayAlpha > 0 { + oc := tt.OverlayColor + flat.SetRGBA255(int(oc.R), int(oc.G), int(oc.B), int(tt.OverlayAlpha)) + flat.DrawRectangle(0, 0, float64(panelW), float64(panelH*4)) + flat.Fill() + } + blurback := toRGBA(imaging.Blur(flat.Image(), tt.Blur)) + + x, y, w, h := 70, 35, 337, 80 + + // 面板1: 完整 drawNewCard + drawNewCard(c, x, y, w, h, blurback, tt) + + // 面板2: 仅阴影 + shadow := getBlurredShadow(w, h, cardRadius) + c.DrawImage(shadow, x-shadowPad, panelH+y-shadowPad+8) + + // 面板3: 仅玻璃(无阴影无描边) + glass := renderLiquidGlass(blurback, x, y, w, h, tt) + c.DrawImage(glass, x, panelH*2+y) + + // 面板4: 仅描边 + 顶部高光(无阴影无玻璃) + r := float64(cardRadius) + py3 := float64(panelH*3 + y) + c.DrawRoundedRectangle(float64(x)+2, py3+1, float64(w)-4, float64(h)*0.4, r) + c.Clip() + hl := gg.NewLinearGradient(0, py3, 0, py3+float64(h)*0.4) + hl.AddColorStop(0, color.RGBA{R: 255, G: 255, B: 255, A: tt.TopLight}) + hl.AddColorStop(0.5, color.RGBA{R: 255, G: 255, B: 255, A: uint8(int(tt.TopLight) / 3)}) + hl.AddColorStop(1, color.RGBA{R: 255, G: 255, B: 255, A: 0}) + c.SetFillStyle(hl) + c.DrawRectangle(float64(x), py3, float64(w), float64(h)*0.4) + c.Fill() + c.ResetClip() + c.SetLineWidth(1.2) + c.SetRGBA255(255, 255, 255, int(tt.StrokeAlpha)) + c.DrawRoundedRectangle(float64(x)+0.5, py3+0.5, float64(w)-1, float64(h)-1, r) + c.Stroke() + + // 放大 2.5x 便于查看 + zoom := imaging.Resize(c.Image(), panelW*5/2, panelH*10, imaging.NearestNeighbor) + _ = imaging.Save(zoom, "glass_layers.png") + t.Log("已输出 glass_layers.png") + + // 像素取证:p4 顶部左角外围 (卡片左上角在 x=70, y=panelH*3+35=485) + img := c.Image() + for _, py := range []int{474, 478, 482, 486, 490} { + row := "" + for px := 56; px <= 88; px += 4 { + r, g, b, a := img.At(px, py).RGBA() + row += fmt.Sprintf("[%3d,%3d,%3d,%3d] ", r>>8, g>>8, b>>8, a>>8) + } + t.Logf("y=%d x=56..88: %s", py, row) + } +} diff --git a/plugin/thesaurus/chat.go b/plugin/thesaurus/chat.go index fafdd5826d..3e62c90794 100644 --- a/plugin/thesaurus/chat.go +++ b/plugin/thesaurus/chat.go @@ -16,8 +16,11 @@ func init() { Brief: "词典匹配回复, 仅@触发", PublicDataFolder: "Chat", }) - engine.OnMessage(zero.OnlyToMe, canmatch()). - SetBlock(false).Handle(func(ctx *zero.Ctx) { + // 优先级设为 10,低于控制命令(SecondPriority=1), + // 避免 /全局禁用、/启用 等管理命令被词库回复抢先拦截 + chatm := engine.OnMessage(zero.OnlyToMe, canmatch()).SetBlock(false) + (*zero.Matcher)(chatm).SetPriority(10) + chatm.Handle(func(ctx *zero.Ctx) { msg := ctx.ExtractPlainText() r, err := kimoi.Chat(msg) if err == nil {