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

This commit is contained in:
yawan
2026-09-22 16:19:06 +08:00
commit f7fc992f57
3929 changed files with 1006607 additions and 0 deletions
+329
View File
@@ -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)))
}
+72
View File
@@ -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
}
+39
View File
@@ -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
}
}
+376
View File
@@ -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
}
+165
View File
@@ -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
}