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" // deleteGroupMemberCommand removes users from a Tencent Cloud Chat group. deleteGroupMemberCommand = "v4/group_open_http_svc/delete_group_member" ) // 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() } // DeleteRoomGroupMember removes a room user from the Tencent Cloud Chat group. func (c *RESTClient) DeleteRoomGroupMember(ctx context.Context, roomID string, userID int64) error { roomID = strings.TrimSpace(roomID) if roomID == "" || userID <= 0 { return fmt.Errorf("room group member removal is incomplete") } request := deleteGroupMemberRequest{ GroupID: roomID, MemberToDelAccount: []string{FormatUserID(userID)}, Silence: 1, } var response restResponse if err := c.post(ctx, deleteGroupMemberCommand, 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 deleteGroupMemberRequest struct { GroupID string `json:"GroupId"` MemberToDelAccount []string `json:"MemberToDel_Account"` Silence int `json:"Silence,omitempty"` Reason string `json:"Reason,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"` }