hyapp-server/pkg/tencentim/rest_client.go
2026-04-27 02:29:42 +08:00

270 lines
8.3 KiB
Go

package tencentim
import (
"bytes"
"context"
"crypto/rand"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
const (
// DefaultEndpoint is Tencent Cloud Chat's China REST API endpoint.
DefaultEndpoint = "console.tim.qq.com"
// DefaultGroupType keeps room messages recoverable; AVChatRoom has no history storage.
DefaultGroupType = "ChatRoom"
// createGroupCommand is Tencent Cloud Chat REST command for group creation.
createGroupCommand = "v4/group_open_http_svc/create_group"
// sendGroupMsgCommand is Tencent Cloud Chat REST command for group message sending.
sendGroupMsgCommand = "v4/group_open_http_svc/send_group_msg"
)
// RESTConfig contains Tencent Cloud Chat server-side REST API settings.
type RESTConfig struct {
// SDKAppID is the application ID assigned by Tencent Cloud Chat.
SDKAppID int64
// SecretKey signs the admin UserSig used by REST API calls.
SecretKey string
// AdminIdentifier is the App administrator account configured in Tencent Cloud Chat.
AdminIdentifier string
// AdminUserSigTTL controls the cached admin UserSig lifetime.
AdminUserSigTTL time.Duration
// Endpoint is the regional REST API host, such as adminapisgp.im.qcloud.com.
Endpoint string
// GroupType is the Tencent group type used for voice rooms.
GroupType string
// HTTPClient allows tests to inject a fake transport.
HTTPClient *http.Client
}
// RoomEvent is the backend-owned room system event sent to Tencent Cloud Chat.
type RoomEvent struct {
EventID string `json:"event_id"`
RoomID string `json:"room_id"`
EventType string `json:"event_type"`
ActorUserID int64 `json:"actor_user_id,omitempty"`
TargetUserID int64 `json:"target_user_id,omitempty"`
SeatNo int32 `json:"seat_no,omitempty"`
GiftValue int64 `json:"gift_value,omitempty"`
RoomHeat int64 `json:"room_heat,omitempty"`
RoomVersion int64 `json:"room_version,omitempty"`
Text string `json:"text,omitempty"`
Attributes map[string]string `json:"attributes,omitempty"`
}
// RESTClient wraps Tencent Cloud Chat REST API and hides UserSig/query boilerplate.
type RESTClient struct {
// cfg is immutable after construction so calls can safely run concurrently.
cfg RESTConfig
// httpClient is reused for keep-alive; Tencent recommends long connections for REST API.
httpClient *http.Client
}
// NewRESTClient validates config and creates a Tencent Cloud Chat REST client.
func NewRESTClient(cfg RESTConfig) (*RESTClient, error) {
if cfg.SDKAppID <= 0 || cfg.SecretKey == "" || strings.TrimSpace(cfg.AdminIdentifier) == "" {
// REST API requires an App administrator identifier and a valid admin UserSig.
return nil, fmt.Errorf("tencent im rest config is incomplete")
}
if cfg.AdminUserSigTTL <= 0 {
cfg.AdminUserSigTTL = 24 * time.Hour
}
if strings.TrimSpace(cfg.Endpoint) == "" {
cfg.Endpoint = DefaultEndpoint
}
if strings.TrimSpace(cfg.GroupType) == "" {
cfg.GroupType = DefaultGroupType
}
if cfg.HTTPClient == nil {
cfg.HTTPClient = &http.Client{Timeout: 5 * time.Second}
}
return &RESTClient{
cfg: cfg,
httpClient: cfg.HTTPClient,
}, nil
}
// EnsureRoomGroup creates the Tencent Cloud Chat group for a voice room.
func (c *RESTClient) EnsureRoomGroup(ctx context.Context, roomID string, ownerUserID int64) error {
roomID = strings.TrimSpace(roomID)
if roomID == "" {
return fmt.Errorf("room_id is required")
}
_ = ownerUserID
request := createGroupRequest{
Type: c.cfg.GroupType,
GroupID: roomID,
Name: roomID,
ApplyJoinOption: "FreeAccess",
}
var response restResponse
if err := c.post(ctx, createGroupCommand, request, &response); err != nil {
return err
}
if response.ErrorCode == 10021 || response.ErrorCode == 10025 {
// Custom GroupId already exists; for idempotent CreateRoom retry this is success.
return nil
}
return response.err()
}
// PublishRoomEvent converts a room system event into a Tencent group custom message.
func (c *RESTClient) PublishRoomEvent(ctx context.Context, event RoomEvent) error {
if strings.TrimSpace(event.RoomID) == "" || strings.TrimSpace(event.EventID) == "" {
// event_id is Tencent-side idempotency anchor in CloudCustomData.
return fmt.Errorf("room event is incomplete")
}
eventPayload, err := json.Marshal(event)
if err != nil {
return err
}
request := sendGroupMsgRequest{
GroupID: event.RoomID,
Random: randomUint32(),
MsgBody: []messageElement{
{
MsgType: "TIMCustomElem",
MsgContent: customMsgContent{
Data: string(eventPayload),
Desc: event.EventType,
Ext: "room_system_message",
Sound: "",
},
},
},
CloudCustomData: string(eventPayload),
}
var response restResponse
if err := c.post(ctx, sendGroupMsgCommand, request, &response); err != nil {
return err
}
return response.err()
}
// FormatUserID converts the internal immutable user_id to the Tencent Chat identifier.
func FormatUserID(userID int64) string {
// Decimal user_id is stable across services and avoids exposing mutable display_user_id.
return strconv.FormatInt(userID, 10)
}
func (c *RESTClient) post(ctx context.Context, command string, payload any, out *restResponse) error {
body, err := json.Marshal(payload)
if err != nil {
return err
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint(command), bytes.NewReader(body))
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
response, err := c.httpClient.Do(request)
if err != nil {
return err
}
defer response.Body.Close()
responseBody, err := io.ReadAll(response.Body)
if err != nil {
return err
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
// Tencent REST normally returns HTTP 200 with ErrorCode; non-2xx is transport failure.
return fmt.Errorf("tencent im http status %d: %s", response.StatusCode, string(responseBody))
}
if err := json.Unmarshal(responseBody, out); err != nil {
return err
}
return nil
}
func (c *RESTClient) endpoint(command string) string {
adminSig, _ := GenerateUserSig(UserSigConfig{
SDKAppID: c.cfg.SDKAppID,
SecretKey: c.cfg.SecretKey,
TTL: c.cfg.AdminUserSigTTL,
}, c.cfg.AdminIdentifier, time.Now())
values := url.Values{}
values.Set("sdkappid", strconv.FormatInt(c.cfg.SDKAppID, 10))
values.Set("identifier", c.cfg.AdminIdentifier)
values.Set("usersig", adminSig.UserSig)
values.Set("random", strconv.FormatUint(uint64(randomUint32()), 10))
values.Set("contenttype", "json")
return "https://" + strings.TrimSuffix(c.cfg.Endpoint, "/") + "/" + strings.TrimPrefix(command, "/") + "?" + values.Encode()
}
func randomUint32() uint32 {
var payload [4]byte
if _, err := rand.Read(payload[:]); err != nil {
// Cryptographic randomness is preferred, but REST random only needs dedupe entropy.
return uint32(time.Now().UnixNano())
}
return binary.BigEndian.Uint32(payload[:])
}
type restResponse struct {
ActionStatus string `json:"ActionStatus"`
ErrorInfo string `json:"ErrorInfo"`
ErrorCode int `json:"ErrorCode"`
}
func (r restResponse) err() error {
if r.ErrorCode == 0 && strings.EqualFold(r.ActionStatus, "OK") {
return nil
}
if r.ErrorCode == 0 && r.ActionStatus == "" {
// Some tests only decode partial responses; keep zero-code as success.
return nil
}
return fmt.Errorf("tencent im rest failed: code=%d info=%s", r.ErrorCode, r.ErrorInfo)
}
type createGroupRequest struct {
OwnerAccount string `json:"Owner_Account,omitempty"`
Type string `json:"Type"`
GroupID string `json:"GroupId"`
Name string `json:"Name"`
ApplyJoinOption string `json:"ApplyJoinOption,omitempty"`
}
type sendGroupMsgRequest struct {
GroupID string `json:"GroupId"`
Random uint32 `json:"Random"`
MsgBody []messageElement `json:"MsgBody"`
CloudCustomData string `json:"CloudCustomData,omitempty"`
FromAccount string `json:"From_Account,omitempty"`
}
type messageElement struct {
MsgType string `json:"MsgType"`
MsgContent customMsgContent `json:"MsgContent"`
}
type customMsgContent struct {
Data string `json:"Data"`
Desc string `json:"Desc,omitempty"`
Ext string `json:"Ext,omitempty"`
Sound string `json:"Sound,omitempty"`
}