commit 9e9ae38858215f9fc4e9dba2e98d37152882ded7 Author: dcsunny <934831065@qq.com> Date: Wed Jul 1 10:11:06 2026 +0800 feat: add htsy CLI tool for Haitun private domain API 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) + } + } + }) + } +}