392 lines
12 KiB
Go
392 lines
12 KiB
Go
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": "悦选客服系统凭据同步",
|
|
}, 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
|
|
}
|