package imclient import ( "bytes" "context" "crypto/hmac" cryptorand "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strconv" "strings" "time" "git.118im.com/im_team/olons_server/internal/config" "github.com/gorilla/websocket" "golang.org/x/crypto/bcrypt" ) const maxResponseBody = 2 << 20 type Error struct { Status int Code int Reason string Message string } func (e *Error) Error() string { if e == nil { return "IM service error" } if e.Reason != "" { return e.Reason } if e.Message != "" { return e.Message } return fmt.Sprintf("IM service returned HTTP %d", e.Status) } type Client struct { cfg config.IMConfig http *http.Client } type CreatedUser struct { UserID FlexibleID `json:"user_id"` Username string `json:"username"` Nickname string `json:"nickname"` } // FlexibleID normalizes the IM internal API's numeric database ID and its // public string user ID into one representation. User creation returns a // numeric ID, while session issuance and product APIs return the public ID. type FlexibleID string func (id *FlexibleID) UnmarshalJSON(data []byte) error { var value string if len(data) > 0 && data[0] == '"' { if err := json.Unmarshal(data, &value); err != nil { return err } } else { value = strings.TrimSpace(string(data)) } if value == "" || value == "null" { return errors.New("empty IM user ID") } *id = FlexibleID(value) return nil } type Session struct { UserID string `json:"user_id"` Token string `json:"token"` ExpiresAt time.Time `json:"expires_at"` } type UserProfile struct { UserID string `json:"user_id"` DisplayName string `json:"display_name"` Relationship string `json:"relationship"` Topic string `json:"topic"` Capabilities struct { CanSend bool `json:"can_send"` } `json:"capabilities"` } type FriendRequest struct { RequestID string `json:"request_id"` Status string `json:"status"` Topic string `json:"topic"` } type Conversation struct { Topic string `json:"topic"` Relationship string `json:"relationship"` CanSend bool `json:"can_send"` } func New(cfg config.IMConfig) *Client { timeout := cfg.Timeout if timeout <= 0 { timeout = 8 * time.Second } return &Client{cfg: cfg, http: &http.Client{Timeout: timeout}} } func (c *Client) Config() config.IMConfig { return c.cfg } func (c *Client) Ready() bool { return strings.TrimSpace(c.cfg.BaseURL) != "" && strings.TrimSpace(c.cfg.HTTPAPIURL) != "" && strings.TrimSpace(c.cfg.WebSocketURL) != "" && strings.TrimSpace(c.cfg.TenantCode) != "" && strings.TrimSpace(c.cfg.APIKey) != "" && strings.TrimSpace(c.cfg.InternalService) != "" && strings.TrimSpace(c.cfg.InternalSecret) != "" } func (c *Client) CreateUser(ctx context.Context, username, nickname, password string) (*CreatedUser, error) { var out CreatedUser path := "/internal/admin-query/tenants/" + url.PathEscape(c.cfg.TenantCode) + "/users" err := c.internalJSON(ctx, http.MethodPost, path, map[string]any{ "username": username, "nickname": nickname, "password": password, }, &out) return &out, err } func (c *Client) FindUserByUsername(ctx context.Context, username string) (*CreatedUser, error) { query := url.Values{"auth_name": []string{username}, "page": []string{"1"}, "per_page": []string{"20"}} path := "/internal/admin-query/tenants/" + url.PathEscape(c.cfg.TenantCode) + "/users?" + query.Encode() var page struct { Items []struct { UserID string `json:"user_id"` Username string `json:"username"` Nickname string `json:"nickname"` } `json:"items"` } if err := c.internalJSON(ctx, http.MethodGet, path, nil, &page); err != nil { return nil, err } for _, item := range page.Items { if strings.EqualFold(strings.TrimSpace(item.Username), strings.TrimSpace(username)) { return &CreatedUser{UserID: FlexibleID(item.UserID), Username: item.Username, Nickname: item.Nickname}, nil } } return nil, &Error{Status: http.StatusNotFound, Reason: "RESOURCE_NOT_FOUND", Message: "IM user not found"} } func (c *Client) LoginBasic(ctx context.Context, username, password, deviceID, platform string) (*Session, error) { endpoint, err := url.Parse(strings.TrimSpace(c.cfg.WebSocketURL)) if err != nil || endpoint.Scheme == "" || endpoint.Host == "" { return nil, errors.New("IM websocket URL is invalid") } query := endpoint.Query() query.Set("apikey", c.cfg.APIKey) endpoint.RawQuery = query.Encode() dialer := websocket.Dialer{HandshakeTimeout: c.cfg.Timeout} conn, _, err := dialer.DialContext(ctx, endpoint.String(), nil) if err != nil { return nil, err } defer conn.Close() deadline := time.Now().Add(c.cfg.Timeout) _ = conn.SetWriteDeadline(deadline) _ = conn.SetReadDeadline(deadline) hiID := "hi-" + requestID() if err = conn.WriteJSON(map[string]any{"hi": map[string]any{ "id": hiID, "ver": "0.25", "ua": "olons-server/1.0", "dev": deviceID, "platf": firstNonEmpty(platform, "server"), "lang": "zh-CN", "tenant": c.cfg.TenantCode, }}); err != nil { return nil, err } if _, err = readCtrl(conn, hiID); err != nil { return nil, err } loginID := "login-" + requestID() secret := base64.StdEncoding.EncodeToString([]byte(username + ":" + password)) if err = conn.WriteJSON(map[string]any{"login": map[string]any{ "id": loginID, "scheme": "basic", "secret": secret, "login_type": 0, "tenant_code": c.cfg.TenantCode, }}); err != nil { return nil, err } ctrl, err := readCtrl(conn, loginID) if err != nil { return nil, err } expiresAt, _ := time.Parse(time.RFC3339, ctrl.Params.Expires) if ctrl.Params.User == "" || ctrl.Params.Token == "" { return nil, errors.New("IM login response is incomplete") } return &Session{UserID: ctrl.Params.User, Token: ctrl.Params.Token, ExpiresAt: expiresAt}, nil } func (c *Client) ResetPassword(ctx context.Context, userID, password string) error { hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return err } path := "/internal/admin-query/tenants/" + url.PathEscape(c.cfg.TenantCode) + "/users/" + url.PathEscape(userID) + "/action" return c.internalJSON(ctx, http.MethodPost, path, map[string]any{ "event_type": "user.password.reset", "password_hash": base64.StdEncoding.EncodeToString(hash), "reason": "Olons客服系统凭据同步", }, nil) } func (c *Client) UserProfile(ctx context.Context, token, userID string) (*UserProfile, error) { var out UserProfile err := c.productJSON(ctx, http.MethodGet, "/users/"+url.PathEscape(userID)+"/profile", token, "", nil, &out) return &out, err } func (c *Client) CreateFriendRequest(ctx context.Context, token, userID, idempotencyKey string) (*FriendRequest, error) { var out FriendRequest err := c.productJSON(ctx, http.MethodPost, "/friend-requests", token, idempotencyKey, map[string]any{ "target_user_id": userID, "message": "咨询客服", "source": "profile_card", }, &out) return &out, err } func (c *Client) EnsureP2P(ctx context.Context, token, userID, idempotencyKey string) (*Conversation, error) { var out Conversation err := c.productJSON(ctx, http.MethodPost, "/conversations/p2p", token, idempotencyKey, map[string]any{"peer_user_id": userID}, &out) return &out, err } func (c *Client) internalJSON(ctx context.Context, method, path string, input, output any) error { body, err := marshalBody(input) if err != nil { return err } baseURL := strings.TrimRight(c.cfg.BaseURL, "/") if baseURL == "" || c.cfg.InternalSecret == "" { return errors.New("IM internal API is not configured") } req, err := http.NewRequestWithContext(ctx, method, baseURL+path, bytes.NewReader(body)) if err != nil { return err } timestamp := strconv.FormatInt(time.Now().UTC().Unix(), 10) nonce, err := randomNonce() if err != nil { return err } bodyHash := sha256.Sum256(body) canonical := strings.Join([]string{method, req.URL.RequestURI(), hex.EncodeToString(bodyHash[:]), timestamp, nonce}, "\n") mac := hmac.New(sha256.New, []byte(c.cfg.InternalSecret)) _, _ = mac.Write([]byte(canonical)) req.Header.Set("Accept", "application/json") req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Service-Name", c.cfg.InternalService) req.Header.Set("X-Timestamp", timestamp) req.Header.Set("X-Nonce", nonce) req.Header.Set("X-Signature", hex.EncodeToString(mac.Sum(nil))) return c.do(req, output) } func (c *Client) productJSON(ctx context.Context, method, path, token, idempotencyKey string, input, output any) error { body, err := marshalBody(input) if err != nil { return err } baseURL := strings.TrimRight(c.cfg.HTTPAPIURL, "/") if baseURL == "" || c.cfg.APIKey == "" { return errors.New("IM product API is not configured") } req, err := http.NewRequestWithContext(ctx, method, baseURL+path, bytes.NewReader(body)) if err != nil { return err } req.Header.Set("Accept", "application/json") req.Header.Set("Content-Type", "application/json") req.Header.Set("X-MeetingIM-APIKey", c.cfg.APIKey) req.Header.Set("X-IM-Schema", "1") req.Header.Set("X-Request-ID", requestID()) req.Header.Set("Authorization", "Bearer "+token) if idempotencyKey != "" { req.Header.Set("Idempotency-Key", idempotencyKey) } return c.do(req, output) } func (c *Client) do(req *http.Request, output any) error { resp, err := c.http.Do(req) if err != nil { return err } defer resp.Body.Close() body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody)) if err != nil { return err } var envelope struct { Code int `json:"code"` Reason string `json:"reason"` Message string `json:"message"` Data json.RawMessage `json:"data"` } if err := json.Unmarshal(body, &envelope); err != nil { return &Error{Status: resp.StatusCode, Reason: "INVALID_RESPONSE", Message: "invalid IM response"} } if resp.StatusCode < 200 || resp.StatusCode >= 300 || envelope.Code != 0 { return &Error{Status: resp.StatusCode, Code: envelope.Code, Reason: envelope.Reason, Message: envelope.Message} } if output == nil || len(envelope.Data) == 0 || string(envelope.Data) == "null" { return nil } return json.Unmarshal(envelope.Data, output) } func marshalBody(input any) ([]byte, error) { if input == nil { return nil, nil } return json.Marshal(input) } func randomNonce() (string, error) { buf := make([]byte, 16) if _, err := cryptorand.Read(buf); err != nil { return "", err } return hex.EncodeToString(buf), nil } func requestID() string { buf := make([]byte, 16) if _, err := cryptorand.Read(buf); err == nil { return hex.EncodeToString(buf) } return strconv.FormatInt(time.Now().UnixNano(), 10) } type websocketCtrl struct { ID string `json:"id"` Code int `json:"code"` Text string `json:"text"` Params struct { User string `json:"user"` Token string `json:"token"` Expires string `json:"expires"` Reason string `json:"reason"` } `json:"params"` } func readCtrl(conn *websocket.Conn, expectedID string) (*websocketCtrl, error) { for { var packet struct { Ctrl *websocketCtrl `json:"ctrl"` } if err := conn.ReadJSON(&packet); err != nil { return nil, err } if packet.Ctrl == nil || packet.Ctrl.ID != expectedID { continue } if packet.Ctrl.Code >= 300 { reason := firstNonEmpty(packet.Ctrl.Params.Reason, packet.Ctrl.Text, "IM websocket request failed") return nil, &Error{Status: packet.Ctrl.Code, Reason: reason, Message: packet.Ctrl.Text} } return packet.Ctrl, nil } } func firstNonEmpty(values ...string) string { for _, value := range values { if strings.TrimSpace(value) != "" { return strings.TrimSpace(value) } } return "" } func IsConflict(err error) bool { var target *Error return errors.As(err, &target) && target.Status == http.StatusConflict } // IsAuthenticationFailed reports whether the IM server rejected user // credentials. Callers may use this to repair credentials which have drifted // from the local encrypted copy, while leaving transport failures untouched. func IsAuthenticationFailed(err error) bool { var target *Error return errors.As(err, &target) && target.Status == http.StatusUnauthorized }