This commit is contained in:
Huaaudio 2026-03-22 04:48:19 +01:00
parent 100720bb74
commit 2ecf49c8d5
9 changed files with 1016 additions and 2 deletions

View file

@ -16,14 +16,23 @@ func NewOnboardCommand() *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "onboard", Use: "onboard",
Aliases: []string{"o"}, Aliases: []string{"o"},
Short: "Initialize picoclaw configuration and workspace", Short: "Initialize picoclaw configuration, workspace, and channel accounts",
// Run without subcommands → original onboard flow
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
onboard(encrypt) if len(args) == 0 {
onboard(encrypt)
} else {
_ = cmd.Help()
}
}, },
} }
cmd.Flags().BoolVar(&encrypt, "enc", false, cmd.Flags().BoolVar(&encrypt, "enc", false,
"Enable credential encryption (generates SSH key and prompts for passphrase)") "Enable credential encryption (generates SSH key and prompts for passphrase)")
// Channel onboarding subcommands
cmd.AddCommand(newWeixinCommand())
return cmd return cmd
} }

View file

@ -0,0 +1,160 @@
package onboard
import (
"context"
"encoding/json"
"fmt"
"os"
"time"
"github.com/spf13/cobra"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/pkg/channels/weixin"
"github.com/sipeed/picoclaw/pkg/config"
)
func newWeixinCommand() *cobra.Command {
var baseURL string
var proxy string
var timeout int
cmd := &cobra.Command{
Use: "weixin",
Short: "Connect a WeChat personal account via QR code",
Long: `Start the interactive Weixin (WeChat personal) QR code login flow.
A QR code is displayed in the terminal. Scan it with the WeChat mobile app
to authorize your account. On success, the bot token is saved to the picoclaw
config so you can start the gateway immediately.
Example:
picoclaw onboard weixin`,
RunE: func(cmd *cobra.Command, _ []string) error {
return runWeixinOnboard(baseURL, proxy, time.Duration(timeout)*time.Second)
},
}
cmd.Flags().StringVar(&baseURL, "base-url", "https://ilinkai.weixin.qq.com/", "iLink API base URL")
cmd.Flags().StringVar(&proxy, "proxy", "", "HTTP proxy URL (e.g. http://localhost:7890)")
cmd.Flags().IntVar(&timeout, "timeout", 300, "Login timeout in seconds")
return cmd
}
func runWeixinOnboard(baseURL, proxy string, timeout time.Duration) error {
fmt.Println("Starting Weixin (WeChat personal) login...")
fmt.Println()
botToken, userID, accountID, returnedBaseURL, err := weixin.PerformLoginInteractive(
context.Background(),
weixin.AuthFlowOpts{
BaseURL: baseURL,
Timeout: timeout,
},
)
if err != nil {
return fmt.Errorf("login failed: %w", err)
}
fmt.Println()
fmt.Println("✅ Login successful!")
fmt.Printf(" Account ID : %s\n", accountID)
if userID != "" {
fmt.Printf(" User ID : %s\n", userID)
}
fmt.Println()
// Prefer the server-returned base URL (may be region-specific)
effectiveBaseURL := returnedBaseURL
if effectiveBaseURL == "" {
effectiveBaseURL = baseURL
}
if err := saveWeixinConfig(botToken, effectiveBaseURL, proxy); err != nil {
fmt.Printf("⚠️ Could not auto-save to config: %v\n", err)
printManualWeixinConfig(botToken, effectiveBaseURL)
return nil
}
fmt.Println("✓ Config updated. Start the gateway with:")
fmt.Println()
fmt.Println(" picoclaw gateway start")
fmt.Println()
fmt.Println("To restrict which WeChat users can send messages, add their user IDs")
fmt.Println("to channels.weixin.allow_from in your config.")
return nil
}
// saveWeixinConfig patches channels.weixin in the config and saves it.
func saveWeixinConfig(token, baseURL, proxy string) error {
cfgPath := internal.GetConfigPath()
raw, err := os.ReadFile(cfgPath)
if err != nil {
// Config doesn't exist yet — create a minimal one
return writeMinimalWeixinConfig(cfgPath, token, baseURL, proxy)
}
var cfg config.Config
if err := json.Unmarshal(raw, &cfg); err != nil {
return fmt.Errorf("failed to parse config: %w", err)
}
cfg.Channels.Weixin.Enabled = true
cfg.Channels.Weixin.Token = token
const defaultBase = "https://ilinkai.weixin.qq.com/"
if baseURL != "" && baseURL != defaultBase {
cfg.Channels.Weixin.BaseURL = baseURL
}
if proxy != "" {
cfg.Channels.Weixin.Proxy = proxy
}
return config.SaveConfig(cfgPath, &cfg)
}
func writeMinimalWeixinConfig(cfgPath, token, baseURL, proxy string) error {
if err := os.MkdirAll(internal.GetPicoclawHome(), 0755); err != nil {
return err
}
weixinCfg := map[string]any{"enabled": true, "token": token}
const defaultBase = "https://ilinkai.weixin.qq.com/"
if baseURL != "" && baseURL != defaultBase {
weixinCfg["base_url"] = baseURL
}
if proxy != "" {
weixinCfg["proxy"] = proxy
}
minimal := map[string]any{
"channels": map[string]any{"weixin": weixinCfg},
}
data, err := json.MarshalIndent(minimal, "", " ")
if err != nil {
return err
}
if err := os.WriteFile(cfgPath, data, 0600); err != nil {
return err
}
fmt.Printf("✓ Created config at %s\n", cfgPath)
return nil
}
func printManualWeixinConfig(token, baseURL string) {
fmt.Println()
fmt.Println("Add the following to the channels section of your picoclaw config:")
fmt.Println()
fmt.Println(` "weixin": {`)
fmt.Println(` "enabled": true,`)
fmt.Printf(" \"token\": %q,\n", token)
const defaultBase = "https://ilinkai.weixin.qq.com/"
if baseURL != "" && baseURL != defaultBase {
fmt.Printf(" \"base_url\": %q,\n", baseURL)
}
fmt.Println(` "allow_from": []`)
fmt.Println(` }`)
}

View file

@ -385,6 +385,10 @@ func (m *Manager) initChannels(channels *config.ChannelsConfig) error {
m.initChannel("wecom_app", "WeCom App") m.initChannel("wecom_app", "WeCom App")
} }
if channels.Weixin.Enabled && channels.Weixin.Token != "" {
m.initChannel("weixin", "Weixin")
}
if channels.Pico.Enabled && channels.Pico.Token != "" { if channels.Pico.Enabled && channels.Pico.Token != "" {
m.initChannel("pico", "Pico") m.initChannel("pico", "Pico")
} }

215
pkg/channels/weixin/api.go Normal file
View file

@ -0,0 +1,215 @@
package weixin
import (
"bytes"
"context"
"crypto/rand"
"encoding/base64"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"path"
)
type ApiClient struct {
BaseURL string
Token string
HttpClient *http.Client
}
func NewApiClient(baseURL, token string, proxy string) (*ApiClient, error) {
if baseURL == "" {
baseURL = "https://ilinkai.weixin.qq.com/"
}
client := &http.Client{
// Default timeout; will be overridden per context
}
if proxy != "" {
proxyURL, err := url.Parse(proxy)
if err != nil {
return nil, fmt.Errorf("invalid proxy URL %q: %v", proxy, err)
}
client.Transport = &http.Transport{
Proxy: http.ProxyURL(proxyURL),
}
}
return &ApiClient{
BaseURL: baseURL,
Token: token,
HttpClient: client,
}, nil
}
func randomWechatUIN() string {
var b [4]byte
_, _ = rand.Read(b[:])
uint32Val := binary.BigEndian.Uint32(b[:])
return base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%d", uint32Val)))
}
func (c *ApiClient) post(ctx context.Context, endpoint string, body interface{}, responseObj interface{}) error {
u, err := url.Parse(c.BaseURL)
if err != nil {
return err
}
u.Path = path.Join(u.Path, endpoint)
jsonData, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("failed to marshal request body: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", u.String(), bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
if endpoint == "ilink/bot/get_bot_qrcode" || endpoint == "ilink/bot/get_qrcode_status" {
// QR routes have different headers sometimes, but let's stick to base ones
if endpoint == "ilink/bot/get_qrcode_status" {
req.Header.Set("iLink-App-ClientVersion", "1")
}
} else {
req.Header.Set("AuthorizationType", "ilink_bot_token")
req.Header.Set("X-WECHAT-UIN", randomWechatUIN())
if c.Token != "" {
req.Header.Set("Authorization", "Bearer "+c.Token)
}
}
resp, err := c.HttpClient.Do(req)
if err != nil {
return fmt.Errorf("http POST %s failed: %w", endpoint, err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read response body: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("http %d %s: %s", resp.StatusCode, resp.Status, string(respBody))
}
if responseObj != nil {
if err := json.Unmarshal(respBody, responseObj); err != nil {
return fmt.Errorf("failed to unmarshal response: %w, body: %s", err, string(respBody))
}
}
return nil
}
func (c *ApiClient) GetUpdates(ctx context.Context, req GetUpdatesReq) (*GetUpdatesResp, error) {
req.BaseInfo = BaseInfo{ChannelVersion: "1.0.2"}
var resp GetUpdatesResp
err := c.post(ctx, "ilink/bot/getupdates", req, &resp)
if err != nil {
return nil, err
}
return &resp, nil
}
func (c *ApiClient) SendMessage(ctx context.Context, req SendMessageReq) error {
req.BaseInfo = BaseInfo{ChannelVersion: "1.0.2"}
var resp SendMessageResp
return c.post(ctx, "ilink/bot/sendmessage", req, &resp)
}
func (c *ApiClient) GetUploadUrl(ctx context.Context, req GetUploadUrlReq) (*GetUploadUrlResp, error) {
req.BaseInfo = BaseInfo{ChannelVersion: "1.0.2"}
var resp GetUploadUrlResp
err := c.post(ctx, "ilink/bot/getuploadurl", req, &resp)
if err != nil {
return nil, err
}
return &resp, nil
}
func (c *ApiClient) SendTyping(ctx context.Context, req SendTypingReq) error {
req.BaseInfo = BaseInfo{ChannelVersion: "1.0.2"}
return c.post(ctx, "ilink/bot/sendtyping", req, nil)
}
func (c *ApiClient) GetQRCode(ctx context.Context, botType string) (*QRCodeResponse, error) {
// get_bot_qrcode is GET, not POST
u, err := url.Parse(c.BaseURL)
if err != nil {
return nil, err
}
u.Path = path.Join(u.Path, "ilink/bot/get_bot_qrcode")
q := u.Query()
q.Set("bot_type", botType)
u.RawQuery = q.Encode()
req, err := http.NewRequestWithContext(ctx, "GET", u.String(), nil)
if err != nil {
return nil, err
}
resp, err := c.HttpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("get_bot_qrcode failed: %d %s", resp.StatusCode, string(respBody))
}
var qrcodeResp QRCodeResponse
if err := json.Unmarshal(respBody, &qrcodeResp); err != nil {
return nil, err
}
return &qrcodeResp, nil
}
func (c *ApiClient) GetQRCodeStatus(ctx context.Context, qrcode string) (*StatusResponse, error) {
// get_qrcode_status is GET
u, err := url.Parse(c.BaseURL)
if err != nil {
return nil, err
}
u.Path = path.Join(u.Path, "ilink/bot/get_qrcode_status")
q := u.Query()
q.Set("qrcode", qrcode)
u.RawQuery = q.Encode()
req, err := http.NewRequestWithContext(ctx, "GET", u.String(), nil)
if err != nil {
return nil, err
}
req.Header.Set("iLink-App-ClientVersion", "1")
resp, err := c.HttpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("get_qrcode_status failed: %d %s", resp.StatusCode, string(respBody))
}
var statusResp StatusResponse
if err := json.Unmarshal(respBody, &statusResp); err != nil {
return nil, err
}
return &statusResp, nil
}

106
pkg/channels/weixin/auth.go Normal file
View file

@ -0,0 +1,106 @@
package weixin
import (
"context"
"fmt"
"os"
"time"
"github.com/mdp/qrterminal/v3"
"github.com/sipeed/picoclaw/pkg/logger"
)
// AuthFlow opts
type AuthFlowOpts struct {
BaseURL string
BotType string
Timeout time.Duration
}
// PerformLoginInteractive starts the Weixin QR login flow and blocks until login is successful or times out.
// It prints a QR code to the terminal for the user to scan.
// Returns the BotToken, UserID, AccountID, and BaseUrl on success.
func PerformLoginInteractive(ctx context.Context, opts AuthFlowOpts) (botToken, userID, accountID, baseUrl string, err error) {
if opts.BaseURL == "" {
opts.BaseURL = "https://ilinkai.weixin.qq.com/"
}
if opts.BotType == "" {
opts.BotType = "3" // Default iLink Bot Type
}
if opts.Timeout == 0 {
opts.Timeout = 5 * time.Minute
}
api, err := NewApiClient(opts.BaseURL, "", "")
if err != nil {
return "", "", "", "", fmt.Errorf("failed to create api client: %w", err)
}
logger.InfoC("weixin", "Requesting Weixin QR code...")
qrResp, err := api.GetQRCode(ctx, opts.BotType)
if err != nil {
return "", "", "", "", fmt.Errorf("failed to get qrcode: %w", err)
}
fmt.Println("\n=======================================================")
fmt.Println("Please scan the following QR code with WeChat to login:")
fmt.Println("=======================================================")
fmt.Println()
// Create Small QR
qrconfig := qrterminal.Config{
Level: qrterminal.L,
Writer: os.Stdout,
HalfBlocks: true,
}
qrterminal.GenerateWithConfig(qrResp.QrcodeImgContent, qrconfig)
fmt.Printf("\nQR Code Link: %s\n\n", qrResp.QrcodeImgContent)
fmt.Println("Waiting for scan...")
timeoutCtx, cancel := context.WithTimeout(ctx, opts.Timeout)
defer cancel()
pollTicker := time.NewTicker(2 * time.Second)
defer pollTicker.Stop()
scannedPrinted := false
for {
select {
case <-timeoutCtx.Done():
return "", "", "", "", fmt.Errorf("login timeout")
case <-pollTicker.C:
statusResp, err := api.GetQRCodeStatus(timeoutCtx, qrResp.Qrcode)
if err != nil {
// Long poll timeout or temporary error
continue
}
switch statusResp.Status {
case "wait":
// still waiting
case "scaned":
if !scannedPrinted {
fmt.Println("👀 QR Code scanned! Please confirm login on your WeChat app...")
scannedPrinted = true
}
case "confirmed":
if statusResp.BotToken == "" || statusResp.IlinkBotID == "" {
return "", "", "", "", fmt.Errorf("login confirmed but missing bot_token or ilink_bot_id")
}
logger.InfoCF("weixin", "Login successful", map[string]interface{}{
"account_id": statusResp.IlinkBotID,
})
return statusResp.BotToken, statusResp.IlinkUserID, statusResp.IlinkBotID, statusResp.Baseurl, nil
case "expired":
return "", "", "", "", fmt.Errorf("qrcode expired, please try again")
default:
logger.WarnCF("weixin", "Unknown QR code status", map[string]interface{}{
"status": statusResp.Status,
})
}
}
}
}

View file

@ -0,0 +1,191 @@
package weixin
// BaseInfo is attached to every outgoing CGI request
type BaseInfo struct {
ChannelVersion string `json:"channel_version,omitempty"`
}
// UploadMediaType constants
const (
UploadMediaTypeImage = 1
UploadMediaTypeVideo = 2
UploadMediaTypeFile = 3
UploadMediaTypeVoice = 4
)
type GetUploadUrlReq struct {
Filekey string `json:"filekey,omitempty"`
MediaType int `json:"media_type,omitempty"`
ToUserID string `json:"to_user_id,omitempty"`
Rawsize int64 `json:"rawsize,omitempty"`
RawfileMD5 string `json:"rawfilemd5,omitempty"`
Filesize int64 `json:"filesize,omitempty"`
ThumbRawsize int64 `json:"thumb_rawsize,omitempty"`
ThumbRawfileMD5 string `json:"thumb_rawfilemd5,omitempty"`
ThumbFilesize int64 `json:"thumb_filesize,omitempty"`
NoNeedThumb bool `json:"no_need_thumb,omitempty"`
Aeskey string `json:"aeskey,omitempty"` // base64
BaseInfo BaseInfo `json:"base_info,omitempty"`
}
type GetUploadUrlResp struct {
UploadParam string `json:"upload_param,omitempty"`
ThumbUploadParam string `json:"thumb_upload_param,omitempty"`
}
const (
MessageTypeNone = 0
MessageTypeUser = 1
MessageTypeBot = 2
)
const (
MessageItemTypeNone = 0
MessageItemTypeText = 1
MessageItemTypeImage = 2
MessageItemTypeVoice = 3
MessageItemTypeFile = 4
MessageItemTypeVideo = 5
)
const (
MessageStateNew = 0
MessageStateGenerating = 1
MessageStateFinish = 2
)
type TextItem struct {
Text string `json:"text,omitempty"`
}
type CDNMedia struct {
EncryptQueryParam string `json:"encrypt_query_param,omitempty"`
AesKey string `json:"aes_key,omitempty"` // base64 encoded
EncryptType int `json:"encrypt_type,omitempty"`
}
type ImageItem struct {
Media *CDNMedia `json:"media,omitempty"`
ThumbMedia *CDNMedia `json:"thumb_media,omitempty"`
Aeskey string `json:"aeskey,omitempty"`
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"`
}
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"`
}
type FileItem struct {
Media *CDNMedia `json:"media,omitempty"`
FileName string `json:"file_name,omitempty"`
MD5 string `json:"md5,omitempty"`
Len string `json:"len,omitempty"`
}
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"`
}
type RefMessage struct {
MessageItem *MessageItem `json:"message_item,omitempty"`
Title string `json:"title,omitempty"`
}
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"`
}
type WeixinMessage struct {
Seq int `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"`
}
type GetUpdatesReq struct {
SyncBuf string `json:"sync_buf,omitempty"`
GetUpdatesBuf string `json:"get_updates_buf,omitempty"`
BaseInfo BaseInfo `json:"base_info,omitempty"`
}
type GetUpdatesResp struct {
Ret int `json:"ret,omitempty"`
Errcode int `json:"errcode,omitempty"`
Errmsg string `json:"errmsg,omitempty"`
Msgs []WeixinMessage `json:"msgs,omitempty"`
SyncBuf string `json:"sync_buf,omitempty"`
GetUpdatesBuf string `json:"get_updates_buf,omitempty"`
LongpollingTimeoutMs int `json:"longpolling_timeout_ms,omitempty"`
}
type SendMessageReq struct {
Msg WeixinMessage `json:"msg,omitempty"`
BaseInfo BaseInfo `json:"base_info,omitempty"`
}
type SendMessageResp struct {
// Usually empty
}
type GetConfigResp struct {
Ret int `json:"ret,omitempty"`
Errmsg string `json:"errmsg,omitempty"`
TypingTicket string `json:"typing_ticket,omitempty"`
}
type SendTypingReq struct {
IlinkUserID string `json:"ilink_user_id,omitempty"`
TypingTicket string `json:"typing_ticket,omitempty"`
Status int `json:"status,omitempty"` // 1=typing, 2=cancel
BaseInfo BaseInfo `json:"base_info,omitempty"`
}
type QRCodeResponse struct {
Qrcode string `json:"qrcode"`
QrcodeImgContent string `json:"qrcode_img_content"`
}
type StatusResponse struct {
Status string `json:"status"` // "wait", "scaned", "confirmed", "expired"
BotToken string `json:"bot_token,omitempty"`
IlinkBotID string `json:"ilink_bot_id,omitempty"`
Baseurl string `json:"baseurl,omitempty"`
IlinkUserID string `json:"ilink_user_id,omitempty"`
}

View file

@ -0,0 +1,318 @@
package weixin
import (
"context"
"fmt"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/identity"
"github.com/sipeed/picoclaw/pkg/logger"
)
// WeixinChannel is the Weixin channel implementation over Tencent iLink REST API.
type WeixinChannel struct {
*channels.BaseChannel
api *ApiClient
config config.WeixinConfig
ctx context.Context
cancel context.CancelFunc
bus *bus.MessageBus
// contextTokens stores the last context_token per user (from_user_id → context_token).
// This is required by the iLink API to associate replies with the right chat session.
contextTokens sync.Map
}
func init() {
channels.RegisterFactory("weixin", func(cfg *config.Config, bus *bus.MessageBus) (channels.Channel, error) {
return NewWeixinChannel(cfg.Channels.Weixin, bus)
})
}
// NewWeixinChannel creates a new WeixinChannel from config.
func NewWeixinChannel(cfg config.WeixinConfig, messageBus *bus.MessageBus) (*WeixinChannel, error) {
api, err := NewApiClient(cfg.BaseURL, cfg.Token, cfg.Proxy)
if err != nil {
return nil, fmt.Errorf("weixin: failed to create API client: %w", err)
}
base := channels.NewBaseChannel(
"weixin",
cfg,
messageBus,
cfg.AllowFrom,
channels.WithMaxMessageLength(4000),
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
)
return &WeixinChannel{
BaseChannel: base,
api: api,
config: cfg,
bus: messageBus,
}, nil
}
func (c *WeixinChannel) Start(ctx context.Context) error {
logger.InfoC("weixin", "Starting Weixin channel")
c.ctx, c.cancel = context.WithCancel(ctx)
c.SetRunning(true)
go c.pollLoop(c.ctx)
logger.InfoC("weixin", "Weixin channel started")
return nil
}
func (c *WeixinChannel) Stop(ctx context.Context) error {
logger.InfoC("weixin", "Stopping Weixin channel")
c.SetRunning(false)
if c.cancel != nil {
c.cancel()
}
return nil
}
// pollLoop is the long-poll receive loop. It runs until ctx is cancelled.
func (c *WeixinChannel) pollLoop(ctx context.Context) {
const (
defaultPollTimeoutMs = 35_000
retryDelay = 2 * time.Second
backoffDelay = 30 * time.Second
maxConsecutiveFails = 3
)
consecutiveFails := 0
getUpdatesBuf := ""
nextTimeoutMs := defaultPollTimeoutMs
for {
select {
case <-ctx.Done():
logger.InfoC("weixin", "Weixin poll loop stopped")
return
default:
}
// Build a context with timeout slightly longer than the long-poll
pollCtx, pollCancel := context.WithTimeout(ctx, time.Duration(nextTimeoutMs+5000)*time.Millisecond)
resp, err := c.api.GetUpdates(pollCtx, GetUpdatesReq{
GetUpdatesBuf: getUpdatesBuf,
})
pollCancel()
if err != nil {
// Check if we're shutting down
if ctx.Err() != nil {
return
}
consecutiveFails++
logger.WarnCF("weixin", "getUpdates failed", map[string]any{
"error": err.Error(),
"attempt": consecutiveFails,
})
if consecutiveFails >= maxConsecutiveFails {
logger.ErrorCF("weixin", "Too many consecutive failures, backing off", map[string]any{
"duration": backoffDelay,
})
consecutiveFails = 0
select {
case <-ctx.Done():
return
case <-time.After(backoffDelay):
}
} else {
select {
case <-ctx.Done():
return
case <-time.After(retryDelay):
}
}
continue
}
// Check for API-level error codes (-14 = session expired)
const sessionExpiredErrcode = -14
if resp.Errcode != 0 || (resp.Ret != 0 && resp.Ret != sessionExpiredErrcode) {
consecutiveFails++
logger.ErrorCF("weixin", "getUpdates API error", map[string]any{
"ret": resp.Ret,
"errcode": resp.Errcode,
"errmsg": resp.Errmsg,
})
select {
case <-ctx.Done():
return
case <-time.After(retryDelay):
}
continue
}
if resp.Errcode == sessionExpiredErrcode || resp.Ret == sessionExpiredErrcode {
logger.ErrorC("weixin", "Session expired — please re-run login")
// Pause for a long time to avoid hammering with a bad token
select {
case <-ctx.Done():
return
case <-time.After(10 * time.Minute):
}
continue
}
consecutiveFails = 0
// Update the long-poll timeout from server hint
if resp.LongpollingTimeoutMs > 0 {
nextTimeoutMs = resp.LongpollingTimeoutMs
}
// Advance cursor
if resp.GetUpdatesBuf != "" {
getUpdatesBuf = resp.GetUpdatesBuf
}
// Dispatch messages
for _, msg := range resp.Msgs {
c.handleInboundMessage(ctx, msg)
}
}
}
// handleInboundMessage converts a WeixinMessage to a bus.InboundMessage.
func (c *WeixinChannel) handleInboundMessage(ctx context.Context, msg WeixinMessage) {
fromUserID := msg.FromUserID
if fromUserID == "" {
return
}
// Build text content from item_list
var parts []string
for _, item := range msg.ItemList {
switch item.Type {
case MessageItemTypeText:
if item.TextItem != nil && item.TextItem.Text != "" {
parts = append(parts, item.TextItem.Text)
}
case MessageItemTypeVoice:
if item.VoiceItem != nil && item.VoiceItem.Text != "" {
// Use voice → text transcription from server
parts = append(parts, item.VoiceItem.Text)
} else {
parts = append(parts, "[voice message]")
}
case MessageItemTypeImage:
parts = append(parts, "[image]")
case MessageItemTypeFile:
if item.FileItem != nil && item.FileItem.FileName != "" {
parts = append(parts, fmt.Sprintf("[file: %s]", item.FileItem.FileName))
} else {
parts = append(parts, "[file]")
}
case MessageItemTypeVideo:
parts = append(parts, "[video]")
}
}
content := strings.Join(parts, "\n")
if content == "" {
return
}
sender := bus.SenderInfo{
Platform: "weixin",
PlatformID: fromUserID,
CanonicalID: identity.BuildCanonicalID("weixin", fromUserID),
Username: fromUserID,
DisplayName: fromUserID,
}
if !c.IsAllowedSender(sender) {
logger.DebugCF("weixin", "Message rejected by allowlist", map[string]any{
"from_user_id": fromUserID,
})
return
}
messageID := msg.ClientID
if messageID == "" {
messageID = uuid.New().String()
}
peer := bus.Peer{Kind: "direct", ID: fromUserID}
metadata := map[string]string{
"from_user_id": fromUserID,
"context_token": msg.ContextToken,
"session_id": msg.SessionID,
}
logger.DebugCF("weixin", "Received message", map[string]any{
"from_user_id": fromUserID,
"content_len": len(content),
})
// Store context_token for outbound reply association
if msg.ContextToken != "" {
c.contextTokens.Store(fromUserID, msg.ContextToken)
}
c.HandleMessage(ctx, peer, messageID, fromUserID, fromUserID, content, nil, metadata, sender)
}
// Send implements channels.Channel by sending a text message to the WeChat user.
func (c *WeixinChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
if !c.IsRunning() {
return channels.ErrNotRunning
}
if msg.Content == "" {
return nil
}
// We need a context_token to send a reply. It should be stored in the conversation metadata.
// The chat_id is the weixin user_id (from_user_id).
toUserID := msg.ChatID
// Retrieve context_token from our per-user map (stored on last inbound)
contextToken := ""
if ct, ok := c.contextTokens.Load(toUserID); ok {
contextToken, _ = ct.(string)
}
clientID := "picoclaw-" + uuid.New().String()
req := SendMessageReq{
Msg: WeixinMessage{
FromUserID: "",
ToUserID: toUserID,
ClientID: clientID,
MessageType: MessageTypeBot,
MessageState: MessageStateFinish,
ItemList: []MessageItem{
{
Type: MessageItemTypeText,
TextItem: &TextItem{
Text: msg.Content,
},
},
},
ContextToken: contextToken,
},
}
if err := c.api.SendMessage(ctx, req); err != nil {
logger.ErrorCF("weixin", "Failed to send message", map[string]any{
"to_user_id": toUserID,
"error": err.Error(),
})
return fmt.Errorf("weixin send: %w", channels.ErrTemporary)
}
return nil
}

View file

@ -296,6 +296,7 @@ type ChannelsConfig struct {
WeCom WeComConfig `json:"wecom"` WeCom WeComConfig `json:"wecom"`
WeComApp WeComAppConfig `json:"wecom_app"` WeComApp WeComAppConfig `json:"wecom_app"`
WeComAIBot WeComAIBotConfig `json:"wecom_aibot"` WeComAIBot WeComAIBotConfig `json:"wecom_aibot"`
Weixin WeixinConfig `json:"weixin"`
Pico PicoConfig `json:"pico"` Pico PicoConfig `json:"pico"`
PicoClient PicoClientConfig `json:"pico_client"` PicoClient PicoClientConfig `json:"pico_client"`
IRC IRCConfig `json:"irc"` IRC IRCConfig `json:"irc"`
@ -499,6 +500,15 @@ type WeComAIBotConfig struct {
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REASONING_CHANNEL_ID"` ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REASONING_CHANNEL_ID"`
} }
type WeixinConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WEIXIN_ENABLED"`
Token string `json:"token" env:"PICOCLAW_CHANNELS_WEIXIN_TOKEN"`
BaseURL string `json:"base_url" env:"PICOCLAW_CHANNELS_WEIXIN_BASE_URL"`
Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_WEIXIN_PROXY"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WEIXIN_ALLOW_FROM"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WEIXIN_REASONING_CHANNEL_ID"`
}
type PicoConfig struct { type PicoConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_PICO_ENABLED"` Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_PICO_ENABLED"`
Token string `json:"token" env:"PICOCLAW_CHANNELS_PICO_TOKEN"` Token string `json:"token" env:"PICOCLAW_CHANNELS_PICO_TOKEN"`

View file

@ -27,6 +27,7 @@ import (
_ "github.com/sipeed/picoclaw/pkg/channels/slack" _ "github.com/sipeed/picoclaw/pkg/channels/slack"
_ "github.com/sipeed/picoclaw/pkg/channels/telegram" _ "github.com/sipeed/picoclaw/pkg/channels/telegram"
_ "github.com/sipeed/picoclaw/pkg/channels/wecom" _ "github.com/sipeed/picoclaw/pkg/channels/wecom"
_ "github.com/sipeed/picoclaw/pkg/channels/weixin"
_ "github.com/sipeed/picoclaw/pkg/channels/whatsapp" _ "github.com/sipeed/picoclaw/pkg/channels/whatsapp"
_ "github.com/sipeed/picoclaw/pkg/channels/whatsapp_native" _ "github.com/sipeed/picoclaw/pkg/channels/whatsapp_native"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"