623 lines
22 KiB
Go
623 lines
22 KiB
Go
package tencentim
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"crypto/rand"
|
||
"encoding/binary"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/url"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
const (
|
||
// DefaultEndpoint 是腾讯云 IM 国内 REST 默认入口;海外部署可在配置中覆盖为对应地域域名。
|
||
DefaultEndpoint = "console.tim.qq.com"
|
||
// DefaultGroupType 使用 ChatRoom,保留消息历史能力;AVChatRoom 不适合需要补偿和漫游的房间消息。
|
||
DefaultGroupType = "ChatRoom"
|
||
// BroadcastGroupType 使用 AVChatRoom(直播群):ChatRoom 受套餐成员上限约束(标准版 2000 人,
|
||
// 已在线上触发 10038 拒绝加群),AVChatRoom 成员无上限、进出免审批,播报横幅也不需要漫游补偿。
|
||
BroadcastGroupType = "AVChatRoom"
|
||
// createGroupCommand 是建群 REST 命令;房间群、全局播报群、区域播报群都复用这一个能力。
|
||
createGroupCommand = "v4/group_open_http_svc/create_group"
|
||
// sendGroupMsgCommand 是服务端发群消息 REST 命令;播报只走服务端管理员账号,不开放客户端直发。
|
||
sendGroupMsgCommand = "v4/group_open_http_svc/send_group_msg"
|
||
// sendC2CMsgCommand 是服务端给单个用户发自定义消息的 REST 命令;钱包余额变更这类私有事件只能走单聊面。
|
||
sendC2CMsgCommand = "v4/openim/sendmsg"
|
||
// accountImportCommand 把内部 user_id 导入腾讯 IM 账号体系;C2C 发送和 SDK 登录依赖该账号存在。
|
||
accountImportCommand = "v4/im_open_login_svc/account_import"
|
||
// destroyGroupCommand 用于真实 IM smoke test 或明确运维动作的临时群清理,业务代码不应自动解散播报群。
|
||
destroyGroupCommand = "v4/group_open_http_svc/destroy_group"
|
||
// deleteGroupMemberCommand 用于把已离房用户从腾讯群里移除,防止 IM 群成员残留扩大消息面。
|
||
deleteGroupMemberCommand = "v4/group_open_http_svc/delete_group_member"
|
||
// kickUserCommand 让指定账号现有 IM 登录态失效;被封禁用户需要立即从腾讯 IM SDK 下线。
|
||
kickUserCommand = "v4/im_open_login_svc/kick"
|
||
)
|
||
|
||
// RESTConfig 保存服务端调用腾讯云 IM REST API 所需配置。
|
||
type RESTConfig struct {
|
||
// SDKAppID 是腾讯云 IM 应用 ID;回调校验和 UserSig 签发都必须使用同一个值。
|
||
SDKAppID int64
|
||
// SecretKey 只用于服务端签 admin UserSig,不能返回给客户端或写入错误日志。
|
||
SecretKey string
|
||
// AdminIdentifier 是腾讯云控制台配置的管理员账号,播报群消息用它证明“服务端发送”。
|
||
AdminIdentifier string
|
||
// AdminUserSigTTL 控制 REST 调用签名有效期;过短会增加签发频率,过长会扩大泄露影响面。
|
||
AdminUserSigTTL time.Duration
|
||
// Endpoint 是 REST 地域入口,例如 adminapisgp.im.qcloud.com;不要把地域选择写死在业务里。
|
||
Endpoint string
|
||
// GroupType 是房间和播报群共用的腾讯群类型;当前保持 ChatRoom 以保留历史能力。
|
||
GroupType string
|
||
// HTTPClient 允许测试注入 fake transport,也让上层统一设置超时。
|
||
HTTPClient *http.Client
|
||
}
|
||
|
||
// GroupSpec 是项目内部的建群请求,屏蔽腾讯 REST 字段命名差异。
|
||
type GroupSpec struct {
|
||
GroupID string
|
||
Name string
|
||
OwnerAccount string
|
||
Type string
|
||
ApplyJoinOption string
|
||
}
|
||
|
||
// CustomGroupMessage 表示一条服务端发出的 TIMCustomElem 群消息。
|
||
// PayloadJSON 同时写入 Data 和 CloudCustomData,客户端和排障侧都能按 event_id 做幂等和追踪。
|
||
type CustomGroupMessage struct {
|
||
GroupID string
|
||
EventID string
|
||
Desc string
|
||
Ext string
|
||
FromAccount string
|
||
PayloadJSON json.RawMessage
|
||
}
|
||
|
||
// CustomUserMessage 表示一条服务端发给单个用户的 TIMCustomElem。
|
||
// PayloadJSON 同时写入 Data 和 CloudCustomData;客户端必须按 event_id 去重。
|
||
type CustomUserMessage struct {
|
||
ToAccount string
|
||
EventID string
|
||
Desc string
|
||
Ext string
|
||
// FromAccount 为空时使用管理员账号;用户主动行为要显式传用户 identifier,避免消息归属变成系统通知。
|
||
FromAccount string
|
||
// SyncOtherMachine 透传腾讯 C2C 同步策略;0 保持历史默认 2,不同步发送方其他终端。
|
||
SyncOtherMachine int
|
||
PayloadJSON json.RawMessage
|
||
}
|
||
|
||
// AccountProfile 表达腾讯 IM 账号导入所需的最小用户资料。
|
||
type AccountProfile struct {
|
||
UserID int64
|
||
Nick string
|
||
FaceURL string
|
||
}
|
||
|
||
// RoomEvent 是 room-service 发给腾讯 IM 的房间系统消息负载。
|
||
type RoomEvent struct {
|
||
GroupID string `json:"-"`
|
||
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"`
|
||
// EntryVehicle 是用户进房瞬间的座驾快照;客户端收到 room_user_joined 后直接播放,不再反查 wallet。
|
||
EntryVehicle *RoomEntryVehicleSnapshot `json:"entry_vehicle,omitempty"`
|
||
Attributes map[string]string `json:"attributes,omitempty"`
|
||
}
|
||
|
||
func (event RoomEvent) MarshalJSON() ([]byte, error) {
|
||
type roomEventAlias RoomEvent
|
||
payload, err := json.Marshal(roomEventAlias(event))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var merged map[string]any
|
||
if err := json.Unmarshal(payload, &merged); err != nil {
|
||
return nil, err
|
||
}
|
||
for key, value := range event.Attributes {
|
||
if strings.TrimSpace(key) == "" {
|
||
continue
|
||
}
|
||
if _, exists := merged[key]; exists {
|
||
// 顶层协议字段由 RoomEvent 结构体负责,attributes 只能补充展示字段,不能覆盖事件身份。
|
||
continue
|
||
}
|
||
merged[key] = value
|
||
}
|
||
return json.Marshal(merged)
|
||
}
|
||
|
||
// RoomEntryVehicleSnapshot 是房间系统消息里可直接渲染的座驾素材快照。
|
||
// 它故意不包含 wallet 内部状态,只保留客户端展示和过期兜底需要的字段。
|
||
type RoomEntryVehicleSnapshot struct {
|
||
ResourceID int64 `json:"resource_id"`
|
||
ResourceCode string `json:"resource_code,omitempty"`
|
||
Name string `json:"name,omitempty"`
|
||
AssetURL string `json:"asset_url,omitempty"`
|
||
PreviewURL string `json:"preview_url,omitempty"`
|
||
AnimationURL string `json:"animation_url,omitempty"`
|
||
MetadataJSON string `json:"metadata_json,omitempty"`
|
||
EntitlementID string `json:"entitlement_id,omitempty"`
|
||
ExpiresAtMS int64 `json:"expires_at_ms,omitempty"`
|
||
}
|
||
|
||
// RESTClient 封装腾讯云 IM REST API,把 UserSig、endpoint query 和错误码处理收敛在一个边界。
|
||
type RESTClient struct {
|
||
// cfg 初始化后不再修改,因此同一个 client 可以被多个 worker 并发复用。
|
||
cfg RESTConfig
|
||
// httpClient 复用 keep-alive,避免高频播报时每条消息都重新建连接。
|
||
httpClient *http.Client
|
||
}
|
||
|
||
// NewRESTClient 校验必需配置并创建 REST client。
|
||
func NewRESTClient(cfg RESTConfig) (*RESTClient, error) {
|
||
if cfg.SDKAppID <= 0 || cfg.SecretKey == "" || strings.TrimSpace(cfg.AdminIdentifier) == "" {
|
||
// REST API 依赖管理员账号和 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 为房间创建腾讯云 IM 群。
|
||
// ownerUserID 暂不映射为腾讯群主,房间状态 owner 仍是 Room Cell,腾讯群只做投递通道。
|
||
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
|
||
|
||
return c.EnsureGroup(ctx, GroupSpec{
|
||
GroupID: roomID,
|
||
Name: roomID,
|
||
Type: c.cfg.GroupType,
|
||
ApplyJoinOption: "FreeAccess",
|
||
})
|
||
}
|
||
|
||
// EnsureGroup 创建服务端拥有的腾讯云 IM 群,并把“自定义 GroupID 已存在”视为成功。
|
||
// 这样启动 reconciler、CreateRoom 重试或发布补偿重复执行时,都不会因为已建群而中断主流程。
|
||
func (c *RESTClient) EnsureGroup(ctx context.Context, spec GroupSpec) error {
|
||
spec.GroupID = strings.TrimSpace(spec.GroupID)
|
||
spec.Name = strings.TrimSpace(spec.Name)
|
||
spec.Type = strings.TrimSpace(spec.Type)
|
||
spec.ApplyJoinOption = strings.TrimSpace(spec.ApplyJoinOption)
|
||
spec.OwnerAccount = strings.TrimSpace(spec.OwnerAccount)
|
||
if spec.GroupID == "" {
|
||
return fmt.Errorf("group_id is required")
|
||
}
|
||
if spec.Name == "" {
|
||
spec.Name = spec.GroupID
|
||
}
|
||
if spec.Type == "" {
|
||
spec.Type = c.cfg.GroupType
|
||
}
|
||
if spec.Type == BroadcastGroupType {
|
||
// AVChatRoom 不支持 ApplyJoinOption,带上会被腾讯以非法参数拒绝。
|
||
spec.ApplyJoinOption = ""
|
||
} else if spec.ApplyJoinOption == "" {
|
||
spec.ApplyJoinOption = "FreeAccess"
|
||
}
|
||
|
||
request := createGroupRequest{
|
||
Type: spec.Type,
|
||
GroupID: spec.GroupID,
|
||
Name: spec.Name,
|
||
OwnerAccount: spec.OwnerAccount,
|
||
ApplyJoinOption: spec.ApplyJoinOption,
|
||
}
|
||
|
||
var response restResponse
|
||
if err := c.post(ctx, createGroupCommand, request, &response); err != nil {
|
||
return err
|
||
}
|
||
if response.ErrorCode == 10021 || response.ErrorCode == 10025 {
|
||
// 腾讯云不同接口版本可能返回 10021/10025 表示群已存在;建群语义要求幂等。
|
||
return nil
|
||
}
|
||
|
||
return response.err()
|
||
}
|
||
|
||
// PublishRoomEvent 把房间系统事件转换为腾讯云群自定义消息。
|
||
func (c *RESTClient) PublishRoomEvent(ctx context.Context, event RoomEvent) error {
|
||
if strings.TrimSpace(event.RoomID) == "" || strings.TrimSpace(event.EventID) == "" {
|
||
// event_id 是客户端去重、日志追踪和补偿投递判断的共同锚点。
|
||
return fmt.Errorf("room event is incomplete")
|
||
}
|
||
groupID := strings.TrimSpace(event.GroupID)
|
||
if groupID == "" {
|
||
groupID = event.RoomID
|
||
}
|
||
|
||
eventPayload, err := json.Marshal(event)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
return c.PublishGroupCustomMessage(ctx, CustomGroupMessage{
|
||
GroupID: groupID,
|
||
EventID: event.EventID,
|
||
Desc: event.EventType,
|
||
Ext: "room_system_message",
|
||
PayloadJSON: eventPayload,
|
||
})
|
||
}
|
||
|
||
// PublishGroupCustomMessage 向任意服务端拥有的腾讯云 IM 群发送 TIMCustomElem。
|
||
// 该方法不判断业务类型,调用方必须已经通过 room/broadcast service 完成权限、幂等和持久化决策。
|
||
func (c *RESTClient) PublishGroupCustomMessage(ctx context.Context, message CustomGroupMessage) error {
|
||
message.GroupID = strings.TrimSpace(message.GroupID)
|
||
message.EventID = strings.TrimSpace(message.EventID)
|
||
message.Desc = strings.TrimSpace(message.Desc)
|
||
message.Ext = strings.TrimSpace(message.Ext)
|
||
message.FromAccount = strings.TrimSpace(message.FromAccount)
|
||
payload := bytes.TrimSpace(message.PayloadJSON)
|
||
if message.GroupID == "" || message.EventID == "" || len(payload) == 0 {
|
||
return fmt.Errorf("group custom message is incomplete")
|
||
}
|
||
|
||
request := sendGroupMsgRequest{
|
||
GroupID: message.GroupID,
|
||
Random: randomUint32(),
|
||
CloudCustomData: string(payload),
|
||
FromAccount: message.FromAccount,
|
||
MsgBody: []messageElement{
|
||
{
|
||
MsgType: "TIMCustomElem",
|
||
MsgContent: customMsgContent{
|
||
Data: string(payload),
|
||
Desc: message.Desc,
|
||
Ext: message.Ext,
|
||
Sound: "",
|
||
},
|
||
},
|
||
},
|
||
}
|
||
|
||
var response restResponse
|
||
if err := c.post(ctx, sendGroupMsgCommand, request, &response); err != nil {
|
||
return err
|
||
}
|
||
|
||
return response.err()
|
||
}
|
||
|
||
// PublishUserCustomMessage 向单个腾讯云 IM identifier 发送 TIMCustomElem。
|
||
// 该方法只负责投递协议;业务幂等、隐私字段和重试策略必须由调用方在 outbox 中保证。
|
||
func (c *RESTClient) PublishUserCustomMessage(ctx context.Context, message CustomUserMessage) error {
|
||
message.ToAccount = strings.TrimSpace(message.ToAccount)
|
||
message.EventID = strings.TrimSpace(message.EventID)
|
||
message.Desc = strings.TrimSpace(message.Desc)
|
||
message.Ext = strings.TrimSpace(message.Ext)
|
||
message.FromAccount = strings.TrimSpace(message.FromAccount)
|
||
if message.FromAccount == "" {
|
||
// C2C 服务端消息默认从管理员账号发出,避免调用方每个业务模块复制同一配置。
|
||
message.FromAccount = c.cfg.AdminIdentifier
|
||
}
|
||
syncOtherMachine := message.SyncOtherMachine
|
||
if syncOtherMachine == 0 {
|
||
// 历史 notice 消息只投递给目标用户;不显式传值时继续保持这个行为,避免改变钱包/CP 通知的漫游面。
|
||
syncOtherMachine = 2
|
||
}
|
||
payload := bytes.TrimSpace(message.PayloadJSON)
|
||
if message.ToAccount == "" || message.EventID == "" || len(payload) == 0 {
|
||
return fmt.Errorf("user custom message is incomplete")
|
||
}
|
||
|
||
request := sendC2CMsgRequest{
|
||
SyncOtherMachine: syncOtherMachine,
|
||
ToAccount: message.ToAccount,
|
||
MsgRandom: randomUint32(),
|
||
CloudCustomData: string(payload),
|
||
FromAccount: message.FromAccount,
|
||
MsgBody: []messageElement{
|
||
{
|
||
MsgType: "TIMCustomElem",
|
||
MsgContent: customMsgContent{
|
||
Data: string(payload),
|
||
Desc: message.Desc,
|
||
Ext: message.Ext,
|
||
Sound: "",
|
||
},
|
||
},
|
||
},
|
||
}
|
||
|
||
var response restResponse
|
||
if err := c.post(ctx, sendC2CMsgCommand, request, &response); err != nil {
|
||
return err
|
||
}
|
||
|
||
return response.err()
|
||
}
|
||
|
||
// ImportAccount 确保内部 user_id 已存在于腾讯 IM 账号体系。
|
||
// 腾讯接口本身没有本地事务语义,因此调用方应在用户事务提交后调用,并允许后续 UserSig 入口补偿。
|
||
func (c *RESTClient) ImportAccount(ctx context.Context, profile AccountProfile) error {
|
||
if profile.UserID <= 0 {
|
||
return fmt.Errorf("im account import is incomplete")
|
||
}
|
||
|
||
request := accountImportRequest{
|
||
UserID: FormatUserID(profile.UserID),
|
||
Nick: strings.TrimSpace(profile.Nick),
|
||
FaceURL: strings.TrimSpace(profile.FaceURL),
|
||
}
|
||
var response restResponse
|
||
if err := c.post(ctx, accountImportCommand, request, &response); err != nil {
|
||
return err
|
||
}
|
||
if response.ErrorCode == 0 || isAccountAlreadyImported(response) {
|
||
return nil
|
||
}
|
||
|
||
return response.err()
|
||
}
|
||
|
||
// DestroyGroup 解散一个腾讯云 IM 群。
|
||
// 生产播报群不应该在业务流里自动调用该方法;它主要用于真实 IM 测试后的临时群清理或显式运维操作。
|
||
func (c *RESTClient) DestroyGroup(ctx context.Context, groupID string) error {
|
||
groupID = strings.TrimSpace(groupID)
|
||
if groupID == "" {
|
||
return fmt.Errorf("group_id is required")
|
||
}
|
||
|
||
request := destroyGroupRequest{GroupID: groupID}
|
||
var response restResponse
|
||
if err := c.post(ctx, destroyGroupCommand, request, &response); err != nil {
|
||
return err
|
||
}
|
||
|
||
return response.err()
|
||
}
|
||
|
||
// DeleteGroupMember 从指定腾讯云 IM 群移除一个用户。
|
||
// 它只改变“这个用户在这个群里的成员关系”,不会删除群,也不会影响其他成员。
|
||
func (c *RESTClient) DeleteGroupMember(ctx context.Context, groupID string, userID int64) error {
|
||
groupID = strings.TrimSpace(groupID)
|
||
if groupID == "" || userID <= 0 {
|
||
return fmt.Errorf("group member removal is incomplete")
|
||
}
|
||
|
||
request := deleteGroupMemberRequest{
|
||
GroupID: groupID,
|
||
MemberToDelAccount: []string{FormatUserID(userID)},
|
||
Silence: 1,
|
||
}
|
||
|
||
var response restResponse
|
||
if err := c.post(ctx, deleteGroupMemberCommand, request, &response); err != nil {
|
||
return err
|
||
}
|
||
|
||
return response.err()
|
||
}
|
||
|
||
// DeleteRoomGroupMember 从房间 IM 群移除用户;这是 presence 清理的外部副作用,不改变房间权威状态。
|
||
func (c *RESTClient) DeleteRoomGroupMember(ctx context.Context, roomID string, userID int64) error {
|
||
return c.DeleteGroupMember(ctx, roomID, userID)
|
||
}
|
||
|
||
// KickUser 失效指定用户的腾讯云 IM 登录态。
|
||
// 该接口不会禁止未来用新 UserSig 登录,因此调用方必须先在业务状态和 token denylist 上完成封禁。
|
||
func (c *RESTClient) KickUser(ctx context.Context, userID int64) error {
|
||
if userID <= 0 {
|
||
return fmt.Errorf("kick user is incomplete")
|
||
}
|
||
|
||
request := kickUserRequest{UserID: FormatUserID(userID)}
|
||
var response restResponse
|
||
if err := c.post(ctx, kickUserCommand, request, &response); err != nil {
|
||
return err
|
||
}
|
||
|
||
return response.err()
|
||
}
|
||
|
||
// FormatUserID 把内部不可变 user_id 映射成腾讯云账号字符串。
|
||
func FormatUserID(userID int64) string {
|
||
// 十进制 user_id 跨服务稳定,也避免把可变 display_user_id 暴露给 IM 账号体系。
|
||
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 {
|
||
// 腾讯 REST 正常业务失败通常是 HTTP 200 + ErrorCode;非 2xx 视为网络/网关层失败。
|
||
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().UTC())
|
||
|
||
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 {
|
||
// REST random 只需要足够的去重熵;加密随机失败时用纳秒时间兜底。
|
||
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"`
|
||
}
|
||
|
||
// RESTError 保留腾讯云 IM 业务错误码,让上层能区分缺群、参数错误和限流。
|
||
type RESTError struct {
|
||
Code int
|
||
Info string
|
||
}
|
||
|
||
// Error 实现 error,沿用既有日志格式,避免线上检索规则失效。
|
||
func (e RESTError) Error() string {
|
||
return fmt.Sprintf("tencent im rest failed: code=%d info=%s", e.Code, e.Info)
|
||
}
|
||
|
||
// IsRESTErrorCode 判断错误链中是否包含指定腾讯云 IM 业务错误码。
|
||
func IsRESTErrorCode(err error, codes ...int) bool {
|
||
var restErr RESTError
|
||
if !errors.As(err, &restErr) {
|
||
return false
|
||
}
|
||
for _, code := range codes {
|
||
if restErr.Code == code {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func (r restResponse) err() error {
|
||
if r.ErrorCode == 0 && strings.EqualFold(r.ActionStatus, "OK") {
|
||
return nil
|
||
}
|
||
if r.ErrorCode == 0 && r.ActionStatus == "" {
|
||
// 部分测试只构造 ErrorCode;腾讯语义里 0 即成功,因此允许空 ActionStatus。
|
||
return nil
|
||
}
|
||
|
||
return RESTError{Code: r.ErrorCode, Info: r.ErrorInfo}
|
||
}
|
||
|
||
func isAccountAlreadyImported(response restResponse) bool {
|
||
// 腾讯不同版本和地域对重复导入的错误码/文案不完全一致;账号已存在是幂等成功。
|
||
if response.ErrorCode == 70107 {
|
||
return true
|
||
}
|
||
info := strings.ToLower(strings.TrimSpace(response.ErrorInfo))
|
||
return strings.Contains(info, "already") || strings.Contains(info, "exist")
|
||
}
|
||
|
||
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 sendC2CMsgRequest struct {
|
||
SyncOtherMachine int `json:"SyncOtherMachine"`
|
||
FromAccount string `json:"From_Account,omitempty"`
|
||
ToAccount string `json:"To_Account"`
|
||
MsgLifeTime int `json:"MsgLifeTime,omitempty"`
|
||
MsgRandom uint32 `json:"MsgRandom"`
|
||
CloudCustomData string `json:"CloudCustomData,omitempty"`
|
||
MsgBody []messageElement `json:"MsgBody"`
|
||
}
|
||
|
||
type accountImportRequest struct {
|
||
UserID string `json:"UserID"`
|
||
Nick string `json:"Nick,omitempty"`
|
||
FaceURL string `json:"FaceUrl,omitempty"`
|
||
}
|
||
|
||
type destroyGroupRequest struct {
|
||
GroupID string `json:"GroupId"`
|
||
}
|
||
|
||
type deleteGroupMemberRequest struct {
|
||
GroupID string `json:"GroupId"`
|
||
MemberToDelAccount []string `json:"MemberToDel_Account"`
|
||
Silence int `json:"Silence,omitempty"`
|
||
Reason string `json:"Reason,omitempty"`
|
||
}
|
||
|
||
type kickUserRequest struct {
|
||
UserID string `json:"UserID"`
|
||
}
|
||
|
||
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"`
|
||
}
|