feat: integrate customer service chat

This commit is contained in:
richjohnrambo
2026-10-14 16:37:12 +08:00
parent f3f59fe7c3
commit 48b3564ac9
22 changed files with 1788 additions and 64 deletions
+383
View File
@@ -0,0 +1,383 @@
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
}
+151
View File
@@ -0,0 +1,151 @@
package imclient
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"git.118im.com/im_team/olons_server/internal/config"
"github.com/gorilla/websocket"
"golang.org/x/crypto/bcrypt"
)
func TestCreateUserAcceptsNumericIDAndSignsRequest(t *testing.T) {
const secret = "internal-secret"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
if r.URL.Path != "/internal/admin-query/tenants/allrich/users" {
t.Fatalf("unexpected path %s", r.URL.Path)
}
if r.Header.Get("X-Service-Name") != "olons-server" {
t.Fatalf("missing service header")
}
canonical := strings.Join([]string{r.Method, r.URL.RequestURI(), hashHex(body),
r.Header.Get("X-Timestamp"), r.Header.Get("X-Nonce")}, "\n")
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(canonical))
if r.Header.Get("X-Signature") != hex.EncodeToString(mac.Sum(nil)) {
t.Fatalf("invalid signature")
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":0,"data":{"user_id":42,"username":"allrich_m_7","nickname":"会员7"}}`))
}))
defer server.Close()
client := New(config.IMConfig{BaseURL: server.URL, HTTPAPIURL: server.URL, TenantCode: "allrich",
APIKey: "public-key", InternalService: "olons-server", InternalSecret: secret, Timeout: time.Second})
created, err := client.CreateUser(context.Background(), "allrich_m_7", "会员7", "password")
if err != nil {
t.Fatal(err)
}
if got := string(created.UserID); got != "42" {
t.Fatalf("user id = %q, want 42", got)
}
}
func TestProductRequestHeaders(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer user-token" || r.Header.Get("X-MeetingIM-APIKey") != "public-key" {
t.Fatalf("missing product API credentials")
}
if r.Header.Get("X-IM-Schema") != "1" {
t.Fatalf("missing schema header")
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":0,"data":{"user_id":"usr_agent","relationship":"friend","topic":"p2p123","capabilities":{"can_send":true}}}`))
}))
defer server.Close()
client := New(config.IMConfig{BaseURL: server.URL, HTTPAPIURL: server.URL, TenantCode: "allrich",
APIKey: "public-key", InternalService: "olons-server", InternalSecret: "secret", Timeout: time.Second})
profile, err := client.UserProfile(context.Background(), "user-token", "usr_agent")
if err != nil {
t.Fatal(err)
}
if profile.Relationship != "friend" || !profile.Capabilities.CanSend {
t.Fatalf("unexpected profile: %+v", profile)
}
}
func TestLoginBasicUsesExistingWebSocketProtocol(t *testing.T) {
upgrader := websocket.Upgrader{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("apikey") != "public-key" {
t.Fatalf("missing API key")
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
var packet map[string]map[string]any
if err = conn.ReadJSON(&packet); err != nil {
t.Fatal(err)
}
if packet["hi"]["tenant"] != "allrich" {
t.Fatalf("unexpected hi: %#v", packet)
}
_ = conn.WriteJSON(map[string]any{"ctrl": map[string]any{"id": packet["hi"]["id"], "code": 201, "text": "created"}})
if err = conn.ReadJSON(&packet); err != nil {
t.Fatal(err)
}
login := packet["login"]
secret, _ := base64.StdEncoding.DecodeString(login["secret"].(string))
if string(secret) != "member01:password" || login["login_type"] != float64(0) || login["tenant_code"] != "allrich" {
t.Fatalf("unexpected login: %#v", login)
}
_ = conn.WriteJSON(map[string]any{"ctrl": map[string]any{"id": login["id"], "code": 200, "text": "ok",
"params": map[string]any{"user": "usr_member", "token": "token", "expires": "2026-10-27T10:00:00Z"}}})
}))
defer server.Close()
client := New(config.IMConfig{BaseURL: server.URL, HTTPAPIURL: server.URL,
WebSocketURL: "ws" + strings.TrimPrefix(server.URL, "http"), TenantCode: "allrich",
APIKey: "public-key", InternalService: "olons-server", InternalSecret: "secret", Timeout: time.Second})
session, err := client.LoginBasic(context.Background(), "member01", "password", "web-1", "web")
if err != nil {
t.Fatal(err)
}
if session.UserID != "usr_member" || session.Token != "token" {
t.Fatalf("unexpected session: %+v", session)
}
}
func TestResetPasswordSendsBase64BcryptHash(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body struct {
EventType string `json:"event_type"`
PasswordHash string `json:"password_hash"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatal(err)
}
hash, err := base64.StdEncoding.DecodeString(body.PasswordHash)
if body.EventType != "user.password.reset" || err != nil || bcrypt.CompareHashAndPassword(hash, []byte("new-password")) != nil {
t.Fatalf("unexpected reset body: %+v", body)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":0,"data":{"status":"succeeded"}}`))
}))
defer server.Close()
client := New(config.IMConfig{BaseURL: server.URL, HTTPAPIURL: server.URL, WebSocketURL: "ws://example.test",
TenantCode: "allrich", APIKey: "public-key", InternalService: "olons-server", InternalSecret: "secret", Timeout: time.Second})
if err := client.ResetPassword(context.Background(), "usr_member", "new-password"); err != nil {
t.Fatal(err)
}
}
func hashHex(body []byte) string {
digest := sha256.Sum256(body)
return hex.EncodeToString(digest[:])
}