package wkapi import ( "bytes" "encoding/json" "errors" "fmt" "io" "net/http" "strings" "time" ) // The WuKongIM access API can briefly reject a foreground write while the // channel route converges to its current authority. A single client message // number is idempotent, so retrying the same request is safe. var sendRetryDelays = []time.Duration{ 50 * time.Millisecond, 100 * time.Millisecond, 200 * time.Millisecond, 400 * time.Millisecond, } // Client wraps HTTP calls to WuKongIM 3.0 API. type Client struct { apiURL string cli *http.Client } // New creates a new WuKongIM API client. func New(apiURL string) *Client { return &Client{apiURL: apiURL, cli: &http.Client{}} } // TokenResponse is the response from POST /user/token. type TokenResponse struct { UID string `json:"uid"` } // RegisterToken registers a user token with WuKongIM. func (c *Client) RegisterToken(uid, token string) error { body := map[string]interface{}{ "uid": uid, "token": token, "device_flag": 0, "device_level": 1, } var resp TokenResponse return c.post("/user/token", body, &resp) } // UpsertChannel creates or updates a channel. func (c *Client) UpsertChannel(channelID string, channelType uint8, subscribers []string) error { body := map[string]interface{}{ "channel_id": channelID, "channel_type": channelType, } if len(subscribers) > 0 { body["subscribers"] = subscribers } return c.post("/channel", body, nil) } // SubscriberAdd adds subscribers to a channel. func (c *Client) SubscriberAdd(channelID string, channelType uint8, uids []string) error { body := map[string]interface{}{ "channel_id": channelID, "channel_type": channelType, "reset": 0, "subscribers": uids, } return c.post("/channel/subscriber_add", body, nil) } // SendMessageRequest is the request body for POST /message/send. type SendMessageRequest struct { FromUID string `json:"from_uid"` ChannelID string `json:"channel_id"` ChannelType uint8 `json:"channel_type"` ClientMsgNo string `json:"client_msg_no"` Payload string `json:"payload"` // base64-encoded } // SendMessageResponse is the response from POST /message/send. type SendMessageResponse struct { MessageID int64 `json:"message_id"` MessageSeq uint64 `json:"message_seq"` Reason uint8 `json:"reason"` } // HTTPStatusError retains an API failure status so callers can distinguish a // transient route response from a permanent validation or permission error. type HTTPStatusError struct { StatusCode int Body string } func (e *HTTPStatusError) Error() string { return fmt.Sprintf("WK API returned status %d: %s", e.StatusCode, e.Body) } // SendMessage sends a message via WuKongIM. func (c *Client) SendMessage(req SendMessageRequest) (*SendMessageResponse, error) { for attempt := 0; ; attempt++ { var resp SendMessageResponse err := c.post("/message/send", req, &resp) if err == nil { return &resp, nil } if !isRetryRequired(err) || attempt >= len(sendRetryDelays) { return nil, err } time.Sleep(sendRetryDelays[attempt]) } } func isRetryRequired(err error) bool { var statusErr *HTTPStatusError return errors.As(err, &statusErr) && statusErr.StatusCode == http.StatusServiceUnavailable && strings.Contains(statusErr.Body, `"retry required"`) } // SyncChannelMessagesRequest is the request body for POST /channel/messagesync. type SyncChannelMessagesRequest struct { LoginUID string `json:"login_uid"` ChannelID string `json:"channel_id"` ChannelType uint8 `json:"channel_type"` StartMessageSeq uint64 `json:"start_message_seq"` Limit int `json:"limit"` PullMode int `json:"pull_mode"` } // LegacyMessageResp is a message in the sync response (v3 legacy format). type LegacyMessageResp struct { Header map[string]interface{} `json:"header"` Setting uint8 `json:"setting"` MessageID uint64 `json:"message_id"` ClientMsgNo string `json:"client_msg_no"` MessageSeq uint64 `json:"message_seq"` FromUID string `json:"from_uid"` ChannelID string `json:"channel_id"` ChannelType uint8 `json:"channel_type"` Timestamp int32 `json:"timestamp"` Payload string `json:"payload"` // base64-encoded } // SyncChannelMessagesResponse is the response from POST /channel/messagesync. type SyncChannelMessagesResponse struct { StartMessageSeq uint64 `json:"start_message_seq"` EndMessageSeq uint64 `json:"end_message_seq"` More int `json:"more"` Messages []LegacyMessageResp `json:"messages"` } // SyncChannelMessages syncs messages from a channel. func (c *Client) SyncChannelMessages(req SyncChannelMessagesRequest) (*SyncChannelMessagesResponse, error) { var resp SyncChannelMessagesResponse if err := c.post("/channel/messagesync", req, &resp); err != nil { return nil, err } return &resp, nil } // RouteResponse is the response from GET /route. type RouteResponse struct { TCPAddr string `json:"tcp_addr"` WSAddr string `json:"ws_addr"` WSSAddr string `json:"wss_addr"` } // GetRoute gets the WebSocket route from WuKongIM. func (c *Client) GetRoute() (*RouteResponse, error) { var resp RouteResponse if err := c.get("/route", &resp); err != nil { return nil, err } return &resp, nil } // --- internal helpers --- func (c *Client) post(path string, body interface{}, dest interface{}) error { b, err := json.Marshal(body) if err != nil { return fmt.Errorf("marshal request: %w", err) } resp, err := c.cli.Post(c.apiURL+path, "application/json", bytes.NewReader(b)) if err != nil { return fmt.Errorf("http post %s: %w", path, err) } defer resp.Body.Close() return c.decodeResponse(resp, dest) } func (c *Client) get(path string, dest interface{}) error { resp, err := c.cli.Get(c.apiURL + path) if err != nil { return fmt.Errorf("http get %s: %w", path, err) } defer resp.Body.Close() return c.decodeResponse(resp, dest) } func (c *Client) decodeResponse(resp *http.Response, dest interface{}) error { if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return &HTTPStatusError{StatusCode: resp.StatusCode, Body: string(body)} } if dest == nil { return nil } return json.NewDecoder(resp.Body).Decode(dest) }