init
This commit is contained in:
parent
100720bb74
commit
2ecf49c8d5
9 changed files with 1016 additions and 2 deletions
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
160
cmd/picoclaw/internal/onboard/weixin.go
Normal file
160
cmd/picoclaw/internal/onboard/weixin.go
Normal 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(` }`)
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
215
pkg/channels/weixin/api.go
Normal file
215
pkg/channels/weixin/api.go
Normal 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
106
pkg/channels/weixin/auth.go
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
191
pkg/channels/weixin/types.go
Normal file
191
pkg/channels/weixin/types.go
Normal 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"`
|
||||
}
|
||||
318
pkg/channels/weixin/weixin.go
Normal file
318
pkg/channels/weixin/weixin.go
Normal 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
|
||||
}
|
||||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue