280 lines
7.9 KiB
Go
280 lines
7.9 KiB
Go
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)
|
|
}
|