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{
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
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")
|
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
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"`
|
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"`
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue