diff --git a/README.md b/README.md
index 4b0852ccd..67ad9f807 100644
--- a/README.md
+++ b/README.md
@@ -363,6 +363,7 @@ Talk to your picoclaw through Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk,
| **Telegram** | Easy (just a token) |
| **Discord** | Easy (bot token + intents) |
| **WhatsApp** | Easy (native: QR scan; or bridge URL) |
+| **Weixin** | Easy (Native QR scan) |
| **Matrix** | Medium (homeserver + bot access token) |
| **QQ** | Easy (AppID + AppSecret) |
| **DingTalk** | Medium (app credentials) |
@@ -509,6 +510,39 @@ If `session_store_path` is empty, the session is stored in `<workspace>/wh
+
+Weixin (WeChat Personal)
+
+PicoClaw supports connecting to your personal WeChat account using the official Tencent iLink API.
+
+**1. Login**
+Run the interactive QR login flow:
+```bash
+picoclaw onboard weixin
+```
+Scan the printed QR code with your WeChat mobile app. On success, the token is saved to your config.
+
+**2. Configure**
+(Optional) Update `allow_from` with your WeChat User ID to restrict who can message the bot:
+```json
+{
+ "channels": {
+ "weixin": {
+ "enabled": true,
+ "token": "YOUR_TOKEN",
+ "allow_from": ["YOUR_USER_ID"]
+ }
+ }
+}
+```
+
+**3. Run**
+```bash
+picoclaw gateway
+```
+
+
+
QQ
@@ -713,6 +747,7 @@ Connect Picoclaw to the Agent Social Network simply by sending a single message
| Command | Description |
| ------------------------- | ----------------------------- |
| `picoclaw onboard` | Initialize config & workspace |
+| `picoclaw onboard weixin` | Connect WeChat account via QR |
| `picoclaw agent -m "..."` | Chat with the agent |
| `picoclaw agent` | Interactive chat mode |
| `picoclaw gateway` | Start the gateway |
diff --git a/README.zh.md b/README.zh.md
index db34f57da..1bc5d1a4b 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -218,6 +218,7 @@ make install
| 命令 | 说明 |
| ------------------------- | ---------------------- |
| `picoclaw onboard` | 初始化配置与工作区 |
+| `picoclaw onboard weixin` | 扫码连接微信个人号 |
| `picoclaw agent -m "..."` | 与 Agent 对话 |
| `picoclaw agent` | 交互式对话模式 |
| `picoclaw gateway` | 启动网关 |
diff --git a/cmd/picoclaw/internal/onboard/command.go b/cmd/picoclaw/internal/onboard/command.go
index 9f8b288c6..1f94c6718 100644
--- a/cmd/picoclaw/internal/onboard/command.go
+++ b/cmd/picoclaw/internal/onboard/command.go
@@ -16,14 +16,22 @@ 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/command_test.go b/cmd/picoclaw/internal/onboard/command_test.go
index 56936190b..6b9fb6e95 100644
--- a/cmd/picoclaw/internal/onboard/command_test.go
+++ b/cmd/picoclaw/internal/onboard/command_test.go
@@ -13,7 +13,7 @@ func TestNewOnboardCommand(t *testing.T) {
require.NotNil(t, cmd)
assert.Equal(t, "onboard", cmd.Use)
- assert.Equal(t, "Initialize picoclaw configuration and workspace", cmd.Short)
+ assert.Equal(t, "Initialize picoclaw configuration, workspace, and channel accounts", cmd.Short)
assert.Len(t, cmd.Aliases, 1)
assert.True(t, cmd.HasAlias("o"))
@@ -28,5 +28,6 @@ func TestNewOnboardCommand(t *testing.T) {
encFlag := cmd.Flags().Lookup("enc")
require.NotNil(t, encFlag, "expected --enc flag to be registered")
assert.Equal(t, "false", encFlag.DefValue, "--enc should default to false")
- assert.False(t, cmd.HasSubCommands())
+ assert.True(t, cmd.HasSubCommands())
+ assert.NotNil(t, cmd.Commands())
}
diff --git a/cmd/picoclaw/internal/onboard/weixin.go b/cmd/picoclaw/internal/onboard/weixin.go
new file mode 100644
index 000000000..721b4f0e9
--- /dev/null
+++ b/cmd/picoclaw/internal/onboard/weixin.go
@@ -0,0 +1,124 @@
+package onboard
+
+import (
+ "context"
+ "fmt"
+ "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,
+ Proxy: proxy,
+ },
+ )
+ 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")
+ 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()
+
+ cfg, err := config.LoadConfig(cfgPath)
+ if err != nil {
+ return fmt.Errorf("failed to load 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 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/docs/channels/weixin/README.md b/docs/channels/weixin/README.md
new file mode 100644
index 000000000..22687fec4
--- /dev/null
+++ b/docs/channels/weixin/README.md
@@ -0,0 +1,58 @@
+# 💬 Weixin (WeChat Personal) Channel
+
+PicoClaw supports connecting to your personal WeChat account using the official Tencent iLink API.
+
+## 🚀 Quick Onboarding
+
+The easiest way to set up the Weixin channel is using the interactive onboarding command:
+
+```bash
+picoclaw onboard weixin
+```
+
+This command will:
+1. Request a QR code from the iLink API and display it in your terminal.
+2. Wait for you to scan the QR code with your WeChat mobile app.
+3. Upon approval, automatically save the generated access token to your `~/.picoclaw/config.json`.
+
+After onboarding, you can start the gateway:
+
+```bash
+picoclaw gateway
+```
+
+---
+
+## ⚙️ Configuration
+
+You can also manually configure the filter rules in `config.json` under the `channels.weixin` section.
+
+```json
+{
+ "channels": {
+ "weixin": {
+ "enabled": true,
+ "token": "YOUR_WEIXIN_TOKEN",
+ "allow_from": [
+ "user_id_1",
+ "user_id_2"
+ ],
+ "proxy": ""
+ }
+ }
+}
+```
+
+### Configuration Fields
+
+| Field | Description |
+|---|---|
+| `enabled` | Set to `true` to enable the channel at startup. |
+| `token` | The authentication token obtained via QR login. |
+| `allow_from` | (Optional) List of WeChat User IDs permitted to interact with the bot. If empty, anyone who can send messages to the connected account can trigger the bot. |
+| `proxy` | (Optional) HTTP proxy address (e.g. `http://localhost:7890`) for environments where connection to `ilinkai.weixin.qq.com` is restricted. |
+
+## ⚠️ Important Notes
+
+- **One Account Only**: The iLink token binds to a single session. Starting a new interaction generally invalidates older tokens if another device authorizes.
+- **Message Rate Limits**: To avoid getting your account restricted by WeChat anti-spam systems, avoid loop triggers or high-frequency broadcasts.
diff --git a/docs/channels/weixin/README.zh.md b/docs/channels/weixin/README.zh.md
new file mode 100644
index 000000000..d5e6f0a49
--- /dev/null
+++ b/docs/channels/weixin/README.zh.md
@@ -0,0 +1,58 @@
+# 💬 微信个人号渠道 (Weixin)
+
+PicoClaw 支持使用腾讯官方 iLink API 连接您的个人微信账号。
+
+## 🚀 快速激活
+
+最简单的方法是使用交互式 onboarding 命令进行一键激活:
+
+```bash
+picoclaw onboard weixin
+```
+
+该命令将:
+1. 从 iLink API 获取二维码并在终端中打印。
+2. 等待您使用手机微信 App 扫码。
+3. 扫码确认后,自动将生成的 Access Token 保存至您的 `~/.picoclaw/config.json` 中。
+
+配置完成后,即可启动网关:
+
+```bash
+picoclaw gateway
+```
+
+---
+
+## ⚙️ 配置说明
+
+您也可以在 `config.json` 的 `channels.weixin` 段目下进行手动维护。
+
+```json
+{
+ "channels": {
+ "weixin": {
+ "enabled": true,
+ "token": "YOUR_WEIXIN_TOKEN",
+ "allow_from": [
+ "user_id_1",
+ "user_id_2"
+ ],
+ "proxy": ""
+ }
+ }
+}
+```
+
+### 字段解析
+
+| 字段 | 说明 |
+|---|---|
+| `enabled` | 设置为 `true` 以在启动时激活该频道。 |
+| `token` | 通过扫码获取的认证令牌。 |
+| `allow_from` | (可选) 允许与机器人交互的微信 User ID 列表。如果为空,任何能给此微信号发消息的人都可以触发机器人。 |
+| `proxy` | (可选) HTTP 代理地址(例如 `http://localhost:7890`),适合网络访问受限环境。 |
+
+## ⚠️ 注意事项
+
+- **单端绑定**: iLink 令牌通常与单个会话绑定。在其他地方重新扫码激活可能会导致旧令牌失效。
+- **频率控制**: 为避免触发微信的风控反垃圾机制,请避免设置死循环触发、高频广播等恶意行为。
diff --git a/docs/chat-apps.md b/docs/chat-apps.md
index 3ed37e814..07297952a 100644
--- a/docs/chat-apps.md
+++ b/docs/chat-apps.md
@@ -13,6 +13,7 @@ Talk to your picoclaw through Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk,
| **Telegram** | ⭐ Easy | Recommended, voice-to-text, long polling (no public IP needed) | [Docs](../channels/telegram/README.md) |
| **Discord** | ⭐ Easy | Socket Mode, group/DM support, rich bot ecosystem | [Docs](../channels/discord/README.md) |
| **WhatsApp** | ⭐ Easy | Native (QR scan) or Bridge URL | [Docs](#whatsapp) |
+| **Weixin** | ⭐ Easy | Native QR scan (Tencent iLink API) | [Docs](../channels/weixin/README.md) |
| **Slack** | ⭐ Easy | **Socket Mode** (no public IP needed), enterprise | [Docs](../channels/slack/README.md) |
| **Matrix** | ⭐⭐ Medium | Federated protocol, self-hosting supported | [Docs](../channels/matrix/README.md) |
| **QQ** | ⭐⭐ Medium | Official bot API, Chinese community | [Docs](../channels/qq/README.md) |
@@ -169,6 +170,39 @@ If `session_store_path` is empty, the session is stored in `/whatsapp
+
+Weixin (WeChat Personal)
+
+PicoClaw supports connecting to your personal WeChat account using the official Tencent iLink API.
+
+**1. Login**
+Run the interactive QR login flow:
+```bash
+picoclaw onboard weixin
+```
+Scan the printed QR code with your WeChat mobile app. On success, the token is saved to your config.
+
+**2. Configure**
+(Optional) Update `allow_from` with your WeChat User ID to restrict who can message the bot:
+```json
+{
+ "channels": {
+ "weixin": {
+ "enabled": true,
+ "token": "YOUR_TOKEN",
+ "allow_from": ["YOUR_USER_ID"]
+ }
+ }
+}
+```
+
+**3. Run**
+```bash
+picoclaw gateway
+```
+
+
+
QQ
diff --git a/docs/zh/chat-apps.md b/docs/zh/chat-apps.md
index a0206a7d6..2d6e55c3d 100644
--- a/docs/zh/chat-apps.md
+++ b/docs/zh/chat-apps.md
@@ -15,6 +15,7 @@ PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方
| **Telegram** | ⭐ 简单 | 推荐,支持语音转文字,长轮询无需公网 | [查看文档](../channels/telegram/README.zh.md) |
| **Discord** | ⭐ 简单 | Socket Mode,支持群组/私信,Bot 生态成熟 | [查看文档](../channels/discord/README.zh.md) |
| **WhatsApp** | ⭐ 简单 | 原生 (QR 扫码) 或 Bridge URL | [查看文档](#whatsapp) |
+| **Weixin** | ⭐ 简单 | 原生扫码登录 (腾讯 iLink API) | [查看文档](../channels/weixin/README.zh.md) |
| **Slack** | ⭐ 简单 | **Socket Mode** (无需公网 IP),企业级支持 | [查看文档](../channels/slack/README.zh.md) |
| **Matrix** | ⭐⭐ 中等 | 联邦协议,支持自建 homeserver 与公开服务器 | [查看文档](../channels/matrix/README.zh.md) |
| **QQ** | ⭐⭐ 中等 | 官方机器人 API,适合国内社群 | [查看文档](../channels/qq/README.zh.md) |
@@ -170,6 +171,39 @@ PicoClaw 支持两种 WhatsApp 连接方式:
+
+Weixin (微信个人号)
+
+PicoClaw 支持使用腾讯官方 iLink API 连接您的个人微信账号。
+
+**1. 登录**
+运行交互式扫码登录流程:
+```bash
+picoclaw onboard weixin
+```
+在终端扫描打印出的二维码。登录成功后,Token 将自动保存到您的配置文件中。
+
+**2. 配置**
+(可选)更新 `allow_from` 填写微信 User ID,以限制哪些用户可以给机器人发消息:
+```json
+{
+ "channels": {
+ "weixin": {
+ "enabled": true,
+ "token": "你的_TOKEN",
+ "allow_from": ["你的_USER_ID"]
+ }
+ }
+}
+```
+
+**3. 运行**
+```bash
+picoclaw gateway
+```
+
+
+
Matrix
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..7f9b3b5c6
--- /dev/null
+++ b/pkg/channels/weixin/api.go
@@ -0,0 +1,241 @@
+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: %w", proxy, err)
+ }
+
+ // Clone the default transport so we preserve all default settings (TLS, HTTP/2, timeouts, keep-alives)
+ if defaultTransport, ok := http.DefaultTransport.(*http.Transport); ok {
+ transport := defaultTransport.Clone()
+ transport.Proxy = http.ProxyURL(proxyURL)
+ client.Transport = transport
+ } else {
+ // Fallback: preserve previous behavior if DefaultTransport is not the expected type
+ 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 any, responseObj any) 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" {
+ // Use direct map assignment to send exact header name the Tencent API expects
+ req.Header["iLink-App-ClientVersion"] = []string{"1"}
+ }
+ } else {
+ req.Header["AuthorizationType"] = []string{"ilink_bot_token"}
+ req.Header["X-WECHAT-UIN"] = []string{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) (*SendMessageResp, error) {
+ req.BaseInfo = BaseInfo{ChannelVersion: "1.0.2"}
+ var resp SendMessageResp
+ if err := c.post(ctx, "ilink/bot/sendmessage", req, &resp); err != nil {
+ return nil, err
+ }
+ return &resp, nil
+}
+
+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) GetConfig(ctx context.Context, req GetConfigReq) (*GetConfigResp, error) {
+ req.BaseInfo = BaseInfo{ChannelVersion: "1.0.2"}
+ var resp GetConfigResp
+ if err := c.post(ctx, "ilink/bot/getconfig", req, &resp); err != nil {
+ return nil, err
+ }
+ return &resp, nil
+}
+
+func (c *ApiClient) SendTyping(ctx context.Context, req SendTypingReq) (*SendTypingResp, error) {
+ req.BaseInfo = BaseInfo{ChannelVersion: "1.0.2"}
+ var resp SendTypingResp
+ if err := c.post(ctx, "ilink/bot/sendtyping", req, &resp); err != nil {
+ return nil, err
+ }
+ return &resp, 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["iLink-App-ClientVersion"] = []string{"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..52ec2a6df
--- /dev/null
+++ b/pkg/channels/weixin/auth.go
@@ -0,0 +1,111 @@
+package weixin
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "time"
+
+ "github.com/mdp/qrterminal/v3"
+
+ "github.com/sipeed/picoclaw/pkg/logger"
+)
+
+// AuthFlowOpts configures the interactive QR login flow.
+type AuthFlowOpts struct {
+ BaseURL string
+ BotType string
+ Timeout time.Duration
+ Proxy string
+}
+
+// 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, "", opts.Proxy)
+ 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]any{
+ "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]any{
+ "status": statusResp.Status,
+ })
+ }
+ }
+ }
+}
diff --git a/pkg/channels/weixin/media.go b/pkg/channels/weixin/media.go
new file mode 100644
index 000000000..0332f48f6
--- /dev/null
+++ b/pkg/channels/weixin/media.go
@@ -0,0 +1,1037 @@
+package weixin
+
+import (
+ "bytes"
+ "context"
+ "crypto/aes"
+ "crypto/md5"
+ "crypto/rand"
+ "encoding/base64"
+ "encoding/hex"
+ "fmt"
+ "io"
+ "mime"
+ "net/http"
+ "net/url"
+ "os"
+ "os/exec"
+ "path"
+ "path/filepath"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/h2non/filetype"
+
+ "github.com/sipeed/picoclaw/pkg/bus"
+ basechannels "github.com/sipeed/picoclaw/pkg/channels"
+ "github.com/sipeed/picoclaw/pkg/logger"
+ "github.com/sipeed/picoclaw/pkg/media"
+)
+
+const (
+ weixinMediaMaxBytes = 100 << 20
+ weixinTypingKeepAlive = 5 * time.Second
+ weixinUploadRetryMax = 3
+ weixinVoiceTranscodeTimeout = 15 * time.Second
+)
+
+type uploadedFileInfo struct {
+ downloadParam string
+ aesKeyHex string
+ fileSize int64
+ cipherSize int64
+ filename string
+}
+
+func pkcs7Pad(src []byte, blockSize int) []byte {
+ padding := blockSize - len(src)%blockSize
+ if padding == 0 {
+ padding = blockSize
+ }
+ out := make([]byte, len(src)+padding)
+ copy(out, src)
+ for i := len(src); i < len(out); i++ {
+ out[i] = byte(padding)
+ }
+ return out
+}
+
+func pkcs7Unpad(src []byte, blockSize int) ([]byte, error) {
+ if len(src) == 0 || len(src)%blockSize != 0 {
+ return nil, fmt.Errorf("invalid padded data size %d", len(src))
+ }
+ padding := int(src[len(src)-1])
+ if padding <= 0 || padding > blockSize || padding > len(src) {
+ return nil, fmt.Errorf("invalid padding size %d", padding)
+ }
+ for i := len(src) - padding; i < len(src); i++ {
+ if src[i] != byte(padding) {
+ return nil, fmt.Errorf("invalid padding content")
+ }
+ }
+ return src[:len(src)-padding], nil
+}
+
+func encryptAESECB(plaintext, key []byte) ([]byte, error) {
+ block, err := aes.NewCipher(key)
+ if err != nil {
+ return nil, err
+ }
+ padded := pkcs7Pad(plaintext, block.BlockSize())
+ out := make([]byte, len(padded))
+ for i := 0; i < len(padded); i += block.BlockSize() {
+ block.Encrypt(out[i:i+block.BlockSize()], padded[i:i+block.BlockSize()])
+ }
+ return out, nil
+}
+
+func decryptAESECB(ciphertext, key []byte) ([]byte, error) {
+ block, err := aes.NewCipher(key)
+ if err != nil {
+ return nil, err
+ }
+ if len(ciphertext)%block.BlockSize() != 0 {
+ return nil, fmt.Errorf("invalid ciphertext size %d", len(ciphertext))
+ }
+ out := make([]byte, len(ciphertext))
+ for i := 0; i < len(ciphertext); i += block.BlockSize() {
+ block.Decrypt(out[i:i+block.BlockSize()], ciphertext[i:i+block.BlockSize()])
+ }
+ return pkcs7Unpad(out, block.BlockSize())
+}
+
+func parseWeixinMediaAESKey(aesKeyBase64 string) ([]byte, error) {
+ decoded, err := base64.StdEncoding.DecodeString(aesKeyBase64)
+ if err != nil {
+ return nil, err
+ }
+ if len(decoded) == 16 {
+ return decoded, nil
+ }
+ if len(decoded) == 32 {
+ if raw, err := hex.DecodeString(string(decoded)); err == nil && len(raw) == 16 {
+ return raw, nil
+ }
+ }
+ return nil, fmt.Errorf("unsupported aes_key length %d", len(decoded))
+}
+
+func imageAESKey(img *ImageItem) ([]byte, bool, error) {
+ if img == nil {
+ return nil, false, nil
+ }
+ if img.Aeskey != "" {
+ raw, err := hex.DecodeString(img.Aeskey)
+ if err != nil {
+ return nil, false, err
+ }
+ return raw, true, nil
+ }
+ if img.Media != nil && img.Media.AesKey != "" {
+ raw, err := parseWeixinMediaAESKey(img.Media.AesKey)
+ if err != nil {
+ return nil, false, err
+ }
+ return raw, true, nil
+ }
+ return nil, false, nil
+}
+
+func genericMediaAESKey(mediaRef *CDNMedia) ([]byte, error) {
+ if mediaRef == nil || mediaRef.AesKey == "" {
+ return nil, fmt.Errorf("missing aes_key")
+ }
+ return parseWeixinMediaAESKey(mediaRef.AesKey)
+}
+
+func aesEcbPaddedSize(size int64) int64 {
+ return (size/16 + 1) * 16
+}
+
+func randomHex(n int) (string, error) {
+ buf := make([]byte, n)
+ if _, err := rand.Read(buf); err != nil {
+ return "", err
+ }
+ return hex.EncodeToString(buf), nil
+}
+
+func buildCDNDownloadURL(base, encryptedQueryParam string) string {
+ return strings.TrimRight(base, "/") +
+ "/download?encrypted_query_param=" + url.QueryEscape(encryptedQueryParam)
+}
+
+func buildCDNUploadURL(base, uploadParam, filekey string) string {
+ return strings.TrimRight(base, "/") +
+ "/upload?encrypted_query_param=" + url.QueryEscape(uploadParam) +
+ "&filekey=" + url.QueryEscape(filekey)
+}
+
+func (c *WeixinChannel) downloadCDNBuffer(ctx context.Context, encryptedQueryParam string) ([]byte, error) {
+ req, err := http.NewRequestWithContext(
+ ctx,
+ http.MethodGet,
+ buildCDNDownloadURL(c.cdnBaseURL(), encryptedQueryParam),
+ nil,
+ )
+ if err != nil {
+ return nil, err
+ }
+ resp, err := c.api.HttpClient.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
+ return nil, fmt.Errorf("cdn download HTTP %d: %s", resp.StatusCode, string(body))
+ }
+
+ data, err := io.ReadAll(io.LimitReader(resp.Body, weixinMediaMaxBytes+1))
+ if err != nil {
+ return nil, err
+ }
+ if len(data) > weixinMediaMaxBytes {
+ return nil, fmt.Errorf("cdn media too large: %d bytes", len(data))
+ }
+ return data, nil
+}
+
+func (c *WeixinChannel) downloadAndDecryptCDNBuffer(
+ ctx context.Context,
+ encryptedQueryParam string,
+ key []byte,
+) ([]byte, error) {
+ data, err := c.downloadCDNBuffer(ctx, encryptedQueryParam)
+ if err != nil {
+ return nil, err
+ }
+ if len(key) == 0 {
+ return data, nil
+ }
+ return decryptAESECB(data, key)
+}
+
+func detectMediaMetadata(data []byte, fallbackName, fallbackContentType string) (string, string) {
+ contentType := strings.TrimSpace(fallbackContentType)
+ ext := filepath.Ext(fallbackName)
+ if kind, err := filetype.Match(data); err == nil && kind != filetype.Unknown {
+ contentType = kind.MIME.Value
+ if kind.Extension != "" {
+ ext = "." + kind.Extension
+ }
+ }
+ if contentType == "" && ext != "" {
+ contentType = mime.TypeByExtension(strings.ToLower(ext))
+ }
+ if contentType == "" {
+ contentType = http.DetectContentType(data)
+ }
+ if ext == "" && contentType != "" {
+ if exts, err := mime.ExtensionsByType(contentType); err == nil && len(exts) > 0 {
+ ext = exts[0]
+ }
+ }
+
+ filename := sanitizeFilename(fallbackName)
+ if filename == "" {
+ filename = "media"
+ }
+ if filepath.Ext(filename) == "" && ext != "" {
+ filename += ext
+ }
+ return filename, contentType
+}
+
+func sanitizeFilename(name string) string {
+ name = filepath.Base(strings.TrimSpace(name))
+ if name == "." || name == "/" || name == "" {
+ return ""
+ }
+ return name
+}
+
+func writeManagedTempFile(prefix, filename string, data []byte) (string, error) {
+ if err := os.MkdirAll(media.TempDir(), 0o700); err != nil {
+ return "", err
+ }
+ pattern := prefix + "-*"
+ if ext := filepath.Ext(filename); ext != "" {
+ pattern += ext
+ }
+ f, err := os.CreateTemp(media.TempDir(), pattern)
+ if err != nil {
+ return "", err
+ }
+ defer f.Close()
+ if _, err := f.Write(data); err != nil {
+ os.Remove(f.Name())
+ return "", err
+ }
+ return f.Name(), nil
+}
+
+func (c *WeixinChannel) storeInboundBytes(
+ chatID,
+ messageID,
+ filename,
+ contentType string,
+ data []byte,
+) (string, error) {
+ store := c.GetMediaStore()
+ if store == nil {
+ return "", fmt.Errorf("no media store available")
+ }
+ filename, contentType = detectMediaMetadata(data, filename, contentType)
+ tmpPath, err := writeManagedTempFile("weixin-inbound", filename, data)
+ if err != nil {
+ return "", err
+ }
+ ref, err := store.Store(tmpPath, media.MediaMeta{
+ Filename: filename,
+ ContentType: contentType,
+ Source: "weixin",
+ }, basechannels.BuildMediaScope("weixin", chatID, messageID))
+ if err != nil {
+ os.Remove(tmpPath)
+ return "", err
+ }
+ return ref, nil
+}
+
+func isDownloadableMediaItem(item *MessageItem) bool {
+ if item == nil {
+ return false
+ }
+
+ switch item.Type {
+ case MessageItemTypeImage:
+ return item.ImageItem != nil && item.ImageItem.Media != nil && item.ImageItem.Media.EncryptQueryParam != ""
+ case MessageItemTypeVideo:
+ return item.VideoItem != nil && item.VideoItem.Media != nil && item.VideoItem.Media.EncryptQueryParam != ""
+ case MessageItemTypeFile:
+ return item.FileItem != nil && item.FileItem.Media != nil && item.FileItem.Media.EncryptQueryParam != ""
+ case MessageItemTypeVoice:
+ return item.VoiceItem != nil &&
+ item.VoiceItem.Media != nil &&
+ item.VoiceItem.Media.EncryptQueryParam != "" &&
+ strings.TrimSpace(item.VoiceItem.Text) == ""
+ default:
+ return false
+ }
+}
+
+func selectInboundMediaItem(msg WeixinMessage) *MessageItem {
+ priorities := []int{
+ MessageItemTypeImage,
+ MessageItemTypeVideo,
+ MessageItemTypeFile,
+ MessageItemTypeVoice,
+ }
+
+ for _, want := range priorities {
+ for i := range msg.ItemList {
+ item := &msg.ItemList[i]
+ if item.Type == want && isDownloadableMediaItem(item) {
+ return item
+ }
+ }
+ }
+
+ for i := range msg.ItemList {
+ item := &msg.ItemList[i]
+ if item.Type != MessageItemTypeText || item.RefMsg == nil || item.RefMsg.MessageItem == nil {
+ continue
+ }
+ if isDownloadableMediaItem(item.RefMsg.MessageItem) {
+ return item.RefMsg.MessageItem
+ }
+ }
+
+ return nil
+}
+
+func tryTranscodeSilkToWAV(ctx context.Context, silk []byte) ([]byte, error) {
+ decoders := []struct {
+ name string
+ args func(inputPath, outputPath string) []string
+ }{
+ {
+ name: "silk_v3_decoder",
+ args: func(inputPath, outputPath string) []string { return []string{inputPath, outputPath, "24000"} },
+ },
+ {
+ name: "silk_decoder",
+ args: func(inputPath, outputPath string) []string { return []string{inputPath, outputPath, "24000"} },
+ },
+ {
+ name: "ffmpeg",
+ args: func(inputPath, outputPath string) []string {
+ return []string{"-y", "-i", inputPath, outputPath}
+ },
+ },
+ }
+
+ for _, decoder := range decoders {
+ bin, err := exec.LookPath(decoder.name)
+ if err != nil {
+ continue
+ }
+
+ tmpIn, err := writeManagedTempFile("weixin-voice", "voice.silk", silk)
+ if err != nil {
+ return nil, err
+ }
+ tmpOut := filepath.Join(media.TempDir(), "weixin-voice-"+uuid.New().String()+".wav")
+ wav, ok := func() ([]byte, bool) {
+ defer os.Remove(tmpIn)
+ defer os.Remove(tmpOut)
+
+ runCtx, cancel := context.WithTimeout(ctx, weixinVoiceTranscodeTimeout)
+ cmd := exec.CommandContext(runCtx, bin, decoder.args(tmpIn, tmpOut)...)
+ out, runErr := cmd.CombinedOutput()
+ cancel()
+ if runErr != nil {
+ logger.DebugCF("weixin", "SILK transcode command failed", map[string]any{
+ "decoder": decoder.name,
+ "error": runErr.Error(),
+ "output": strings.TrimSpace(string(out)),
+ })
+ return nil, false
+ }
+
+ wav, readErr := os.ReadFile(tmpOut)
+ if readErr != nil {
+ logger.DebugCF("weixin", "Failed to read transcoded WAV", map[string]any{
+ "decoder": decoder.name,
+ "error": readErr.Error(),
+ })
+ return nil, false
+ }
+ return wav, len(wav) > 0
+ }()
+ if ok {
+ return wav, nil
+ }
+ }
+
+ return nil, fmt.Errorf("no SILK decoder available")
+}
+
+func (c *WeixinChannel) downloadMediaFromItem(
+ ctx context.Context,
+ chatID,
+ messageID string,
+ item *MessageItem,
+) (string, error) {
+ if item == nil {
+ return "", nil
+ }
+
+ switch item.Type {
+ case MessageItemTypeImage:
+ key, ok, err := imageAESKey(item.ImageItem)
+ if err != nil {
+ return "", err
+ }
+ data, err := c.downloadAndDecryptCDNBuffer(ctx, item.ImageItem.Media.EncryptQueryParam, func() []byte {
+ if ok {
+ return key
+ }
+ return nil
+ }())
+ if err != nil {
+ return "", err
+ }
+ return c.storeInboundBytes(chatID, messageID, "image", "", data)
+
+ case MessageItemTypeVoice:
+ key, err := genericMediaAESKey(item.VoiceItem.Media)
+ if err != nil {
+ return "", err
+ }
+ silk, err := c.downloadAndDecryptCDNBuffer(ctx, item.VoiceItem.Media.EncryptQueryParam, key)
+ if err != nil {
+ return "", err
+ }
+ if wav, err := tryTranscodeSilkToWAV(ctx, silk); err == nil && len(wav) > 0 {
+ return c.storeInboundBytes(chatID, messageID, "voice.wav", "audio/wav", wav)
+ }
+ return c.storeInboundBytes(chatID, messageID, "voice.silk", "audio/silk", silk)
+
+ case MessageItemTypeFile:
+ key, err := genericMediaAESKey(item.FileItem.Media)
+ if err != nil {
+ return "", err
+ }
+ data, err := c.downloadAndDecryptCDNBuffer(ctx, item.FileItem.Media.EncryptQueryParam, key)
+ if err != nil {
+ return "", err
+ }
+ filename := item.FileItem.FileName
+ if filename == "" {
+ filename = "file.bin"
+ }
+ contentType := mime.TypeByExtension(strings.ToLower(filepath.Ext(filename)))
+ return c.storeInboundBytes(chatID, messageID, filename, contentType, data)
+
+ case MessageItemTypeVideo:
+ key, err := genericMediaAESKey(item.VideoItem.Media)
+ if err != nil {
+ return "", err
+ }
+ data, err := c.downloadAndDecryptCDNBuffer(ctx, item.VideoItem.Media.EncryptQueryParam, key)
+ if err != nil {
+ return "", err
+ }
+ return c.storeInboundBytes(chatID, messageID, "video.mp4", "video/mp4", data)
+ }
+
+ return "", nil
+}
+
+func outboundMediaKind(partType, filename, contentType string) int {
+ switch strings.ToLower(strings.TrimSpace(partType)) {
+ case "image":
+ return UploadMediaTypeImage
+ case "video":
+ return UploadMediaTypeVideo
+ }
+
+ ct := strings.ToLower(contentType)
+ switch {
+ case strings.HasPrefix(ct, "image/"):
+ return UploadMediaTypeImage
+ case strings.HasPrefix(ct, "video/"):
+ return UploadMediaTypeVideo
+ default:
+ return UploadMediaTypeFile
+ }
+}
+
+func detectLocalContentType(localPath, hintContentType string) string {
+ if strings.TrimSpace(hintContentType) != "" {
+ return hintContentType
+ }
+ if kind, err := filetype.MatchFile(localPath); err == nil && kind != filetype.Unknown {
+ return kind.MIME.Value
+ }
+ if ext := filepath.Ext(localPath); ext != "" {
+ if ct := mime.TypeByExtension(strings.ToLower(ext)); ct != "" {
+ return ct
+ }
+ }
+ return "application/octet-stream"
+}
+
+func downloadFilenameFromURL(rawURL, fallback string) string {
+ if fallback = sanitizeFilename(fallback); fallback != "" {
+ return fallback
+ }
+ parsed, err := url.Parse(rawURL)
+ if err == nil {
+ if base := sanitizeFilename(path.Base(parsed.Path)); base != "" {
+ return base
+ }
+ }
+ return "remote-media"
+}
+
+func (c *WeixinChannel) downloadRemoteMediaToTemp(
+ ctx context.Context,
+ rawURL,
+ fallbackName string,
+) (string, string, string, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
+ if err != nil {
+ return "", "", "", err
+ }
+ resp, err := c.api.HttpClient.Do(req)
+ if err != nil {
+ return "", "", "", err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
+ return "", "", "", fmt.Errorf("remote media HTTP %d: %s", resp.StatusCode, string(body))
+ }
+
+ data, err := io.ReadAll(io.LimitReader(resp.Body, weixinMediaMaxBytes+1))
+ if err != nil {
+ return "", "", "", err
+ }
+ if len(data) > weixinMediaMaxBytes {
+ return "", "", "", fmt.Errorf("remote media too large: %d bytes", len(data))
+ }
+
+ filename, contentType := detectMediaMetadata(
+ data,
+ downloadFilenameFromURL(rawURL, fallbackName),
+ resp.Header.Get("Content-Type"),
+ )
+ tmpPath, err := writeManagedTempFile("weixin-remote", filename, data)
+ if err != nil {
+ return "", "", "", err
+ }
+ return tmpPath, filename, contentType, nil
+}
+
+func (c *WeixinChannel) resolveOutboundPart(
+ ctx context.Context,
+ part bus.MediaPart,
+) (string, string, string, func(), error) {
+ cleanup := func() {}
+ filename := sanitizeFilename(part.Filename)
+ contentType := strings.TrimSpace(part.ContentType)
+
+ switch {
+ case strings.HasPrefix(part.Ref, "http://") || strings.HasPrefix(part.Ref, "https://"):
+ localPath, name, ct, err := c.downloadRemoteMediaToTemp(ctx, part.Ref, filename)
+ if err != nil {
+ return "", "", "", cleanup, err
+ }
+ return localPath, name, ct, func() { os.Remove(localPath) }, nil
+
+ case strings.HasPrefix(part.Ref, "media://"):
+ store := c.GetMediaStore()
+ if store == nil {
+ return "", "", "", cleanup, fmt.Errorf("no media store available")
+ }
+ localPath, meta, err := store.ResolveWithMeta(part.Ref)
+ if err != nil {
+ return "", "", "", cleanup, err
+ }
+ if filename == "" {
+ filename = sanitizeFilename(meta.Filename)
+ }
+ if contentType == "" {
+ contentType = meta.ContentType
+ }
+ if strings.HasPrefix(localPath, "http://") || strings.HasPrefix(localPath, "https://") {
+ tmpPath, name, ct, err := c.downloadRemoteMediaToTemp(ctx, localPath, filename)
+ if err != nil {
+ return "", "", "", cleanup, err
+ }
+ return tmpPath, name, ct, func() { os.Remove(tmpPath) }, nil
+ }
+ if filename == "" {
+ filename = sanitizeFilename(filepath.Base(localPath))
+ }
+ if contentType == "" {
+ contentType = detectLocalContentType(localPath, "")
+ }
+ return localPath, filename, contentType, cleanup, nil
+
+ case strings.HasPrefix(part.Ref, "file://"):
+ u, err := url.Parse(part.Ref)
+ if err != nil {
+ return "", "", "", cleanup, err
+ }
+ localPath := u.Path
+ if filename == "" {
+ filename = sanitizeFilename(filepath.Base(localPath))
+ }
+ if contentType == "" {
+ contentType = detectLocalContentType(localPath, "")
+ }
+ return localPath, filename, contentType, cleanup, nil
+
+ default:
+ localPath := part.Ref
+ if filename == "" {
+ filename = sanitizeFilename(filepath.Base(localPath))
+ }
+ if contentType == "" {
+ contentType = detectLocalContentType(localPath, "")
+ }
+ return localPath, filename, contentType, cleanup, nil
+ }
+}
+
+func (c *WeixinChannel) uploadLocalFile(
+ ctx context.Context,
+ localPath,
+ filename,
+ toUserID string,
+ mediaType int,
+) (*uploadedFileInfo, error) {
+ data, err := os.ReadFile(localPath)
+ if err != nil {
+ return nil, err
+ }
+ if len(data) > weixinMediaMaxBytes {
+ return nil, fmt.Errorf("media too large: %d bytes", len(data))
+ }
+
+ filekey, err := randomHex(16)
+ if err != nil {
+ return nil, err
+ }
+ aesKey := make([]byte, 16)
+ if _, readErr := rand.Read(aesKey); readErr != nil {
+ return nil, readErr
+ }
+ aesKeyHex := hex.EncodeToString(aesKey)
+ rawMD5 := md5.Sum(data)
+
+ resp, err := c.api.GetUploadUrl(ctx, GetUploadUrlReq{
+ Filekey: filekey,
+ MediaType: mediaType,
+ ToUserID: toUserID,
+ Rawsize: int64(len(data)),
+ RawfileMD5: hex.EncodeToString(rawMD5[:]),
+ Filesize: aesEcbPaddedSize(int64(len(data))),
+ NoNeedThumb: true,
+ Aeskey: aesKeyHex,
+ })
+ if err != nil {
+ return nil, err
+ }
+ if resp == nil {
+ return nil, fmt.Errorf("getuploadurl returned nil response")
+ }
+ if resp.Ret != 0 || resp.Errcode != 0 {
+ if isSessionExpiredStatus(resp.Ret, resp.Errcode) {
+ c.pauseSession("getuploadurl", resp.Ret, resp.Errcode, resp.Errmsg)
+ }
+ return nil, fmt.Errorf("getuploadurl failed: ret=%d errcode=%d errmsg=%s", resp.Ret, resp.Errcode, resp.Errmsg)
+ }
+ if strings.TrimSpace(resp.UploadParam) == "" {
+ return nil, fmt.Errorf("getuploadurl returned empty upload_param")
+ }
+
+ downloadParam, err := c.uploadBufferToCDN(ctx, data, resp.UploadParam, filekey, aesKey)
+ if err != nil {
+ return nil, err
+ }
+
+ return &uploadedFileInfo{
+ downloadParam: downloadParam,
+ aesKeyHex: aesKeyHex,
+ fileSize: int64(len(data)),
+ cipherSize: aesEcbPaddedSize(int64(len(data))),
+ filename: filename,
+ }, nil
+}
+
+func (c *WeixinChannel) uploadBufferToCDN(
+ ctx context.Context,
+ plaintext []byte,
+ uploadParam,
+ filekey string,
+ aesKey []byte,
+) (string, error) {
+ ciphertext, err := encryptAESECB(plaintext, aesKey)
+ if err != nil {
+ return "", err
+ }
+
+ uploadURL := buildCDNUploadURL(c.cdnBaseURL(), uploadParam, filekey)
+ var lastErr error
+
+ for attempt := 1; attempt <= weixinUploadRetryMax; attempt++ {
+ req, reqErr := http.NewRequestWithContext(ctx, http.MethodPost, uploadURL, bytes.NewReader(ciphertext))
+ if reqErr != nil {
+ return "", reqErr
+ }
+ req.Header.Set("Content-Type", "application/octet-stream")
+
+ resp, doErr := c.api.HttpClient.Do(req)
+ if doErr != nil {
+ lastErr = doErr
+ } else {
+ func() {
+ defer resp.Body.Close()
+ if resp.StatusCode >= 400 && resp.StatusCode < 500 {
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
+ lastErr = fmt.Errorf(
+ "cdn upload client error %d: %s",
+ resp.StatusCode,
+ strings.TrimSpace(string(body)),
+ )
+ return
+ }
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
+ lastErr = fmt.Errorf(
+ "cdn upload server error %d: %s",
+ resp.StatusCode,
+ strings.TrimSpace(string(body)),
+ )
+ return
+ }
+ if encrypted := strings.TrimSpace(resp.Header.Get("X-Encrypted-Param")); encrypted != "" {
+ lastErr = nil
+ uploadParam = encrypted
+ return
+ }
+ lastErr = fmt.Errorf("cdn upload missing x-encrypted-param header")
+ }()
+ }
+
+ if lastErr == nil {
+ return uploadParam, nil
+ }
+ if strings.Contains(lastErr.Error(), "client error") || attempt == weixinUploadRetryMax {
+ break
+ }
+ }
+
+ return "", lastErr
+}
+
+func (c *WeixinChannel) sendMessageItem(
+ ctx context.Context,
+ toUserID,
+ contextToken string,
+ item MessageItem,
+) error {
+ resp, err := c.api.SendMessage(ctx, SendMessageReq{
+ Msg: WeixinMessage{
+ ToUserID: toUserID,
+ ClientID: "picoclaw-" + uuid.New().String(),
+ MessageType: MessageTypeBot,
+ MessageState: MessageStateFinish,
+ ItemList: []MessageItem{item},
+ ContextToken: contextToken,
+ },
+ })
+ if err != nil {
+ return err
+ }
+ if resp == nil {
+ return fmt.Errorf("sendmessage returned nil response")
+ }
+ if resp.Ret != 0 || resp.Errcode != 0 {
+ if isSessionExpiredStatus(resp.Ret, resp.Errcode) {
+ c.pauseSession("sendmessage", resp.Ret, resp.Errcode, resp.Errmsg)
+ }
+ return fmt.Errorf("sendmessage failed: ret=%d errcode=%d errmsg=%s", resp.Ret, resp.Errcode, resp.Errmsg)
+ }
+ return nil
+}
+
+func (c *WeixinChannel) sendTextMessage(
+ ctx context.Context,
+ toUserID,
+ contextToken,
+ text string,
+) error {
+ if strings.TrimSpace(text) == "" {
+ return nil
+ }
+ return c.sendMessageItem(ctx, toUserID, contextToken, MessageItem{
+ Type: MessageItemTypeText,
+ TextItem: &TextItem{
+ Text: text,
+ },
+ })
+}
+
+func encodeWeixinOutboundAESKey(aesKeyHex string) string {
+ return base64.StdEncoding.EncodeToString([]byte(aesKeyHex))
+}
+
+func (c *WeixinChannel) sendUploadedMedia(
+ ctx context.Context,
+ toUserID,
+ contextToken,
+ caption string,
+ mediaType int,
+ uploaded *uploadedFileInfo,
+) error {
+ if err := c.sendTextMessage(ctx, toUserID, contextToken, caption); err != nil {
+ return err
+ }
+
+ mediaRef := &CDNMedia{
+ EncryptQueryParam: uploaded.downloadParam,
+ AesKey: encodeWeixinOutboundAESKey(uploaded.aesKeyHex),
+ EncryptType: 1,
+ }
+
+ switch mediaType {
+ case UploadMediaTypeImage:
+ return c.sendMessageItem(ctx, toUserID, contextToken, MessageItem{
+ Type: MessageItemTypeImage,
+ ImageItem: &ImageItem{
+ Media: mediaRef,
+ MidSize: uploaded.cipherSize,
+ },
+ })
+
+ case UploadMediaTypeVideo:
+ return c.sendMessageItem(ctx, toUserID, contextToken, MessageItem{
+ Type: MessageItemTypeVideo,
+ VideoItem: &VideoItem{
+ Media: mediaRef,
+ VideoSize: uploaded.cipherSize,
+ },
+ })
+
+ default:
+ return c.sendMessageItem(ctx, toUserID, contextToken, MessageItem{
+ Type: MessageItemTypeFile,
+ FileItem: &FileItem{
+ Media: mediaRef,
+ FileName: uploaded.filename,
+ Len: fmt.Sprintf("%d", uploaded.fileSize),
+ },
+ })
+ }
+}
+
+func (c *WeixinChannel) sendTypingStatus(
+ ctx context.Context,
+ chatID,
+ typingTicket string,
+ status int,
+) error {
+ resp, err := c.api.SendTyping(ctx, SendTypingReq{
+ IlinkUserID: chatID,
+ TypingTicket: typingTicket,
+ Status: status,
+ })
+ if err != nil {
+ return err
+ }
+ if resp == nil {
+ return fmt.Errorf("sendtyping returned nil response")
+ }
+ if resp.Ret != 0 || resp.Errcode != 0 {
+ if isSessionExpiredStatus(resp.Ret, resp.Errcode) {
+ c.pauseSession("sendtyping", resp.Ret, resp.Errcode, resp.Errmsg)
+ }
+ return fmt.Errorf("sendtyping failed: ret=%d errcode=%d errmsg=%s", resp.Ret, resp.Errcode, resp.Errmsg)
+ }
+ return nil
+}
+
+// StartTyping implements channels.TypingCapable.
+func (c *WeixinChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {
+ if strings.TrimSpace(chatID) == "" {
+ return func() {}, nil
+ }
+ if c.remainingPause() > 0 {
+ return func() {}, nil
+ }
+
+ ticket, err := c.getTypingTicket(ctx, chatID)
+ if err != nil {
+ if ticket == "" {
+ return func() {}, err
+ }
+ logger.DebugCF("weixin", "GetConfig refresh failed; using cached typing ticket", map[string]any{
+ "chat_id": chatID,
+ "error": err.Error(),
+ })
+ }
+ if ticket == "" {
+ return func() {}, nil
+ }
+
+ typingCtx, cancel := context.WithCancel(ctx)
+ var once sync.Once
+ stop := func() {
+ once.Do(func() {
+ cancel()
+ stopCtx, stopCancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer stopCancel()
+ if err := c.sendTypingStatus(stopCtx, chatID, ticket, TypingStatusCancel); err != nil {
+ logger.DebugCF("weixin", "Failed to cancel typing indicator", map[string]any{
+ "chat_id": chatID,
+ "error": err.Error(),
+ })
+ }
+ })
+ }
+
+ if err := c.sendTypingStatus(typingCtx, chatID, ticket, TypingStatusTyping); err != nil {
+ stop()
+ return func() {}, err
+ }
+
+ ticker := time.NewTicker(weixinTypingKeepAlive)
+ go func() {
+ defer ticker.Stop()
+ for {
+ select {
+ case <-typingCtx.Done():
+ return
+ case <-ticker.C:
+ if err := c.sendTypingStatus(typingCtx, chatID, ticket, TypingStatusTyping); err != nil {
+ logger.DebugCF("weixin", "Failed to refresh typing indicator", map[string]any{
+ "chat_id": chatID,
+ "error": err.Error(),
+ })
+ }
+ }
+ }
+ }()
+
+ return stop, nil
+}
+
+// SendMedia implements channels.MediaSender.
+func (c *WeixinChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
+ if !c.IsRunning() {
+ return basechannels.ErrNotRunning
+ }
+ if err := c.ensureSessionActive(); err != nil {
+ return err
+ }
+
+ contextToken := ""
+ if v, ok := c.contextTokens.Load(msg.ChatID); ok {
+ contextToken, _ = v.(string)
+ }
+ if contextToken == "" {
+ return fmt.Errorf(
+ "weixin send media: missing context token for chat %s: %w",
+ msg.ChatID,
+ basechannels.ErrSendFailed,
+ )
+ }
+
+ for _, part := range msg.Parts {
+ localPath, filename, contentType, cleanup, err := c.resolveOutboundPart(ctx, part)
+ if err != nil {
+ logger.ErrorCF("weixin", "Failed to resolve outbound media", map[string]any{
+ "chat_id": msg.ChatID,
+ "ref": part.Ref,
+ "error": err.Error(),
+ })
+ return fmt.Errorf("weixin send media: %w", basechannels.ErrSendFailed)
+ }
+ func() {
+ if cleanup != nil {
+ defer cleanup()
+ }
+
+ kind := outboundMediaKind(part.Type, filename, contentType)
+ uploaded, uploadErr := c.uploadLocalFile(ctx, localPath, filename, msg.ChatID, kind)
+ if uploadErr != nil {
+ err = uploadErr
+ return
+ }
+ err = c.sendUploadedMedia(ctx, msg.ChatID, contextToken, part.Caption, kind, uploaded)
+ }()
+ if err != nil {
+ logger.ErrorCF("weixin", "Failed to send outbound media", map[string]any{
+ "chat_id": msg.ChatID,
+ "ref": part.Ref,
+ "error": err.Error(),
+ })
+ if c.remainingPause() > 0 {
+ return fmt.Errorf("weixin send media: %w", basechannels.ErrSendFailed)
+ }
+ return fmt.Errorf("weixin send media: %w", basechannels.ErrTemporary)
+ }
+ }
+
+ return nil
+}
diff --git a/pkg/channels/weixin/state.go b/pkg/channels/weixin/state.go
new file mode 100644
index 000000000..02c137b83
--- /dev/null
+++ b/pkg/channels/weixin/state.go
@@ -0,0 +1,226 @@
+package weixin
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ basechannels "github.com/sipeed/picoclaw/pkg/channels"
+ "github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/fileutil"
+ "github.com/sipeed/picoclaw/pkg/logger"
+)
+
+const (
+ weixinDefaultCDNBaseURL = "https://novac2c.cdn.weixin.qq.com/c2c"
+ weixinConfigCacheTTL = 24 * time.Hour
+ weixinConfigRetryInitial = 2 * time.Second
+ weixinConfigRetryMax = time.Hour
+ weixinSessionPauseDuration = time.Hour
+ weixinSessionExpiredCode = -14
+)
+
+type typingTicketCacheEntry struct {
+ ticket string
+ nextFetchAt time.Time
+ retryDelay time.Duration
+}
+
+type syncCursorFile struct {
+ GetUpdatesBuf string `json:"get_updates_buf"`
+}
+
+func picoclawHomeDir() string {
+ if home := os.Getenv(config.EnvHome); home != "" {
+ return home
+ }
+ userHome, _ := os.UserHomeDir()
+ return filepath.Join(userHome, ".picoclaw")
+}
+
+func buildWeixinSyncBufPath(cfg config.WeixinConfig) string {
+ key := "default"
+ token := strings.TrimSpace(cfg.Token)
+ if token != "" {
+ sum := sha256.Sum256([]byte(strings.TrimSpace(cfg.BaseURL) + "|" + token))
+ key = hex.EncodeToString(sum[:8])
+ }
+ return filepath.Join(picoclawHomeDir(), "channels", "weixin", "sync", key+".json")
+}
+
+func loadGetUpdatesBuf(path string) (string, error) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return "", nil
+ }
+ return "", err
+ }
+
+ var decoded syncCursorFile
+ if err := json.Unmarshal(data, &decoded); err != nil {
+ return "", err
+ }
+
+ return decoded.GetUpdatesBuf, nil
+}
+
+func saveGetUpdatesBuf(path, cursor string) error {
+ data, err := json.Marshal(syncCursorFile{GetUpdatesBuf: cursor})
+ if err != nil {
+ return err
+ }
+ return fileutil.WriteFileAtomic(path, data, 0o600)
+}
+
+func (c *WeixinChannel) cdnBaseURL() string {
+ if base := strings.TrimSpace(c.config.CDNBaseURL); base != "" {
+ return strings.TrimRight(base, "/")
+ }
+ return weixinDefaultCDNBaseURL
+}
+
+func isSessionExpiredStatus(ret, errcode int) bool {
+ return ret == weixinSessionExpiredCode || errcode == weixinSessionExpiredCode
+}
+
+func (c *WeixinChannel) pauseSession(operation string, ret, errcode int, errmsg string) time.Duration {
+ c.pauseMu.Lock()
+ defer c.pauseMu.Unlock()
+
+ until := time.Now().Add(weixinSessionPauseDuration)
+ if until.After(c.pauseUntil) {
+ c.pauseUntil = until
+ }
+
+ remaining := time.Until(c.pauseUntil)
+ logger.ErrorCF("weixin", "Session expired; pausing Weixin channel", map[string]any{
+ "operation": operation,
+ "ret": ret,
+ "errcode": errcode,
+ "errmsg": errmsg,
+ "until": c.pauseUntil.Format(time.RFC3339),
+ "minutes": int((remaining + time.Minute - 1) / time.Minute),
+ })
+ return remaining
+}
+
+func (c *WeixinChannel) remainingPause() time.Duration {
+ c.pauseMu.Lock()
+ defer c.pauseMu.Unlock()
+
+ if c.pauseUntil.IsZero() {
+ return 0
+ }
+ remaining := time.Until(c.pauseUntil)
+ if remaining <= 0 {
+ c.pauseUntil = time.Time{}
+ return 0
+ }
+ return remaining
+}
+
+func (c *WeixinChannel) waitWhileSessionPaused(ctx context.Context) error {
+ remaining := c.remainingPause()
+ if remaining <= 0 {
+ return nil
+ }
+
+ timer := time.NewTimer(remaining)
+ defer timer.Stop()
+
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-timer.C:
+ return nil
+ }
+}
+
+func (c *WeixinChannel) ensureSessionActive() error {
+ remaining := c.remainingPause()
+ if remaining <= 0 {
+ return nil
+ }
+ return fmt.Errorf(
+ "weixin session paused (%d min remaining): %w",
+ int((remaining+time.Minute-1)/time.Minute),
+ basechannels.ErrSendFailed,
+ )
+}
+
+func (c *WeixinChannel) getTypingTicket(ctx context.Context, userID string) (string, error) {
+ now := time.Now()
+
+ c.typingMu.Lock()
+ entry, ok := c.typingCache[userID]
+ if ok && now.Before(entry.nextFetchAt) {
+ ticket := entry.ticket
+ c.typingMu.Unlock()
+ return ticket, nil
+ }
+ cachedTicket := entry.ticket
+ retryDelay := entry.retryDelay
+ c.typingMu.Unlock()
+
+ contextToken := ""
+ if v, ok := c.contextTokens.Load(userID); ok {
+ contextToken, _ = v.(string)
+ }
+
+ resp, err := c.api.GetConfig(ctx, GetConfigReq{
+ IlinkUserID: userID,
+ ContextToken: contextToken,
+ })
+ if err == nil && resp != nil && resp.Ret == 0 && resp.Errcode == 0 {
+ ticket := strings.TrimSpace(resp.TypingTicket)
+ c.typingMu.Lock()
+ c.typingCache[userID] = typingTicketCacheEntry{
+ ticket: ticket,
+ nextFetchAt: now.Add(weixinConfigCacheTTL),
+ retryDelay: weixinConfigRetryInitial,
+ }
+ c.typingMu.Unlock()
+ return ticket, nil
+ }
+
+ if resp != nil && isSessionExpiredStatus(resp.Ret, resp.Errcode) {
+ c.pauseSession("getconfig", resp.Ret, resp.Errcode, resp.Errmsg)
+ }
+
+ if retryDelay <= 0 {
+ retryDelay = weixinConfigRetryInitial
+ } else {
+ retryDelay *= 2
+ if retryDelay > weixinConfigRetryMax {
+ retryDelay = weixinConfigRetryMax
+ }
+ }
+
+ c.typingMu.Lock()
+ c.typingCache[userID] = typingTicketCacheEntry{
+ ticket: cachedTicket,
+ nextFetchAt: now.Add(retryDelay),
+ retryDelay: retryDelay,
+ }
+ c.typingMu.Unlock()
+
+ if err != nil {
+ return cachedTicket, err
+ }
+ if resp == nil {
+ return cachedTicket, fmt.Errorf("getconfig returned nil response")
+ }
+ return cachedTicket, fmt.Errorf(
+ "getconfig failed: ret=%d errcode=%d errmsg=%s",
+ resp.Ret,
+ resp.Errcode,
+ resp.Errmsg,
+ )
+}
diff --git a/pkg/channels/weixin/types.go b/pkg/channels/weixin/types.go
new file mode 100644
index 000000000..74c6e63c3
--- /dev/null
+++ b/pkg/channels/weixin/types.go
@@ -0,0 +1,210 @@
+package weixin
+
+// BaseInfo is attached to every outgoing CGI request
+type BaseInfo struct {
+ ChannelVersion string `json:"channel_version,omitempty"`
+}
+
+type APIStatus struct {
+ Ret int `json:"ret,omitempty"`
+ Errcode int `json:"errcode,omitempty"`
+ Errmsg string `json:"errmsg,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"` // hex-encoded 16-byte AES key
+ BaseInfo BaseInfo `json:"base_info,omitempty"`
+}
+
+type GetUploadUrlResp struct {
+ APIStatus
+ 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 {
+ APIStatus
+ 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 {
+ APIStatus
+}
+
+type GetConfigReq struct {
+ IlinkUserID string `json:"ilink_user_id,omitempty"`
+ ContextToken string `json:"context_token,omitempty"`
+ BaseInfo BaseInfo `json:"base_info,omitempty"`
+}
+
+type GetConfigResp struct {
+ APIStatus
+ TypingTicket string `json:"typing_ticket,omitempty"`
+}
+
+const (
+ TypingStatusTyping = 1
+ TypingStatusCancel = 2
+)
+
+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 SendTypingResp struct {
+ APIStatus
+}
+
+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..43c776f98
--- /dev/null
+++ b/pkg/channels/weixin/weixin.go
@@ -0,0 +1,359 @@
+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
+ typingMu sync.Mutex
+ typingCache map[string]typingTicketCacheEntry
+ pauseMu sync.Mutex
+ pauseUntil time.Time
+ syncBufPath string
+}
+
+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,
+ typingCache: make(map[string]typingTicketCacheEntry),
+ syncBufPath: buildWeixinSyncBufPath(cfg),
+ }, 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 canceled.
+func (c *WeixinChannel) pollLoop(ctx context.Context) {
+ const (
+ defaultPollTimeoutMs = 35_000
+ retryDelay = 2 * time.Second
+ backoffDelay = 30 * time.Second
+ maxConsecutiveFails = 3
+ )
+
+ consecutiveFails := 0
+ getUpdatesBuf, err := loadGetUpdatesBuf(c.syncBufPath)
+ if err != nil {
+ logger.WarnCF("weixin", "Failed to load persisted get_updates_buf", map[string]any{
+ "path": c.syncBufPath,
+ "error": err.Error(),
+ })
+ getUpdatesBuf = ""
+ } else if getUpdatesBuf != "" {
+ logger.InfoCF("weixin", "Resuming persisted get_updates_buf", map[string]any{
+ "path": c.syncBufPath,
+ "bytes": len(getUpdatesBuf),
+ "source": "disk",
+ })
+ }
+ nextTimeoutMs := defaultPollTimeoutMs
+
+ for {
+ select {
+ case <-ctx.Done():
+ logger.InfoC("weixin", "Weixin poll loop stopped")
+ return
+ default:
+ }
+
+ if err := c.waitWhileSessionPaused(ctx); err != nil {
+ if ctx.Err() != nil {
+ return
+ }
+ continue
+ }
+
+ // 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
+ }
+
+ if isSessionExpiredStatus(resp.Ret, resp.Errcode) {
+ remaining := c.pauseSession("getupdates", resp.Ret, resp.Errcode, resp.Errmsg)
+ select {
+ case <-ctx.Done():
+ return
+ case <-time.After(remaining):
+ }
+ continue
+ }
+
+ if resp.Errcode != 0 || resp.Ret != 0 {
+ 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
+ }
+
+ 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
+ if err := saveGetUpdatesBuf(c.syncBufPath, getUpdatesBuf); err != nil {
+ logger.WarnCF("weixin", "Failed to persist get_updates_buf", map[string]any{
+ "path": c.syncBufPath,
+ "error": err.Error(),
+ })
+ }
+ }
+
+ // 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
+ }
+
+ messageID := msg.ClientID
+ if messageID == "" {
+ messageID = uuid.New().String()
+ }
+
+ // 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, "[audio]")
+ }
+ 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]")
+ }
+ }
+
+ var mediaRefs []string
+ if mediaItem := selectInboundMediaItem(msg); mediaItem != nil {
+ ref, err := c.downloadMediaFromItem(ctx, fromUserID, messageID, mediaItem)
+ if err != nil {
+ logger.ErrorCF("weixin", "Failed to download inbound media", map[string]any{
+ "from_user_id": fromUserID,
+ "message_id": messageID,
+ "type": mediaItem.Type,
+ "error": err.Error(),
+ })
+ } else if ref != "" {
+ mediaRefs = append(mediaRefs, ref)
+ }
+ }
+
+ content := strings.Join(parts, "\n")
+ if content == "" && len(mediaRefs) == 0 {
+ 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
+ }
+
+ 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),
+ "media_count": len(mediaRefs),
+ })
+
+ // Store context_token for outbound reply association
+ if msg.ContextToken != "" {
+ c.contextTokens.Store(fromUserID, msg.ContextToken)
+ }
+
+ c.HandleMessage(ctx, peer, messageID, fromUserID, fromUserID, content, mediaRefs, 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 err := c.ensureSessionActive(); err != nil {
+ return err
+ }
+
+ 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)
+ }
+
+ // If we don't have a context token for this user, we cannot send a valid reply.
+ // Treat this as a non-temporary error so the manager doesn't keep retrying.
+ if contextToken == "" {
+ logger.ErrorCF("weixin", "Missing context token, cannot send message", map[string]any{
+ "to_user_id": toUserID,
+ })
+ return fmt.Errorf("weixin send: %w: missing context token for chat %s", channels.ErrSendFailed, toUserID)
+ }
+
+ if err := c.sendTextMessage(ctx, toUserID, contextToken, msg.Content); err != nil {
+ logger.ErrorCF("weixin", "Failed to send message", map[string]any{
+ "to_user_id": toUserID,
+ "error": err.Error(),
+ })
+ if c.remainingPause() > 0 {
+ return fmt.Errorf("weixin send: %w", channels.ErrSendFailed)
+ }
+ return fmt.Errorf("weixin send: %w", channels.ErrTemporary)
+ }
+
+ return nil
+}
diff --git a/pkg/channels/weixin/weixin_test.go b/pkg/channels/weixin/weixin_test.go
new file mode 100644
index 000000000..115675395
--- /dev/null
+++ b/pkg/channels/weixin/weixin_test.go
@@ -0,0 +1,210 @@
+package weixin
+
+import (
+ "bytes"
+ "context"
+ "encoding/base64"
+ "errors"
+ "io"
+ "net/http"
+ "path/filepath"
+ "testing"
+ "time"
+
+ basechannels "github.com/sipeed/picoclaw/pkg/channels"
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+type roundTripFunc func(*http.Request) (*http.Response, error)
+
+func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
+ return f(req)
+}
+
+func TestParseWeixinMediaAESKey(t *testing.T) {
+ raw := []byte("1234567890abcdef")
+
+ got, err := parseWeixinMediaAESKey(base64.StdEncoding.EncodeToString(raw))
+ if err != nil {
+ t.Fatalf("parseWeixinMediaAESKey(raw) error = %v", err)
+ }
+ if !bytes.Equal(got, raw) {
+ t.Fatalf("parseWeixinMediaAESKey(raw) = %x, want %x", got, raw)
+ }
+
+ hexEncoded := base64.StdEncoding.EncodeToString([]byte("31323334353637383930616263646566"))
+ got, err = parseWeixinMediaAESKey(hexEncoded)
+ if err != nil {
+ t.Fatalf("parseWeixinMediaAESKey(hex-string) error = %v", err)
+ }
+ if !bytes.Equal(got, raw) {
+ t.Fatalf("parseWeixinMediaAESKey(hex-string) = %x, want %x", got, raw)
+ }
+}
+
+func TestDownloadAndDecryptCDNBuffer(t *testing.T) {
+ key := []byte("1234567890abcdef")
+ plaintext := []byte("hello weixin")
+ ciphertext, err := encryptAESECB(plaintext, key)
+ if err != nil {
+ t.Fatalf("encryptAESECB() error = %v", err)
+ }
+
+ ch := &WeixinChannel{
+ api: &ApiClient{
+ HttpClient: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
+ if r.URL.Path != "/download" {
+ t.Fatalf("download path = %q, want /download", r.URL.Path)
+ }
+ if r.URL.Query().Get("encrypted_query_param") != "token" {
+ t.Fatalf("encrypted_query_param = %q, want token", r.URL.Query().Get("encrypted_query_param"))
+ }
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(bytes.NewReader(ciphertext)),
+ Header: make(http.Header),
+ }, nil
+ })},
+ },
+ config: config.WeixinConfig{
+ CDNBaseURL: "https://cdn.example.com",
+ },
+ typingCache: make(map[string]typingTicketCacheEntry),
+ }
+
+ got, err := ch.downloadAndDecryptCDNBuffer(context.Background(), "token", key)
+ if err != nil {
+ t.Fatalf("downloadAndDecryptCDNBuffer() error = %v", err)
+ }
+ if !bytes.Equal(got, plaintext) {
+ t.Fatalf("downloadAndDecryptCDNBuffer() = %q, want %q", got, plaintext)
+ }
+}
+
+func TestUploadBufferToCDN(t *testing.T) {
+ key := []byte("1234567890abcdef")
+ plaintext := []byte("upload me")
+ wantCipher, err := encryptAESECB(plaintext, key)
+ if err != nil {
+ t.Fatalf("encryptAESECB() error = %v", err)
+ }
+
+ ch := &WeixinChannel{
+ api: &ApiClient{
+ HttpClient: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
+ if r.URL.Path != "/upload" {
+ t.Fatalf("upload path = %q, want /upload", r.URL.Path)
+ }
+ if got := r.URL.Query().Get("encrypted_query_param"); got != "upload-param" {
+ t.Fatalf("encrypted_query_param = %q, want upload-param", got)
+ }
+ if got := r.URL.Query().Get("filekey"); got != "file-key" {
+ t.Fatalf("filekey = %q, want file-key", got)
+ }
+ body, _ := io.ReadAll(r.Body)
+ if !bytes.Equal(body, wantCipher) {
+ t.Fatalf("upload body = %x, want %x", body, wantCipher)
+ }
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(bytes.NewReader(nil)),
+ Header: http.Header{
+ "X-Encrypted-Param": []string{"download-param"},
+ },
+ }, nil
+ })},
+ },
+ config: config.WeixinConfig{
+ CDNBaseURL: "https://cdn.example.com",
+ },
+ typingCache: make(map[string]typingTicketCacheEntry),
+ }
+
+ got, err := ch.uploadBufferToCDN(context.Background(), plaintext, "upload-param", "file-key", key)
+ if err != nil {
+ t.Fatalf("uploadBufferToCDN() error = %v", err)
+ }
+ if got != "download-param" {
+ t.Fatalf("uploadBufferToCDN() = %q, want download-param", got)
+ }
+}
+
+func TestLoadSaveGetUpdatesBuf(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "sync.json")
+
+ if err := saveGetUpdatesBuf(path, "cursor-123"); err != nil {
+ t.Fatalf("saveGetUpdatesBuf() error = %v", err)
+ }
+
+ got, err := loadGetUpdatesBuf(path)
+ if err != nil {
+ t.Fatalf("loadGetUpdatesBuf() error = %v", err)
+ }
+ if got != "cursor-123" {
+ t.Fatalf("loadGetUpdatesBuf() = %q, want cursor-123", got)
+ }
+}
+
+func TestBuildWeixinSyncBufPathUsesPicoclawHome(t *testing.T) {
+ home := t.TempDir()
+ t.Setenv(config.EnvHome, home)
+
+ got := buildWeixinSyncBufPath(config.WeixinConfig{
+ BaseURL: "https://ilinkai.weixin.qq.com/",
+ Token: "token-123",
+ })
+ if filepath.Dir(got) != filepath.Join(home, "channels", "weixin", "sync") {
+ t.Fatalf("sync path dir = %q", filepath.Dir(got))
+ }
+}
+
+func TestSessionPauseGuard(t *testing.T) {
+ ch := &WeixinChannel{
+ typingCache: make(map[string]typingTicketCacheEntry),
+ }
+
+ ch.pauseSession("getupdates", 0, weixinSessionExpiredCode, "expired")
+
+ if err := ch.ensureSessionActive(); !errors.Is(err, basechannels.ErrSendFailed) {
+ t.Fatalf("ensureSessionActive() error = %v, want ErrSendFailed", err)
+ }
+
+ ch.pauseMu.Lock()
+ ch.pauseUntil = time.Now().Add(-time.Second)
+ ch.pauseMu.Unlock()
+
+ if err := ch.ensureSessionActive(); err != nil {
+ t.Fatalf("ensureSessionActive() after expiry error = %v, want nil", err)
+ }
+}
+
+func TestSelectInboundMediaItemFallsBackToRefMessage(t *testing.T) {
+ msg := WeixinMessage{
+ ItemList: []MessageItem{
+ {
+ Type: MessageItemTypeText,
+ TextItem: &TextItem{
+ Text: "look",
+ },
+ RefMsg: &RefMessage{
+ MessageItem: &MessageItem{
+ Type: MessageItemTypeImage,
+ ImageItem: &ImageItem{
+ Media: &CDNMedia{
+ EncryptQueryParam: "abc",
+ },
+ },
+ },
+ },
+ },
+ },
+ }
+
+ item := selectInboundMediaItem(msg)
+ if item == nil {
+ t.Fatal("selectInboundMediaItem() = nil, want ref media item")
+ }
+ if item.Type != MessageItemTypeImage {
+ t.Fatalf("selectInboundMediaItem().Type = %d, want %d", item.Type, MessageItemTypeImage)
+ }
+}
diff --git a/pkg/config/config.go b/pkg/config/config.go
index ddafc409d..eab770991 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -297,6 +297,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"`
@@ -500,6 +501,16 @@ 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"`
+ CDNBaseURL string `json:"cdn_base_url" env:"PICOCLAW_CHANNELS_WEIXIN_CDN_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/config/defaults.go b/pkg/config/defaults.go
index cec333888..f4056eca6 100644
--- a/pkg/config/defaults.go
+++ b/pkg/config/defaults.go
@@ -175,6 +175,14 @@ func DefaultConfig() *Config {
WelcomeMessage: "Hello! I'm your AI assistant. How can I help you today?",
ProcessingMessage: DefaultWeComAIBotProcessingMessage,
},
+ Weixin: WeixinConfig{
+ Enabled: false,
+ Token: "",
+ BaseURL: "https://ilinkai.weixin.qq.com/",
+ CDNBaseURL: "https://novac2c.cdn.weixin.qq.com/c2c",
+ AllowFrom: FlexibleStringSlice{},
+ Proxy: "",
+ },
Pico: PicoConfig{
Enabled: false,
Token: "",
diff --git a/pkg/gateway/gateway.go b/pkg/gateway/gateway.go
index 4ad4e950e..92bef6c15 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"