519 lines
13 KiB
Go
519 lines
13 KiB
Go
package gui
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/wailsapp/wails/v2/pkg/runtime"
|
|
|
|
"huya-go/internal/huya"
|
|
"huya-go/internal/sniper"
|
|
)
|
|
|
|
const (
|
|
eventLog = "gui:log"
|
|
eventMonitorResult = "gui:monitor-result"
|
|
eventMonitorState = "gui:monitor-state"
|
|
maxLogEntries = 500
|
|
)
|
|
|
|
type App struct {
|
|
ctx context.Context
|
|
|
|
logMu sync.Mutex
|
|
logs []LogEntry
|
|
|
|
runMu sync.Mutex
|
|
monitorCancel context.CancelFunc
|
|
monitorRunning bool
|
|
}
|
|
|
|
type Config struct {
|
|
Transport string `json:"transport"`
|
|
WSSHost string `json:"wssHost"`
|
|
SID int32 `json:"sid"`
|
|
PID int32 `json:"pid"`
|
|
Name string `json:"name"`
|
|
CookieText string `json:"cookieText"`
|
|
Loops int `json:"loops"`
|
|
DelayMS int `json:"delayMs"`
|
|
Concurrency int `json:"concurrency"`
|
|
StopAfterSuccess bool `json:"stopAfterSuccess"`
|
|
TimeoutSec int `json:"timeoutSec"`
|
|
StartAt string `json:"startAt"`
|
|
}
|
|
|
|
type AccountPreview struct {
|
|
Line int `json:"line"`
|
|
UID int64 `json:"uid"`
|
|
Valid bool `json:"valid"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
type LogEntry struct {
|
|
Time string `json:"time"`
|
|
Level string `json:"level"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
type PrizeListResult struct {
|
|
UID int64 `json:"uid"`
|
|
Status int32 `json:"status"`
|
|
Msg string `json:"msg"`
|
|
Prizes []huya.PrizeInfo `json:"prizes"`
|
|
Raw *huya.GetActPrizeListResp `json:"raw,omitempty"`
|
|
}
|
|
|
|
type ScoreResult struct {
|
|
UID int64 `json:"uid"`
|
|
Score int64 `json:"score"`
|
|
Resp *huya.GetUserScoreResp `json:"resp,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
type RecordResult struct {
|
|
UID int64 `json:"uid"`
|
|
Records []huya.UserPrizeInfo `json:"records"`
|
|
Resp *huya.GetUserPrizeRecordsResp `json:"resp,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
type ExchangeResult struct {
|
|
UID int64 `json:"uid"`
|
|
Resp *huya.ScoreExchangePrizeResp `json:"resp,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
type MonitorState struct {
|
|
Running bool `json:"running"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
type cookieLine struct {
|
|
lineNo int
|
|
text string
|
|
}
|
|
|
|
func NewApp() *App {
|
|
return &App{}
|
|
}
|
|
|
|
func (a *App) Startup(ctx context.Context) {
|
|
a.ctx = ctx
|
|
a.addLog("info", "GUI 已启动")
|
|
}
|
|
|
|
func (a *App) Shutdown(ctx context.Context) {
|
|
a.StopMonitor()
|
|
}
|
|
|
|
func (a *App) DefaultConfig() Config {
|
|
return Config{
|
|
Transport: "wss",
|
|
WSSHost: huya.DefaultWSSHost,
|
|
SID: 2203,
|
|
PID: 12889,
|
|
Name: "小黄鸭",
|
|
Loops: 20,
|
|
DelayMS: 120,
|
|
Concurrency: 5,
|
|
StopAfterSuccess: true,
|
|
TimeoutSec: 15,
|
|
}
|
|
}
|
|
|
|
func (a *App) PreviewAccounts(cookieText string) []AccountPreview {
|
|
previews := make([]AccountPreview, 0)
|
|
for _, line := range splitCookieLines(cookieText) {
|
|
uid := huya.ResolveUID(line.text)
|
|
item := AccountPreview{Line: line.lineNo, UID: uid, Valid: uid > 0}
|
|
if uid <= 0 {
|
|
item.Error = "无法解析 yyuid/udb_uid"
|
|
}
|
|
previews = append(previews, item)
|
|
}
|
|
return previews
|
|
}
|
|
|
|
func (a *App) GetLogs() []LogEntry {
|
|
a.logMu.Lock()
|
|
defer a.logMu.Unlock()
|
|
return append([]LogEntry(nil), a.logs...)
|
|
}
|
|
|
|
func (a *App) IsRunning() bool {
|
|
a.runMu.Lock()
|
|
defer a.runMu.Unlock()
|
|
return a.monitorRunning
|
|
}
|
|
|
|
func (a *App) ListPrizes(cfg Config) (PrizeListResult, error) {
|
|
cfg = normalizeConfig(cfg)
|
|
accounts, err := parseAccounts(cfg.CookieText)
|
|
if err != nil {
|
|
return PrizeListResult{}, err
|
|
}
|
|
if cfg.SID <= 0 {
|
|
return PrizeListResult{}, errors.New("SID 不能为空")
|
|
}
|
|
client, closeClient, err := a.newClient(cfg)
|
|
if err != nil {
|
|
return PrizeListResult{}, err
|
|
}
|
|
defer closeClient()
|
|
|
|
ctx, cancel := requestContext(cfg.TimeoutSec)
|
|
defer cancel()
|
|
resp, err := client.GetActPrizeList(ctx, accounts[0].UID, accounts[0].Cookie, cfg.SID)
|
|
if err != nil {
|
|
a.addLog("error", "查询奖品失败: %v", err)
|
|
return PrizeListResult{}, err
|
|
}
|
|
a.addLog("info", "查询奖品完成: uid=%d sid=%d count=%d", accounts[0].UID, cfg.SID, len(resp.Prizes))
|
|
return PrizeListResult{
|
|
UID: accounts[0].UID,
|
|
Status: resp.Status,
|
|
Msg: resp.Msg,
|
|
Prizes: resp.Prizes,
|
|
Raw: resp,
|
|
}, nil
|
|
}
|
|
|
|
func (a *App) QueryScores(cfg Config) ([]ScoreResult, error) {
|
|
cfg = normalizeConfig(cfg)
|
|
accounts, err := parseAccounts(cfg.CookieText)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if cfg.SID <= 0 {
|
|
return nil, errors.New("SID 不能为空")
|
|
}
|
|
client, closeClient, err := a.newClient(cfg)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer closeClient()
|
|
|
|
results := make([]ScoreResult, len(accounts))
|
|
a.runConcurrent(accounts, cfg.Concurrency, func(i int, account sniper.Account) {
|
|
ctx, cancel := requestContext(cfg.TimeoutSec)
|
|
defer cancel()
|
|
resp, err := client.QueryUserScore(ctx, account.UID, account.Cookie, cfg.SID)
|
|
item := ScoreResult{UID: account.UID, Resp: resp, Error: errorString(err)}
|
|
if resp != nil {
|
|
item.Score = resp.AvailableScore()
|
|
}
|
|
results[i] = item
|
|
})
|
|
a.addLog("info", "查询积分完成: accounts=%d", len(accounts))
|
|
return results, nil
|
|
}
|
|
|
|
func (a *App) GetRecords(cfg Config) ([]RecordResult, error) {
|
|
cfg = normalizeConfig(cfg)
|
|
accounts, err := parseAccounts(cfg.CookieText)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if cfg.SID <= 0 {
|
|
return nil, errors.New("SID 不能为空")
|
|
}
|
|
client, closeClient, err := a.newClient(cfg)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer closeClient()
|
|
|
|
results := make([]RecordResult, len(accounts))
|
|
a.runConcurrent(accounts, cfg.Concurrency, func(i int, account sniper.Account) {
|
|
ctx, cancel := requestContext(cfg.TimeoutSec)
|
|
defer cancel()
|
|
resp, err := client.GetUserPrizeRecords(ctx, account.UID, account.Cookie, cfg.SID)
|
|
item := RecordResult{UID: account.UID, Resp: resp, Error: errorString(err)}
|
|
if resp != nil {
|
|
item.Records = resp.Records
|
|
}
|
|
results[i] = item
|
|
})
|
|
a.addLog("info", "查询兑换记录完成: accounts=%d", len(accounts))
|
|
return results, nil
|
|
}
|
|
|
|
func (a *App) Exchange(cfg Config) ([]ExchangeResult, error) {
|
|
cfg = normalizeConfig(cfg)
|
|
accounts, err := parseAccounts(cfg.CookieText)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if cfg.SID <= 0 || cfg.PID <= 0 {
|
|
return nil, errors.New("SID 和 PID 都不能为空")
|
|
}
|
|
client, closeClient, err := a.newClient(cfg)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer closeClient()
|
|
|
|
results := make([]ExchangeResult, len(accounts))
|
|
a.runConcurrent(accounts, cfg.Concurrency, func(i int, account sniper.Account) {
|
|
ctx, cancel := requestContext(cfg.TimeoutSec)
|
|
defer cancel()
|
|
resp, err := client.ScoreExchangePrize(ctx, account.UID, account.Cookie, cfg.SID, cfg.PID)
|
|
results[i] = ExchangeResult{UID: account.UID, Resp: resp, Error: errorString(err)}
|
|
})
|
|
a.addLog("info", "直接兑换完成: sid=%d pid=%d accounts=%d", cfg.SID, cfg.PID, len(accounts))
|
|
return results, nil
|
|
}
|
|
|
|
func (a *App) StartMonitor(cfg Config) error {
|
|
cfg = normalizeConfig(cfg)
|
|
accounts, err := parseAccounts(cfg.CookieText)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if cfg.SID <= 0 {
|
|
return errors.New("SID 不能为空")
|
|
}
|
|
if cfg.PID <= 0 && strings.TrimSpace(cfg.Name) == "" {
|
|
return errors.New("PID 和名称至少填写一个")
|
|
}
|
|
startAt, err := parseStartTime(cfg.StartAt)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
a.runMu.Lock()
|
|
if a.monitorRunning {
|
|
a.runMu.Unlock()
|
|
return errors.New("监控已在运行")
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
a.monitorCancel = cancel
|
|
a.monitorRunning = true
|
|
a.runMu.Unlock()
|
|
|
|
client, closeClient, err := a.newClient(cfg)
|
|
if err != nil {
|
|
cancel()
|
|
a.finishMonitor("启动失败")
|
|
return err
|
|
}
|
|
|
|
a.emitState(true, "运行中")
|
|
a.addLog("info", "启动监控: sid=%d pid=%d name=%s accounts=%d", cfg.SID, cfg.PID, cfg.Name, len(accounts))
|
|
|
|
go func() {
|
|
defer closeClient()
|
|
defer a.finishMonitor("已停止")
|
|
|
|
if !startAt.IsZero() {
|
|
a.addLog("info", "等待定时开始: %s", startAt.Format("2006-01-02 15:04:05"))
|
|
if err := waitUntil(ctx, startAt); err != nil {
|
|
a.addLog("info", "定时等待已取消")
|
|
return
|
|
}
|
|
}
|
|
|
|
runner := sniper.Runner{Client: client}
|
|
err := runner.Run(ctx, accounts, sniper.Target{
|
|
PID: cfg.PID,
|
|
Name: strings.TrimSpace(cfg.Name),
|
|
}, sniper.Config{
|
|
SID: cfg.SID,
|
|
Loops: cfg.Loops,
|
|
Delay: time.Duration(cfg.DelayMS) * time.Millisecond,
|
|
Concurrency: cfg.Concurrency,
|
|
StopAfterSuccess: cfg.StopAfterSuccess,
|
|
}, func(result sniper.Result) {
|
|
a.emit(eventMonitorResult, result)
|
|
a.addLog("info", "监控结果: loop=%d uid=%d action=%s error=%s", result.Loop, result.UID, result.Action, result.Error)
|
|
})
|
|
if err != nil && !errors.Is(err, context.Canceled) {
|
|
a.addLog("error", "监控结束异常: %v", err)
|
|
}
|
|
}()
|
|
return nil
|
|
}
|
|
|
|
func (a *App) StopMonitor() bool {
|
|
a.runMu.Lock()
|
|
cancel := a.monitorCancel
|
|
a.runMu.Unlock()
|
|
if cancel == nil {
|
|
return false
|
|
}
|
|
cancel()
|
|
return true
|
|
}
|
|
|
|
func (a *App) newClient(cfg Config) (huya.ActivityClient, func(), error) {
|
|
return huya.NewActivityClient(cfg.Transport, cfg.WSSHost, time.Duration(cfg.TimeoutSec)*time.Second, func(msg string) {
|
|
a.addLog("debug", "%s", msg)
|
|
})
|
|
}
|
|
|
|
func (a *App) runConcurrent(accounts []sniper.Account, concurrency int, fn func(i int, account sniper.Account)) {
|
|
if concurrency <= 0 {
|
|
concurrency = 1
|
|
}
|
|
sem := make(chan struct{}, concurrency)
|
|
var wg sync.WaitGroup
|
|
for i, account := range accounts {
|
|
i, account := i, account
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
sem <- struct{}{}
|
|
defer func() { <-sem }()
|
|
fn(i, account)
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
}
|
|
|
|
func (a *App) finishMonitor(message string) {
|
|
a.runMu.Lock()
|
|
a.monitorCancel = nil
|
|
a.monitorRunning = false
|
|
a.runMu.Unlock()
|
|
a.emitState(false, message)
|
|
}
|
|
|
|
func (a *App) addLog(level, format string, args ...any) {
|
|
entry := LogEntry{
|
|
Time: time.Now().Format("15:04:05.000"),
|
|
Level: level,
|
|
Message: fmt.Sprintf(format, args...),
|
|
}
|
|
a.logMu.Lock()
|
|
a.logs = append(a.logs, entry)
|
|
if len(a.logs) > maxLogEntries {
|
|
a.logs = append([]LogEntry(nil), a.logs[len(a.logs)-maxLogEntries:]...)
|
|
}
|
|
a.logMu.Unlock()
|
|
a.emit(eventLog, entry)
|
|
}
|
|
|
|
func (a *App) emitState(running bool, message string) {
|
|
a.emit(eventMonitorState, MonitorState{Running: running, Message: message})
|
|
}
|
|
|
|
func (a *App) emit(event string, data any) {
|
|
if a.ctx == nil {
|
|
return
|
|
}
|
|
runtime.EventsEmit(a.ctx, event, data)
|
|
}
|
|
|
|
func normalizeConfig(cfg Config) Config {
|
|
if strings.TrimSpace(cfg.Transport) == "" {
|
|
cfg.Transport = "wss"
|
|
}
|
|
if strings.TrimSpace(cfg.WSSHost) == "" {
|
|
cfg.WSSHost = huya.DefaultWSSHost
|
|
}
|
|
if cfg.Loops <= 0 {
|
|
cfg.Loops = 1
|
|
}
|
|
if cfg.DelayMS <= 0 {
|
|
cfg.DelayMS = 150
|
|
}
|
|
if cfg.Concurrency <= 0 {
|
|
cfg.Concurrency = 1
|
|
}
|
|
if cfg.TimeoutSec <= 0 {
|
|
cfg.TimeoutSec = 15
|
|
}
|
|
cfg.Name = strings.TrimSpace(cfg.Name)
|
|
cfg.CookieText = strings.TrimSpace(cfg.CookieText)
|
|
cfg.StartAt = strings.TrimSpace(cfg.StartAt)
|
|
return cfg
|
|
}
|
|
|
|
func parseAccounts(cookieText string) ([]sniper.Account, error) {
|
|
lines := splitCookieLines(cookieText)
|
|
if len(lines) == 0 {
|
|
return nil, errors.New("Cookie 不能为空")
|
|
}
|
|
accounts := make([]sniper.Account, 0, len(lines))
|
|
for _, cookie := range lines {
|
|
uid := huya.ResolveUID(cookie.text)
|
|
if uid <= 0 {
|
|
return nil, fmt.Errorf("第 %d 行 Cookie 无法解析 yyuid/udb_uid", cookie.lineNo)
|
|
}
|
|
accounts = append(accounts, sniper.Account{UID: uid, Cookie: cookie.text})
|
|
}
|
|
return accounts, nil
|
|
}
|
|
|
|
func splitCookieLines(cookieText string) []cookieLine {
|
|
lines := strings.Split(cookieText, "\n")
|
|
out := make([]cookieLine, 0, len(lines))
|
|
for i, line := range lines {
|
|
item := strings.TrimSpace(line)
|
|
if item == "" || strings.HasPrefix(item, "#") {
|
|
continue
|
|
}
|
|
out = append(out, cookieLine{lineNo: i + 1, text: item})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func requestContext(timeoutSec int) (context.Context, context.CancelFunc) {
|
|
if timeoutSec <= 0 {
|
|
timeoutSec = 15
|
|
}
|
|
return context.WithTimeout(context.Background(), time.Duration(timeoutSec)*time.Second)
|
|
}
|
|
|
|
func parseStartTime(text string) (time.Time, error) {
|
|
text = strings.TrimSpace(text)
|
|
if text == "" {
|
|
return time.Time{}, nil
|
|
}
|
|
if ts, err := strconv.ParseInt(text, 10, 64); err == nil {
|
|
return time.Unix(ts, 0), nil
|
|
}
|
|
for _, layout := range []string{time.RFC3339, "2006-01-02 15:04:05", "2006-01-02 15:04"} {
|
|
if t, err := time.ParseInLocation(layout, text, time.Local); err == nil {
|
|
return t, nil
|
|
}
|
|
}
|
|
return time.Time{}, fmt.Errorf("无法解析开始时间: %s", text)
|
|
}
|
|
|
|
func waitUntil(ctx context.Context, when time.Time) error {
|
|
for {
|
|
remaining := time.Until(when)
|
|
if remaining <= 0 {
|
|
return nil
|
|
}
|
|
timer := time.NewTimer(minDuration(remaining, 200*time.Millisecond))
|
|
select {
|
|
case <-timer.C:
|
|
case <-ctx.Done():
|
|
timer.Stop()
|
|
return ctx.Err()
|
|
}
|
|
}
|
|
}
|
|
|
|
func minDuration(a, b time.Duration) time.Duration {
|
|
if a < b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
func errorString(err error) string {
|
|
if err == nil {
|
|
return ""
|
|
}
|
|
return err.Error()
|
|
}
|