WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,329 @@
|
||||
// Adapter implements im.Adapter and im.FileDownloader for WeChat personal
|
||||
// accounts via the Tencent iLink Bot API.
|
||||
//
|
||||
// WeChat iLink uses HTTP long-polling for receiving messages (no WebSocket,
|
||||
// no Webhook). Sending is done via REST API.
|
||||
//
|
||||
// API base: https://ilinkai.weixin.qq.com
|
||||
// API paths: /ilink/bot/getupdates, /ilink/bot/sendmessage, etc.
|
||||
// Auth: Bearer token obtained via QR code login flow.
|
||||
package wechat
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/im"
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
secutils "github.com/Tencent/WeKnora/internal/utils"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
ilinkBaseURL = "https://ilinkai.weixin.qq.com"
|
||||
// cdnBaseURL is the Weixin CDN base for media download/upload.
|
||||
cdnBaseURL = "https://novac2c.cdn.weixin.qq.com/c2c"
|
||||
// defaultBotType is the bot_type for iLink get_bot_qrcode / get_qrcode_status.
|
||||
defaultBotType = "3"
|
||||
// channelVersion is sent in base_info with every API request.
|
||||
channelVersion = "weknora-1.0.0"
|
||||
)
|
||||
|
||||
var ilinkHTTPClient = secutils.NewSSRFSafeHTTPClient(secutils.SSRFSafeHTTPClientConfig{
|
||||
Timeout: 30 * time.Second,
|
||||
MaxRedirects: 5,
|
||||
})
|
||||
|
||||
// BuildCDNDownloadURL constructs a CDN download URL from an encrypt_query_param.
|
||||
func BuildCDNDownloadURL(encryptQueryParam string) string {
|
||||
return cdnBaseURL + "/download?encrypted_query_param=" + url.QueryEscape(encryptQueryParam)
|
||||
}
|
||||
|
||||
// Compile-time interface checks.
|
||||
var (
|
||||
_ im.Adapter = (*Adapter)(nil)
|
||||
_ im.FileDownloader = (*Adapter)(nil)
|
||||
)
|
||||
|
||||
// baseInfo is included in every outgoing API request body.
|
||||
type baseInfo struct {
|
||||
ChannelVersion string `json:"channel_version"`
|
||||
}
|
||||
|
||||
func newBaseInfo() baseInfo {
|
||||
return baseInfo{ChannelVersion: channelVersion}
|
||||
}
|
||||
|
||||
// Adapter implements im.Adapter for WeChat via iLink Bot API.
|
||||
type Adapter struct {
|
||||
botToken string
|
||||
ilinkBotID string
|
||||
}
|
||||
|
||||
// NewAdapter creates a new WeChat iLink adapter.
|
||||
func NewAdapter(botToken, ilinkBotID string) *Adapter {
|
||||
return &Adapter{
|
||||
botToken: botToken,
|
||||
ilinkBotID: ilinkBotID,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Adapter) Platform() im.Platform {
|
||||
return im.PlatformWeChat
|
||||
}
|
||||
|
||||
// VerifyCallback is not supported — WeChat iLink uses long-polling, not webhooks.
|
||||
func (a *Adapter) VerifyCallback(c *gin.Context) error {
|
||||
return fmt.Errorf("WeChat adapter does not support webhook callbacks")
|
||||
}
|
||||
|
||||
// ParseCallback is not supported — messages arrive via long-polling.
|
||||
func (a *Adapter) ParseCallback(c *gin.Context) (*im.IncomingMessage, error) {
|
||||
return nil, fmt.Errorf("WeChat adapter does not support webhook callbacks")
|
||||
}
|
||||
|
||||
// HandleURLVerification is not applicable for WeChat.
|
||||
func (a *Adapter) HandleURLVerification(c *gin.Context) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// SendReply sends a text reply to the user via iLink /ilink/bot/sendmessage API.
|
||||
func (a *Adapter) SendReply(ctx context.Context, incoming *im.IncomingMessage, reply *im.ReplyMessage) error {
|
||||
contextToken := ""
|
||||
if incoming.Extra != nil {
|
||||
contextToken = incoming.Extra["context_token"]
|
||||
}
|
||||
|
||||
// Build the send message request matching the iLink protocol
|
||||
payload := map[string]interface{}{
|
||||
"msg": map[string]interface{}{
|
||||
"from_user_id": "",
|
||||
"to_user_id": incoming.UserID,
|
||||
"client_id": fmt.Sprintf("weknora_%d", time.Now().UnixNano()),
|
||||
"message_type": 2, // BOT
|
||||
"message_state": 2, // FINISH
|
||||
"item_list": []map[string]interface{}{
|
||||
{
|
||||
"type": 1, // TEXT
|
||||
"text_item": map[string]string{"text": reply.Content},
|
||||
},
|
||||
},
|
||||
"context_token": contextToken,
|
||||
},
|
||||
"base_info": newBaseInfo(),
|
||||
}
|
||||
|
||||
return a.ilinkPost(ctx, "/ilink/bot/sendmessage", payload)
|
||||
}
|
||||
|
||||
// SendTyping sends a typing indicator to the user.
|
||||
func (a *Adapter) SendTyping(ctx context.Context, incoming *im.IncomingMessage) error {
|
||||
userID := incoming.UserID
|
||||
contextToken := ""
|
||||
if incoming.Extra != nil {
|
||||
contextToken = incoming.Extra["context_token"]
|
||||
}
|
||||
_ = contextToken // typing may not need context_token
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"ilink_user_id": userID,
|
||||
"status": 1, // TYPING
|
||||
"base_info": newBaseInfo(),
|
||||
}
|
||||
|
||||
return a.ilinkPost(ctx, "/ilink/bot/sendtyping", payload)
|
||||
}
|
||||
|
||||
// DownloadFile downloads a media file from the iLink CDN.
|
||||
// Files are AES-128-ECB encrypted; the key is provided in the message Extra.
|
||||
func (a *Adapter) DownloadFile(ctx context.Context, msg *im.IncomingMessage) (io.ReadCloser, string, error) {
|
||||
if msg.FileKey == "" {
|
||||
return nil, "", fmt.Errorf("no file URL in message")
|
||||
}
|
||||
if err := secutils.ValidateURLForSSRF(msg.FileKey); err != nil {
|
||||
return nil, "", fmt.Errorf("file URL rejected by SSRF policy: %w", err)
|
||||
}
|
||||
|
||||
fileName := msg.FileName
|
||||
if fileName == "" {
|
||||
fileName = msg.FileKey
|
||||
}
|
||||
|
||||
// Download the file
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, msg.FileKey, nil)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("create download request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := ilinkHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("download file: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
resp.Body.Close()
|
||||
return nil, "", fmt.Errorf("download failed: status=%d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// If no AES key provided, return raw content
|
||||
aesKeyB64 := ""
|
||||
if msg.Extra != nil {
|
||||
aesKeyB64 = msg.Extra["aes_key"]
|
||||
}
|
||||
if aesKeyB64 == "" {
|
||||
return resp.Body, fileName, nil
|
||||
}
|
||||
|
||||
// Read and decrypt with AES-128-ECB
|
||||
encryptedData, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("read encrypted file: %w", err)
|
||||
}
|
||||
|
||||
// Parse the AES key: base64 → raw bytes (16) or hex string (32 chars → 16 bytes)
|
||||
aesKey, err := parseAESKey(aesKeyB64)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("parse aes key: %w", err)
|
||||
}
|
||||
|
||||
logger.Debugf(ctx, "[WeChat] Decrypting file: name=%s encrypted_size=%d", fileName, len(encryptedData))
|
||||
|
||||
decrypted, err := decryptAES128ECB(encryptedData, aesKey)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("decrypt file: %w", err)
|
||||
}
|
||||
|
||||
return io.NopCloser(bytes.NewReader(decrypted)), fileName, nil
|
||||
}
|
||||
|
||||
// parseAESKey decodes an AES key from various formats seen in iLink responses.
|
||||
//
|
||||
// Three formats are encountered:
|
||||
// 1. base64(raw 16 bytes) → CDNMedia.aes_key for file/voice/video
|
||||
// 2. base64(hex string of 32 chars) → CDNMedia.aes_key (alternative)
|
||||
// 3. raw hex string (32 chars) → ImageItem.aeskey field (NOT base64-encoded)
|
||||
//
|
||||
// The function auto-detects the format and always returns a 16-byte key.
|
||||
func parseAESKey(aesKeyStr string) ([]byte, error) {
|
||||
if aesKeyStr == "" {
|
||||
return nil, fmt.Errorf("empty aes key")
|
||||
}
|
||||
|
||||
// Case 3: raw hex string (32 hex chars = 16 bytes)
|
||||
if len(aesKeyStr) == 32 && isHex(aesKeyStr) {
|
||||
return hexDecode(aesKeyStr)
|
||||
}
|
||||
|
||||
// Case 1 & 2: base64-encoded
|
||||
decoded, err := base64.StdEncoding.DecodeString(aesKeyStr)
|
||||
if err != nil {
|
||||
decoded, err = base64.RawStdEncoding.DecodeString(aesKeyStr)
|
||||
if err != nil {
|
||||
// Last resort: maybe it's a hex string of other length
|
||||
if isHex(aesKeyStr) && len(aesKeyStr)%2 == 0 {
|
||||
return hexDecode(aesKeyStr)
|
||||
}
|
||||
return nil, fmt.Errorf("cannot decode aes key (len=%d): %w", len(aesKeyStr), err)
|
||||
}
|
||||
}
|
||||
|
||||
// base64 decoded to exactly 16 raw bytes → direct key
|
||||
if len(decoded) == 16 {
|
||||
return decoded, nil
|
||||
}
|
||||
|
||||
// base64 decoded to 32 ASCII hex chars → parse hex to get 16 bytes
|
||||
if len(decoded) == 32 && isHex(string(decoded)) {
|
||||
return hexDecode(string(decoded))
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("aes key decoded to %d bytes (expected 16 raw or 32 hex), input len=%d", len(decoded), len(aesKeyStr))
|
||||
}
|
||||
|
||||
// isHex returns true if s contains only hexadecimal characters.
|
||||
func isHex(s string) bool {
|
||||
for _, c := range s {
|
||||
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return len(s) > 0
|
||||
}
|
||||
|
||||
// hexDecode decodes a hex string to bytes.
|
||||
func hexDecode(s string) ([]byte, error) {
|
||||
if len(s)%2 != 0 {
|
||||
return nil, fmt.Errorf("odd-length hex string: %d", len(s))
|
||||
}
|
||||
result := make([]byte, len(s)/2)
|
||||
for i := 0; i < len(result); i++ {
|
||||
var b byte
|
||||
_, err := fmt.Sscanf(s[i*2:i*2+2], "%02x", &b)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hex decode at pos %d: %w", i, err)
|
||||
}
|
||||
result[i] = b
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ilinkPost sends a POST request to the iLink API with authentication headers.
|
||||
func (a *Adapter) ilinkPost(ctx context.Context, path string, payload interface{}) error {
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal payload: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, ilinkBaseURL+path, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
|
||||
a.setAuthHeaders(req, body)
|
||||
|
||||
resp, err := ilinkHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ilink request %s: %w", path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("ilink api %s returned status %d: %s", path, resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// setAuthHeaders sets the required iLink Bot authentication headers.
|
||||
func (a *Adapter) setAuthHeaders(req *http.Request, body []byte) {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("AuthorizationType", "ilink_bot_token")
|
||||
if a.botToken != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+a.botToken)
|
||||
}
|
||||
req.Header.Set("X-WECHAT-UIN", generateWeChatUIN())
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Length", fmt.Sprintf("%d", len(body)))
|
||||
}
|
||||
}
|
||||
|
||||
// generateWeChatUIN generates a random X-WECHAT-UIN header value.
|
||||
// Format: random uint32 → decimal string → base64.
|
||||
func generateWeChatUIN() string {
|
||||
buf := make([]byte, 4)
|
||||
_, _ = rand.Read(buf)
|
||||
n := binary.BigEndian.Uint32(buf)
|
||||
return base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%d", n)))
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// Package wechat implements the WeChat personal account IM adapter for WeKnora
|
||||
// via the Tencent iLink Bot API (ilinkai.weixin.qq.com).
|
||||
package wechat
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// decryptAES128ECB decrypts data encrypted with AES-128-ECB (no padding removal).
|
||||
// iLink media files are encrypted with a 16-byte AES key using ECB mode.
|
||||
func decryptAES128ECB(ciphertext, key []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new aes cipher: %w", err)
|
||||
}
|
||||
|
||||
bs := block.BlockSize()
|
||||
if len(ciphertext) == 0 || len(ciphertext)%bs != 0 {
|
||||
return nil, fmt.Errorf("ciphertext length %d is not a multiple of block size %d", len(ciphertext), bs)
|
||||
}
|
||||
|
||||
plaintext := make([]byte, len(ciphertext))
|
||||
for i := 0; i < len(ciphertext); i += bs {
|
||||
block.Decrypt(plaintext[i:i+bs], ciphertext[i:i+bs])
|
||||
}
|
||||
|
||||
// Remove PKCS#7 padding if present
|
||||
if len(plaintext) > 0 {
|
||||
padLen := int(plaintext[len(plaintext)-1])
|
||||
if padLen > 0 && padLen <= bs && padLen <= len(plaintext) {
|
||||
valid := true
|
||||
for i := 0; i < padLen; i++ {
|
||||
if plaintext[len(plaintext)-1-i] != byte(padLen) {
|
||||
valid = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if valid {
|
||||
plaintext = plaintext[:len(plaintext)-padLen]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return plaintext, nil
|
||||
}
|
||||
|
||||
// encryptAES128ECB encrypts data with AES-128-ECB and PKCS#7 padding.
|
||||
// Used for uploading media files to the iLink CDN.
|
||||
func encryptAES128ECB(plaintext, key []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new aes cipher: %w", err)
|
||||
}
|
||||
|
||||
bs := block.BlockSize()
|
||||
|
||||
// PKCS#7 padding
|
||||
padLen := bs - len(plaintext)%bs
|
||||
padded := make([]byte, len(plaintext)+padLen)
|
||||
copy(padded, plaintext)
|
||||
for i := len(plaintext); i < len(padded); i++ {
|
||||
padded[i] = byte(padLen)
|
||||
}
|
||||
|
||||
ciphertext := make([]byte, len(padded))
|
||||
for i := 0; i < len(padded); i += bs {
|
||||
block.Encrypt(ciphertext[i:i+bs], padded[i:i+bs])
|
||||
}
|
||||
|
||||
return ciphertext, nil
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package wechat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/im"
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
)
|
||||
|
||||
// NewFactory returns an im.AdapterFactory for WeChat channels (iLink bot).
|
||||
// WeChat only supports a long-polling mode, so there is no mode branch.
|
||||
func NewFactory() im.AdapterFactory {
|
||||
return func(factoryCtx context.Context, channel *im.IMChannel, msgHandler func(context.Context, *im.IncomingMessage) error) (im.Adapter, context.CancelFunc, error) {
|
||||
creds, err := im.ParseCredentials(channel.Credentials)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("parse wechat credentials: %w", err)
|
||||
}
|
||||
|
||||
botToken := im.GetString(creds, "bot_token")
|
||||
ilinkBotID := im.GetString(creds, "ilink_bot_id")
|
||||
|
||||
if botToken == "" || ilinkBotID == "" {
|
||||
return nil, nil, fmt.Errorf("wechat credentials require bot_token and ilink_bot_id")
|
||||
}
|
||||
|
||||
adapter := NewAdapter(botToken, ilinkBotID)
|
||||
client := NewLongPollClient(botToken, ilinkBotID, msgHandler)
|
||||
|
||||
pollCtx, pollCancel := context.WithCancel(context.Background())
|
||||
go func() {
|
||||
if err := client.Start(pollCtx); err != nil && pollCtx.Err() == nil {
|
||||
logger.Errorf(context.Background(), "[IM] WeChat long-poll stopped for channel %s: %v", channel.ID, err)
|
||||
}
|
||||
}()
|
||||
|
||||
return adapter, pollCancel, nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
// Long-polling client for the WeChat iLink Bot API.
|
||||
//
|
||||
// Flow:
|
||||
// 1. POST /ilink/bot/getupdates with get_updates_buf + base_info
|
||||
// 2. Parse response msgs[] into IncomingMessage
|
||||
// 3. Call msgHandler for each message
|
||||
// 4. Update cursor (get_updates_buf) for next poll
|
||||
// 5. On error, exponential backoff retry
|
||||
//
|
||||
// Token expiry: errcode -14 signals the token is no longer valid.
|
||||
package wechat
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/im"
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
secutils "github.com/Tencent/WeKnora/internal/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
longPollTimeout = 35 * time.Second
|
||||
longPollHTTPTimeout = 40 * time.Second // slightly longer than poll timeout
|
||||
reconnectBaseDelay = 1 * time.Second
|
||||
reconnectMaxDelay = 30 * time.Second
|
||||
maxReconnectAttempts = -1 // infinite
|
||||
)
|
||||
|
||||
// ErrTokenExpired indicates the bot token has expired and a re-login is required.
|
||||
var ErrTokenExpired = fmt.Errorf("wechat bot token expired")
|
||||
|
||||
// LongPollClient receives messages from WeChat via HTTP long-polling.
|
||||
type LongPollClient struct {
|
||||
botToken string
|
||||
ilinkBotID string
|
||||
handler func(ctx context.Context, msg *im.IncomingMessage) error
|
||||
httpClient *http.Client
|
||||
cursor string // get_updates_buf: opaque cursor for pagination
|
||||
}
|
||||
|
||||
// NewLongPollClient creates a new WeChat long-polling client.
|
||||
func NewLongPollClient(botToken, ilinkBotID string, handler func(ctx context.Context, msg *im.IncomingMessage) error) *LongPollClient {
|
||||
return &LongPollClient{
|
||||
botToken: botToken,
|
||||
ilinkBotID: ilinkBotID,
|
||||
handler: handler,
|
||||
httpClient: secutils.NewSSRFSafeHTTPClient(secutils.SSRFSafeHTTPClientConfig{
|
||||
Timeout: longPollHTTPTimeout,
|
||||
MaxRedirects: 5,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins the long-polling loop. It reconnects automatically on transient errors.
|
||||
// Returns ErrTokenExpired when the bot token expires (errcode -14).
|
||||
func (c *LongPollClient) Start(ctx context.Context) error {
|
||||
logger.Infof(ctx, "[IM] WeChat long-poll starting (bot_id=%s)...", c.ilinkBotID)
|
||||
|
||||
attempts := 0
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
pollStart := time.Now()
|
||||
err := c.poll(ctx)
|
||||
if err == nil {
|
||||
// Successful poll — reset attempts
|
||||
attempts = 0
|
||||
continue
|
||||
}
|
||||
|
||||
if err == ErrTokenExpired {
|
||||
logger.Warnf(ctx, "[WeChat] Bot token expired, stopping long-poll")
|
||||
return err
|
||||
}
|
||||
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
// If we ran for a while before failing, reset backoff
|
||||
if time.Since(pollStart) > reconnectMaxDelay {
|
||||
attempts = 0
|
||||
}
|
||||
|
||||
attempts++
|
||||
if maxReconnectAttempts >= 0 && attempts >= maxReconnectAttempts {
|
||||
return fmt.Errorf("max reconnect attempts reached: %w", err)
|
||||
}
|
||||
|
||||
delay := pollReconnectDelay(attempts)
|
||||
logger.Warnf(ctx, "[WeChat] Poll error (%v), retrying in %v (attempt %d)...", err, delay, attempts)
|
||||
|
||||
select {
|
||||
case <-time.After(delay):
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// poll performs a single long-poll request to /ilink/bot/getupdates.
|
||||
func (c *LongPollClient) poll(ctx context.Context) error {
|
||||
payload := map[string]interface{}{
|
||||
"get_updates_buf": c.cursor,
|
||||
"base_info": newBaseInfo(),
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, ilinkBaseURL+"/ilink/bot/getupdates", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("AuthorizationType", "ilink_bot_token")
|
||||
req.Header.Set("Authorization", "Bearer "+c.botToken)
|
||||
req.Header.Set("X-WECHAT-UIN", generateWeChatUIN())
|
||||
req.Header.Set("Content-Length", fmt.Sprintf("%d", len(body)))
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("poll request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("getupdates returned status %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var result getUpdatesResponse
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return fmt.Errorf("decode response: %w", err)
|
||||
}
|
||||
|
||||
// Token expired
|
||||
if result.ErrCode == -14 {
|
||||
return ErrTokenExpired
|
||||
}
|
||||
|
||||
if result.Ret != 0 && result.ErrCode != 0 {
|
||||
return fmt.Errorf("getupdates error: ret=%d errcode=%d msg=%s", result.Ret, result.ErrCode, result.ErrMsg)
|
||||
}
|
||||
|
||||
// Update cursor for next poll
|
||||
if result.GetUpdatesBuf != "" {
|
||||
c.cursor = result.GetUpdatesBuf
|
||||
}
|
||||
|
||||
// Process messages
|
||||
for i := range result.Msgs {
|
||||
msg := &result.Msgs[i]
|
||||
incoming := c.parseMessage(msg)
|
||||
if incoming == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle in a detached goroutine so we don't block polling
|
||||
go func(m *im.IncomingMessage) {
|
||||
if err := c.handler(ctx, m); err != nil {
|
||||
logger.Errorf(ctx, "[WeChat] Handle message error: %v", err)
|
||||
}
|
||||
}(incoming)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseMessage converts a WeixinMessage from getupdates to a unified IncomingMessage.
|
||||
func (c *LongPollClient) parseMessage(msg *weixinMessage) *im.IncomingMessage {
|
||||
contextToken := msg.ContextToken
|
||||
|
||||
// Only process user messages (message_type=1), skip bot messages (message_type=2)
|
||||
if msg.MessageType == 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(msg.ItemList) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Process the first item
|
||||
item := msg.ItemList[0]
|
||||
|
||||
switch item.Type {
|
||||
case 1: // TEXT
|
||||
content := ""
|
||||
if item.TextItem != nil {
|
||||
content = strings.TrimSpace(item.TextItem.Text)
|
||||
}
|
||||
if content == "" {
|
||||
return nil
|
||||
}
|
||||
return &im.IncomingMessage{
|
||||
Platform: im.PlatformWeChat,
|
||||
MessageType: im.MessageTypeText,
|
||||
UserID: msg.FromUserID,
|
||||
ChatType: im.ChatTypeDirect,
|
||||
Content: content,
|
||||
MessageID: fmt.Sprintf("%d", msg.MessageID),
|
||||
Extra: map[string]string{"context_token": contextToken},
|
||||
}
|
||||
|
||||
case 2: // IMAGE
|
||||
if item.ImageItem == nil || item.ImageItem.Media == nil {
|
||||
return nil
|
||||
}
|
||||
encryptParam := item.ImageItem.Media.EncryptQueryParam
|
||||
if encryptParam == "" {
|
||||
return nil
|
||||
}
|
||||
// Build full CDN download URL from encrypt_query_param
|
||||
downloadURL := BuildCDNDownloadURL(encryptParam)
|
||||
// For images, prefer aeskey (hex format) from image_item, else media.aes_key (base64)
|
||||
aesKey := ""
|
||||
if item.ImageItem.AESKey != "" {
|
||||
// hex → base64 for uniform handling
|
||||
aesKey = item.ImageItem.AESKey
|
||||
} else if item.ImageItem.Media.AESKey != "" {
|
||||
aesKey = item.ImageItem.Media.AESKey
|
||||
}
|
||||
return &im.IncomingMessage{
|
||||
Platform: im.PlatformWeChat,
|
||||
MessageType: im.MessageTypeImage,
|
||||
UserID: msg.FromUserID,
|
||||
ChatType: im.ChatTypeDirect,
|
||||
MessageID: fmt.Sprintf("%d", msg.MessageID),
|
||||
FileKey: downloadURL,
|
||||
FileName: fmt.Sprintf("%d.png", msg.MessageID),
|
||||
Extra: map[string]string{
|
||||
"context_token": contextToken,
|
||||
"aes_key": aesKey,
|
||||
},
|
||||
}
|
||||
|
||||
case 3: // VOICE (speech-to-text)
|
||||
if item.VoiceItem != nil && item.VoiceItem.Text != "" {
|
||||
return &im.IncomingMessage{
|
||||
Platform: im.PlatformWeChat,
|
||||
MessageType: im.MessageTypeText,
|
||||
UserID: msg.FromUserID,
|
||||
ChatType: im.ChatTypeDirect,
|
||||
Content: strings.TrimSpace(item.VoiceItem.Text),
|
||||
MessageID: fmt.Sprintf("%d", msg.MessageID),
|
||||
Extra: map[string]string{"context_token": contextToken},
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
case 4: // FILE
|
||||
if item.FileItem == nil || item.FileItem.Media == nil {
|
||||
return nil
|
||||
}
|
||||
encryptParam := item.FileItem.Media.EncryptQueryParam
|
||||
if encryptParam == "" {
|
||||
return nil
|
||||
}
|
||||
// Build full CDN download URL from encrypt_query_param
|
||||
downloadURL := BuildCDNDownloadURL(encryptParam)
|
||||
fileName := item.FileItem.FileName
|
||||
if fileName == "" {
|
||||
fileName = fmt.Sprintf("file_%d", msg.MessageID)
|
||||
}
|
||||
var fileSize int64
|
||||
if item.FileItem.Len != "" {
|
||||
fmt.Sscanf(item.FileItem.Len, "%d", &fileSize)
|
||||
}
|
||||
return &im.IncomingMessage{
|
||||
Platform: im.PlatformWeChat,
|
||||
MessageType: im.MessageTypeFile,
|
||||
UserID: msg.FromUserID,
|
||||
ChatType: im.ChatTypeDirect,
|
||||
MessageID: fmt.Sprintf("%d", msg.MessageID),
|
||||
FileKey: downloadURL,
|
||||
FileName: fileName,
|
||||
FileSize: fileSize,
|
||||
Extra: map[string]string{
|
||||
"context_token": contextToken,
|
||||
"aes_key": item.FileItem.Media.AESKey,
|
||||
},
|
||||
}
|
||||
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func pollReconnectDelay(attempt int) time.Duration {
|
||||
if attempt < 1 {
|
||||
return reconnectBaseDelay
|
||||
}
|
||||
// Cap the exponent to avoid int64 overflow: base (1e9 ns) * 2^shift
|
||||
// overflows when shift ≥ 34, producing a negative duration that would
|
||||
// bypass the max-delay check and cause a busy reconnect loop.
|
||||
shift := attempt - 1
|
||||
if shift > 30 {
|
||||
return reconnectMaxDelay
|
||||
}
|
||||
delay := reconnectBaseDelay * (1 << shift)
|
||||
if delay > reconnectMaxDelay {
|
||||
delay = reconnectMaxDelay
|
||||
}
|
||||
return delay
|
||||
}
|
||||
|
||||
// ── iLink API response types (matches proto: GetUpdatesResp, WeixinMessage) ──
|
||||
|
||||
type getUpdatesResponse struct {
|
||||
Ret int `json:"ret"`
|
||||
ErrCode int `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
Msgs []weixinMessage `json:"msgs"`
|
||||
GetUpdatesBuf string `json:"get_updates_buf"`
|
||||
}
|
||||
|
||||
type weixinMessage struct {
|
||||
Seq int `json:"seq"`
|
||||
MessageID int64 `json:"message_id"`
|
||||
FromUserID string `json:"from_user_id"`
|
||||
ToUserID string `json:"to_user_id"`
|
||||
ClientID string `json:"client_id"`
|
||||
CreateTimeMs int64 `json:"create_time_ms"`
|
||||
SessionID string `json:"session_id"`
|
||||
MessageType int `json:"message_type"` // 1=USER, 2=BOT
|
||||
MessageState int `json:"message_state"` // 0=NEW, 1=GENERATING, 2=FINISH
|
||||
ItemList []messageItem `json:"item_list"`
|
||||
ContextToken string `json:"context_token"`
|
||||
}
|
||||
|
||||
type messageItem struct {
|
||||
Type int `json:"type"` // 1=TEXT, 2=IMAGE, 3=VOICE, 4=FILE, 5=VIDEO
|
||||
TextItem *textItem `json:"text_item,omitempty"`
|
||||
ImageItem *imageItem `json:"image_item,omitempty"`
|
||||
VoiceItem *voiceItem `json:"voice_item,omitempty"`
|
||||
FileItem *fileItem `json:"file_item,omitempty"`
|
||||
}
|
||||
|
||||
type textItem struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
type cdnMedia struct {
|
||||
EncryptQueryParam string `json:"encrypt_query_param"`
|
||||
AESKey string `json:"aes_key"`
|
||||
}
|
||||
|
||||
type imageItem struct {
|
||||
Media *cdnMedia `json:"media,omitempty"`
|
||||
AESKey string `json:"aeskey"` // hex string, preferred for inbound decryption
|
||||
URL string `json:"url,omitempty"`
|
||||
}
|
||||
|
||||
type voiceItem struct {
|
||||
Media *cdnMedia `json:"media,omitempty"`
|
||||
Text string `json:"text"` // speech-to-text result
|
||||
}
|
||||
|
||||
type fileItem struct {
|
||||
Media *cdnMedia `json:"media,omitempty"`
|
||||
FileName string `json:"file_name"`
|
||||
Len string `json:"len"` // plaintext bytes as string
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
// QR code login flow for WeChat iLink Bot API.
|
||||
//
|
||||
// API endpoints (all relative to the iLink base URL):
|
||||
//
|
||||
// GET /ilink/bot/get_bot_qrcode?bot_type=3 → returns {qrcode, qrcode_img_content}
|
||||
// GET /ilink/bot/get_qrcode_status?qrcode=xxx → returns {status, bot_token, ilink_bot_id, ...}
|
||||
//
|
||||
// Flow:
|
||||
// 1. Call GetLoginQRCode to obtain a QR code URL and opaque qrcode token
|
||||
// 2. Display qrcode_img_content URL to user (in frontend)
|
||||
// 3. Long-poll PollQRCodeStatus until user scans and confirms
|
||||
// 4. On success, receive bot_token + ilink_bot_id + ilink_user_id + baseurl
|
||||
package wechat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
secutils "github.com/Tencent/WeKnora/internal/utils"
|
||||
)
|
||||
|
||||
// QRCodeResult holds the result of requesting a login QR code.
|
||||
type QRCodeResult struct {
|
||||
// QRCodeURL is the URL to render as a QR code image (qrcode_img_content).
|
||||
QRCodeURL string `json:"qrcode_url"`
|
||||
// QRCode is the opaque token used to poll for scan status.
|
||||
QRCode string `json:"qrcode"`
|
||||
}
|
||||
|
||||
// LoginResult holds the credentials returned after successful QR code scan.
|
||||
type LoginResult struct {
|
||||
Status string `json:"status"` // "wait", "scaned", "confirmed", "expired"
|
||||
BotToken string `json:"bot_token"` // Bearer token for API calls
|
||||
ILinkBotID string `json:"ilink_bot_id"` // Bot identifier
|
||||
ILinkUserID string `json:"ilink_user_id"` // User identifier
|
||||
BaseURL string `json:"baseurl"` // API base URL (may override default)
|
||||
}
|
||||
|
||||
// QRCodeService handles WeChat QR code login operations.
|
||||
type QRCodeService struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewQRCodeService creates a new QR code service.
|
||||
func NewQRCodeService() *QRCodeService {
|
||||
return &QRCodeService{
|
||||
client: secutils.NewSSRFSafeHTTPClient(secutils.SSRFSafeHTTPClientConfig{
|
||||
Timeout: pollTimeout,
|
||||
MaxRedirects: 5,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// GetLoginQRCode requests a new login QR code from iLink API.
|
||||
// GET /ilink/bot/get_bot_qrcode?bot_type=3
|
||||
func (s *QRCodeService) GetLoginQRCode(ctx context.Context) (*QRCodeResult, error) {
|
||||
u := ilinkBaseURL + "/ilink/bot/get_bot_qrcode?bot_type=" + url.QueryEscape(defaultBotType)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request qrcode: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response body: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("qrcode API returned status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
QRCode string `json:"qrcode"`
|
||||
QRCodeImgContent string `json:"qrcode_img_content"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("decode response: %w (body: %s)", err, string(body))
|
||||
}
|
||||
|
||||
if result.QRCode == "" {
|
||||
return nil, fmt.Errorf("empty qrcode in response: %s", string(body))
|
||||
}
|
||||
|
||||
return &QRCodeResult{
|
||||
QRCodeURL: result.QRCodeImgContent,
|
||||
QRCode: result.QRCode,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// pollTimeout is the client-side timeout for the long-poll get_qrcode_status
|
||||
// request. The iLink server may hold the request up to 35s, so we set a
|
||||
// slightly longer timeout. We use a DETACHED context (not the gin request
|
||||
// context) to avoid "context canceled" when gin's own timeout fires first.
|
||||
const pollTimeout = 38 * time.Second
|
||||
|
||||
// PollQRCodeStatus checks the scan status of a QR code.
|
||||
// GET /ilink/bot/get_qrcode_status?qrcode=xxx
|
||||
// This is a long-poll endpoint: the server holds the connection until there
|
||||
// is a status change or ~35 seconds elapse.
|
||||
// Status values: "wait", "scaned", "confirmed", "expired"
|
||||
func (s *QRCodeService) PollQRCodeStatus(ctx context.Context, qrcode string) (*LoginResult, error) {
|
||||
u := ilinkBaseURL + "/ilink/bot/get_qrcode_status?qrcode=" + url.QueryEscape(qrcode)
|
||||
|
||||
// Use a DETACHED context with our own timeout so we are not bound by the
|
||||
// caller's (gin) request context which may be shorter than the iLink
|
||||
// long-poll hold time.
|
||||
pollCtx, cancel := context.WithTimeout(context.Background(), pollTimeout)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(pollCtx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
req.Header.Set("iLink-App-ClientVersion", "1")
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
// Client-side timeout is normal for long-poll; return "wait" status
|
||||
if pollCtx.Err() != nil {
|
||||
return &LoginResult{Status: "wait"}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("request qrcode status: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response body: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("qrcode status API returned status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Status string `json:"status"` // "wait", "scaned", "confirmed", "expired"
|
||||
BotToken string `json:"bot_token"`
|
||||
ILinkBotID string `json:"ilink_bot_id"`
|
||||
ILinkUserID string `json:"ilink_user_id"`
|
||||
BaseURL string `json:"baseurl"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("decode response: %w (body: %s)", err, string(body))
|
||||
}
|
||||
|
||||
return &LoginResult{
|
||||
Status: result.Status,
|
||||
BotToken: result.BotToken,
|
||||
ILinkBotID: result.ILinkBotID,
|
||||
ILinkUserID: result.ILinkUserID,
|
||||
BaseURL: result.BaseURL,
|
||||
}, nil
|
||||
}
|
||||
Reference in New Issue
Block a user