feat: add Go API library
This commit is contained in:
@@ -0,0 +1,303 @@
|
||||
package mijia
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultBaseURL = "https://api.mijia.tech/app"
|
||||
defaultLoginURL = "https://account.xiaomi.com/longPolling/loginUrl"
|
||||
defaultServiceLoginURL = "https://account.xiaomi.com/pass/serviceLogin"
|
||||
availabilityTTL = 60 * time.Second
|
||||
)
|
||||
|
||||
type Option func(*Client) error
|
||||
|
||||
// WithHTTPClient configures the HTTP transport used by the client.
|
||||
func WithHTTPClient(httpClient *http.Client) Option {
|
||||
return func(client *Client) error {
|
||||
if httpClient == nil {
|
||||
return fmt.Errorf("HTTP client must not be nil")
|
||||
}
|
||||
client.httpClient = httpClient
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
authPath string
|
||||
authMu sync.RWMutex
|
||||
authData AuthData
|
||||
loginMu sync.Mutex
|
||||
httpClientMu sync.RWMutex
|
||||
httpClient *http.Client
|
||||
baseURL string
|
||||
loginURL string
|
||||
serviceLoginURL string
|
||||
locale string
|
||||
qrWriter io.Writer
|
||||
|
||||
availabilityMu sync.Mutex
|
||||
availability bool
|
||||
availabilityValid bool
|
||||
availabilityAt time.Time
|
||||
}
|
||||
|
||||
func NewClient(authPath string, options ...Option) (*Client, error) {
|
||||
resolvedPath, err := resolveAuthPath(authPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client := &Client{
|
||||
authPath: resolvedPath,
|
||||
httpClient: http.DefaultClient,
|
||||
baseURL: defaultBaseURL,
|
||||
loginURL: defaultLoginURL,
|
||||
serviceLoginURL: defaultServiceLoginURL,
|
||||
locale: systemLocale(),
|
||||
qrWriter: os.Stdout,
|
||||
}
|
||||
for _, option := range options {
|
||||
if option == nil {
|
||||
continue
|
||||
}
|
||||
if err := option(client); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := client.loadAuthData(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client.ensureIdentity()
|
||||
client.initSession()
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func resolveAuthPath(authPath string) (string, error) {
|
||||
if authPath == "" {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve home directory: %w", err)
|
||||
}
|
||||
return filepath.Join(home, ".config", "mijia-api", "auth.json"), nil
|
||||
}
|
||||
info, err := os.Stat(authPath)
|
||||
if err == nil && info.IsDir() {
|
||||
return filepath.Join(authPath, "auth.json"), nil
|
||||
}
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return "", fmt.Errorf("inspect auth path: %w", err)
|
||||
}
|
||||
return authPath, nil
|
||||
}
|
||||
|
||||
func systemLocale() string {
|
||||
locale := strings.Split(os.Getenv("LC_ALL"), ".")[0]
|
||||
if locale == "" {
|
||||
locale = strings.Split(os.Getenv("LANG"), ".")[0]
|
||||
}
|
||||
if parts := strings.Split(locale, "_"); len(parts) == 2 && len(parts[1]) >= 2 {
|
||||
return parts[0] + "_" + strings.ToUpper(parts[1][:2])
|
||||
}
|
||||
return "zh_CN"
|
||||
}
|
||||
|
||||
func (client *Client) initSession() {
|
||||
client.httpClientMu.Lock()
|
||||
defer client.httpClientMu.Unlock()
|
||||
client.httpClient = cloneHTTPClient(client.httpClient)
|
||||
}
|
||||
|
||||
func cloneHTTPClient(httpClient *http.Client) *http.Client {
|
||||
clone := *httpClient
|
||||
clone.Jar, _ = cookiejar.New(nil)
|
||||
return &clone
|
||||
}
|
||||
|
||||
func (client *Client) session() *http.Client {
|
||||
client.httpClientMu.RLock()
|
||||
defer client.httpClientMu.RUnlock()
|
||||
return client.httpClient
|
||||
}
|
||||
|
||||
func (client *Client) newSession() *http.Client {
|
||||
return cloneHTTPClient(client.session())
|
||||
}
|
||||
|
||||
func (client *Client) request(ctx context.Context, uri string, data any, refresh bool) (json.RawMessage, error) {
|
||||
if refresh {
|
||||
if err := client.refreshToken(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
payload, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal request data: %w", err)
|
||||
}
|
||||
nonce, err := generateNonce()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
authData := client.AuthData()
|
||||
signedNonceValue, err := signedNonce(authData.Ssecurity, nonce)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
params, err := generateEncryptedParams(uri, http.MethodPost, signedNonceValue, nonce, string(payload), authData.Ssecurity)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
form := url.Values{
|
||||
"data": {params.Data},
|
||||
"rc4_hash__": {params.RC4Hash},
|
||||
"signature": {params.Signature},
|
||||
"ssecurity": {params.Ssecurity},
|
||||
"_nonce": {params.Nonce},
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, client.baseURL+uri, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create API request: %w", err)
|
||||
}
|
||||
client.setAPIHeaders(req, authData)
|
||||
response, err := client.session().Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("send API request: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
body, err := readHTTPResponse(response)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read API response: %w", err)
|
||||
}
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("API HTTP status %d: %s", response.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
result, err := decodeAPIResponse(authData.Ssecurity, nonce, body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode API response from %s: %w", uri, err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func decodeAPIResponse(ssecurity, nonce string, body []byte) (json.RawMessage, error) {
|
||||
var envelope struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Desc string `json:"desc"`
|
||||
Result json.RawMessage `json:"result"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &envelope); err != nil {
|
||||
decrypted, decryptErr := decryptPayload(ssecurity, nonce, string(body))
|
||||
if decryptErr != nil {
|
||||
return nil, fmt.Errorf("decode API response: %w", decryptErr)
|
||||
}
|
||||
if err := json.Unmarshal([]byte(decrypted), &envelope); err != nil {
|
||||
return nil, fmt.Errorf("decode decrypted API response: %w", err)
|
||||
}
|
||||
}
|
||||
if envelope.Code != 0 || envelope.Result == nil {
|
||||
message := envelope.Message
|
||||
if message == "" {
|
||||
message = envelope.Desc
|
||||
}
|
||||
if message == "" {
|
||||
message = "未知错误"
|
||||
}
|
||||
return nil, &APIError{Code: envelope.Code, Message: message}
|
||||
}
|
||||
return envelope.Result, nil
|
||||
}
|
||||
|
||||
func (client *Client) setAPIHeaders(request *http.Request, authData AuthData) {
|
||||
request.Header.Set("User-Agent", authData.UA)
|
||||
request.Header.Set("Accept-Encoding", "identity")
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.Header.Set("miot-accept-encoding", "GZIP")
|
||||
request.Header.Set("miot-encrypt-algorithm", "ENCRYPT-RC4")
|
||||
request.Header.Set("x-xiaomi-protocal-flag-cli", "PROTOCAL-HTTP2")
|
||||
request.Header.Set("Cookie", client.apiCookieHeader(authData))
|
||||
}
|
||||
|
||||
func (client *Client) apiCookieHeader(authData AuthData) string {
|
||||
now := time.Now()
|
||||
_, offset := now.Zone()
|
||||
zoneName := now.Location().String()
|
||||
if zoneName == "Local" {
|
||||
zoneName, _ = now.Zone()
|
||||
}
|
||||
isDaylight, dstOffset := daylightValues(now)
|
||||
countryCode := "CN"
|
||||
if parts := strings.Split(client.locale, "_"); len(parts) == 2 {
|
||||
countryCode = parts[1]
|
||||
}
|
||||
timezone := fmt.Sprintf("GMT%+03d:%02d", offset/3600, abs(offset/60)%60)
|
||||
values := []string{
|
||||
"cUserId=" + authData.CUserID,
|
||||
"yetAnotherServiceToken=" + authData.yetAnotherServiceToken(),
|
||||
"serviceToken=" + authData.ServiceToken,
|
||||
"timezone_id=" + zoneName,
|
||||
"timezone=" + timezone,
|
||||
"is_daylight=" + strconv.Itoa(isDaylight),
|
||||
"dst_offset=" + strconv.Itoa(dstOffset),
|
||||
"channel=MI_APP_STORE",
|
||||
"countryCode=" + countryCode,
|
||||
"PassportDeviceId=" + authData.DeviceID,
|
||||
"locale=" + client.locale,
|
||||
}
|
||||
return strings.Join(values, ";")
|
||||
}
|
||||
|
||||
func daylightValues(now time.Time) (int, int) {
|
||||
location := now.Location()
|
||||
dstOffset := 0
|
||||
if now.In(location).IsDST() {
|
||||
dstOffset = 60 * 60 * 1000
|
||||
}
|
||||
for date := time.Date(now.Year(), time.January, 1, 12, 0, 0, 0, location); date.Year() == now.Year(); date = date.AddDate(0, 0, 1) {
|
||||
if date.IsDST() {
|
||||
return 1, dstOffset
|
||||
}
|
||||
}
|
||||
return 0, dstOffset
|
||||
}
|
||||
|
||||
func abs(value int) int {
|
||||
if value < 0 {
|
||||
return -value
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func (client *Client) Available(ctx context.Context) (bool, error) {
|
||||
if !client.AuthData().complete() {
|
||||
return false, nil
|
||||
}
|
||||
client.availabilityMu.Lock()
|
||||
defer client.availabilityMu.Unlock()
|
||||
if client.availabilityValid && time.Since(client.availabilityAt) < availabilityTTL {
|
||||
return client.availability, nil
|
||||
}
|
||||
|
||||
_, err := client.request(ctx, "/v2/message/v2/check_new_msg", map[string]int64{"begin_at": time.Now().Unix() - 3600}, false)
|
||||
if err != nil {
|
||||
client.availability = false
|
||||
client.availabilityValid = false
|
||||
client.availabilityAt = time.Time{}
|
||||
return false, err
|
||||
}
|
||||
client.availability = true
|
||||
client.availabilityValid = true
|
||||
client.availabilityAt = time.Now()
|
||||
return true, nil
|
||||
}
|
||||
Reference in New Issue
Block a user