300 lines
9.3 KiB
Go
300 lines
9.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"
|
|
// 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"`
|
|
}
|