2026-05-08 18:55:47 +08:00

235 lines
6.3 KiB
Go

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()
}