diff --git a/cmd/picoclaw/internal/onboard/command.go b/cmd/picoclaw/internal/onboard/command.go index 9f8b288c6..103d03e47 100644 --- a/cmd/picoclaw/internal/onboard/command.go +++ b/cmd/picoclaw/internal/onboard/command.go @@ -16,14 +16,23 @@ func NewOnboardCommand() *cobra.Command { cmd := &cobra.Command{ Use: "onboard", 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) { - onboard(encrypt) + if len(args) == 0 { + onboard(encrypt) + } else { + _ = cmd.Help() + } }, } cmd.Flags().BoolVar(&encrypt, "enc", false, "Enable credential encryption (generates SSH key and prompts for passphrase)") + // Channel onboarding subcommands + cmd.AddCommand(newWeixinCommand()) + return cmd } + diff --git a/cmd/picoclaw/internal/onboard/weixin.go b/cmd/picoclaw/internal/onboard/weixin.go new file mode 100644 index 000000000..8c9badbb5 --- /dev/null +++ b/cmd/picoclaw/internal/onboard/weixin.go @@ -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(` }`) +} diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index ff3fa399c..dd0b129e4 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -385,6 +385,10 @@ func (m *Manager) initChannels(channels *config.ChannelsConfig) error { m.initChannel("wecom_app", "WeCom App") } + if channels.Weixin.Enabled && channels.Weixin.Token != "" { + m.initChannel("weixin", "Weixin") + } + if channels.Pico.Enabled && channels.Pico.Token != "" { m.initChannel("pico", "Pico") } diff --git a/pkg/channels/weixin/api.go b/pkg/channels/weixin/api.go new file mode 100644 index 000000000..1733c7ecc --- /dev/null +++ b/pkg/channels/weixin/api.go @@ -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 +} diff --git a/pkg/channels/weixin/auth.go b/pkg/channels/weixin/auth.go new file mode 100644 index 000000000..e439d0c4c --- /dev/null +++ b/pkg/channels/weixin/auth.go @@ -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, + }) + } + } + } +} diff --git a/pkg/channels/weixin/types.go b/pkg/channels/weixin/types.go new file mode 100644 index 000000000..f62de54df --- /dev/null +++ b/pkg/channels/weixin/types.go @@ -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"` +} diff --git a/pkg/channels/weixin/weixin.go b/pkg/channels/weixin/weixin.go new file mode 100644 index 000000000..e17abec6d --- /dev/null +++ b/pkg/channels/weixin/weixin.go @@ -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 +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 235cb0641..fc1f1b7f4 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -296,6 +296,7 @@ type ChannelsConfig struct { WeCom WeComConfig `json:"wecom"` WeComApp WeComAppConfig `json:"wecom_app"` WeComAIBot WeComAIBotConfig `json:"wecom_aibot"` + Weixin WeixinConfig `json:"weixin"` Pico PicoConfig `json:"pico"` PicoClient PicoClientConfig `json:"pico_client"` 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"` } +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 { Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_PICO_ENABLED"` Token string `json:"token" env:"PICOCLAW_CHANNELS_PICO_TOKEN"` diff --git a/pkg/gateway/gateway.go b/pkg/gateway/gateway.go index 9a2706b3b..07ee45e16 100644 --- a/pkg/gateway/gateway.go +++ b/pkg/gateway/gateway.go @@ -27,6 +27,7 @@ import ( _ "github.com/sipeed/picoclaw/pkg/channels/slack" _ "github.com/sipeed/picoclaw/pkg/channels/telegram" _ "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_native" "github.com/sipeed/picoclaw/pkg/config"