235 lines
6.3 KiB
Go
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()
|
|
}
|