初始化仓库
This commit is contained in:
@@ -0,0 +1,279 @@
|
||||
package weixin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultBaseURL = "https://ilinkai.weixin.qq.com"
|
||||
defaultCDNBaseURL = "https://novac2c.cdn.weixin.qq.com/c2c"
|
||||
|
||||
defaultLongPollTimeout = 35 * time.Second
|
||||
defaultAPITimeout = 15 * time.Second
|
||||
|
||||
// maxIlinkHTTPResponseBody caps JSON response size (getUpdates may batch many msgs).
|
||||
maxIlinkHTTPResponseBody = 64 << 20
|
||||
|
||||
channelVersion = "cc-connect-weixin/1.0"
|
||||
)
|
||||
|
||||
type apiClient struct {
|
||||
baseURL string
|
||||
token string
|
||||
routeTag string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func newAPIClient(baseURL, token, routeTag string, httpClient *http.Client) *apiClient {
|
||||
if strings.TrimSpace(baseURL) == "" {
|
||||
baseURL = defaultBaseURL
|
||||
}
|
||||
baseURL = strings.TrimRight(baseURL, "/") + "/"
|
||||
if httpClient == nil {
|
||||
httpClient = &http.Client{Timeout: defaultAPITimeout}
|
||||
}
|
||||
return &apiClient{
|
||||
baseURL: baseURL,
|
||||
token: strings.TrimSpace(token),
|
||||
routeTag: strings.TrimSpace(routeTag),
|
||||
httpClient: httpClient,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *apiClient) longPollClient(timeout time.Duration) *http.Client {
|
||||
if timeout <= 0 {
|
||||
timeout = defaultLongPollTimeout
|
||||
}
|
||||
tr := http.DefaultTransport
|
||||
if t, ok := http.DefaultTransport.(*http.Transport); ok {
|
||||
cloned := t.Clone()
|
||||
tr = cloned
|
||||
}
|
||||
return &http.Client{
|
||||
Timeout: timeout + 5*time.Second,
|
||||
Transport: tr,
|
||||
}
|
||||
}
|
||||
|
||||
func randomWechatUIN() string {
|
||||
var b [4]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return base64.StdEncoding.EncodeToString([]byte("0000"))
|
||||
}
|
||||
u := uint32(b[0])<<24 | uint32(b[1])<<16 | uint32(b[2])<<8 | uint32(b[3])
|
||||
return base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%d", u)))
|
||||
}
|
||||
|
||||
func (c *apiClient) post(ctx context.Context, endpoint string, body []byte, timeout time.Duration, label string) ([]byte, error) {
|
||||
url := c.baseURL + strings.TrimPrefix(endpoint, "/")
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("weixin: %s: new request: %w", label, err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("AuthorizationType", "ilink_bot_token")
|
||||
req.Header.Set("Content-Length", fmt.Sprintf("%d", len(body)))
|
||||
req.Header.Set("X-WECHAT-UIN", randomWechatUIN())
|
||||
if c.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
}
|
||||
if c.routeTag != "" {
|
||||
req.Header.Set("SKRouteTag", c.routeTag)
|
||||
}
|
||||
|
||||
client := c.httpClient
|
||||
if timeout > 0 {
|
||||
// Dedicated client so long-poll does not inherit short Timeout from default client.
|
||||
client = c.longPollClient(timeout)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("weixin: %s: %w", label, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxIlinkHTTPResponseBody+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("weixin: %s: read body: %w", label, err)
|
||||
}
|
||||
if len(raw) > maxIlinkHTTPResponseBody {
|
||||
return nil, fmt.Errorf("weixin: %s: response body exceeds %d bytes", label, maxIlinkHTTPResponseBody)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("weixin: %s: http %d: %s", label, resp.StatusCode, truncateForLog(raw, 512))
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func truncateForLog(b []byte, max int) string {
|
||||
s := string(b)
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[:max] + "…"
|
||||
}
|
||||
|
||||
func (c *apiClient) getUpdates(ctx context.Context, buf string, timeoutMs int) (*getUpdatesResp, error) {
|
||||
timeout := defaultLongPollTimeout
|
||||
if timeoutMs > 0 {
|
||||
timeout = time.Duration(timeoutMs) * time.Millisecond
|
||||
}
|
||||
req := getUpdatesReq{
|
||||
GetUpdatesBuf: buf,
|
||||
BaseInfo: baseInfo{ChannelVersion: channelVersion},
|
||||
}
|
||||
payload, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, err := c.post(ctx, "ilink/bot/getupdates", payload, timeout, "getUpdates")
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return &getUpdatesResp{Ret: 0, Msgs: nil, GetUpdatesBuf: buf}, nil
|
||||
}
|
||||
var ne net.Error
|
||||
if errors.As(err, &ne) && ne.Timeout() {
|
||||
return &getUpdatesResp{Ret: 0, Msgs: nil, GetUpdatesBuf: buf}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var out getUpdatesResp
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return nil, fmt.Errorf("weixin: getUpdates json: %w", err)
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (c *apiClient) sendMessage(ctx context.Context, msg *sendMessageReq) error {
|
||||
if msg == nil {
|
||||
return fmt.Errorf("weixin: sendMessage: nil request")
|
||||
}
|
||||
msg.BaseInfo = baseInfo{ChannelVersion: channelVersion}
|
||||
payload, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := c.post(ctx, "ilink/bot/sendmessage", payload, 0, "sendMessage")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(bytes.TrimSpace(raw)) == 0 {
|
||||
return nil
|
||||
}
|
||||
var resp sendMessageResp
|
||||
if err := json.Unmarshal(raw, &resp); err != nil {
|
||||
return fmt.Errorf("weixin: sendMessage: response json: %w: %s", err, truncateForLog(raw, 256))
|
||||
}
|
||||
if resp.Ret != 0 {
|
||||
slog.Warn("weixin: sendMessage declined by API",
|
||||
"ret", resp.Ret, "errcode", resp.Errcode, "errmsg", resp.Errmsg,
|
||||
"content_len", len(payload))
|
||||
return fmt.Errorf("weixin: sendMessage: ret=%d errcode=%d errmsg=%s",
|
||||
resp.Ret, resp.Errcode, resp.Errmsg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *apiClient) getUploadURL(ctx context.Context, req getUploadURLRequest) (*getUploadURLResponse, error) {
|
||||
req.BaseInfo = baseInfo{ChannelVersion: channelVersion}
|
||||
payload, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, err := c.post(ctx, "ilink/bot/getuploadurl", payload, 0, "getUploadUrl")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out getUploadURLResponse
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return nil, fmt.Errorf("weixin: getUploadUrl json: %w", err)
|
||||
}
|
||||
// 兼容微信 iLink API 变更:新版返回 upload_full_url 而非 upload_param
|
||||
// upload_full_url 是完整的 CDN 上传地址,可独立作为成功路径
|
||||
if strings.TrimSpace(out.UploadParam) == "" && strings.TrimSpace(out.UploadFullURL) == "" {
|
||||
return nil, fmt.Errorf("weixin: getUploadUrl: empty upload_param and upload_full_url in %s", truncateForLog(raw, 512))
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (c *apiClient) getConfig(ctx context.Context, userID, contextToken string) (*getConfigResp, error) {
|
||||
req := getConfigReq{
|
||||
UserID: userID,
|
||||
ContextToken: contextToken,
|
||||
BaseInfo: baseInfo{ChannelVersion: channelVersion},
|
||||
}
|
||||
payload, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, err := c.post(ctx, "ilink/bot/getconfig", payload, 0, "getConfig")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out getConfigResp
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return nil, fmt.Errorf("weixin: getConfig json: %w", err)
|
||||
}
|
||||
if out.Ret != 0 || out.Errcode != 0 {
|
||||
return nil, fmt.Errorf("weixin: getConfig ret=%d errcode=%d errmsg=%s", out.Ret, out.Errcode, out.Errmsg)
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (c *apiClient) sendTyping(ctx context.Context, userID, typingTicket string, status int) error {
|
||||
req := sendTypingReq{
|
||||
IlinkUserID: userID,
|
||||
TypingTicket: typingTicket,
|
||||
Status: status,
|
||||
BaseInfo: baseInfo{ChannelVersion: channelVersion},
|
||||
}
|
||||
payload, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = c.post(ctx, "ilink/bot/sendtyping", payload, 0, "sendTyping")
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *apiClient) sendText(ctx context.Context, to, text, contextToken, clientID string) error {
|
||||
if strings.TrimSpace(contextToken) == "" {
|
||||
return fmt.Errorf("weixin: context_token is required for send")
|
||||
}
|
||||
items := []messageItem{}
|
||||
if strings.TrimSpace(text) != "" {
|
||||
items = append(items, messageItem{
|
||||
Type: messageItemText,
|
||||
TextItem: &textItem{Text: text},
|
||||
})
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return fmt.Errorf("weixin: sendText: empty item_list")
|
||||
}
|
||||
msg := sendMessageReq{
|
||||
Msg: weixinOutboundMsg{
|
||||
FromUserID: "",
|
||||
ToUserID: to,
|
||||
ClientID: clientID,
|
||||
MessageType: messageTypeBot,
|
||||
MessageState: messageStateFinish,
|
||||
ItemList: items,
|
||||
ContextToken: contextToken,
|
||||
},
|
||||
}
|
||||
return c.sendMessage(ctx, &msg)
|
||||
}
|
||||
Reference in New Issue
Block a user