Files
mijia-go-api/client.go
T

370 lines
10 KiB
Go

package mijia
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"net/url"
"os"
"path/filepath"
"reflect"
"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 ClientOption func(*Client) error
// Option is kept as an alias for compatibility with existing callers.
type Option = ClientOption
// 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
}
}
// WithQRWriter configures where QR login output is written.
func WithQRWriter(writer io.Writer) Option {
return func(client *Client) error {
isNil := writer == nil
if !isNil {
value := reflect.ValueOf(writer)
switch value.Kind() {
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Ptr, reflect.Slice, reflect.UnsafePointer:
isNil = value.IsNil()
}
}
if isNil {
return fmt.Errorf("QR writer must not be nil")
}
client.qrWriter = writer
return nil
}
}
type Client struct {
authPath string
authDataChanged func(AuthData) error
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, err := newClient(options...)
if err != nil {
return nil, err
}
client.authPath = resolvedPath
if err := client.loadAuthData(); err != nil {
return nil, err
}
client.ensureIdentity()
return client, nil
}
// NewClientWithAuthData creates a client whose authentication state is kept in memory.
// Zero AuthData is accepted for QR login; non-zero AuthData must be complete.
func NewClientWithAuthData(authData AuthData, options ...ClientOption) (*Client, error) {
if !authData.zero() && !authData.complete() {
return nil, fmt.Errorf("incomplete auth data")
}
client, err := newClient(options...)
if err != nil {
return nil, err
}
client.setAuthData(authData)
return client, nil
}
func newClient(options ...ClientOption) (*Client, error) {
client := &Client{
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
}
}
client.initSession()
return client, nil
}
// WithAuthDataChanged configures serialized persistence for in-memory auth updates.
// The callback runs under login serialization, but never while the auth data lock is held.
func WithAuthDataChanged(callback func(AuthData) error) ClientOption {
return func(client *Client) error {
if callback == nil {
return fmt.Errorf("auth data changed callback must not be nil")
}
client.authDataChanged = callback
return 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 {
if response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden {
loginErr := &LoginError{Code: response.StatusCode, Message: strings.TrimSpace(string(body))}
return nil, fmt.Errorf("API HTTP status from %s: %w: %w", uri, ErrReauthenticationRequired, loginErr)
}
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 = "未知错误"
}
apiErr := &APIError{Code: envelope.Code, Message: message}
if envelope.Code == -10020 || envelope.Code == -10030 {
return nil, fmt.Errorf("%w: %w", ErrReauthenticationRequired, apiErr)
}
return nil, apiErr
}
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
}