From 9e9ae38858215f9fc4e9dba2e98d37152882ded7 Mon Sep 17 00:00:00 2001 From: dcsunny <934831065@qq.com> Date: Wed, 1 Jul 2026 10:11:06 +0800 Subject: [PATCH] feat: add htsy CLI tool for Haitun private domain API --- .gitignore | 7 + Makefile | 29 +++ README.md | 37 ++++ config.go | 109 +++++++++ go.mod | 3 + http.go | 198 +++++++++++++++++ main.go | 606 +++++++++++++++++++++++++++++++++++++++++++++++++++ main_test.go | 153 +++++++++++++ 8 files changed, 1142 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README.md create mode 100644 config.go create mode 100644 go.mod create mode 100644 http.go create mode 100644 main.go create mode 100644 main_test.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..304b695 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/bin/ +/dist/ +/.htsy/ +*.test + +# Added by aicommit after detecting protected files +/htsy-cli diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..4669ace --- /dev/null +++ b/Makefile @@ -0,0 +1,29 @@ +BINARY ?= htsy +VERSION ?= dev +LDFLAGS := -s -w -X main.version=$(VERSION) +PLATFORMS := linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64 windows/arm64 + +.PHONY: test build release clean + +test: + go test ./... + +build: + mkdir -p bin + CGO_ENABLED=0 go build -trimpath -ldflags "$(LDFLAGS)" -o bin/$(BINARY) . + +release: clean test + mkdir -p dist + for platform in $(PLATFORMS); do \ + GOOS=$${platform%/*}; \ + GOARCH=$${platform#*/}; \ + EXT=""; \ + if [ "$$GOOS" = "windows" ]; then EXT=".exe"; fi; \ + OUT="dist/$(BINARY)_$${GOOS}_$${GOARCH}$${EXT}"; \ + echo "building $$OUT"; \ + CGO_ENABLED=0 GOOS=$$GOOS GOARCH=$$GOARCH go build -trimpath -ldflags "$(LDFLAGS)" -o "$$OUT" .; \ + done + cd dist && shasum -a 256 * > checksums.txt + +clean: + rm -rf bin dist diff --git a/README.md b/README.md new file mode 100644 index 0000000..8b51fa6 --- /dev/null +++ b/README.md @@ -0,0 +1,37 @@ +# htsy-cli + +海豚私域接口命令行工具。 + +## 使用 + +```bash +./htsy-cli login --username USER --password PASS +./htsy-cli status +./htsy-cli call /api/account/user/info -d '{}' +./htsy-cli call /api/bot/wx/list -d '{"page":1,"pageSize":20,"botType":1}' +``` + +Windows 下二进制文件名是 `htsy-cli.exe`,命令示例里的 `./htsy-cli` 对应替换为 `htsy-cli.exe`。 + +`login` 会按用户名是否包含 `@` 自动分流: + +- `ldc@18030042216` -> `/api/account/user/login/in` +- `18030042216` -> `/api/account/login/in` + +登录成功后,CLI 会把登录信息保存到当前用户主目录下的 `~/.htsy/config.json`。Linux、macOS、Windows 均支持。 + +## 构建 + +```bash +make build +make release VERSION=v0.1.0 +``` + +`make release` 会构建: + +- `linux/amd64` +- `linux/arm64` +- `darwin/amd64` +- `darwin/arm64` +- `windows/amd64` +- `windows/arm64` diff --git a/config.go b/config.go new file mode 100644 index 0000000..1c3cc61 --- /dev/null +++ b/config.go @@ -0,0 +1,109 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "time" +) + +const ( + configDirName = ".htsy" + configFileName = "config.json" + defaultBaseURL = "https://htsy-gateway.cpshelp.cn" +) + +type Config struct { + BaseURL string `json:"baseUrl,omitempty"` + Token string `json:"token,omitempty"` + Username string `json:"username,omitempty"` + LoginKind string `json:"loginKind,omitempty"` + UpdatedAt time.Time `json:"updatedAt,omitempty"` +} + +func defaultConfigPath() (string, error) { + if value := os.Getenv("HTSY_CONFIG"); value != "" { + return value, nil + } + + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve home directory: %w", err) + } + + return filepath.Join(home, configDirName, configFileName), nil +} + +func resolveConfigPath(path string) (string, error) { + if path != "" { + return path, nil + } + return defaultConfigPath() +} + +func loadConfig(path string) (Config, error) { + resolved, err := resolveConfigPath(path) + if err != nil { + return Config{}, err + } + + data, err := os.ReadFile(resolved) + if errors.Is(err, os.ErrNotExist) { + return Config{BaseURL: defaultBaseURL}, nil + } + if err != nil { + return Config{}, fmt.Errorf("read config %s: %w", resolved, err) + } + + var cfg Config + if err := json.Unmarshal(data, &cfg); err != nil { + return Config{}, fmt.Errorf("parse config %s: %w", resolved, err) + } + if cfg.BaseURL == "" { + cfg.BaseURL = defaultBaseURL + } + + return cfg, nil +} + +func saveConfig(path string, cfg Config) error { + resolved, err := resolveConfigPath(path) + if err != nil { + return err + } + + if cfg.BaseURL == "" { + cfg.BaseURL = defaultBaseURL + } + cfg.UpdatedAt = time.Now().UTC() + + dir := filepath.Dir(resolved) + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("create config dir %s: %w", dir, err) + } + + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return fmt.Errorf("encode config: %w", err) + } + data = append(data, '\n') + + if err := os.WriteFile(resolved, data, 0o600); err != nil { + return fmt.Errorf("write config %s: %w", resolved, err) + } + + return nil +} + +func clearLogin(path string) error { + cfg, err := loadConfig(path) + if err != nil { + return err + } + cfg.Token = "" + cfg.Username = "" + cfg.LoginKind = "" + return saveConfig(path, cfg) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a227875 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module htsy-cli + +go 1.24 diff --git a/http.go b/http.go new file mode 100644 index 0000000..f865bcf --- /dev/null +++ b/http.go @@ -0,0 +1,198 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "time" +) + +type requestOptions struct { + BaseURL string + Path string + Body []byte + Token string + NoAuth bool + Timeout time.Duration +} + +type responseData struct { + StatusCode int + Body []byte +} + +func normalizeBaseURL(raw string) (string, error) { + if raw == "" { + raw = defaultBaseURL + } + + parsed, err := url.Parse(raw) + if err != nil { + return "", fmt.Errorf("parse base url: %w", err) + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return "", fmt.Errorf("base url must start with http:// or https://") + } + if parsed.Host == "" { + return "", fmt.Errorf("base url must include a host") + } + + return strings.TrimRight(parsed.String(), "/"), nil +} + +func endpointURL(baseURL, path string) (string, error) { + base, err := normalizeBaseURL(baseURL) + if err != nil { + return "", err + } + if path == "" { + return "", fmt.Errorf("endpoint path is required") + } + if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") { + return path, nil + } + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + return base + path, nil +} + +func postJSON(ctx context.Context, opts requestOptions) (responseData, error) { + target, err := endpointURL(opts.BaseURL, opts.Path) + if err != nil { + return responseData{}, err + } + if len(opts.Body) == 0 { + opts.Body = []byte("{}") + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, target, bytes.NewReader(opts.Body)) + if err != nil { + return responseData{}, fmt.Errorf("create request: %w", err) + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + if !opts.NoAuth && opts.Token != "" { + req.Header.Set("Authorization", "Bearer "+opts.Token) + } + + timeout := opts.Timeout + if timeout <= 0 { + timeout = 30 * time.Second + } + client := &http.Client{Timeout: timeout} + + resp, err := client.Do(req) + if err != nil { + return responseData{}, fmt.Errorf("http post %s: %w", target, err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return responseData{}, fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return responseData{StatusCode: resp.StatusCode, Body: body}, httpStatusError{StatusCode: resp.StatusCode, Body: body} + } + + return responseData{StatusCode: resp.StatusCode, Body: body}, nil +} + +type httpStatusError struct { + StatusCode int + Body []byte +} + +func (e httpStatusError) Error() string { + body := strings.TrimSpace(string(e.Body)) + if body == "" { + return fmt.Sprintf("request failed with status %d", e.StatusCode) + } + return fmt.Sprintf("request failed with status %d: %s", e.StatusCode, body) +} + +func readJSONData(value string) ([]byte, error) { + if value == "" { + return []byte("{}"), nil + } + + var data []byte + var err error + switch { + case value == "-": + data, err = io.ReadAll(os.Stdin) + case strings.HasPrefix(value, "@"): + path := strings.TrimPrefix(value, "@") + if path == "" { + return nil, fmt.Errorf("missing file after @") + } + cleanPath, err := filepath.Abs(path) + if err != nil { + return nil, fmt.Errorf("resolve json file: %w", err) + } + data, err = os.ReadFile(cleanPath) + default: + data = []byte(value) + } + if err != nil { + return nil, fmt.Errorf("read json body: %w", err) + } + + data = bytes.TrimSpace(data) + if len(data) == 0 { + data = []byte("{}") + } + if !json.Valid(data) { + return nil, fmt.Errorf("request body must be valid JSON") + } + + return data, nil +} + +func writeJSONResponse(w io.Writer, body []byte, raw bool) error { + body = bytes.TrimSpace(body) + if len(body) == 0 { + _, err := fmt.Fprintln(w, "{}") + return err + } + if raw { + _, err := w.Write(append(body, '\n')) + return err + } + + var out bytes.Buffer + if err := json.Indent(&out, body, "", " "); err != nil { + _, writeErr := w.Write(append(body, '\n')) + return writeErr + } + out.WriteByte('\n') + _, err := w.Write(out.Bytes()) + return err +} + +func tokenFromResponse(body []byte) (string, error) { + var decoded map[string]any + if err := json.Unmarshal(body, &decoded); err != nil { + return "", fmt.Errorf("login response is not JSON: %w", err) + } + if token, ok := decoded["token"].(string); ok && token != "" { + return token, nil + } + + if data, ok := decoded["data"].(map[string]any); ok { + if token, ok := data["token"].(string); ok && token != "" { + return token, nil + } + } + + return "", fmt.Errorf("login response does not contain token") +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..ef49a1d --- /dev/null +++ b/main.go @@ -0,0 +1,606 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "os" + "runtime" + "strings" + "time" +) + +var version = "dev" + +func main() { + if err := run(os.Args[1:]); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + os.Exit(1) + } +} + +func run(args []string) error { + if len(args) == 0 { + printUsage() + return nil + } + + switch args[0] { + case "login": + return runLogin(args[1:], false) + case "account-login": + return runLogin(args[1:], true) + case "register": + return runRegister(args[1:]) + case "send-code": + return runSendCode(args[1:]) + case "call": + return runCall(args[1:]) + case "status": + return runStatus(args[1:]) + case "logout": + return runLogout(args[1:]) + case "config": + return runConfig(args[1:]) + case "version", "--version", "-v": + fmt.Printf("htsy %s %s/%s\n", version, runtime.GOOS, runtime.GOARCH) + return nil + case "help", "--help", "-h": + printUsage() + return nil + default: + return fmt.Errorf("unknown command %q", args[0]) + } +} + +func runLogin(args []string, accountLogin bool) error { + fs := flag.NewFlagSet("login", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + + var username, phone, password, code, baseURL, configPath string + var timeout time.Duration + var printToken bool + fs.StringVar(&username, "username", "", "user login username") + fs.StringVar(&phone, "phone", "", "account login phone") + fs.StringVar(&password, "password", "", "password; defaults to HTSY_PASSWORD when omitted") + fs.StringVar(&code, "code", "", "sms code for account phone login") + fs.StringVar(&baseURL, "base-url", "", "API base URL") + fs.StringVar(&configPath, "config", "", "config file path") + fs.DurationVar(&timeout, "timeout", 30*time.Second, "request timeout") + fs.BoolVar(&printToken, "print-token", false, "print token in command output") + + if err := fs.Parse(args); err != nil { + return err + } + + if password == "" && code == "" { + password = os.Getenv("HTSY_PASSWORD") + } + if baseURL == "" { + baseURL = os.Getenv("HTSY_BASE_URL") + } + + loginID := username + if loginID == "" { + loginID = phone + } + if loginID == "" && fs.NArg() > 0 { + loginID = fs.Arg(0) + } + if password == "" && fs.NArg() > 1 { + password = fs.Arg(1) + } + + if loginID == "" { + if accountLogin { + return fmt.Errorf("--phone is required") + } + return fmt.Errorf("--username or --phone is required") + } + + endpoint, payloadKey, kind := loginRoute(loginID, accountLogin) + loginName := loginID + payload := map[string]string{payloadKey: loginID} + if accountLogin { + if password == "" && code == "" { + return fmt.Errorf("--password or --code is required") + } + } else if password == "" { + return fmt.Errorf("--password is required or set HTSY_PASSWORD") + } + if password != "" { + payload["password"] = password + } + if code != "" { + payload["code"] = code + } + + body, err := json.Marshal(payload) + if err != nil { + return err + } + resolvedBaseURL, err := baseURLFromConfigOrFlag(configPath, baseURL) + if err != nil { + return err + } + + resp, err := postJSON(context.Background(), requestOptions{ + BaseURL: resolvedBaseURL, + Path: endpoint, + Body: body, + NoAuth: true, + Timeout: timeout, + }) + if err != nil { + return err + } + + token, err := tokenFromResponse(resp.Body) + if err != nil { + return err + } + cfg := Config{ + BaseURL: resolvedBaseURL, + Token: token, + Username: loginName, + LoginKind: kind, + } + if err := saveConfig(configPath, cfg); err != nil { + return err + } + + path, err := resolveConfigPath(configPath) + if err != nil { + return err + } + out := map[string]any{ + "ok": true, + "baseUrl": resolvedBaseURL, + "configPath": path, + "loginKind": kind, + "username": loginName, + "tokenSaved": true, + } + if printToken { + out["token"] = token + } + return printObject(out) +} + +func runRegister(args []string) error { + fs := flag.NewFlagSet("register", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + + var username, password, smsCode, inviteToken, baseURL, configPath string + var timeout time.Duration + var printToken bool + fs.StringVar(&username, "username", "", "username, normally the phone number") + fs.StringVar(&password, "password", "", "password; defaults to HTSY_PASSWORD when omitted") + fs.StringVar(&smsCode, "sms-code", "", "sms verification code") + fs.StringVar(&inviteToken, "invite-token", "", "optional invite token") + fs.StringVar(&baseURL, "base-url", "", "API base URL") + fs.StringVar(&configPath, "config", "", "config file path") + fs.DurationVar(&timeout, "timeout", 30*time.Second, "request timeout") + fs.BoolVar(&printToken, "print-token", false, "print token in command output") + if err := fs.Parse(args); err != nil { + return err + } + + if username == "" && fs.NArg() > 0 { + username = fs.Arg(0) + } + if password == "" && fs.NArg() > 1 { + password = fs.Arg(1) + } + if smsCode == "" && fs.NArg() > 2 { + smsCode = fs.Arg(2) + } + if password == "" { + password = os.Getenv("HTSY_PASSWORD") + } + if username == "" { + return fmt.Errorf("--username is required") + } + if password == "" { + return fmt.Errorf("--password is required or set HTSY_PASSWORD") + } + if smsCode == "" { + return fmt.Errorf("--sms-code is required") + } + if baseURL == "" { + baseURL = os.Getenv("HTSY_BASE_URL") + } + resolvedBaseURL, err := baseURLFromConfigOrFlag(configPath, baseURL) + if err != nil { + return err + } + + payload := map[string]string{ + "username": username, + "password": password, + "smsCode": smsCode, + } + if inviteToken != "" { + payload["inviteToken"] = inviteToken + } + body, err := json.Marshal(payload) + if err != nil { + return err + } + + resp, err := postJSON(context.Background(), requestOptions{ + BaseURL: resolvedBaseURL, + Path: "/api/account/user/register", + Body: body, + NoAuth: true, + Timeout: timeout, + }) + if err != nil { + return err + } + token, err := tokenFromResponse(resp.Body) + if err != nil { + return err + } + + cfg := Config{ + BaseURL: resolvedBaseURL, + Token: token, + Username: username, + LoginKind: "user", + } + if err := saveConfig(configPath, cfg); err != nil { + return err + } + path, err := resolveConfigPath(configPath) + if err != nil { + return err + } + out := map[string]any{ + "ok": true, + "baseUrl": resolvedBaseURL, + "configPath": path, + "loginKind": "user", + "username": username, + "tokenSaved": true, + } + if printToken { + out["token"] = token + } + return printObject(out) +} + +func runSendCode(args []string) error { + fs := flag.NewFlagSet("send-code", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + + var phone, scene, baseURL, configPath string + var timeout time.Duration + var raw bool + fs.StringVar(&phone, "phone", "", "phone number") + fs.StringVar(&scene, "scene", "", "scene: register, resetPwd, login, bind") + fs.StringVar(&baseURL, "base-url", "", "API base URL") + fs.StringVar(&configPath, "config", "", "config file path") + fs.DurationVar(&timeout, "timeout", 30*time.Second, "request timeout") + fs.BoolVar(&raw, "raw", false, "print raw response") + if err := fs.Parse(args); err != nil { + return err + } + + if phone == "" && fs.NArg() > 0 { + phone = fs.Arg(0) + } + if scene == "" && fs.NArg() > 1 { + scene = fs.Arg(1) + } + if phone == "" { + return fmt.Errorf("--phone is required") + } + if scene == "" { + return fmt.Errorf("--scene is required") + } + if baseURL == "" { + baseURL = os.Getenv("HTSY_BASE_URL") + } + resolvedBaseURL, err := baseURLFromConfigOrFlag(configPath, baseURL) + if err != nil { + return err + } + body, err := json.Marshal(map[string]string{"phone": phone, "scene": scene}) + if err != nil { + return err + } + + resp, err := postJSON(context.Background(), requestOptions{ + BaseURL: resolvedBaseURL, + Path: "/api/account/user/phone/sendCode", + Body: body, + NoAuth: true, + Timeout: timeout, + }) + if err != nil { + return err + } + return writeJSONResponse(os.Stdout, resp.Body, raw) +} + +func runCall(args []string) error { + path, flagArgs, err := splitCallArgs(args) + if err != nil { + return err + } + + fs := flag.NewFlagSet("call", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + + var data, baseURL, configPath, token string + var timeout time.Duration + var noAuth, raw bool + fs.StringVar(&data, "d", "{}", "JSON request body, @file, or - for stdin") + fs.StringVar(&data, "data", "{}", "JSON request body, @file, or - for stdin") + fs.StringVar(&baseURL, "base-url", "", "API base URL") + fs.StringVar(&configPath, "config", "", "config file path") + fs.StringVar(&token, "token", "", "override bearer token") + fs.DurationVar(&timeout, "timeout", 30*time.Second, "request timeout") + fs.BoolVar(&noAuth, "no-auth", false, "do not attach bearer token") + fs.BoolVar(&raw, "raw", false, "print raw response") + if err := fs.Parse(flagArgs); err != nil { + return err + } + if fs.NArg() > 0 { + return fmt.Errorf("unexpected call arguments: %s", strings.Join(fs.Args(), " ")) + } + + cfg, err := loadConfig(configPath) + if err != nil { + return err + } + if baseURL == "" { + baseURL = os.Getenv("HTSY_BASE_URL") + } + if baseURL == "" { + baseURL = cfg.BaseURL + } + if token == "" { + token = os.Getenv("HTSY_TOKEN") + } + if token == "" { + token = cfg.Token + } + if !noAuth && token == "" { + return fmt.Errorf("not logged in; run htsy login first or pass --token") + } + body, err := readJSONData(data) + if err != nil { + return err + } + + resp, err := postJSON(context.Background(), requestOptions{ + BaseURL: baseURL, + Path: path, + Body: body, + Token: token, + NoAuth: noAuth, + Timeout: timeout, + }) + if err != nil { + return err + } + return writeJSONResponse(os.Stdout, resp.Body, raw) +} + +func splitCallArgs(args []string) (string, []string, error) { + var endpoint string + var flagArgs []string + expectValue := false + + for _, arg := range args { + if expectValue { + flagArgs = append(flagArgs, arg) + expectValue = false + continue + } + + if isCallValueFlag(arg) { + flagArgs = append(flagArgs, arg) + if !strings.Contains(arg, "=") { + expectValue = true + } + continue + } + + if isCallBoolFlag(arg) || strings.HasPrefix(arg, "-") { + flagArgs = append(flagArgs, arg) + continue + } + + if endpoint == "" { + endpoint = arg + continue + } + + return "", nil, fmt.Errorf("unexpected call argument %q", arg) + } + + if expectValue { + return "", nil, fmt.Errorf("missing value for call flag") + } + if endpoint == "" { + return "", nil, fmt.Errorf("endpoint path is required") + } + + return endpoint, flagArgs, nil +} + +func isCallValueFlag(arg string) bool { + for _, name := range []string{"-d", "--data", "-data", "--base-url", "-base-url", "--config", "-config", "--token", "-token", "--timeout", "-timeout"} { + if arg == name || strings.HasPrefix(arg, name+"=") { + return true + } + } + return false +} + +func isCallBoolFlag(arg string) bool { + for _, name := range []string{"--no-auth", "-no-auth", "--raw", "-raw"} { + if arg == name || strings.HasPrefix(arg, name+"=") { + return true + } + } + return false +} + +func runStatus(args []string) error { + fs := flag.NewFlagSet("status", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + var configPath string + fs.StringVar(&configPath, "config", "", "config file path") + if err := fs.Parse(args); err != nil { + return err + } + cfg, err := loadConfig(configPath) + if err != nil { + return err + } + path, err := resolveConfigPath(configPath) + if err != nil { + return err + } + tokenPrefix := "" + if cfg.Token != "" { + if len(cfg.Token) <= 8 { + tokenPrefix = cfg.Token + } else { + tokenPrefix = cfg.Token[:8] + } + } + return printObject(map[string]any{ + "baseUrl": cfg.BaseURL, + "configPath": path, + "loggedIn": cfg.Token != "", + "loginKind": cfg.LoginKind, + "username": cfg.Username, + "tokenPrefix": tokenPrefix, + "updatedAt": cfg.UpdatedAt, + }) +} + +func runLogout(args []string) error { + fs := flag.NewFlagSet("logout", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + var configPath string + fs.StringVar(&configPath, "config", "", "config file path") + if err := fs.Parse(args); err != nil { + return err + } + if err := clearLogin(configPath); err != nil { + return err + } + return printObject(map[string]any{"ok": true}) +} + +func runConfig(args []string) error { + if len(args) == 0 { + return fmt.Errorf("config subcommand is required: path or set-base-url") + } + switch args[0] { + case "path": + fs := flag.NewFlagSet("config path", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + var configPath string + fs.StringVar(&configPath, "config", "", "config file path") + if err := fs.Parse(args[1:]); err != nil { + return err + } + path, err := resolveConfigPath(configPath) + if err != nil { + return err + } + fmt.Println(path) + return nil + case "set-base-url": + fs := flag.NewFlagSet("config set-base-url", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + var configPath string + fs.StringVar(&configPath, "config", "", "config file path") + if err := fs.Parse(args[1:]); err != nil { + return err + } + if fs.NArg() < 1 { + return fmt.Errorf("base url is required") + } + baseURL, err := normalizeBaseURL(fs.Arg(0)) + if err != nil { + return err + } + cfg, err := loadConfig(configPath) + if err != nil { + return err + } + cfg.BaseURL = baseURL + if err := saveConfig(configPath, cfg); err != nil { + return err + } + return printObject(map[string]any{"ok": true, "baseUrl": baseURL}) + default: + return fmt.Errorf("unknown config subcommand %q", args[0]) + } +} + +func baseURLFromConfigOrFlag(configPath, baseURL string) (string, error) { + if baseURL == "" { + cfg, err := loadConfig(configPath) + if err != nil { + return "", err + } + baseURL = cfg.BaseURL + } + return normalizeBaseURL(baseURL) +} + +func printObject(value any) error { + data, err := json.MarshalIndent(value, "", " ") + if err != nil { + return err + } + fmt.Println(string(data)) + return nil +} + +func printUsage() { + fmt.Println(strings.TrimSpace(` +htsy 是海豚私域接口命令行工具。 + +用法: + htsy login --username USER --password PASS [--base-url URL] + htsy account-login --phone PHONE --password PASS + htsy send-code --phone PHONE --scene register + htsy register --username PHONE --password PASS --sms-code CODE [--invite-token TOKEN] + htsy call /api/account/user/info -d '{}' + htsy status + htsy logout + htsy config path + htsy config set-base-url https://htsy-gateway.cpshelp.cn + +配置: + 登录信息会保存到当前用户主目录下的 ~/.htsy/config.json。 + 可用 HTSY_CONFIG 覆盖配置文件路径。 + 自动化场景可使用 HTSY_BASE_URL、HTSY_TOKEN、HTSY_PASSWORD。 + +登录规则: + login 命令里带 @ 的用户名会走子账号登录,调用 /api/account/user/login/in。 + 不带 @ 的用户名会走主账号登录,调用 /api/account/login/in。 + account-login 始终走主账号登录,调用 /api/account/login/in。 +`)) +} + +func loginRoute(loginID string, accountLogin bool) (endpoint string, payloadKey string, kind string) { + if accountLogin { + return "/api/account/login/in", "phone", "account" + } + if strings.Contains(loginID, "@") { + return "/api/account/user/login/in", "username", "user" + } + return "/api/account/login/in", "phone", "account" +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..ac71b4a --- /dev/null +++ b/main_test.go @@ -0,0 +1,153 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestNormalizeBaseURL(t *testing.T) { + got, err := normalizeBaseURL("https://mpbot.cpshelp.cn/") + if err != nil { + t.Fatal(err) + } + if got != "https://mpbot.cpshelp.cn" { + t.Fatalf("got %q", got) + } + + if _, err := normalizeBaseURL("mpbot.cpshelp.cn"); err == nil { + t.Fatal("expected invalid base url error") + } +} + +func TestEndpointURL(t *testing.T) { + got, err := endpointURL("https://mpbot.cpshelp.cn/", "api/account/user/info") + if err != nil { + t.Fatal(err) + } + want := "https://mpbot.cpshelp.cn/api/account/user/info" + if got != want { + t.Fatalf("got %q want %q", got, want) + } +} + +func TestReadJSONDataLiteralAndFile(t *testing.T) { + if _, err := readJSONData(`{"ok":true}`); err != nil { + t.Fatal(err) + } + if _, err := readJSONData(`{"ok":`); err == nil { + t.Fatal("expected invalid json error") + } + + dir := t.TempDir() + path := filepath.Join(dir, "body.json") + if err := os.WriteFile(path, []byte(`{"from":"file"}`), 0o644); err != nil { + t.Fatal(err) + } + if _, err := readJSONData("@" + path); err != nil { + t.Fatal(err) + } +} + +func TestConfigRoundTrip(t *testing.T) { + path := filepath.Join(t.TempDir(), ".htsy", "config.json") + cfg := Config{ + BaseURL: "https://mpbot.cpshelp.cn", + Token: "token-value", + Username: "user", + LoginKind: "user", + } + if err := saveConfig(path, cfg); err != nil { + t.Fatal(err) + } + loaded, err := loadConfig(path) + if err != nil { + t.Fatal(err) + } + if loaded.Token != cfg.Token || loaded.Username != cfg.Username || loaded.BaseURL != cfg.BaseURL { + t.Fatalf("loaded config mismatch: %+v", loaded) + } +} + +func TestTokenFromResponse(t *testing.T) { + token, err := tokenFromResponse([]byte(`{"token":"top-level"}`)) + if err != nil { + t.Fatal(err) + } + if token != "top-level" { + t.Fatalf("got %q", token) + } + + token, err = tokenFromResponse([]byte(`{"code":0,"data":{"token":"nested"}}`)) + if err != nil { + t.Fatal(err) + } + if token != "nested" { + t.Fatalf("got %q", token) + } +} + +func TestSplitCallArgs(t *testing.T) { + tests := []struct { + name string + args []string + wantPath string + wantFlags []string + wantErr bool + }{ + { + name: "path first", + args: []string{"/api/account/user/info", "-d", "{}"}, + wantPath: "/api/account/user/info", + wantFlags: []string{"-d", "{}"}, + }, + { + name: "flags first", + args: []string{"-d", "{}", "--raw", "/api/account/user/info"}, + wantPath: "/api/account/user/info", + wantFlags: []string{"-d", "{}", "--raw"}, + }, + { + name: "equals flag", + args: []string{"--data={}", "/api/account/user/info"}, + wantPath: "/api/account/user/info", + wantFlags: []string{"--data={}"}, + }, + { + name: "missing endpoint", + args: []string{"-d", "{}"}, + wantErr: true, + }, + { + name: "missing flag value", + args: []string{"/api/account/user/info", "-d"}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotPath, gotFlags, err := splitCallArgs(tt.args) + if tt.wantErr { + if err == nil { + t.Fatal("expected error") + } + return + } + if err != nil { + t.Fatal(err) + } + if gotPath != tt.wantPath { + t.Fatalf("path got %q want %q", gotPath, tt.wantPath) + } + if len(gotFlags) != len(tt.wantFlags) { + t.Fatalf("flags got %#v want %#v", gotFlags, tt.wantFlags) + } + for i := range gotFlags { + if gotFlags[i] != tt.wantFlags[i] { + t.Fatalf("flags got %#v want %#v", gotFlags, tt.wantFlags) + } + } + }) + } +}