package tencentim import ( "bytes" "compress/zlib" "context" "crypto/hmac" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/json" "errors" "fmt" "io" "math/big" "net/http" "net/url" "strconv" "strings" "time" "chatapp3-golang/internal/config" ) var ErrNotConfigured = errors.New("tencent im config is missing") type Client struct { cfg config.TencentIMConfig httpClient *http.Client } type apiResponse struct { ActionStatus string `json:"ActionStatus"` ErrorCode int `json:"ErrorCode"` ErrorInfo string `json:"ErrorInfo"` GroupID string `json:"GroupId"` } type apiError struct { code int status string info string } func (e *apiError) Error() string { return fmt.Sprintf("tencent im error code=%d status=%s info=%s", e.code, e.status, e.info) } func NewClient(cfg config.TencentIMConfig) *Client { timeoutSeconds := cfg.RequestTimeoutSeconds if timeoutSeconds <= 0 { timeoutSeconds = 8 } return &Client{ cfg: cfg, httpClient: &http.Client{ Timeout: time.Duration(timeoutSeconds) * time.Second, }, } } func (c *Client) CreateAVChatRoom(ctx context.Context, groupID string, groupName string) (string, error) { groupID = strings.TrimSpace(groupID) groupName = strings.TrimSpace(groupName) if groupID == "" { return "", errors.New("group id is required") } if groupName == "" { groupName = groupID } resp, err := c.post(ctx, "/v4/group_open_http_svc/create_group", map[string]any{ "Type": "AVChatRoom", "GroupId": groupID, "Name": groupName, "ApplyJoinOption": "FreeAccess", }) if err != nil { if isTencentIMGroupAlreadyExists(err) { return groupID, nil } return "", err } if strings.TrimSpace(resp.GroupID) != "" { return strings.TrimSpace(resp.GroupID), nil } return groupID, nil } func (c *Client) SendCustomGroupMessage(ctx context.Context, groupID string, body any) error { groupID = strings.TrimSpace(groupID) if groupID == "" { return errors.New("group id is required") } data, err := json.Marshal(body) if err != nil { return err } _, err = c.post(ctx, "/v4/group_open_http_svc/send_group_msg", map[string]any{ "GroupId": groupID, "Random": randomInt31(), "MsgBody": []map[string]any{ { "MsgType": "TIMCustomElem", "MsgContent": map[string]any{ "Data": string(data), "Desc": "customBody", "Sound": "dingdong.aiff", }, }, }, }) return err } func (c *Client) Post(ctx context.Context, path string, payload any) error { _, err := c.post(ctx, path, payload) return err } func (c *Client) post(ctx context.Context, path string, payload any) (*apiResponse, error) { if c == nil || c.cfg.AppID <= 0 || strings.TrimSpace(c.cfg.Key) == "" || strings.TrimSpace(c.cfg.Identifier) == "" { return nil, ErrNotConfigured } baseEndpoint := strings.TrimRight(strings.TrimSpace(c.cfg.BaseEndpoint), "/") if baseEndpoint == "" { baseEndpoint = "https://console.tim.qq.com" } expireSeconds := c.cfg.UserSigExpireSeconds if expireSeconds <= 0 { expireSeconds = 7200000 } userSig, err := genTencentIMUserSig(c.cfg.AppID, strings.TrimSpace(c.cfg.Key), strings.TrimSpace(c.cfg.Identifier), expireSeconds, time.Now().Unix()) if err != nil { return nil, err } query := url.Values{} query.Set("sdkappid", strconv.FormatInt(c.cfg.AppID, 10)) query.Set("identifier", strings.TrimSpace(c.cfg.Identifier)) query.Set("usersig", userSig) query.Set("random", strconv.FormatInt(randomInt31(), 10)) query.Set("contenttype", "json") body, err := json.Marshal(payload) if err != nil { return nil, err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseEndpoint+path+"?"+query.Encode(), bytes.NewReader(body)) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") resp, err := c.httpClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() respBody, readErr := io.ReadAll(resp.Body) if readErr != nil { return nil, readErr } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, fmt.Errorf("tencent im http status %d: %s", resp.StatusCode, string(respBody)) } var apiResp apiResponse if err := json.Unmarshal(respBody, &apiResp); err != nil { return nil, fmt.Errorf("decode tencent im response: %w body=%s", err, string(respBody)) } if apiResp.ErrorCode != 0 || (apiResp.ActionStatus != "" && !strings.EqualFold(apiResp.ActionStatus, "OK")) { if strings.TrimSpace(apiResp.ErrorInfo) == "" { apiResp.ErrorInfo = string(respBody) } return nil, &apiError{code: apiResp.ErrorCode, status: apiResp.ActionStatus, info: apiResp.ErrorInfo} } return &apiResp, nil } func isTencentIMGroupAlreadyExists(err error) bool { var apiErr *apiError if !errors.As(err, &apiErr) { return false } if apiErr.code == 10021 || apiErr.code == 10025 { return true } info := strings.ToLower(strings.TrimSpace(apiErr.info)) return strings.Contains(info, "group id in use") || strings.Contains(info, "group id has been used") } func genTencentIMUserSig(appID int64, key string, identifier string, expireSeconds int64, now int64) (string, error) { if appID <= 0 || strings.TrimSpace(key) == "" || strings.TrimSpace(identifier) == "" { return "", ErrNotConfigured } if expireSeconds <= 0 { expireSeconds = 7200000 } content := "TLS.identifier:" + identifier + "\n" + "TLS.sdkappid:" + strconv.FormatInt(appID, 10) + "\n" + "TLS.time:" + strconv.FormatInt(now, 10) + "\n" + "TLS.expire:" + strconv.FormatInt(expireSeconds, 10) + "\n" mac := hmac.New(sha256.New, []byte(key)) _, _ = mac.Write([]byte(content)) sig := base64.StdEncoding.EncodeToString(mac.Sum(nil)) raw, err := json.Marshal(map[string]any{ "TLS.ver": "2.0", "TLS.identifier": identifier, "TLS.sdkappid": appID, "TLS.expire": expireSeconds, "TLS.time": now, "TLS.sig": sig, }) if err != nil { return "", err } var compressed bytes.Buffer writer := zlib.NewWriter(&compressed) if _, err := writer.Write(raw); err != nil { _ = writer.Close() return "", err } if err := writer.Close(); err != nil { return "", err } return strings.NewReplacer("+", "*", "/", "-", "=", "_").Replace(base64.StdEncoding.EncodeToString(compressed.Bytes())), nil } func randomInt31() int64 { n, err := rand.Int(rand.Reader, big.NewInt(2147483647)) if err != nil { return time.Now().UnixNano() & 0x7fffffff } return n.Int64() }