From 21ac4b181a4bfa9b0198e38be7a85de3a7e312d6 Mon Sep 17 00:00:00 2001 From: Vaayne Date: Sun, 22 Mar 2026 20:48:45 +0800 Subject: [PATCH 01/31] =?UTF-8?q?=E2=9C=A8=20feat(weixin):=20add=20protoco?= =?UTF-8?q?l=20types=20for=20iLink=20Bot=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All message, item, media, and API request/response types for the WeChat iLink Bot protocol. Includes constants for message type, message state, item type, and media type. --- internal/channel/weixin/model.go | 236 +++++++++++++++++++++++++++++++ 1 file changed, 236 insertions(+) create mode 100644 internal/channel/weixin/model.go diff --git a/internal/channel/weixin/model.go b/internal/channel/weixin/model.go new file mode 100644 index 0000000..f890bc1 --- /dev/null +++ b/internal/channel/weixin/model.go @@ -0,0 +1,236 @@ +package weixin + +// Message type constants. +const ( + MessageTypeUser = 1 // USER — incoming message from a WeChat user. + MessageTypeBot = 2 // BOT — outgoing message from the bot. +) + +// Message state constants. +const ( + MessageStateNew = 0 // NEW — initial state. + MessageStateGenerating = 1 // GENERATING — content being generated (not used for sending). + MessageStateFinish = 2 // FINISH — final state; always use this for outbound messages. +) + +// Item type constants. +const ( + ItemTypeText = 1 + ItemTypeImage = 2 + ItemTypeVoice = 3 + ItemTypeFile = 4 + ItemTypeVideo = 5 +) + +// Media type constants for getuploadurl. +const ( + MediaTypeImage = 1 + MediaTypeVideo = 2 + MediaTypeFile = 3 + MediaTypeVoice = 4 +) + +// WeixinMessage represents a single message in the iLink Bot protocol. +type WeixinMessage struct { + Seq int64 `json:"seq,omitempty"` + MessageID int64 `json:"message_id,omitempty"` + FromUserID string `json:"from_user_id,omitempty"` + ToUserID string `json:"to_user_id,omitempty"` + ClientID string `json:"client_id,omitempty"` + CreateTimeMS int64 `json:"create_time_ms,omitempty"` + UpdateTimeMS int64 `json:"update_time_ms,omitempty"` + DeleteTimeMS int64 `json:"delete_time_ms,omitempty"` + SessionID string `json:"session_id,omitempty"` + GroupID string `json:"group_id,omitempty"` + MessageType int `json:"message_type,omitempty"` + MessageState int `json:"message_state,omitempty"` + ItemList []MessageItem `json:"item_list,omitempty"` + ContextToken string `json:"context_token,omitempty"` +} + +// MessageItem represents a single content item within a message. +type MessageItem struct { + Type int `json:"type,omitempty"` + CreateTimeMS int64 `json:"create_time_ms,omitempty"` + UpdateTimeMS int64 `json:"update_time_ms,omitempty"` + IsCompleted *bool `json:"is_completed,omitempty"` + MsgID string `json:"msg_id,omitempty"` + RefMsg *RefMessage `json:"ref_msg,omitempty"` + TextItem *TextItem `json:"text_item,omitempty"` + ImageItem *ImageItem `json:"image_item,omitempty"` + VoiceItem *VoiceItem `json:"voice_item,omitempty"` + FileItem *FileItem `json:"file_item,omitempty"` + VideoItem *VideoItem `json:"video_item,omitempty"` +} + +// TextItem holds text content. +type TextItem struct { + Text string `json:"text,omitempty"` +} + +// ImageItem holds image content and CDN references. +type ImageItem struct { + Media *CDNMedia `json:"media,omitempty"` + ThumbMedia *CDNMedia `json:"thumb_media,omitempty"` + AESKey string `json:"aeskey,omitempty"` // hex string, 32 chars + URL string `json:"url,omitempty"` + MidSize int64 `json:"mid_size,omitempty"` + ThumbSize int64 `json:"thumb_size,omitempty"` + ThumbHeight int `json:"thumb_height,omitempty"` + ThumbWidth int `json:"thumb_width,omitempty"` + HDSize int64 `json:"hd_size,omitempty"` +} + +// VoiceItem holds voice content. +type VoiceItem struct { + Media *CDNMedia `json:"media,omitempty"` + EncodeType int `json:"encode_type,omitempty"` + BitsPerSample int `json:"bits_per_sample,omitempty"` + SampleRate int `json:"sample_rate,omitempty"` + Playtime int `json:"playtime,omitempty"` + Text string `json:"text,omitempty"` +} + +// FileItem holds file content. +type FileItem struct { + Media *CDNMedia `json:"media,omitempty"` + FileName string `json:"file_name,omitempty"` + MD5 string `json:"md5,omitempty"` + Len string `json:"len,omitempty"` // plaintext file size as string +} + +// VideoItem holds video content. +type VideoItem struct { + Media *CDNMedia `json:"media,omitempty"` + VideoSize int64 `json:"video_size,omitempty"` + PlayLength int `json:"play_length,omitempty"` + VideoMD5 string `json:"video_md5,omitempty"` + ThumbMedia *CDNMedia `json:"thumb_media,omitempty"` + ThumbSize int64 `json:"thumb_size,omitempty"` + ThumbHeight int `json:"thumb_height,omitempty"` + ThumbWidth int `json:"thumb_width,omitempty"` +} + +// CDNMedia is the shared CDN reference for images, voice, files, and videos. +type CDNMedia struct { + EncryptQueryParam string `json:"encrypt_query_param,omitempty"` + AESKey string `json:"aes_key,omitempty"` // base64 encoded + EncryptType int `json:"encrypt_type,omitempty"` +} + +// RefMessage represents a quoted/referenced message. +type RefMessage struct { + Title string `json:"title,omitempty"` + MessageItem *MessageItem `json:"message_item,omitempty"` +} + +// BaseInfo is included in every business POST request body. +type BaseInfo struct { + ChannelVersion string `json:"channel_version"` +} + +// --- API request/response types --- + +// GetUpdatesRequest is the request body for getupdates. +type GetUpdatesRequest struct { + GetUpdatesBuf string `json:"get_updates_buf"` + BaseInfo BaseInfo `json:"base_info"` +} + +// GetUpdatesResponse is the response body for getupdates. +type GetUpdatesResponse struct { + Ret int `json:"ret"` + ErrCode int `json:"errcode,omitempty"` + ErrMsg string `json:"errmsg,omitempty"` + Msgs []WeixinMessage `json:"msgs,omitempty"` + GetUpdatesBuf string `json:"get_updates_buf,omitempty"` + LongPollingTimeoutMS int `json:"longpolling_timeout_ms,omitempty"` +} + +// SendMessageRequest is the request body for sendmessage. +type SendMessageRequest struct { + Msg WeixinMessage `json:"msg"` + BaseInfo BaseInfo `json:"base_info"` +} + +// SendMessageResponse is the response body for sendmessage. +type SendMessageResponse struct { + Ret int `json:"ret,omitempty"` + ErrCode int `json:"errcode,omitempty"` + ErrMsg string `json:"errmsg,omitempty"` +} + +// GetConfigRequest is the request body for getconfig. +type GetConfigRequest struct { + ILinkUserID string `json:"ilink_user_id"` + ContextToken string `json:"context_token,omitempty"` + BaseInfo BaseInfo `json:"base_info"` +} + +// GetConfigResponse is the response body for getconfig. +type GetConfigResponse struct { + Ret int `json:"ret"` + ErrCode int `json:"errcode,omitempty"` + ErrMsg string `json:"errmsg,omitempty"` + TypingTicket string `json:"typing_ticket,omitempty"` +} + +// SendTypingRequest is the request body for sendtyping. +type SendTypingRequest struct { + ILinkUserID string `json:"ilink_user_id"` + TypingTicket string `json:"typing_ticket"` + Status int `json:"status"` + BaseInfo BaseInfo `json:"base_info"` +} + +// SendTypingResponse is the response body for sendtyping. +type SendTypingResponse struct { + Ret int `json:"ret"` + ErrCode int `json:"errcode,omitempty"` + ErrMsg string `json:"errmsg,omitempty"` +} + +// UploadParams holds the parameters for getuploadurl. +type UploadParams struct { + FileKey string `json:"filekey"` + MediaType int `json:"media_type"` + ToUserID string `json:"to_user_id"` + RawSize int `json:"rawsize"` + RawFileMD5 string `json:"rawfilemd5"` + FileSize int `json:"filesize"` + NoNeedThumb bool `json:"no_need_thumb,omitempty"` + AESKey string `json:"aeskey,omitempty"` // hex string + ThumbRawSize int `json:"thumb_rawsize,omitempty"` + ThumbRawMD5 string `json:"thumb_rawfilemd5,omitempty"` + ThumbFileSize int `json:"thumb_filesize,omitempty"` +} + +// GetUploadURLRequest is the request body for getuploadurl. +type GetUploadURLRequest struct { + UploadParams + BaseInfo BaseInfo `json:"base_info"` +} + +// GetUploadURLResponse is the response body for getuploadurl. +type GetUploadURLResponse struct { + Ret int `json:"ret,omitempty"` + ErrCode int `json:"errcode,omitempty"` + ErrMsg string `json:"errmsg,omitempty"` + UploadParam string `json:"upload_param,omitempty"` + ThumbUploadParam string `json:"thumb_upload_param,omitempty"` +} + +// QRCodeResponse is the response from get_bot_qrcode. +type QRCodeResponse struct { + QRCode string `json:"qrcode,omitempty"` + QRCodeImgContent string `json:"qrcode_img_content,omitempty"` +} + +// QRCodeStatusResponse is the response from get_qrcode_status. +type QRCodeStatusResponse struct { + Status string `json:"status,omitempty"` + BotToken string `json:"bot_token,omitempty"` + ILinkBotID string `json:"ilink_bot_id,omitempty"` + ILinkUserID string `json:"ilink_user_id,omitempty"` + BaseURL string `json:"baseurl,omitempty"` +} From 814187d2225f11314e6bd1181de9d41f29a34249 Mon Sep 17 00:00:00 2001 From: Vaayne Date: Sun, 22 Mar 2026 20:49:33 +0800 Subject: [PATCH 02/31] =?UTF-8?q?=E2=9C=A8=20feat(weixin):=20add=20HTTP=20?= =?UTF-8?q?client=20for=20iLink=20Bot=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Client with methods for all iLink API endpoints: GetQRCode, GetQRCodeStatus, GetUpdates (long-poll), SendMessage, GetConfig, SendTyping, GetUploadURL. Includes X-WECHAT-UIN header generation, common header builder, and ErrSessionExpired for ret=-14. --- internal/channel/weixin/client.go | 355 ++++++++++++++++++++++++++++++ 1 file changed, 355 insertions(+) create mode 100644 internal/channel/weixin/client.go diff --git a/internal/channel/weixin/client.go b/internal/channel/weixin/client.go new file mode 100644 index 0000000..5a6bc42 --- /dev/null +++ b/internal/channel/weixin/client.go @@ -0,0 +1,355 @@ +package weixin + +import ( + "bytes" + "crypto/rand" + "encoding/base64" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "strconv" + "time" +) + +const ( + // DefaultBaseURL is the default iLink API base URL. + DefaultBaseURL = "https://ilinkai.weixin.qq.com" + + // DefaultCDNBaseURL is the CDN base URL for media upload/download. + DefaultCDNBaseURL = "https://novac2c.cdn.weixin.qq.com" + + // DefaultChannelVersion is the SDK/client version string. + DefaultChannelVersion = "1.0.0" + + // authorizationType is the fixed value for the AuthorizationType header. + authorizationType = "ilink_bot_token" + + // defaultTimeout is the default HTTP client timeout. + defaultTimeout = 40 * time.Second +) + +// ErrSessionExpired indicates the iLink session has expired (ret=-14 or errcode=-14). +var ErrSessionExpired = errors.New("weixin: session expired (ret=-14)") + +// logger returns the package logger. +func logger() *slog.Logger { return slog.With("component", "weixin") } + +// Client is an HTTP client for the iLink Bot API. +type Client struct { + baseURL string + cdnBaseURL string + token string + httpClient *http.Client +} + +// NewClient creates a new iLink API client. +func NewClient(baseURL, cdnBaseURL, token string) *Client { + if baseURL == "" { + baseURL = DefaultBaseURL + } + if cdnBaseURL == "" { + cdnBaseURL = DefaultCDNBaseURL + } + return &Client{ + baseURL: baseURL, + cdnBaseURL: cdnBaseURL, + token: token, + httpClient: &http.Client{Timeout: defaultTimeout}, + } +} + +// randomWechatUIN generates a random X-WECHAT-UIN header value. +// Algorithm: random uint32 → decimal string → base64. +func randomWechatUIN() string { + var buf [4]byte + _, _ = rand.Read(buf[:]) + val := binary.BigEndian.Uint32(buf[:]) + dec := strconv.FormatUint(uint64(val), 10) + return base64.StdEncoding.EncodeToString([]byte(dec)) +} + +// commonHeaders sets the required headers for all business POST requests. +func (c *Client) commonHeaders(req *http.Request) { + req.Header.Set("Content-Type", "application/json") + req.Header.Set("AuthorizationType", authorizationType) + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("X-WECHAT-UIN", randomWechatUIN()) +} + +// GetQRCode requests a new QR code for login. +func (c *Client) GetQRCode(skRouteTag string) (*QRCodeResponse, error) { + u := c.baseURL + "/ilink/bot/get_bot_qrcode?bot_type=3" + req, err := http.NewRequest(http.MethodGet, u, nil) + if err != nil { + return nil, fmt.Errorf("weixin: build qrcode request: %w", err) + } + if skRouteTag != "" { + req.Header.Set("SKRouteTag", skRouteTag) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("weixin: get qrcode: %w", err) + } + defer resp.Body.Close() + + var result QRCodeResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("weixin: decode qrcode response: %w", err) + } + return &result, nil +} + +// GetQRCodeStatus polls the QR code scan status. +func (c *Client) GetQRCodeStatus(qrcode, skRouteTag string) (*QRCodeStatusResponse, error) { + u := c.baseURL + "/ilink/bot/get_qrcode_status?qrcode=" + url.QueryEscape(qrcode) + req, err := http.NewRequest(http.MethodGet, u, nil) + if err != nil { + return nil, fmt.Errorf("weixin: build qrcode status request: %w", err) + } + req.Header.Set("iLink-App-ClientVersion", "1") + if skRouteTag != "" { + req.Header.Set("SKRouteTag", skRouteTag) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("weixin: get qrcode status: %w", err) + } + defer resp.Body.Close() + + var result QRCodeStatusResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("weixin: decode qrcode status: %w", err) + } + return &result, nil +} + +// GetUpdates performs a long-poll for new messages. +// Returns the response including messages, new cursor, and timeout hint. +func (c *Client) GetUpdates(buf, channelVersion string, timeout time.Duration) (*GetUpdatesResponse, error) { + if channelVersion == "" { + channelVersion = DefaultChannelVersion + } + + body := GetUpdatesRequest{ + GetUpdatesBuf: buf, + BaseInfo: BaseInfo{ChannelVersion: channelVersion}, + } + + data, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("weixin: marshal getupdates: %w", err) + } + + req, err := http.NewRequest(http.MethodPost, c.baseURL+"/ilink/bot/getupdates", bytes.NewReader(data)) + if err != nil { + return nil, fmt.Errorf("weixin: build getupdates request: %w", err) + } + c.commonHeaders(req) + + // Use a per-request client with adaptive timeout for long-polling. + client := &http.Client{Timeout: timeout} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("weixin: getupdates: %w", err) + } + defer resp.Body.Close() + + var result GetUpdatesResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("weixin: decode getupdates: %w", err) + } + + if err := checkError(result.Ret, result.ErrCode, result.ErrMsg); err != nil { + return nil, err + } + + return &result, nil +} + +// SendMessage sends a message to a user. +func (c *Client) SendMessage(msg WeixinMessage, channelVersion string) error { + if channelVersion == "" { + channelVersion = DefaultChannelVersion + } + + body := SendMessageRequest{ + Msg: msg, + BaseInfo: BaseInfo{ChannelVersion: channelVersion}, + } + + data, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("weixin: marshal sendmessage: %w", err) + } + + req, err := http.NewRequest(http.MethodPost, c.baseURL+"/ilink/bot/sendmessage", bytes.NewReader(data)) + if err != nil { + return fmt.Errorf("weixin: build sendmessage request: %w", err) + } + c.commonHeaders(req) + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("weixin: sendmessage: %w", err) + } + defer resp.Body.Close() + + var result SendMessageResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + // SendMessageResp may be empty on success. + return nil + } + + return checkError(result.Ret, result.ErrCode, result.ErrMsg) +} + +// GetConfig retrieves the typing_ticket for a user. +func (c *Client) GetConfig(userID, contextToken, channelVersion string) (*GetConfigResponse, error) { + if channelVersion == "" { + channelVersion = DefaultChannelVersion + } + + body := GetConfigRequest{ + ILinkUserID: userID, + ContextToken: contextToken, + BaseInfo: BaseInfo{ChannelVersion: channelVersion}, + } + + data, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("weixin: marshal getconfig: %w", err) + } + + req, err := http.NewRequest(http.MethodPost, c.baseURL+"/ilink/bot/getconfig", bytes.NewReader(data)) + if err != nil { + return nil, fmt.Errorf("weixin: build getconfig request: %w", err) + } + c.commonHeaders(req) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("weixin: getconfig: %w", err) + } + defer resp.Body.Close() + + var result GetConfigResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("weixin: decode getconfig: %w", err) + } + + if err := checkError(result.Ret, result.ErrCode, result.ErrMsg); err != nil { + return nil, err + } + + return &result, nil +} + +// SendTyping sends or cancels a typing indicator. +// status=1 starts typing, status=2 cancels. +func (c *Client) SendTyping(userID, typingTicket string, status int, channelVersion string) error { + if channelVersion == "" { + channelVersion = DefaultChannelVersion + } + + body := SendTypingRequest{ + ILinkUserID: userID, + TypingTicket: typingTicket, + Status: status, + BaseInfo: BaseInfo{ChannelVersion: channelVersion}, + } + + data, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("weixin: marshal sendtyping: %w", err) + } + + req, err := http.NewRequest(http.MethodPost, c.baseURL+"/ilink/bot/sendtyping", bytes.NewReader(data)) + if err != nil { + return fmt.Errorf("weixin: build sendtyping request: %w", err) + } + c.commonHeaders(req) + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("weixin: sendtyping: %w", err) + } + defer resp.Body.Close() + + var result SendTypingResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil + } + + return checkError(result.Ret, result.ErrCode, result.ErrMsg) +} + +// GetUploadURL requests CDN upload parameters for a file. +func (c *Client) GetUploadURL(params UploadParams, channelVersion string) (*GetUploadURLResponse, error) { + if channelVersion == "" { + channelVersion = DefaultChannelVersion + } + + // Build the request body by embedding UploadParams fields + base_info. + reqBody := struct { + UploadParams + BaseInfo BaseInfo `json:"base_info"` + }{ + UploadParams: params, + BaseInfo: BaseInfo{ChannelVersion: channelVersion}, + } + + data, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("weixin: marshal getuploadurl: %w", err) + } + + req, err := http.NewRequest(http.MethodPost, c.baseURL+"/ilink/bot/getuploadurl", bytes.NewReader(data)) + if err != nil { + return nil, fmt.Errorf("weixin: build getuploadurl request: %w", err) + } + c.commonHeaders(req) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("weixin: getuploadurl: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("weixin: read getuploadurl response: %w", err) + } + + var result GetUploadURLResponse + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("weixin: decode getuploadurl: %w", err) + } + + if err := checkError(result.Ret, result.ErrCode, result.ErrMsg); err != nil { + return nil, err + } + + return &result, nil +} + +// checkError returns ErrSessionExpired for ret=-14 or errcode=-14, +// a generic error for other non-zero codes, or nil on success. +func checkError(ret, errcode int, errmsg string) error { + if ret == -14 || errcode == -14 { + return ErrSessionExpired + } + if ret != 0 { + return fmt.Errorf("weixin: API error ret=%d errcode=%d: %s", ret, errcode, errmsg) + } + if errcode != 0 { + return fmt.Errorf("weixin: API error errcode=%d: %s", errcode, errmsg) + } + return nil +} From ad0449ff6637c20b9b399d8349fd3d782b136a9e Mon Sep 17 00:00:00 2001 From: Vaayne Date: Sun, 22 Mar 2026 20:50:29 +0800 Subject: [PATCH 03/31] =?UTF-8?q?=E2=9C=A8=20feat(weixin):=20add=20AES-128?= =?UTF-8?q?-ECB=20crypto=20and=20CDN=20upload/download?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements media handling for the iLink Bot protocol: - AES-128-ECB encrypt/decrypt with PKCS7 padding - Dual AES key format decode (raw 16 bytes vs hex string) - Image key resolution with precedence rules - CDN upload (POST with encrypted body, extract x-encrypted-param) - CDN download (GET encrypted data) - RandomFileKey and RandomClientID generators - CiphertextSize calculation --- internal/channel/weixin/media.go | 235 +++++++++++++++++++++++++++++++ 1 file changed, 235 insertions(+) create mode 100644 internal/channel/weixin/media.go diff --git a/internal/channel/weixin/media.go b/internal/channel/weixin/media.go new file mode 100644 index 0000000..6de88cb --- /dev/null +++ b/internal/channel/weixin/media.go @@ -0,0 +1,235 @@ +package weixin + +import ( + "bytes" + "crypto/aes" + + "crypto/rand" + "encoding/base64" + "encoding/hex" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "time" +) + +// EncryptAESECB encrypts plaintext using AES-128-ECB with PKCS7 padding. +func EncryptAESECB(plaintext, key []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, fmt.Errorf("weixin: aes cipher: %w", err) + } + + padded := pkcs7Pad(plaintext, aes.BlockSize) + ciphertext := make([]byte, len(padded)) + + for i := 0; i < len(padded); i += aes.BlockSize { + block.Encrypt(ciphertext[i:i+aes.BlockSize], padded[i:i+aes.BlockSize]) + } + + return ciphertext, nil +} + +// DecryptAESECB decrypts ciphertext using AES-128-ECB and removes PKCS7 padding. +func DecryptAESECB(ciphertext, key []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, fmt.Errorf("weixin: aes cipher: %w", err) + } + + if len(ciphertext)%aes.BlockSize != 0 { + return nil, fmt.Errorf("weixin: ciphertext not multiple of block size") + } + + plaintext := make([]byte, len(ciphertext)) + for i := 0; i < len(ciphertext); i += aes.BlockSize { + block.Decrypt(plaintext[i:i+aes.BlockSize], ciphertext[i:i+aes.BlockSize]) + } + + return pkcs7Unpad(plaintext, aes.BlockSize) +} + +// CiphertextSize returns the AES-128-ECB + PKCS7 ciphertext size for a given raw size. +// Formula: ceil((rawSize+1)/16) * 16 +func CiphertextSize(rawSize int) int { + return ((rawSize + 1 + aes.BlockSize - 1) / aes.BlockSize) * aes.BlockSize +} + +// DecodeAESKey decodes an AES key from the base64 value in CDNMedia.aes_key. +// It handles two formats: +// - Format A: base64(raw 16 bytes) → decode to 16 bytes, use directly +// - Format B: base64(hex string) → decode to 32 hex chars, hex-decode to 16 bytes +func DecodeAESKey(aesKeyB64 string) ([]byte, error) { + decoded, err := base64.StdEncoding.DecodeString(aesKeyB64) + if err != nil { + return nil, fmt.Errorf("weixin: base64 decode aes_key: %w", err) + } + + switch len(decoded) { + case 16: + // Format A: raw 16 bytes + return decoded, nil + case 32: + // Format B: 32 hex characters → decode to 16 bytes + if !isHexString(decoded) { + return nil, fmt.Errorf("weixin: aes_key 32 bytes but not valid hex") + } + key, err := hex.DecodeString(string(decoded)) + if err != nil { + return nil, fmt.Errorf("weixin: hex decode aes_key: %w", err) + } + return key, nil + default: + return nil, fmt.Errorf("weixin: unexpected aes_key length %d after base64 decode", len(decoded)) + } +} + +// ResolveImageKey determines the AES key for an image item. +// Precedence: image_item.aeskey (hex) > media.aes_key (base64). +func ResolveImageKey(imageItem *ImageItem) ([]byte, error) { + if imageItem == nil { + return nil, fmt.Errorf("weixin: nil image item") + } + + // Priority 1: image_item.aeskey is a 32-char hex string. + if imageItem.AESKey != "" { + key, err := hex.DecodeString(imageItem.AESKey) + if err != nil { + return nil, fmt.Errorf("weixin: hex decode image aeskey: %w", err) + } + if len(key) != 16 { + return nil, fmt.Errorf("weixin: image aeskey decoded to %d bytes, want 16", len(key)) + } + return key, nil + } + + // Priority 2: media.aes_key is base64. + if imageItem.Media != nil && imageItem.Media.AESKey != "" { + return DecodeAESKey(imageItem.Media.AESKey) + } + + return nil, fmt.Errorf("weixin: no AES key found for image") +} + +// UploadToCDN uploads encrypted data to the WeChat CDN. +// Returns the x-encrypted-param response header value. +func UploadToCDN(cdnBaseURL, uploadParam, filekey string, encrypted []byte) (string, error) { + if cdnBaseURL == "" { + cdnBaseURL = DefaultCDNBaseURL + } + + u := cdnBaseURL + "/c2c/upload?" + + "encrypted_query_param=" + url.QueryEscape(uploadParam) + + "&filekey=" + url.QueryEscape(filekey) + + req, err := http.NewRequest(http.MethodPost, u, bytes.NewReader(encrypted)) + if err != nil { + return "", fmt.Errorf("weixin: build cdn upload request: %w", err) + } + req.Header.Set("Content-Type", "application/octet-stream") + + client := &http.Client{Timeout: 60 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("weixin: cdn upload: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + errMsg := resp.Header.Get("x-error-message") + return "", fmt.Errorf("weixin: cdn upload status %d: %s %s", resp.StatusCode, errMsg, string(body)) + } + + encryptedParam := resp.Header.Get("x-encrypted-param") + if encryptedParam == "" { + return "", fmt.Errorf("weixin: cdn upload missing x-encrypted-param header") + } + + return encryptedParam, nil +} + +// DownloadFromCDN downloads encrypted data from the WeChat CDN. +func DownloadFromCDN(cdnBaseURL, encryptedQueryParam string) ([]byte, error) { + if cdnBaseURL == "" { + cdnBaseURL = DefaultCDNBaseURL + } + + u := cdnBaseURL + "/c2c/download?encrypted_query_param=" + url.QueryEscape(encryptedQueryParam) + + resp, err := http.Get(u) //nolint:gosec // URL is constructed from trusted CDN base + API param + if err != nil { + return nil, fmt.Errorf("weixin: cdn download: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("weixin: cdn download status %d", resp.StatusCode) + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("weixin: read cdn download: %w", err) + } + + return data, nil +} + +// RandomFileKey generates a random 16-byte hex string for CDN upload filekey. +func RandomFileKey() string { + var buf [16]byte + _, _ = rand.Read(buf[:]) + return hex.EncodeToString(buf[:]) +} + +// RandomClientID generates a unique client_id with the given prefix. +// Format: prefix:timestamp-random +func RandomClientID(prefix string) string { + ts := time.Now().UnixMilli() + var buf [4]byte + _, _ = rand.Read(buf[:]) + suffix := hex.EncodeToString(buf[:]) + return prefix + ":" + strconv.FormatInt(ts, 10) + "-" + suffix +} + +// --- internal helpers --- + +// pkcs7Pad pads data to a multiple of blockSize using PKCS7. +func pkcs7Pad(data []byte, blockSize int) []byte { + padding := blockSize - len(data)%blockSize + pad := bytes.Repeat([]byte{byte(padding)}, padding) + return append(data, pad...) +} + +// pkcs7Unpad removes PKCS7 padding. +func pkcs7Unpad(data []byte, blockSize int) ([]byte, error) { + if len(data) == 0 || len(data)%blockSize != 0 { + return nil, fmt.Errorf("weixin: invalid padded data length") + } + + padding := int(data[len(data)-1]) + if padding == 0 || padding > blockSize { + return nil, fmt.Errorf("weixin: invalid PKCS7 padding value %d", padding) + } + + for i := len(data) - padding; i < len(data); i++ { + if data[i] != byte(padding) { + return nil, fmt.Errorf("weixin: invalid PKCS7 padding") + } + } + + return data[:len(data)-padding], nil +} + +// isHexString returns true if all bytes are valid hex characters [0-9a-fA-F]. +func isHexString(b []byte) bool { + for _, c := range b { + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) { + return false + } + } + return true +} + From 11a8b5d9552eb5178df1d7e97d11144eae9dfb32 Mon Sep 17 00:00:00 2001 From: Vaayne Date: Sun, 22 Mar 2026 20:51:22 +0800 Subject: [PATCH 04/31] =?UTF-8?q?=E2=9C=85=20test(weixin):=20add=20unit=20?= =?UTF-8?q?tests=20for=20client,=20crypto,=20and=20key=20format=20parsing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests cover: - randomWechatUIN format validation and uniqueness - AES-128-ECB encrypt/decrypt round-trip with various sizes - Known-vector AES test - CiphertextSize for edge cases and spec example - DecodeAESKey for format A (raw) and format B (hex string) - ResolveImageKey precedence (hex field > media base64 > error) - RandomFileKey length/format/uniqueness - RandomClientID format/uniqueness --- internal/channel/weixin/weixin_test.go | 300 +++++++++++++++++++++++++ 1 file changed, 300 insertions(+) create mode 100644 internal/channel/weixin/weixin_test.go diff --git a/internal/channel/weixin/weixin_test.go b/internal/channel/weixin/weixin_test.go new file mode 100644 index 0000000..dc91dcf --- /dev/null +++ b/internal/channel/weixin/weixin_test.go @@ -0,0 +1,300 @@ +package weixin + +import ( + "encoding/base64" + "encoding/hex" + "regexp" + "strconv" + "strings" + "testing" +) + +func TestRandomWechatUIN(t *testing.T) { + t.Parallel() + + seen := make(map[string]struct{}) + for range 100 { + uin := randomWechatUIN() + if uin == "" { + t.Fatal("randomWechatUIN returned empty string") + } + + // Must be valid base64. + decoded, err := base64.StdEncoding.DecodeString(uin) + if err != nil { + t.Fatalf("randomWechatUIN not valid base64: %v", err) + } + + // Decoded must be a decimal string representing a uint32. + val, err := strconv.ParseUint(string(decoded), 10, 32) + if err != nil { + t.Fatalf("decoded UIN %q is not a valid uint32 decimal: %v", string(decoded), err) + } + _ = val + + seen[uin] = struct{}{} + } + + // With 100 random uint32 values, collisions should be essentially impossible. + if len(seen) < 95 { + t.Errorf("too many collisions: only %d unique values out of 100", len(seen)) + } +} + +func TestAESEncryptDecryptRoundTrip(t *testing.T) { + t.Parallel() + + key, _ := hex.DecodeString("00112233445566778899aabbccddeeff") + testCases := []struct { + name string + plaintext []byte + }{ + {"empty", []byte{}}, + {"short", []byte("hello")}, + {"exact block", []byte("0123456789abcdef")}, // 16 bytes + {"two blocks", []byte("0123456789abcdef0123456789abcdef")}, // 32 bytes + {"odd length", []byte("this is 17 bytes!")}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + encrypted, err := EncryptAESECB(tc.plaintext, key) + if err != nil { + t.Fatalf("encrypt: %v", err) + } + + if len(encrypted)%16 != 0 { + t.Fatalf("ciphertext length %d not multiple of 16", len(encrypted)) + } + + decrypted, err := DecryptAESECB(encrypted, key) + if err != nil { + t.Fatalf("decrypt: %v", err) + } + + if string(decrypted) != string(tc.plaintext) { + t.Errorf("round-trip mismatch: got %q, want %q", decrypted, tc.plaintext) + } + }) + } +} + +func TestAESKnownVector(t *testing.T) { + t.Parallel() + + // Test with known AES-128-ECB output. + // Single block: 16 bytes of 0x00, key = 16 bytes of 0x00. + key := make([]byte, 16) + plaintext := []byte("hello world12345") // exactly 16 bytes + + encrypted, err := EncryptAESECB(plaintext, key) + if err != nil { + t.Fatalf("encrypt: %v", err) + } + + // With PKCS7, 16 bytes of plaintext gets padded to 32 bytes. + if len(encrypted) != 32 { + t.Fatalf("expected 32 bytes ciphertext, got %d", len(encrypted)) + } + + decrypted, err := DecryptAESECB(encrypted, key) + if err != nil { + t.Fatalf("decrypt: %v", err) + } + + if string(decrypted) != string(plaintext) { + t.Errorf("got %q, want %q", decrypted, plaintext) + } +} + +func TestCiphertextSize(t *testing.T) { + t.Parallel() + + tests := []struct { + rawSize int + expected int + }{ + {0, 16}, // ceil((0+1)/16)*16 = 16 + {1, 16}, // ceil((1+1)/16)*16 = 16 + {15, 16}, // ceil((15+1)/16)*16 = 16 + {16, 32}, // ceil((16+1)/16)*16 = 32 + {17, 32}, // ceil((17+1)/16)*16 = 32 + {31, 32}, // ceil((31+1)/16)*16 = 32 + {32, 48}, // ceil((32+1)/16)*16 = 48 + {248731, 248736}, // from spec example + } + + for _, tc := range tests { + got := CiphertextSize(tc.rawSize) + if got != tc.expected { + t.Errorf("CiphertextSize(%d) = %d, want %d", tc.rawSize, got, tc.expected) + } + } +} + +func TestDecodeAESKeyFormatA(t *testing.T) { + t.Parallel() + + // Format A: base64 of raw 16 bytes. + rawKey, _ := hex.DecodeString("00112233445566778899aabbccddeeff") + encoded := base64.StdEncoding.EncodeToString(rawKey) + // Should be "ABEiM0RVZneImaq7zN3u/w==" + + key, err := DecodeAESKey(encoded) + if err != nil { + t.Fatalf("DecodeAESKey format A: %v", err) + } + + if hex.EncodeToString(key) != "00112233445566778899aabbccddeeff" { + t.Errorf("got key %x, want 00112233445566778899aabbccddeeff", key) + } +} + +func TestDecodeAESKeyFormatB(t *testing.T) { + t.Parallel() + + // Format B: base64 of hex string "00112233445566778899aabbccddeeff" (32 ASCII bytes). + hexStr := "00112233445566778899aabbccddeeff" + encoded := base64.StdEncoding.EncodeToString([]byte(hexStr)) + // Should be "MDAxMTIyMzM0NDU1NjY3Nzg4OTlhYWJiY2NkZGVlZmY=" + + key, err := DecodeAESKey(encoded) + if err != nil { + t.Fatalf("DecodeAESKey format B: %v", err) + } + + if hex.EncodeToString(key) != "00112233445566778899aabbccddeeff" { + t.Errorf("got key %x, want 00112233445566778899aabbccddeeff", key) + } +} + +func TestDecodeAESKeyInvalid(t *testing.T) { + t.Parallel() + + // Not valid base64. + _, err := DecodeAESKey("not-valid-base64!!!") + if err == nil { + t.Error("expected error for invalid base64") + } + + // Valid base64 but wrong length (e.g., 8 bytes). + encoded := base64.StdEncoding.EncodeToString([]byte("12345678")) + _, err = DecodeAESKey(encoded) + if err == nil { + t.Error("expected error for wrong length") + } +} + +func TestResolveImageKeyFromHexField(t *testing.T) { + t.Parallel() + + img := &ImageItem{ + AESKey: "00112233445566778899aabbccddeeff", + Media: &CDNMedia{ + AESKey: base64.StdEncoding.EncodeToString([]byte("ffffffffffffffffffffffffffffffff")), + }, + } + + key, err := ResolveImageKey(img) + if err != nil { + t.Fatalf("ResolveImageKey: %v", err) + } + + // Should use image_item.aeskey (hex), not media.aes_key. + if hex.EncodeToString(key) != "00112233445566778899aabbccddeeff" { + t.Errorf("got key %x, want hex field key", key) + } +} + +func TestResolveImageKeyFallbackToMedia(t *testing.T) { + t.Parallel() + + rawKey, _ := hex.DecodeString("aabbccddeeff00112233445566778899") + img := &ImageItem{ + Media: &CDNMedia{ + AESKey: base64.StdEncoding.EncodeToString(rawKey), + }, + } + + key, err := ResolveImageKey(img) + if err != nil { + t.Fatalf("ResolveImageKey: %v", err) + } + + if hex.EncodeToString(key) != "aabbccddeeff00112233445566778899" { + t.Errorf("got key %x, want media key", key) + } +} + +func TestResolveImageKeyNoKey(t *testing.T) { + t.Parallel() + + img := &ImageItem{} + _, err := ResolveImageKey(img) + if err == nil { + t.Error("expected error when no key present") + } +} + +func TestRandomFileKey(t *testing.T) { + t.Parallel() + + key := RandomFileKey() + + // Must be 32 hex characters (16 bytes). + if len(key) != 32 { + t.Errorf("RandomFileKey length = %d, want 32", len(key)) + } + + matched, _ := regexp.MatchString("^[0-9a-f]{32}$", key) + if !matched { + t.Errorf("RandomFileKey %q does not match hex pattern", key) + } + + // Uniqueness check. + key2 := RandomFileKey() + if key == key2 { + t.Error("two consecutive RandomFileKey calls returned the same value") + } +} + +func TestRandomClientID(t *testing.T) { + t.Parallel() + + id := RandomClientID("anna-weixin") + + if !strings.HasPrefix(id, "anna-weixin:") { + t.Errorf("RandomClientID %q doesn't have expected prefix", id) + } + + // Format: prefix:timestamp-random + parts := strings.SplitN(id, ":", 2) + if len(parts) != 2 { + t.Fatalf("RandomClientID %q missing colon separator", id) + } + + rest := parts[1] + dashIdx := strings.LastIndex(rest, "-") + if dashIdx < 0 { + t.Fatalf("RandomClientID %q missing dash in suffix", id) + } + + tsStr := rest[:dashIdx] + _, err := strconv.ParseInt(tsStr, 10, 64) + if err != nil { + t.Errorf("RandomClientID timestamp %q not a valid int64: %v", tsStr, err) + } + + suffix := rest[dashIdx+1:] + if len(suffix) != 8 { // 4 bytes = 8 hex chars + t.Errorf("RandomClientID suffix %q length = %d, want 8", suffix, len(suffix)) + } + + // Uniqueness. + id2 := RandomClientID("anna-weixin") + if id == id2 { + t.Error("two consecutive RandomClientID calls returned the same value") + } +} From 68567a37bf5e00d8e5d52370d4c15f7f4e266e08 Mon Sep 17 00:00:00 2001 From: Vaayne Date: Sun, 22 Mar 2026 20:54:15 +0800 Subject: [PATCH 05/31] =?UTF-8?q?=F0=9F=90=9B=20fix:=20add=20timeout=20to?= =?UTF-8?q?=20weixin=20CDN=20download=20client?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DownloadFromCDN was using bare http.Get (no timeout), which could hang indefinitely on slow CDN responses. Now uses 60s timeout matching the upload path. --- internal/channel/weixin/media.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/channel/weixin/media.go b/internal/channel/weixin/media.go index 6de88cb..58b5810 100644 --- a/internal/channel/weixin/media.go +++ b/internal/channel/weixin/media.go @@ -159,7 +159,8 @@ func DownloadFromCDN(cdnBaseURL, encryptedQueryParam string) ([]byte, error) { u := cdnBaseURL + "/c2c/download?encrypted_query_param=" + url.QueryEscape(encryptedQueryParam) - resp, err := http.Get(u) //nolint:gosec // URL is constructed from trusted CDN base + API param + client := &http.Client{Timeout: 60 * time.Second} + resp, err := client.Get(u) //nolint:gosec // URL is constructed from trusted CDN base + API param if err != nil { return nil, fmt.Errorf("weixin: cdn download: %w", err) } From 48f239ea1d524b60e6a54ecc2e23d5d40ecd6eb4 Mon Sep 17 00:00:00 2001 From: Vaayne Date: Sun, 22 Mar 2026 20:56:24 +0800 Subject: [PATCH 06/31] =?UTF-8?q?=E2=9C=A8=20feat(weixin):=20add=20Bot=20s?= =?UTF-8?q?truct,=20Config,=20New(),=20WithAuth(),=20options?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 Task 2.1: Bot core types and constructor following telegram/qq patterns. Includes Config with AllowedIDs, BotOption pattern, WithAuth(), isAllowed(), Name(), Stop(). --- internal/channel/weixin/weixin.go | 115 ++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 internal/channel/weixin/weixin.go diff --git a/internal/channel/weixin/weixin.go b/internal/channel/weixin/weixin.go new file mode 100644 index 0000000..7df0ba5 --- /dev/null +++ b/internal/channel/weixin/weixin.go @@ -0,0 +1,115 @@ +package weixin + +import ( + "context" + "fmt" + "sync" + + "github.com/vaayne/anna/internal/agent" + "github.com/vaayne/anna/internal/auth" + "github.com/vaayne/anna/internal/channel" + "github.com/vaayne/anna/internal/config" +) + +// Config holds WeChat iLink bot settings. +type Config struct { + BotToken string `json:"bot_token"` // iLink bot_token + BaseURL string `json:"base_url"` // iLink base URL (default: https://ilinkai.weixin.qq.com) + BotID string `json:"bot_id"` // ilink_bot_id + UserID string `json:"user_id"` // ilink_user_id + NotifyChat string `json:"notify_chat"` // default user ID for notifications (requires context_token) + AllowedIDs []string `json:"allowed_ids"` // user IDs allowed (empty = allow all) +} + +// dbConfig is the JSON shape persisted in settings_channels.config. +// It extends Config with runtime state fields. +type dbConfig struct { + Config + GetUpdatesBuf string `json:"get_updates_buf,omitempty"` +} + +// Bot wraps a WeChat iLink bot with agent pool integration. +// It implements channel.Channel. +type Bot struct { + client *Client + poolManager *agent.PoolManager + store config.Store + authStore auth.AuthStore + engine *auth.PolicyEngine + linkCodes *auth.LinkCodeStore + agentCmd *channel.AgentCommander + listFn channel.ModelListFunc + switchFn channel.ModelSwitchFunc + + contextTokens sync.Map // key: userID string, value: contextToken string + typingTickets sync.Map // key: userID string, value: typingTicket string + + allowed map[string]struct{} // empty map = allow all + cfg Config + ctx context.Context + cancel context.CancelFunc + mu sync.RWMutex +} + +// BotOption configures the WeChat Bot. +type BotOption func(*Bot) + +// WithAuth configures the bot with auth store and link code store for +// account linking support. +func WithAuth(authStore auth.AuthStore, engine *auth.PolicyEngine, linkCodes *auth.LinkCodeStore) BotOption { + return func(b *Bot) { + b.authStore = authStore + b.engine = engine + b.linkCodes = linkCodes + b.agentCmd = channel.NewAgentCommander(b.store, authStore) + } +} + +// New creates a WeChat iLink bot. Call Start to begin polling. +func New(cfg Config, pm *agent.PoolManager, store config.Store, listFn channel.ModelListFunc, switchFn channel.ModelSwitchFunc, opts ...BotOption) (*Bot, error) { + if cfg.BotToken == "" { + return nil, fmt.Errorf("weixin: bot_token is required") + } + + allowed := make(map[string]struct{}, len(cfg.AllowedIDs)) + for _, id := range cfg.AllowedIDs { + allowed[id] = struct{}{} + } + + b := &Bot{ + poolManager: pm, + store: store, + agentCmd: channel.NewAgentCommander(store, nil), + listFn: listFn, + switchFn: switchFn, + allowed: allowed, + cfg: cfg, + } + + for _, opt := range opts { + opt(b) + } + + return b, nil +} + +// Name returns the channel name. Implements channel.Channel. +func (b *Bot) Name() string { return "weixin" } + +// Stop gracefully shuts down the WeChat bot. Implements channel.Channel. +func (b *Bot) Stop() { + logger().Info("stopping weixin bot") + if b.cancel != nil { + b.cancel() + } +} + +// isAllowed returns true if the user is in the allowed list. +// An empty allowed list means everyone is allowed. +func (b *Bot) isAllowed(userID string) bool { + if len(b.allowed) == 0 { + return true + } + _, ok := b.allowed[userID] + return ok +} From e300d0f86d83dd41db12a83659b9cd08b79040f3 Mon Sep 17 00:00:00 2001 From: Vaayne Date: Sun, 22 Mar 2026 20:57:07 +0800 Subject: [PATCH 07/31] =?UTF-8?q?=E2=9C=A8=20feat(weixin):=20add=20Start()?= =?UTF-8?q?=20with=20getupdates=20long-poll=20loop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 Task 2.2: Long-polling message loop with adaptive timeout, cursor persistence to DB, retry/backoff (2s wait, 30s after 3 consecutive failures), session expiry handling (clear all credentials and state on ret=-14), local timeout treated as empty response. --- internal/channel/weixin/weixin.go | 170 ++++++++++++++++++++++++++++++ 1 file changed, 170 insertions(+) diff --git a/internal/channel/weixin/weixin.go b/internal/channel/weixin/weixin.go index 7df0ba5..ca752aa 100644 --- a/internal/channel/weixin/weixin.go +++ b/internal/channel/weixin/weixin.go @@ -2,8 +2,11 @@ package weixin import ( "context" + "encoding/json" + "errors" "fmt" "sync" + "time" "github.com/vaayne/anna/internal/agent" "github.com/vaayne/anna/internal/auth" @@ -113,3 +116,170 @@ func (b *Bot) isAllowed(userID string) bool { _, ok := b.allowed[userID] return ok } + +// Start begins long-polling for messages. It blocks until ctx is cancelled. +func (b *Bot) Start(ctx context.Context) error { + b.ctx, b.cancel = context.WithCancel(ctx) + + // Create the client with config values. + b.client = NewClient(b.cfg.BaseURL, "", b.cfg.BotToken) + + // Load saved cursor from DB channel config. + buf := b.loadCursor() + + // Poll loop with retry/backoff. + timeout := 35 * time.Second + consecutiveFailures := 0 + + logger().Info("polling started") + + for { + select { + case <-b.ctx.Done(): + logger().Info("polling stopped") + return b.ctx.Err() + default: + } + + resp, err := b.client.GetUpdates(buf, "", timeout) + if err != nil { + if errors.Is(err, ErrSessionExpired) { + logger().Error("session expired, clearing all state") + b.clearCredentials() + return fmt.Errorf("weixin: session expired (ret=-14), credentials cleared") + } + + // Local timeout — treat as empty response, continue. + if isTimeoutError(err) { + consecutiveFailures = 0 + continue + } + + consecutiveFailures++ + wait := 2 * time.Second + if consecutiveFailures >= 3 { + wait = 30 * time.Second + } + logger().Warn("getupdates failed, retrying", + "error", err, "failures", consecutiveFailures, "wait", wait) + + select { + case <-b.ctx.Done(): + return b.ctx.Err() + case <-time.After(wait): + continue + } + } + + // Success — reset failure counter. + consecutiveFailures = 0 + + // Update cursor and persist. + if resp.GetUpdatesBuf != "" { + buf = resp.GetUpdatesBuf + b.persistCursor(buf) + } + + // Use adaptive timeout from response. + if resp.LongPollingTimeoutMS > 0 { + timeout = time.Duration(resp.LongPollingTimeoutMS) * time.Millisecond + } + + // Dispatch messages. + if len(resp.Msgs) > 0 { + b.handleUpdates(resp.Msgs) + } + } +} + +// handleUpdates is a placeholder for Phase 3 message handling. +func (b *Bot) handleUpdates(msgs []WeixinMessage) { + logger().Info("received messages", "count", len(msgs)) +} + +// loadCursor loads the get_updates_buf cursor from the DB channel config. +func (b *Bot) loadCursor() string { + ch, err := b.store.GetChannel(context.Background(), "weixin") + if err != nil { + return "" + } + var dc dbConfig + if err := json.Unmarshal([]byte(ch.Config), &dc); err != nil { + return "" + } + return dc.GetUpdatesBuf +} + +// persistCursor saves the get_updates_buf cursor to the DB channel config. +func (b *Bot) persistCursor(buf string) { + ch, err := b.store.GetChannel(context.Background(), "weixin") + if err != nil { + logger().Warn("failed to load channel config for cursor persist", "error", err) + return + } + + // Merge into existing JSON config. + var raw map[string]any + if err := json.Unmarshal([]byte(ch.Config), &raw); err != nil { + raw = make(map[string]any) + } + raw["get_updates_buf"] = buf + + data, err := json.Marshal(raw) + if err != nil { + logger().Warn("failed to marshal cursor update", "error", err) + return + } + + if err := b.store.UpsertChannel(context.Background(), config.Channel{ + ID: "weixin", + Enabled: true, + Config: string(data), + }); err != nil { + logger().Warn("failed to persist cursor", "error", err) + } +} + +// clearCredentials removes credentials and state from DB and in-memory caches. +func (b *Bot) clearCredentials() { + // Clear in-memory caches. + b.contextTokens = sync.Map{} + b.typingTickets = sync.Map{} + + // Clear credentials from DB. + ch, err := b.store.GetChannel(context.Background(), "weixin") + if err != nil { + logger().Warn("failed to load channel config for credential clear", "error", err) + return + } + + var raw map[string]any + if err := json.Unmarshal([]byte(ch.Config), &raw); err != nil { + raw = make(map[string]any) + } + + delete(raw, "bot_token") + delete(raw, "bot_id") + delete(raw, "user_id") + delete(raw, "get_updates_buf") + + data, err := json.Marshal(raw) + if err != nil { + logger().Warn("failed to marshal credential clear", "error", err) + return + } + + if err := b.store.UpsertChannel(context.Background(), config.Channel{ + ID: "weixin", + Enabled: true, + Config: string(data), + }); err != nil { + logger().Warn("failed to clear credentials in DB", "error", err) + } +} + +// isTimeoutError checks if an error is a network timeout. +func isTimeoutError(err error) bool { + var netErr interface{ Timeout() bool } + return errors.As(err, &netErr) && netErr.Timeout() +} From c00217410ea45c581995f6a4fdbdf9462baa773f Mon Sep 17 00:00:00 2001 From: Vaayne Date: Sun, 22 Mar 2026 20:57:44 +0800 Subject: [PATCH 08/31] =?UTF-8?q?=E2=9C=A8=20feat(weixin):=20add=20Notify(?= =?UTF-8?q?)=20for=20push=20notifications?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 Task 2.3: Send text notifications via sendmessage using cached context_token. Returns explicit error when no token is available. Falls back to NotifyChat config when ChatID is empty. --- internal/channel/weixin/weixin.go | 37 +++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/internal/channel/weixin/weixin.go b/internal/channel/weixin/weixin.go index ca752aa..b1c6ca7 100644 --- a/internal/channel/weixin/weixin.go +++ b/internal/channel/weixin/weixin.go @@ -197,6 +197,43 @@ func (b *Bot) handleUpdates(msgs []WeixinMessage) { logger().Info("received messages", "count", len(msgs)) } +// Notify sends a notification message via sendmessage. Implements channel.Channel. +func (b *Bot) Notify(_ context.Context, n channel.Notification) error { + targetUser := n.ChatID + if targetUser == "" { + targetUser = b.cfg.NotifyChat + } + if targetUser == "" { + return fmt.Errorf("weixin: no target user ID for notification") + } + + // Look up cached context_token for the target user. + tokenVal, ok := b.contextTokens.Load(targetUser) + if !ok { + return fmt.Errorf("weixin: no context_token for user %s (tokens are in-memory only, repopulated when user sends a message)", targetUser) + } + contextToken, _ := tokenVal.(string) + + msg := WeixinMessage{ + ToUserID: targetUser, + ClientID: RandomClientID("notify"), + MessageType: MessageTypeBot, + MessageState: MessageStateFinish, + ContextToken: contextToken, + ItemList: []MessageItem{ + { + Type: ItemTypeText, + TextItem: &TextItem{Text: n.Text}, + }, + }, + } + + if err := b.client.SendMessage(msg, ""); err != nil { + return fmt.Errorf("weixin: send notification: %w", err) + } + return nil +} + // loadCursor loads the get_updates_buf cursor from the DB channel config. func (b *Bot) loadCursor() string { ch, err := b.store.GetChannel(context.Background(), "weixin") From 94f562db5403d3f6b53d24bd25f3db72c2b5309e Mon Sep 17 00:00:00 2001 From: Vaayne Date: Sun, 22 Mar 2026 20:58:04 +0800 Subject: [PATCH 09/31] =?UTF-8?q?=E2=9C=A8=20feat(weixin):=20add=20resolve?= =?UTF-8?q?()=20for=20user/agent/pool=20resolution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 Task 2.4: Delegate to channel.Resolve() with platform="weixin", DM-only mode (isGroup=false), same pattern as telegram/qq channels. --- internal/channel/weixin/weixin.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/internal/channel/weixin/weixin.go b/internal/channel/weixin/weixin.go index b1c6ca7..2bac828 100644 --- a/internal/channel/weixin/weixin.go +++ b/internal/channel/weixin/weixin.go @@ -234,6 +234,22 @@ func (b *Bot) Notify(_ context.Context, n channel.Notification) error { return nil } +// resolve performs full user/agent/pool/session-key resolution. +func (b *Bot) resolve(userID string) (*channel.ResolvedChat, error) { + return channel.Resolve( + context.Background(), + b.poolManager, + b.store, + b.authStore, + b.engine, + "weixin", + userID, + "", // no display name available from iLink + userID, // chatID = userID for DM + false, // DM only for v1 + ) +} + // loadCursor loads the get_updates_buf cursor from the DB channel config. func (b *Bot) loadCursor() string { ch, err := b.store.GetChannel(context.Background(), "weixin") From ebd158c9184ea77d06db85f59cf2b290a06fc023 Mon Sep 17 00:00:00 2001 From: Vaayne Date: Sun, 22 Mar 2026 20:58:33 +0800 Subject: [PATCH 10/31] =?UTF-8?q?=F0=9F=93=9D=20docs:=20update=20handoff?= =?UTF-8?q?=20with=20Phase=202=20summary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-03-22-weixin-channel/handoff.md | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 .agents/sessions/2026-03-22-weixin-channel/handoff.md diff --git a/.agents/sessions/2026-03-22-weixin-channel/handoff.md b/.agents/sessions/2026-03-22-weixin-channel/handoff.md new file mode 100644 index 0000000..be18e10 --- /dev/null +++ b/.agents/sessions/2026-03-22-weixin-channel/handoff.md @@ -0,0 +1,52 @@ +# Handoff + + + +## Phase 1: Protocol Types & HTTP Client — DONE + +### Commits + +1. `21ac4b18` — `model.go` — All protocol types (WeixinMessage, MessageItem, TextItem, ImageItem, FileItem, VideoItem, VoiceItem, CDNMedia, RefMessage, BaseInfo) and constants (message type, state, item type, media type). API request/response types for all endpoints. QRCodeResponse and QRCodeStatusResponse for auth flow. +2. `814187d2` — `client.go` — HTTP Client struct with methods: randomWechatUIN(), commonHeaders(), GetQRCode(), GetQRCodeStatus() (with iLink-App-ClientVersion:1), GetUpdates() (adaptive timeout), SendMessage(), GetConfig(), SendTyping(), GetUploadURL(). Error handling: ErrSessionExpired for ret=-14/errcode=-14, generic errors for other non-zero codes. +3. `ad0449ff` — `media.go` — AES-128-ECB encrypt/decrypt with PKCS7, CiphertextSize(), DecodeAESKey() (dual format A/B), ResolveImageKey() (precedence: hex field > media base64), UploadToCDN(), DownloadFromCDN(), RandomFileKey(), RandomClientID(). +4. `11a8b5d9` — `weixin_test.go` — 17 tests covering randomWechatUIN format, AES round-trip, known vector, CiphertextSize, DecodeAESKey both formats, ResolveImageKey precedence, RandomFileKey, RandomClientID. All pass with `-race`. + +### Files Created + +- `internal/channel/weixin/model.go` (236 lines) +- `internal/channel/weixin/client.go` (355 lines — slightly over 300 but each method is focused) +- `internal/channel/weixin/media.go` (235 lines) +- `internal/channel/weixin/weixin_test.go` (300 lines) + +### Notes for Phase 2 + +- `Client` is fully stateless — token and URLs are set at construction. Phase 2's Bot struct will own a Client instance. +- `GetUpdates` accepts a `timeout` parameter so the caller can adapt based on `longpolling_timeout_ms` from previous response. +- `SendMessage` accepts the full `WeixinMessage` struct — Phase 2/3 will construct messages with proper client_id, context_token, etc. +- No external dependencies added — all stdlib (`crypto/aes`, `encoding/base64`, `encoding/hex`, `net/http`, `crypto/rand`). +- The `logger()` function is defined in `client.go` following the same pattern as telegram/qq channels. + +## Phase 2: Bot Core & Message Loop — DONE + +### Commits + +1. `48f239ea` — Task 2.1: Bot struct, Config, New(), WithAuth(), BotOption pattern, isAllowed(), Name(), Stop(). Follows telegram/qq patterns exactly. +2. `e300d0f8` — Task 2.2: Start() with getupdates long-poll loop. Adaptive timeout from `longpolling_timeout_ms`, cursor persistence to DB via `persistCursor()`, retry/backoff (2s wait, 30s after 3 consecutive failures), session expiry handling (clear all credentials + state on ret=-14), local timeout treated as empty response. Includes `loadCursor()`, `clearCredentials()`, `isTimeoutError()`, and placeholder `handleUpdates()`. +3. `c0021741` — Task 2.3: Notify() sends text via sendmessage using cached context_token from sync.Map. Returns explicit error when no token available. Falls back to NotifyChat config. +4. `94f562db` — Task 2.4: resolve() delegates to channel.Resolve() with platform="weixin", DM-only (isGroup=false). + +### Files Created + +- `internal/channel/weixin/weixin.go` (338 lines) + +### Notes for Phase 3 + +- `handleUpdates()` is a placeholder that just logs message count. Phase 3 must implement full message dispatch. +- `contextTokens` sync.Map is ready for Phase 3 to populate via `b.contextTokens.Store(userID, contextToken)` when processing incoming messages. +- `typingTickets` sync.Map is ready for Phase 3's typing indicator goroutine. +- `resolve()` is ready — returns `*channel.ResolvedChat` for agent dispatch. +- `agentCmd` is initialized in New() and recreated in WithAuth() — ready for /agent command handling. +- `listFn` and `switchFn` are stored — ready for /model command handling. +- `client` is created in Start() from config values, not in New() — this matches the pattern where credentials may come from DB. +- `dbConfig` type embeds Config + GetUpdatesBuf for DB JSON serialization. +- `clearCredentials()` deletes bot_token, bot_id, user_id, get_updates_buf from DB config and clears both sync.Maps. From fc53f4b4bb687c11f571923f709531224737fd86 Mon Sep 17 00:00:00 2001 From: Vaayne Date: Sun, 22 Mar 2026 21:01:15 +0800 Subject: [PATCH 11/31] =?UTF-8?q?=F0=9F=90=9B=20fix:=20address=20Phase=202?= =?UTF-8?q?=20review=20findings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add nil guard for b.client in Notify() to prevent panic before Start() - Preserve Enabled flag in persistCursor() and clearCredentials() DB upserts - Remove unused mu sync.RWMutex field --- internal/channel/weixin/weixin.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/internal/channel/weixin/weixin.go b/internal/channel/weixin/weixin.go index 2bac828..5cf17cc 100644 --- a/internal/channel/weixin/weixin.go +++ b/internal/channel/weixin/weixin.go @@ -51,7 +51,6 @@ type Bot struct { cfg Config ctx context.Context cancel context.CancelFunc - mu sync.RWMutex } // BotOption configures the WeChat Bot. @@ -199,6 +198,10 @@ func (b *Bot) handleUpdates(msgs []WeixinMessage) { // Notify sends a notification message via sendmessage. Implements channel.Channel. func (b *Bot) Notify(_ context.Context, n channel.Notification) error { + if b.client == nil { + return fmt.Errorf("weixin: bot not started") + } + targetUser := n.ChatID if targetUser == "" { targetUser = b.cfg.NotifyChat @@ -286,7 +289,7 @@ func (b *Bot) persistCursor(buf string) { if err := b.store.UpsertChannel(context.Background(), config.Channel{ ID: "weixin", - Enabled: true, + Enabled: ch.Enabled, Config: string(data), }); err != nil { logger().Warn("failed to persist cursor", "error", err) @@ -324,7 +327,7 @@ func (b *Bot) clearCredentials() { if err := b.store.UpsertChannel(context.Background(), config.Channel{ ID: "weixin", - Enabled: true, + Enabled: ch.Enabled, Config: string(data), }); err != nil { logger().Warn("failed to clear credentials in DB", "error", err) From 4b922bcefc0b6e1e8c75e47a2cc398b2d12a0404 Mon Sep 17 00:00:00 2001 From: Vaayne Date: Sun, 22 Mar 2026 21:05:47 +0800 Subject: [PATCH 12/31] =?UTF-8?q?=E2=9C=A8=20feat(weixin):=20add=20message?= =?UTF-8?q?=20handler=20with=20command=20routing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - handleUpdates: filter by message_type/state, cache context_token, dispatch by item type - handleText: link code, shared commands, /model (text-based), /agent, fallthrough to agent - handleImage: CDN download, AES decrypt, MIME detect, multimodal content - handleMessage: resolve chat, stream events, send final response - handleModelCommand: list/filter/switch by provider/model name (qq/feishu pattern) - sendReply: text reply helper using cached context_token - Remove placeholder handleUpdates from weixin.go --- internal/channel/weixin/handler.go | 331 +++++++++++++++++++++++++++++ internal/channel/weixin/weixin.go | 5 - 2 files changed, 331 insertions(+), 5 deletions(-) create mode 100644 internal/channel/weixin/handler.go diff --git a/internal/channel/weixin/handler.go b/internal/channel/weixin/handler.go new file mode 100644 index 0000000..0a3a555 --- /dev/null +++ b/internal/channel/weixin/handler.go @@ -0,0 +1,331 @@ +package weixin + +import ( + "context" + "encoding/base64" + "fmt" + "net/http" + "strings" + + "github.com/vaayne/anna/internal/agent/runner" + "github.com/vaayne/anna/internal/ai" + "github.com/vaayne/anna/internal/channel" +) + +// handleUpdates dispatches incoming messages from the getupdates response. +func (b *Bot) handleUpdates(msgs []WeixinMessage) { + for i := range msgs { + msg := msgs[i] + + // Skip bot echoes — only process user messages. + if msg.MessageType != MessageTypeUser { + continue + } + + // Skip partial/generating states — only process finished messages. + if msg.MessageState != MessageStateFinish { + continue + } + + // Check allowlist. + if !b.isAllowed(msg.FromUserID) { + logger().Warn("unauthorized access", "user_id", msg.FromUserID) + continue + } + + // Cache context_token for this user. + if msg.ContextToken != "" { + b.contextTokens.Store(msg.FromUserID, msg.ContextToken) + } + + // Dispatch by first item type. + if len(msg.ItemList) == 0 { + continue + } + + first := msg.ItemList[0] + switch first.Type { + case ItemTypeText: + if first.TextItem != nil { + b.handleText(msg, first.TextItem.Text) + } + case ItemTypeImage: + if first.ImageItem != nil { + b.handleImage(msg, first.ImageItem) + } + case ItemTypeFile: + logger().Debug("file message received, skipping", "user_id", msg.FromUserID) + case ItemTypeVideo: + logger().Debug("video message received, skipping", "user_id", msg.FromUserID) + default: + logger().Debug("unsupported item type", "type", first.Type, "user_id", msg.FromUserID) + } + } +} + +// handleText processes incoming text messages. +func (b *Bot) handleText(msg WeixinMessage, text string) { + text = strings.TrimSpace(text) + if text == "" { + return + } + + reply := func(resp string) { b.sendReply(msg, resp) } + + // Try link code before anything else. + if b.authStore != nil && b.linkCodes != nil { + if resp, ok := channel.TryLinkCode(b.ctx, b.authStore, b.linkCodes, text, "weixin", msg.FromUserID, ""); ok { + reply(resp) + return + } + } + + // Resolve user/agent/session. + rc, err := b.resolve(msg.FromUserID) + if err != nil { + logger().Error("resolve failed", "user_id", msg.FromUserID, "error", err) + reply(fmt.Sprintf("Error: %v", err)) + return + } + + // Try shared commands (/start, /new, /compact, /whoami). + if resp, ok := channel.HandleCommand(b.ctx, rc, text, msg.FromUserID); ok { + reply(resp) + return + } + + // Parse command for channel-specific handling. + fields := strings.Fields(text) + if len(fields) > 0 { + cmd := strings.ToLower(fields[0]) + args := channel.ParseCommandArgs(text, fields[0]) + + switch cmd { + case "/model": + b.handleModelCommand(rc, args, reply) + return + case "/agent": + channel.HandleAgentCommand(b.ctx, b.agentCmd, rc, args, reply) + return + } + } + + // Normal message — send to agent. + b.handleMessage(msg, rc, text) +} + +// handleImage processes incoming image messages. +func (b *Bot) handleImage(msg WeixinMessage, imageItem *ImageItem) { + // Resolve AES key for decryption. + key, err := ResolveImageKey(imageItem) + if err != nil { + // Plaintext fallback: try downloading without decryption. + logger().Warn("no AES key for image, attempting plaintext", "error", err) + } + + // Determine CDN download URL. + if imageItem.Media == nil || imageItem.Media.EncryptQueryParam == "" { + logger().Warn("image missing CDN media reference", "user_id", msg.FromUserID) + b.sendReply(msg, "Failed to process image: no CDN reference.") + return + } + + // Download encrypted data from CDN. + encrypted, err := DownloadFromCDN("", imageItem.Media.EncryptQueryParam) + if err != nil { + logger().Error("cdn download failed", "user_id", msg.FromUserID, "error", err) + b.sendReply(msg, fmt.Sprintf("Failed to download image: %v", err)) + return + } + + var data []byte + if key != nil { + // Decrypt. + data, err = DecryptAESECB(encrypted, key) + if err != nil { + logger().Error("image decrypt failed", "user_id", msg.FromUserID, "error", err) + b.sendReply(msg, "Failed to decrypt image.") + return + } + } else { + // Plaintext fallback. + data = encrypted + } + + // Detect MIME type and encode. + mimeType := http.DetectContentType(data) + encoded := base64.StdEncoding.EncodeToString(data) + + var content []ai.ContentBlock + + // Check for caption text in other items. + for _, item := range msg.ItemList { + if item.Type == ItemTypeText && item.TextItem != nil { + caption := strings.TrimSpace(item.TextItem.Text) + if caption != "" { + content = append(content, ai.TextContent{Text: caption}) + break + } + } + } + + content = append(content, ai.ImageContent{Data: encoded, MimeType: mimeType}) + + logger().Debug("image received", "user_id", msg.FromUserID, "size", len(data), "mime", mimeType) + + // Resolve and handle. + rc, err := b.resolve(msg.FromUserID) + if err != nil { + logger().Error("resolve failed", "user_id", msg.FromUserID, "error", err) + b.sendReply(msg, fmt.Sprintf("Error: %v", err)) + return + } + + b.handleMessage(msg, rc, content) +} + +// handleMessage is the common flow for text and multimodal messages. +func (b *Bot) handleMessage(msg WeixinMessage, rc *channel.ResolvedChat, content runner.MessageContent) { + events, sessionID, err := rc.Chat(b.ctx, content) + if err != nil { + logger().Error("chat failed", "user_id", msg.FromUserID, "error", err) + b.sendReply(msg, fmt.Sprintf("Session error: %v", err)) + return + } + + logger().Debug("message received", "user_id", msg.FromUserID, "session", sessionID) + + // Start typing indicator. + typingCtx, stopTyping := context.WithCancel(b.ctx) + go b.keepTyping(typingCtx, msg) + + response, tracker, images, streamErr := b.streamEvents(msg, events) + + stopTyping() + + if streamErr != nil { + logger().Error("agent stream error", "session_id", sessionID, "error", streamErr) + if response == "" { + response = fmt.Sprintf("Agent error: %v", streamErr) + } else { + response += fmt.Sprintf("\n\n[Agent error: %v]", streamErr) + } + } + + if strings.TrimSpace(response) == "" { + response = "(empty response)" + } + + if tracker != nil && tracker.hasHistory() { + response += tracker.renderFinal() + } + + b.sendFinalResponse(msg, response, images) + logger().Debug("response sent", "user_id", msg.FromUserID, "response_len", len(response)) +} + +// handleModelCommand processes /model with optional arguments. +// No args → list models; text with "/" → switch by name; text → filter. +func (b *Bot) handleModelCommand(rc *channel.ResolvedChat, args string, reply func(string)) { + query := channel.ParseModelArgs(args) + + // If the query looks like "provider/model", try switching directly. + if query != "" && strings.Contains(query, "/") { + b.switchModelByName(rc, query, reply) + return + } + + models := b.modelList(query) + if len(models) == 0 { + if query != "" { + reply(fmt.Sprintf("No models matching %q.", query)) + } else { + reply("No models configured.") + } + return + } + reply(formatModelList(models, query)) +} + +// modelList returns models optionally filtered by query. +func (b *Bot) modelList(query string) []channel.IndexedModel { + models := b.listFn() + if query == "" { + return channel.IndexModels(models) + } + return channel.FilterModels(models, query) +} + +// switchModelByName handles model switching by "provider/model" name. +func (b *Bot) switchModelByName(rc *channel.ResolvedChat, name string, reply func(string)) { + name = strings.ToLower(strings.TrimSpace(name)) + models := b.listFn() + var selected channel.ModelOption + found := false + for _, m := range models { + if strings.ToLower(m.Provider+"/"+m.Model) == name { + selected = m + found = true + break + } + } + if !found { + reply(fmt.Sprintf("Unknown model %q, use /model to list available models.", name)) + return + } + + if _, err := rc.RotateSession(); err != nil { + reply(fmt.Sprintf("Error rotating session: %v", err)) + return + } + if b.switchFn != nil { + if err := b.switchFn(selected.Provider, selected.Model); err != nil { + reply(fmt.Sprintf("Error switching model: %v", err)) + return + } + } + logger().Info("model switched", "key", rc.SessionKey, "provider", selected.Provider, "model", selected.Model) + reply(fmt.Sprintf("Switched to %s/%s. Session reset.", selected.Provider, selected.Model)) +} + +// formatModelList builds a text-based model list. +func formatModelList(models []channel.IndexedModel, query string) string { + var sb strings.Builder + sb.WriteString("Available models") + if query != "" { + fmt.Fprintf(&sb, " (filter: %q)", query) + } + sb.WriteString(":\n\n") + for _, m := range models { + fmt.Fprintf(&sb, "• %s/%s\n", m.Provider, m.Model) + } + sb.WriteString("\nUse /model to switch.") + return sb.String() +} + +// sendReply sends a text reply to the message sender using the cached context_token. +func (b *Bot) sendReply(msg WeixinMessage, text string) { + // Load context_token for this user. + contextToken := "" + if v, ok := b.contextTokens.Load(msg.FromUserID); ok { + contextToken, _ = v.(string) + } + + reply := WeixinMessage{ + ToUserID: msg.FromUserID, + ClientID: RandomClientID("reply"), + MessageType: MessageTypeBot, + MessageState: MessageStateFinish, + ContextToken: contextToken, + ItemList: []MessageItem{ + { + Type: ItemTypeText, + TextItem: &TextItem{Text: text}, + }, + }, + } + + if err := b.client.SendMessage(reply, ""); err != nil { + logger().Error("send reply failed", "user_id", msg.FromUserID, "error", err) + } +} diff --git a/internal/channel/weixin/weixin.go b/internal/channel/weixin/weixin.go index 5cf17cc..ba242b2 100644 --- a/internal/channel/weixin/weixin.go +++ b/internal/channel/weixin/weixin.go @@ -191,11 +191,6 @@ func (b *Bot) Start(ctx context.Context) error { } } -// handleUpdates is a placeholder for Phase 3 message handling. -func (b *Bot) handleUpdates(msgs []WeixinMessage) { - logger().Info("received messages", "count", len(msgs)) -} - // Notify sends a notification message via sendmessage. Implements channel.Channel. func (b *Bot) Notify(_ context.Context, n channel.Notification) error { if b.client == nil { From b7eceb84cbb7e4c4f1ceef843206ff0cadd1c3ba Mon Sep 17 00:00:00 2001 From: Vaayne Date: Sun, 22 Mar 2026 21:05:55 +0800 Subject: [PATCH 13/31] =?UTF-8?q?=E2=9C=A8=20feat(weixin):=20add=20event?= =?UTF-8?q?=20streaming=20and=20typing=20indicators?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - streamEvents: consume runner.Event channel, accumulate text, track tools, collect images - toolTracker: full tool tracking with history, renderFinal summary (same pattern as telegram) - keepTyping: goroutine sends typing status=1 every 5s, status=2 on cancel - getTypingTicket: cache typing_ticket via getconfig API --- internal/channel/weixin/stream.go | 297 ++++++++++++++++++++++++++++++ 1 file changed, 297 insertions(+) create mode 100644 internal/channel/weixin/stream.go diff --git a/internal/channel/weixin/stream.go b/internal/channel/weixin/stream.go new file mode 100644 index 0000000..3b6b2d7 --- /dev/null +++ b/internal/channel/weixin/stream.go @@ -0,0 +1,297 @@ +package weixin + +import ( + "context" + "fmt" + "strings" + "time" + "unicode/utf8" + + "github.com/vaayne/anna/internal/agent/runner" + "github.com/vaayne/anna/internal/channel" +) + +const ( + // weixinMaxMessageLen is the maximum text message length for WeChat iLink. + weixinMaxMessageLen = 2000 + + // typingInterval is how often we re-send the typing indicator. + // WeChat typing status expires after a few seconds. + typingInterval = 5 * time.Second + + // typingCursor is appended to streaming display to indicate activity. + typingCursor = " \u258D" +) + +// toolEmoji maps known tool names to display emoji. +var toolEmoji = map[string]string{ + "bash": "⚡", + "read": "📖", + "write": "✏️", + "edit": "🔧", + "search": "🔍", + "default": "🔧", +} + +// toolRecord holds a completed tool invocation for the summary section. +type toolRecord struct { + Tool string + Input string + Status string // "done" or "error" + Detail string + Duration time.Duration +} + +// toolTracker tracks active and completed tool invocations during streaming. +type toolTracker struct { + history []toolRecord + activeTool string + activeInput string + activeStart time.Time + displayUntil time.Time +} + +// start registers a new tool as running. +func (tt *toolTracker) start(t *runner.ToolUseEvent) { + if tt.activeTool != "" { + tt.history = append(tt.history, toolRecord{ + Tool: tt.activeTool, + Input: tt.activeInput, + Status: "done", + Duration: time.Since(tt.activeStart), + }) + } + tt.activeTool = t.Tool + tt.activeInput = t.Input + tt.activeStart = time.Now() + tt.displayUntil = time.Time{} +} + +// finish records the active tool as completed. +func (tt *toolTracker) finish(t *runner.ToolUseEvent) { + dur := time.Since(tt.activeStart) + input := tt.activeInput + if t.Input != "" { + input = t.Input + } + tt.history = append(tt.history, toolRecord{ + Tool: t.Tool, + Input: input, + Status: t.Status, + Detail: t.Detail, + Duration: dur, + }) + tt.displayUntil = time.Now().Add(2 * time.Second) + tt.activeTool = "" + tt.activeInput = "" + tt.activeStart = time.Time{} +} + +// handle processes a tool event, returning true if a display refresh is needed. +func (tt *toolTracker) handle(t *runner.ToolUseEvent) bool { + switch t.Status { + case "running": + tt.start(t) + return true + case "done", "error": + tt.finish(t) + return true + } + return false +} + +// hasHistory returns true if any tools were tracked. +func (tt *toolTracker) hasHistory() bool { + return len(tt.history) > 0 +} + +// renderFinal builds a compact tool summary for the final message. +func (tt *toolTracker) renderFinal() string { + if len(tt.history) == 0 { + return "" + } + + type toolCount struct { + name string + count int + } + seen := map[string]int{} + var counts []toolCount + var totalDur time.Duration + var errors []toolRecord + + for _, rec := range tt.history { + totalDur += rec.Duration + if rec.Status == "error" { + errors = append(errors, rec) + } + if idx, ok := seen[rec.Tool]; ok { + counts[idx].count++ + } else { + seen[rec.Tool] = len(counts) + counts = append(counts, toolCount{name: rec.Tool, count: 1}) + } + } + + var sb strings.Builder + sb.WriteString("\n\n——————————————————\n") + + total := len(tt.history) + fmt.Fprintf(&sb, "📎 %d tool", total) + if total != 1 { + sb.WriteByte('s') + } + sb.WriteString(" (") + for i, tc := range counts { + if i > 0 { + sb.WriteString(", ") + } + emoji := emojiFor(tc.name) + if tc.count > 1 { + fmt.Fprintf(&sb, "%d× %s%s", tc.count, emoji, tc.name) + } else { + sb.WriteString(emoji + tc.name) + } + } + sb.WriteString(") · ") + sb.WriteString(channel.FormatDuration(totalDur)) + + for _, rec := range errors { + sb.WriteByte('\n') + sb.WriteString(renderToolRecord(rec)) + } + + return sb.String() +} + +// renderToolRecord formats a single completed tool record. +func renderToolRecord(rec toolRecord) string { + statusEmoji := "✅" + if rec.Status == "error" { + statusEmoji = "❌" + } + emoji := emojiFor(rec.Tool) + line := fmt.Sprintf("%s %s %s", statusEmoji, emoji, rec.Tool) + if rec.Input != "" { + input := truncate(rec.Input, 60) + line += ": " + input + } + if rec.Detail != "" { + detail := truncate(rec.Detail, 80) + line += " → " + detail + } + line += fmt.Sprintf(" (%s)", channel.FormatDuration(rec.Duration)) + return line +} + +// emojiFor returns the emoji for a tool name. +func emojiFor(tool string) string { + if e, ok := toolEmoji[tool]; ok { + return e + } + return toolEmoji["default"] +} + +// truncate shortens s to maxLen bytes, appending "..." if truncated. +func truncate(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + if maxLen <= 3 { + return "..." + } + cutAt := maxLen - 3 + for cutAt > 0 && !utf8.RuneStart(s[cutAt]) { + cutAt-- + } + return s[:cutAt] + "..." +} + +// streamEvents consumes the agent event stream, accumulates text, and tracks tools. +// Returns the final response text, tool tracker, collected images, and any stream error. +func (b *Bot) streamEvents(msg WeixinMessage, events <-chan runner.Event) (string, *toolTracker, []runner.ImageEvent, error) { + var sb strings.Builder + var streamErr error + var tt toolTracker + var images []runner.ImageEvent + + for evt := range events { + if evt.Err != nil { + streamErr = evt.Err + break + } + + if evt.Image != nil { + images = append(images, *evt.Image) + continue + } + + if evt.ToolUse != nil { + tt.handle(evt.ToolUse) + } + + sb.WriteString(evt.Text) + } + + return sb.String(), &tt, images, streamErr +} + +// keepTyping sends typing indicators every 5 seconds until the context is cancelled. +// On cancel, it sends a stop-typing signal (status=2). +func (b *Bot) keepTyping(ctx context.Context, msg WeixinMessage) { + ticket := b.getTypingTicket(msg.FromUserID) + if ticket == "" { + return + } + + // Send initial typing indicator. + if err := b.client.SendTyping(msg.FromUserID, ticket, 1, ""); err != nil { + logger().Debug("typing start failed", "user_id", msg.FromUserID, "error", err) + } + + ticker := time.NewTicker(typingInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + // Send stop-typing. + if err := b.client.SendTyping(msg.FromUserID, ticket, 2, ""); err != nil { + logger().Debug("typing stop failed", "user_id", msg.FromUserID, "error", err) + } + return + case <-ticker.C: + if err := b.client.SendTyping(msg.FromUserID, ticket, 1, ""); err != nil { + logger().Debug("typing refresh failed", "user_id", msg.FromUserID, "error", err) + } + } + } +} + +// getTypingTicket retrieves or fetches the typing_ticket for a user. +func (b *Bot) getTypingTicket(userID string) string { + // Check cache first. + if v, ok := b.typingTickets.Load(userID); ok { + if ticket, ok := v.(string); ok && ticket != "" { + return ticket + } + } + + // Fetch from API. + contextToken := "" + if v, ok := b.contextTokens.Load(userID); ok { + contextToken, _ = v.(string) + } + + resp, err := b.client.GetConfig(userID, contextToken, "") + if err != nil { + logger().Debug("getconfig for typing_ticket failed", "user_id", userID, "error", err) + return "" + } + + if resp.TypingTicket != "" { + b.typingTickets.Store(userID, resp.TypingTicket) + } + + return resp.TypingTicket +} From eb9ef83dda8b79277a16a34cc1b7bbfd02a5502e Mon Sep 17 00:00:00 2001 From: Vaayne Date: Sun, 22 Mar 2026 21:06:05 +0800 Subject: [PATCH 14/31] =?UTF-8?q?=E2=9C=A8=20feat(weixin):=20add=20respons?= =?UTF-8?q?e=20rendering=20and=20media=20sending?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sendFinalResponse: split text at 2000 chars via SplitMessage, send chunks + images - sendImage: base64 decode → AES encrypt → CDN upload → sendmessage with image_item - sendFile: encrypt → upload CDN (media_type=3) → sendmessage with file_item - sendVideo: encrypt → upload CDN (media_type=2) → sendmessage with video_item - All media sends use no_need_thumb: true per protocol convention --- internal/channel/weixin/render.go | 280 ++++++++++++++++++++++++++++++ 1 file changed, 280 insertions(+) create mode 100644 internal/channel/weixin/render.go diff --git a/internal/channel/weixin/render.go b/internal/channel/weixin/render.go new file mode 100644 index 0000000..8b6cbd9 --- /dev/null +++ b/internal/channel/weixin/render.go @@ -0,0 +1,280 @@ +package weixin + +import ( + "crypto/md5" //nolint:gosec // MD5 is required by the WeChat CDN upload protocol + "encoding/base64" + "encoding/hex" + "strconv" + + "github.com/vaayne/anna/internal/agent/runner" + "github.com/vaayne/anna/internal/channel" +) + +// sendFinalResponse splits text at 2000 chars and sends each chunk, +// then sends any collected images. +func (b *Bot) sendFinalResponse(msg WeixinMessage, response string, images []runner.ImageEvent) { + chunks := channel.SplitMessage(response, weixinMaxMessageLen) + + contextToken := "" + if v, ok := b.contextTokens.Load(msg.FromUserID); ok { + contextToken, _ = v.(string) + } + + for _, chunk := range chunks { + reply := WeixinMessage{ + ToUserID: msg.FromUserID, + ClientID: RandomClientID("resp"), + MessageType: MessageTypeBot, + MessageState: MessageStateFinish, + ContextToken: contextToken, + ItemList: []MessageItem{ + { + Type: ItemTypeText, + TextItem: &TextItem{Text: chunk}, + }, + }, + } + if err := b.client.SendMessage(reply, ""); err != nil { + logger().Error("send response chunk failed", "user_id", msg.FromUserID, "error", err) + } + } + + for _, img := range images { + b.sendImage(msg, img) + } +} + +// sendImage encrypts and uploads an image to CDN, then sends it as a message. +func (b *Bot) sendImage(msg WeixinMessage, img runner.ImageEvent) { + data, err := decodeBase64(img.Data) + if err != nil { + logger().Error("decode image failed", "error", err) + return + } + + // Generate random AES key (16 bytes). + key, keyHex := RandomFileKey(), "" + keyBytes, err := hex.DecodeString(key) + if err != nil { + logger().Error("decode file key failed", "error", err) + return + } + keyHex = key // 32-char hex string for the aeskey field + + // Encrypt with AES-128-ECB. + encrypted, err := EncryptAESECB(data, keyBytes) + if err != nil { + logger().Error("encrypt image failed", "error", err) + return + } + + // Calculate MD5 of raw data. + rawMD5 := md5Sum(data) + fileKey := RandomFileKey() + + // Get upload URL. + uploadResp, err := b.client.GetUploadURL(UploadParams{ + FileKey: fileKey, + MediaType: MediaTypeImage, + ToUserID: msg.FromUserID, + RawSize: len(data), + RawFileMD5: rawMD5, + FileSize: len(encrypted), + NoNeedThumb: true, + AESKey: keyHex, + }, "") + if err != nil { + logger().Error("getuploadurl for image failed", "error", err) + return + } + + // Upload to CDN. + encryptedParam, err := UploadToCDN("", uploadResp.UploadParam, fileKey, encrypted) + if err != nil { + logger().Error("cdn upload image failed", "error", err) + return + } + + // Send image message. + contextToken := "" + if v, ok := b.contextTokens.Load(msg.FromUserID); ok { + contextToken, _ = v.(string) + } + + reply := WeixinMessage{ + ToUserID: msg.FromUserID, + ClientID: RandomClientID("img"), + MessageType: MessageTypeBot, + MessageState: MessageStateFinish, + ContextToken: contextToken, + ItemList: []MessageItem{ + { + Type: ItemTypeImage, + ImageItem: &ImageItem{ + Media: &CDNMedia{ + EncryptQueryParam: encryptedParam, + }, + AESKey: keyHex, + MidSize: int64(len(data)), + }, + }, + }, + } + if err := b.client.SendMessage(reply, ""); err != nil { + logger().Error("send image message failed", "user_id", msg.FromUserID, "error", err) + } +} + +// sendFile encrypts and uploads a file to CDN, then sends it as a message. +func (b *Bot) sendFile(msg WeixinMessage, fileName string, data []byte) { + key, keyHex := RandomFileKey(), "" + keyBytes, err := hex.DecodeString(key) + if err != nil { + logger().Error("decode file key failed", "error", err) + return + } + keyHex = key + + encrypted, err := EncryptAESECB(data, keyBytes) + if err != nil { + logger().Error("encrypt file failed", "error", err) + return + } + + rawMD5 := md5Sum(data) + fileKey := RandomFileKey() + + uploadResp, err := b.client.GetUploadURL(UploadParams{ + FileKey: fileKey, + MediaType: MediaTypeFile, + ToUserID: msg.FromUserID, + RawSize: len(data), + RawFileMD5: rawMD5, + FileSize: len(encrypted), + NoNeedThumb: true, + AESKey: keyHex, + }, "") + if err != nil { + logger().Error("getuploadurl for file failed", "error", err) + return + } + + encryptedParam, err := UploadToCDN("", uploadResp.UploadParam, fileKey, encrypted) + if err != nil { + logger().Error("cdn upload file failed", "error", err) + return + } + + contextToken := "" + if v, ok := b.contextTokens.Load(msg.FromUserID); ok { + contextToken, _ = v.(string) + } + + reply := WeixinMessage{ + ToUserID: msg.FromUserID, + ClientID: RandomClientID("file"), + MessageType: MessageTypeBot, + MessageState: MessageStateFinish, + ContextToken: contextToken, + ItemList: []MessageItem{ + { + Type: ItemTypeFile, + FileItem: &FileItem{ + Media: &CDNMedia{ + EncryptQueryParam: encryptedParam, + }, + FileName: fileName, + Len: strconv.Itoa(len(data)), + }, + }, + }, + } + if err := b.client.SendMessage(reply, ""); err != nil { + logger().Error("send file message failed", "user_id", msg.FromUserID, "error", err) + } +} + +// sendVideo encrypts and uploads a video to CDN, then sends it as a message. +func (b *Bot) sendVideo(msg WeixinMessage, data []byte) { + key, keyHex := RandomFileKey(), "" + keyBytes, err := hex.DecodeString(key) + if err != nil { + logger().Error("decode file key failed", "error", err) + return + } + keyHex = key + + encrypted, err := EncryptAESECB(data, keyBytes) + if err != nil { + logger().Error("encrypt video failed", "error", err) + return + } + + rawMD5 := md5Sum(data) + fileKey := RandomFileKey() + + uploadResp, err := b.client.GetUploadURL(UploadParams{ + FileKey: fileKey, + MediaType: MediaTypeVideo, + ToUserID: msg.FromUserID, + RawSize: len(data), + RawFileMD5: rawMD5, + FileSize: len(encrypted), + NoNeedThumb: true, + AESKey: keyHex, + }, "") + if err != nil { + logger().Error("getuploadurl for video failed", "error", err) + return + } + + encryptedParam, err := UploadToCDN("", uploadResp.UploadParam, fileKey, encrypted) + if err != nil { + logger().Error("cdn upload video failed", "error", err) + return + } + + contextToken := "" + if v, ok := b.contextTokens.Load(msg.FromUserID); ok { + contextToken, _ = v.(string) + } + + reply := WeixinMessage{ + ToUserID: msg.FromUserID, + ClientID: RandomClientID("video"), + MessageType: MessageTypeBot, + MessageState: MessageStateFinish, + ContextToken: contextToken, + ItemList: []MessageItem{ + { + Type: ItemTypeVideo, + VideoItem: &VideoItem{ + Media: &CDNMedia{ + EncryptQueryParam: encryptedParam, + }, + VideoSize: int64(len(data)), + }, + }, + }, + } + if err := b.client.SendMessage(reply, ""); err != nil { + logger().Error("send video message failed", "user_id", msg.FromUserID, "error", err) + } +} + +// --- helpers --- + +// decodeBase64 decodes a base64-encoded string, trying standard then URL encoding. +func decodeBase64(s string) ([]byte, error) { + data, err := base64.StdEncoding.DecodeString(s) + if err == nil { + return data, nil + } + return base64.URLEncoding.DecodeString(s) +} + +// md5Sum returns the hex-encoded MD5 of data. +func md5Sum(data []byte) string { + h := md5.Sum(data) //nolint:gosec // MD5 is required by WeChat CDN protocol + return hex.EncodeToString(h[:]) +} From ca9f1a0506c256fd30e4925df6227f1f28d22987 Mon Sep 17 00:00:00 2001 From: Vaayne Date: Sun, 22 Mar 2026 21:06:31 +0800 Subject: [PATCH 15/31] =?UTF-8?q?=F0=9F=93=9D=20docs:=20update=20weixin=20?= =?UTF-8?q?channel=20handoff=20with=20Phase=203=20summary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-03-22-weixin-channel/handoff.md | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/.agents/sessions/2026-03-22-weixin-channel/handoff.md b/.agents/sessions/2026-03-22-weixin-channel/handoff.md index be18e10..b12ffd1 100644 --- a/.agents/sessions/2026-03-22-weixin-channel/handoff.md +++ b/.agents/sessions/2026-03-22-weixin-channel/handoff.md @@ -50,3 +50,33 @@ - `client` is created in Start() from config values, not in New() — this matches the pattern where credentials may come from DB. - `dbConfig` type embeds Config + GetUpdatesBuf for DB JSON serialization. - `clearCredentials()` deletes bot_token, bot_id, user_id, get_updates_buf from DB config and clears both sync.Maps. + +## Phase 3: Message Handling & Streaming — DONE + +### Commits + +1. `4b922bce` — Task 3.1: `handler.go` — handleUpdates (filter by message_type/state, cache context_token, dispatch by item type), handleText (link code → shared commands → /model → /agent → agent chat), handleImage (CDN download → AES decrypt → MIME detect → multimodal content), handleMessage (resolve → stream → render), handleModelCommand (text-based list/filter/switch, qq pattern), sendReply helper. Removed placeholder handleUpdates from weixin.go. +2. `b7eceb84` — Task 3.2: `stream.go` — streamEvents (consume events, accumulate text, track tools), toolTracker (full history, renderFinal summary matching telegram pattern), keepTyping (typing status=1 every 5s, status=2 on cancel), getTypingTicket (cache via getconfig API). +3. `eb9ef83d` — Task 3.3: `render.go` — sendFinalResponse (split at 2000 chars via SplitMessage, send chunks + images), sendImage (base64 decode → AES encrypt → CDN upload → sendmessage with image_item), sendFile (encrypt → upload media_type=3 → file_item), sendVideo (encrypt → upload media_type=2 → video_item). All media sends use no_need_thumb: true. + +### Files Created + +- `internal/channel/weixin/handler.go` (331 lines) +- `internal/channel/weixin/stream.go` (297 lines) +- `internal/channel/weixin/render.go` (280 lines) + +### Files Modified + +- `internal/channel/weixin/weixin.go` — removed placeholder handleUpdates method + +### Notes for Phase 4 + +- All message handling is complete: text, image, file (log+skip), video (log+skip). +- Commands: /start, /help, /new, /compact, /whoami (shared), /model (text-based list/switch), /agent (shared HandleAgentCommand). +- Link code account linking works via channel.TryLinkCode. +- context_token is cached in sync.Map on every incoming message. +- typing_ticket is cached per user, fetched via getconfig on first use. +- Tool tracker renders compact summary in final message (same format as telegram). +- sendFile and sendVideo are implemented but not invoked from inbound handling (files/videos are logged and skipped per plan). They are available for future use by agents or notifications. +- The handleModelCommand follows the qq/feishu text-based pattern — no inline keyboard since WeChat doesn't support that. Uses /model to switch. +- No chatModels per-session cache was added since the weixin Bot struct doesn't have one. switchFn persists the model globally. From dd42b5965411ba75d6dc11d83f665fc14dcfe316 Mon Sep 17 00:00:00 2001 From: Vaayne Date: Sun, 22 Mar 2026 21:09:40 +0800 Subject: [PATCH 16/31] =?UTF-8?q?=F0=9F=90=9B=20fix:=20add=20CDNMedia=20ae?= =?UTF-8?q?s=5Fkey/encrypt=5Ftype=20to=20outbound=20media,=20fix=20ciphert?= =?UTF-8?q?ext=20sizes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sendImage: populate CDNMedia.AESKey (base64 of hex) + EncryptType=1, use ciphertext length for MidSize - sendFile: add CDNMedia.AESKey + EncryptType=1 - sendVideo: add CDNMedia.AESKey + EncryptType=1, use ciphertext length for VideoSize - Remove unused typingCursor constant from stream.go --- internal/channel/weixin/render.go | 10 ++++++++-- internal/channel/weixin/stream.go | 2 -- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/internal/channel/weixin/render.go b/internal/channel/weixin/render.go index 8b6cbd9..72f7d08 100644 --- a/internal/channel/weixin/render.go +++ b/internal/channel/weixin/render.go @@ -113,9 +113,11 @@ func (b *Bot) sendImage(msg WeixinMessage, img runner.ImageEvent) { ImageItem: &ImageItem{ Media: &CDNMedia{ EncryptQueryParam: encryptedParam, + AESKey: base64.StdEncoding.EncodeToString([]byte(keyHex)), + EncryptType: 1, }, AESKey: keyHex, - MidSize: int64(len(data)), + MidSize: int64(len(encrypted)), }, }, }, @@ -182,6 +184,8 @@ func (b *Bot) sendFile(msg WeixinMessage, fileName string, data []byte) { FileItem: &FileItem{ Media: &CDNMedia{ EncryptQueryParam: encryptedParam, + AESKey: base64.StdEncoding.EncodeToString([]byte(keyHex)), + EncryptType: 1, }, FileName: fileName, Len: strconv.Itoa(len(data)), @@ -251,8 +255,10 @@ func (b *Bot) sendVideo(msg WeixinMessage, data []byte) { VideoItem: &VideoItem{ Media: &CDNMedia{ EncryptQueryParam: encryptedParam, + AESKey: base64.StdEncoding.EncodeToString([]byte(keyHex)), + EncryptType: 1, }, - VideoSize: int64(len(data)), + VideoSize: int64(len(encrypted)), }, }, }, diff --git a/internal/channel/weixin/stream.go b/internal/channel/weixin/stream.go index 3b6b2d7..7b92181 100644 --- a/internal/channel/weixin/stream.go +++ b/internal/channel/weixin/stream.go @@ -19,8 +19,6 @@ const ( // WeChat typing status expires after a few seconds. typingInterval = 5 * time.Second - // typingCursor is appended to streaming display to indicate activity. - typingCursor = " \u258D" ) // toolEmoji maps known tool names to display emoji. From 3b4f52ad5984fd28d223a11e9c8ccc44212b1346 Mon Sep 17 00:00:00 2001 From: Vaayne Date: Sun, 22 Mar 2026 21:11:41 +0800 Subject: [PATCH 17/31] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20weixin=20channe?= =?UTF-8?q?l=20gateway=20integration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add weixinChannelConfig type and initialization block in gateway.go, following the telegram/qq/feishu pattern. Loads config from DB, creates bot with auth, registers with channels and notifier. --- cmd/anna/gateway.go | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/cmd/anna/gateway.go b/cmd/anna/gateway.go index 9c9e55a..e5c0e43 100644 --- a/cmd/anna/gateway.go +++ b/cmd/anna/gateway.go @@ -22,6 +22,7 @@ import ( "github.com/vaayne/anna/internal/channel/feishu" "github.com/vaayne/anna/internal/channel/qq" "github.com/vaayne/anna/internal/channel/telegram" + "github.com/vaayne/anna/internal/channel/weixin" "github.com/vaayne/anna/internal/config" appdb "github.com/vaayne/anna/internal/db" "github.com/vaayne/anna/internal/scheduler" @@ -111,6 +112,7 @@ func runServer(ctx context.Context, s *setupResult, listFn channel.ModelListFunc tgCfg := loadChannelConfig[telegramChannelConfig](s.store, "telegram") qqCfg := loadChannelConfig[qqChannelConfig](s.store, "qq") fsCfg := loadChannelConfig[feishuChannelConfig](s.store, "feishu") + wxCfg := loadChannelConfig[weixinChannelConfig](s.store, "weixin") // --- Telegram --- if tgCfg != nil && tgCfg.Token != "" { @@ -195,6 +197,30 @@ func runServer(ctx context.Context, s *setupResult, listFn channel.ModelListFunc } } + // --- Weixin --- + if wxCfg != nil && wxCfg.BotToken != "" { + slog.Info("starting weixin bot") + + wxBot, err := weixin.New(weixin.Config{ + BotToken: wxCfg.BotToken, + BaseURL: wxCfg.BaseURL, + BotID: wxCfg.BotID, + UserID: wxCfg.UserID, + NotifyChat: wxCfg.NotifyChat, + AllowedIDs: wxCfg.AllowedIDs, + }, s.poolManager, s.store, listFn, switchFn, + weixin.WithAuth(as, engine, linkCodes), + ) + if err != nil { + return fmt.Errorf("create weixin bot: %w", err) + } + + channels = append(channels, wxBot) + if wxCfg.EnableNotify { + s.notifier.Register(wxBot, wxCfg.NotifyChat) + } + } + if len(channels) == 0 { if adminPort > 0 { slog.Warn("no channel services configured; running admin panel only") @@ -341,6 +367,16 @@ type feishuChannelConfig struct { EnableNotify bool `json:"enable_notify"` } +type weixinChannelConfig struct { + BotToken string `json:"bot_token"` + BaseURL string `json:"base_url"` + BotID string `json:"bot_id"` + UserID string `json:"user_id"` + NotifyChat string `json:"notify_chat"` + AllowedIDs []string `json:"allowed_ids"` + EnableNotify bool `json:"enable_notify"` +} + // loadChannelConfig loads a channel's JSON config from the store and // deserializes it into the given type. Returns nil if not found. func loadChannelConfig[T any](store config.Store, channelID string) *T { From f3d1368193ed929d34839c51cade356b932f5684 Mon Sep 17 00:00:00 2001 From: Vaayne Date: Sun, 22 Mar 2026 21:12:27 +0800 Subject: [PATCH 18/31] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20weixin=20QR=20l?= =?UTF-8?q?ogin=20API=20endpoints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add POST /api/channels/weixin/qr to start QR flow and GET /api/channels/weixin/qr/status to poll scan status. On confirmed, credentials are merged into DB channel config. --- internal/admin/server.go | 4 ++ internal/admin/weixin_qr.go | 89 +++++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 internal/admin/weixin_qr.go diff --git a/internal/admin/server.go b/internal/admin/server.go index 36d0857..d17f2e4 100644 --- a/internal/admin/server.go +++ b/internal/admin/server.go @@ -117,6 +117,10 @@ func New(store config.Store, authStore auth.AuthStore, engine *auth.PolicyEngine s.mux.Handle("GET /api/channels/{platform}", adminAPI(s.getChannel)) s.mux.Handle("PUT /api/channels/{platform}", adminAPI(s.updateChannel)) + // Weixin QR login APIs (admin-only). + s.mux.Handle("POST /api/channels/weixin/qr", adminAPI(s.startWeixinQR)) + s.mux.Handle("GET /api/channels/weixin/qr/status", adminAPI(s.pollWeixinQRStatus)) + // User APIs (admin-only) — memory management and default agent. s.mux.Handle("PUT /api/users/{id}/default-agent", adminAPI(s.updateUserDefaultAgent)) s.mux.Handle("GET /api/users/{id}/memories", adminAPI(s.listUserMemories)) diff --git a/internal/admin/weixin_qr.go b/internal/admin/weixin_qr.go new file mode 100644 index 0000000..9fb7a14 --- /dev/null +++ b/internal/admin/weixin_qr.go @@ -0,0 +1,89 @@ +package admin + +import ( + "context" + "encoding/json" + "net/http" + "time" + + "github.com/vaayne/anna/internal/channel/weixin" + "github.com/vaayne/anna/internal/config" +) + +// startWeixinQR initiates the WeChat QR login flow by requesting a QR code +// from the iLink API. +// POST /api/channels/weixin/qr +func (s *Server) startWeixinQR(w http.ResponseWriter, r *http.Request) { + client := weixin.NewClient("", "", "") + qr, err := client.GetQRCode("") + if err != nil { + writeError(w, http.StatusBadGateway, "failed to get QR code: "+err.Error()) + return + } + writeData(w, http.StatusOK, qr) +} + +// pollWeixinQRStatus polls the QR code scan status. +// GET /api/channels/weixin/qr/status?qrcode=... +func (s *Server) pollWeixinQRStatus(w http.ResponseWriter, r *http.Request) { + qrcode := r.URL.Query().Get("qrcode") + if qrcode == "" { + writeError(w, http.StatusBadRequest, "qrcode parameter required") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + client := weixin.NewClient("", "", "") + _ = ctx // timeout is handled by the client's HTTP timeout; we use a short-lived context + status, err := client.GetQRCodeStatus(qrcode, "") + if err != nil { + writeError(w, http.StatusBadGateway, "failed to poll QR status: "+err.Error()) + return + } + + // On confirmed: save credentials to DB. + if status.Status == "confirmed" && status.BotToken != "" { + if err := s.saveWeixinCredentials(r.Context(), status); err != nil { + s.log.Error("save weixin credentials", "error", err) + writeError(w, http.StatusInternalServerError, "QR confirmed but failed to save credentials: "+err.Error()) + return + } + } + + writeData(w, http.StatusOK, status) +} + +// saveWeixinCredentials merges iLink credentials into the existing weixin +// channel config in the DB. +func (s *Server) saveWeixinCredentials(ctx context.Context, status *weixin.QRCodeStatusResponse) error { + // Load existing config (may not exist yet). + var raw map[string]any + ch, err := s.store.GetChannel(ctx, "weixin") + if err != nil { + raw = make(map[string]any) + ch = config.Channel{ID: "weixin", Enabled: true} + } else { + if err := json.Unmarshal([]byte(ch.Config), &raw); err != nil { + raw = make(map[string]any) + } + } + + // Merge credentials. + raw["bot_token"] = status.BotToken + raw["base_url"] = status.BaseURL + raw["bot_id"] = status.ILinkBotID + raw["user_id"] = status.ILinkUserID + + data, err := json.Marshal(raw) + if err != nil { + return err + } + + return s.store.UpsertChannel(ctx, config.Channel{ + ID: "weixin", + Enabled: ch.Enabled, + Config: string(data), + }) +} From bd608523edb1940a725f5ae2f4cf432dae691ec8 Mon Sep 17 00:00:00 2001 From: Vaayne Date: Sun, 22 Mar 2026 21:13:22 +0800 Subject: [PATCH 19/31] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20weixin=20channe?= =?UTF-8?q?l=20config=20form=20and=20QR=20login=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Weixin block to channels page with enable/notify toggles, QR login button with status badges, notify chat with context_token tooltip, and allowed IDs field. JS handles QR flow polling. --- internal/admin/ui/pages/channels.templ | 73 ++++++++++++++++++- internal/admin/ui/static/js/pages/channels.js | 67 +++++++++++++++++ 2 files changed, 139 insertions(+), 1 deletion(-) diff --git a/internal/admin/ui/pages/channels.templ b/internal/admin/ui/pages/channels.templ index d40de6c..faf3fd2 100644 --- a/internal/admin/ui/pages/channels.templ +++ b/internal/admin/ui/pages/channels.templ @@ -4,7 +4,7 @@ import "github.com/vaayne/anna/internal/admin/ui" templ ChannelsPage() {
- @ui.PageHeader("Messaging platforms", "Connect Anna to Telegram, QQ, and Feishu.") + @ui.PageHeader("Messaging platforms", "Connect Anna to Telegram, QQ, Feishu, and Weixin.")
@channelBlock("Telegram", "telegram") { @@ -210,6 +210,77 @@ templ ChannelsPage() {
} + + @channelBlock("Weixin", "weixin") { + + +
+
+

QR Login

+ + +
+
+
+
+ @ui.FormField("Notify Chat") { + +

+ Requires cached context_token (repopulated when user sends a message after restart). +

+ } +
+
+ @ui.FormField("Allowed IDs") { + + } +
+
+ + }
} diff --git a/internal/admin/ui/static/js/pages/channels.js b/internal/admin/ui/static/js/pages/channels.js index 4ad7b51..fe6ee31 100644 --- a/internal/admin/ui/static/js/pages/channels.js +++ b/internal/admin/ui/static/js/pages/channels.js @@ -47,8 +47,20 @@ export function register(Alpine) { verification_token: '', notify_chat: '', group_mode: '', allowed_ids: [], }, + weixin: { + enabled: false, enable_notify: false, + notify_chat: '', allowed_ids: [], + bot_token: '', base_url: '', bot_id: '', user_id: '', + }, }, + // QR login state + qrUrl: '', + qrStatus: '', + qrCode: '', + qrPolling: false, + _qrInterval: null, + // Expose helpers to templates parseAllowedIds, formatAllowedIds, @@ -94,6 +106,17 @@ export function register(Alpine) { group_mode: cfg.group_mode || '', allowed_ids: cfg.allowed_ids || [], } + } else if (ch.id === 'weixin') { + this.channelData.weixin = { + enabled: ch.enabled, + enable_notify: cfg.enable_notify || false, + notify_chat: cfg.notify_chat || '', + allowed_ids: cfg.allowed_ids || [], + bot_token: cfg.bot_token || '', + base_url: cfg.base_url || '', + bot_id: cfg.bot_id || '', + user_id: cfg.user_id || '', + } } } } catch (e) { @@ -116,5 +139,49 @@ export function register(Alpine) { this.$store.toast.show(e.message, 'error') } }, + + async startQR() { + this.qrUrl = '' + this.qrStatus = '' + this.qrCode = '' + this.qrPolling = true + if (this._qrInterval) { + clearInterval(this._qrInterval) + this._qrInterval = null + } + try { + const result = await api('POST', '/api/channels/weixin/qr') + this.qrCode = result.qrcode || '' + this.qrUrl = result.qrcode_img_content || '' + this.qrStatus = 'waiting' + this._qrInterval = setInterval(() => this.pollQRStatus(), 3000) + } catch (e) { + this.$store.toast.show('QR code request failed: ' + e.message, 'error') + this.qrPolling = false + } + }, + + async pollQRStatus() { + if (!this.qrCode) return + try { + const result = await api('GET', '/api/channels/weixin/qr/status?qrcode=' + encodeURIComponent(this.qrCode)) + if (result.status) { + this.qrStatus = result.status + } + if (result.status === 'confirmed') { + clearInterval(this._qrInterval) + this._qrInterval = null + this.qrPolling = false + this.$store.toast.show('Weixin login successful') + await this.loadChannels() + } else if (result.status === 'expired') { + clearInterval(this._qrInterval) + this._qrInterval = null + this.qrPolling = false + } + } catch (e) { + console.error('QR status poll error:', e) + } + }, })) } From 55bb36ef85bfa205acbc8e18c651c4b3f63e02dd Mon Sep 17 00:00:00 2001 From: Vaayne Date: Sun, 22 Mar 2026 21:13:37 +0800 Subject: [PATCH 20/31] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20weixin=20to=20p?= =?UTF-8?q?rofile=20link=20code=20platforms?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add weixin as a platform option for identity linking in the profile page, alongside telegram, qq, and feishu. --- internal/admin/ui/pages/profile.templ | 6 +++--- internal/admin/ui/static/js/pages/profile.js | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/admin/ui/pages/profile.templ b/internal/admin/ui/pages/profile.templ index 7846fef..5500297 100644 --- a/internal/admin/ui/pages/profile.templ +++ b/internal/admin/ui/pages/profile.templ @@ -80,13 +80,13 @@ templ ProfilePage() { -