diff --git a/README.md b/README.md index bb27152d0..86c6d641d 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 @@ -1356,6 +1390,7 @@ picoclaw agent -m "Hello" | 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 a7c73f2d9..b551a38e3 100644 --- a/README.zh.md +++ b/README.zh.md @@ -219,6 +219,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/qq/audio_duration.go b/pkg/channels/qq/audio_duration.go new file mode 100644 index 000000000..28a9b2e83 --- /dev/null +++ b/pkg/channels/qq/audio_duration.go @@ -0,0 +1,231 @@ +package qq + +import ( + "encoding/binary" + "io" + "os" + "path/filepath" + "strings" + "time" +) + +const qqVoiceMaxDuration = 60 * time.Second + +func qqAudioDuration(localPath, filename, contentType string) (time.Duration, bool, error) { + if localPath == "" { + return 0, false, nil + } + + switch qqAudioDurationFormat(localPath, filename, contentType) { + case "wav": + return qqWAVDuration(localPath) + case "ogg": + return qqOggDuration(localPath) + default: + return 0, false, nil + } +} + +func qqAudioDurationFormat(localPath, filename, contentType string) string { + contentType = strings.ToLower(contentType) + + switch { + case strings.HasPrefix(contentType, "audio/wav"), strings.HasPrefix(contentType, "audio/x-wav"): + return "wav" + case strings.HasPrefix(contentType, "audio/ogg"), + contentType == "application/ogg", + contentType == "application/x-ogg": + return "ogg" + } + + switch filepath.Ext(strings.ToLower(filename)) { + case ".wav": + return "wav" + case ".ogg", ".opus": + return "ogg" + } + + switch filepath.Ext(strings.ToLower(localPath)) { + case ".wav": + return "wav" + case ".ogg", ".opus": + return "ogg" + } + + return "" +} + +func qqWAVDuration(localPath string) (time.Duration, bool, error) { + file, err := os.Open(localPath) + if err != nil { + return 0, false, err + } + defer file.Close() + + var header [12]byte + if _, err := io.ReadFull(file, header[:]); err != nil { + return 0, false, err + } + + var order binary.ByteOrder + switch string(header[:4]) { + case "RIFF": + order = binary.LittleEndian + case "RIFX": + order = binary.BigEndian + default: + return 0, false, nil + } + + if string(header[8:12]) != "WAVE" { + return 0, false, nil + } + + var byteRate uint32 + var dataSize uint32 + var foundFmt bool + var foundData bool + + for { + var chunkHeader [8]byte + if _, err := io.ReadFull(file, chunkHeader[:]); err != nil { + if err == io.EOF { + break + } + return 0, false, err + } + + chunkSize := order.Uint32(chunkHeader[4:8]) + switch string(chunkHeader[:4]) { + case "fmt ": + chunkData := make([]byte, chunkSize) + if _, err := io.ReadFull(file, chunkData); err != nil { + return 0, false, err + } + if len(chunkData) >= 12 { + byteRate = order.Uint32(chunkData[8:12]) + foundFmt = true + } + case "data": + dataSize = chunkSize + foundData = true + if _, err := io.CopyN(io.Discard, file, int64(chunkSize)); err != nil { + return 0, false, err + } + default: + if _, err := io.CopyN(io.Discard, file, int64(chunkSize)); err != nil { + return 0, false, err + } + } + + if chunkSize%2 == 1 { + if _, err := io.CopyN(io.Discard, file, 1); err != nil { + return 0, false, err + } + } + + if foundFmt && foundData { + break + } + } + + if !foundFmt || !foundData || byteRate == 0 { + return 0, false, nil + } + + durationNS := int64(dataSize) * int64(time.Second) / int64(byteRate) + return time.Duration(durationNS), true, nil +} + +func qqOggDuration(localPath string) (time.Duration, bool, error) { + file, err := os.Open(localPath) + if err != nil { + return 0, false, err + } + defer file.Close() + + var firstPacket []byte + var codec string + var sampleRate uint32 + var lastGranule uint64 + var haveGranule bool + + for { + var header [27]byte + if _, err := io.ReadFull(file, header[:]); err != nil { + if err == io.EOF { + break + } + return 0, false, err + } + + if string(header[:4]) != "OggS" { + return 0, false, nil + } + + pageSegments := int(header[26]) + segments := make([]byte, pageSegments) + if _, err := io.ReadFull(file, segments); err != nil { + return 0, false, err + } + + payloadLen := 0 + for _, segLen := range segments { + payloadLen += int(segLen) + } + + payload := make([]byte, payloadLen) + if _, err := io.ReadFull(file, payload); err != nil { + return 0, false, err + } + + granule := binary.LittleEndian.Uint64(header[6:14]) + if granule != ^uint64(0) { + lastGranule = granule + haveGranule = true + } + + if codec == "" { + offset := 0 + for _, segLen := range segments { + firstPacket = append(firstPacket, payload[offset:offset+int(segLen)]...) + offset += int(segLen) + if segLen < 255 { + codec, sampleRate = qqParseOggCodec(firstPacket) + break + } + } + } + } + + if !haveGranule || codec == "" { + return 0, false, nil + } + + switch codec { + case "opus": + return time.Duration(lastGranule) * time.Second / 48000, true, nil + case "vorbis": + if sampleRate == 0 { + return 0, false, nil + } + return time.Duration(lastGranule) * time.Second / time.Duration(sampleRate), true, nil + default: + return 0, false, nil + } +} + +func qqParseOggCodec(packet []byte) (string, uint32) { + if len(packet) >= 8 && string(packet[:8]) == "OpusHead" { + return "opus", 48000 + } + + if len(packet) >= 16 && packet[0] == 0x01 && string(packet[1:7]) == "vorbis" { + sampleRate := binary.LittleEndian.Uint32(packet[12:16]) + if sampleRate > 0 { + return "vorbis", sampleRate + } + } + + return "", 0 +} diff --git a/pkg/channels/qq/qq.go b/pkg/channels/qq/qq.go index 1a48369f8..2cd6e1747 100644 --- a/pkg/channels/qq/qq.go +++ b/pkg/channels/qq/qq.go @@ -387,12 +387,11 @@ func (c *QQChannel) uploadMedia( } func (c *QQChannel) buildMediaUpload(part bus.MediaPart) (*qqMediaUpload, error) { - payload := &qqMediaUpload{ - FileType: qqFileType(part.Type), - } + payload := &qqMediaUpload{} mediaRef := part.Ref if isHTTPURL(mediaRef) { + payload.FileType = qqFileType(c.outboundMediaType(part, "")) payload.URL = mediaRef return payload, nil } @@ -402,15 +401,23 @@ func (c *QQChannel) buildMediaUpload(part bus.MediaPart) (*qqMediaUpload, error) return nil, fmt.Errorf("no media store available: %w", channels.ErrSendFailed) } - resolved, err := store.Resolve(part.Ref) + resolved, meta, err := store.ResolveWithMeta(part.Ref) if err != nil { return nil, fmt.Errorf("qq resolve media ref %q: %v: %w", part.Ref, err, channels.ErrSendFailed) } + if part.Filename == "" { + part.Filename = meta.Filename + } + if part.ContentType == "" { + part.ContentType = meta.ContentType + } if isHTTPURL(resolved) { + payload.FileType = qqFileType(c.outboundMediaType(part, "")) payload.URL = resolved return payload, nil } + payload.FileType = qqFileType(c.outboundMediaType(part, resolved)) if limitBytes := c.maxBase64FileSizeBytes(); limitBytes > 0 { info, statErr := os.Stat(resolved) @@ -437,6 +444,48 @@ func (c *QQChannel) buildMediaUpload(part bus.MediaPart) (*qqMediaUpload, error) return payload, nil } +func (c *QQChannel) outboundMediaType(part bus.MediaPart, localPath string) string { + if part.Type != "audio" { + return part.Type + } + + if localPath == "" { + logger.InfoCF("qq", "Sending audio as file because duration is unavailable", map[string]any{ + "ref": part.Ref, + "filename": part.Filename, + }) + return "file" + } + + duration, ok, err := qqAudioDuration(localPath, part.Filename, part.ContentType) + if err != nil { + logger.WarnCF("qq", "Failed to detect audio duration, sending as file", map[string]any{ + "ref": part.Ref, + "filename": part.Filename, + "error": err.Error(), + }) + return "file" + } + if !ok { + logger.InfoCF("qq", "Sending audio as file because duration is unavailable", map[string]any{ + "ref": part.Ref, + "filename": part.Filename, + }) + return "file" + } + if duration > qqVoiceMaxDuration { + logger.InfoCF("qq", "Sending audio as file because it exceeds QQ voice limit", map[string]any{ + "ref": part.Ref, + "filename": part.Filename, + "duration_seconds": duration.Seconds(), + "limit_seconds": qqVoiceMaxDuration.Seconds(), + }) + return "file" + } + + return "audio" +} + func (c *QQChannel) sendUploadedMedia( ctx context.Context, chatKind, chatID string, diff --git a/pkg/channels/qq/qq_test.go b/pkg/channels/qq/qq_test.go index 3cb3d39bd..108965c00 100644 --- a/pkg/channels/qq/qq_test.go +++ b/pkg/channels/qq/qq_test.go @@ -1,8 +1,10 @@ package qq import ( + "bytes" "context" "encoding/base64" + "encoding/binary" "encoding/json" "errors" "os" @@ -264,6 +266,142 @@ func TestSendMedia_UploadsLocalFileAsBase64(t *testing.T) { } } +func TestSendMedia_AudioAt60SecondsUsesVoiceUpload(t *testing.T) { + assertAudioWAVUploadType(t, 60*time.Second, 3) +} + +func TestSendMedia_AudioOver60SecondsFallsBackToFileUpload(t *testing.T) { + assertAudioWAVUploadType(t, 61*time.Second, 4) +} + +func assertAudioWAVUploadType(t *testing.T, duration time.Duration, wantFileType uint64) { + t.Helper() + + messageBus := bus.NewMessageBus() + store := media.NewFileMediaStore() + + localPath := writeWAVFile(t, t.TempDir(), "voice.wav", duration) + ref, err := store.Store(localPath, media.MediaMeta{ + Filename: "voice.wav", + ContentType: "audio/wav", + }, "qq:test") + if err != nil { + t.Fatalf("Store() error = %v", err) + } + + api := &fakeQQAPI{ + transportResp: mustJSON(t, dto.Message{FileInfo: []byte("file-info")}), + } + ch := &QQChannel{ + BaseChannel: channels.NewBaseChannel("qq", nil, messageBus, nil), + api: api, + dedup: make(map[string]time.Time), + done: make(chan struct{}), + ctx: context.Background(), + } + ch.SetRunning(true) + ch.SetMediaStore(store) + ch.chatType.Store("group-1", "group") + + err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + ChatID: "group-1", + Parts: []bus.MediaPart{{ + Type: "audio", + Ref: ref, + }}, + }) + if err != nil { + t.Fatalf("SendMedia() error = %v", err) + } + + if len(api.transportCalls) != 1 { + t.Fatalf("transportCalls = %d, want 1", len(api.transportCalls)) + } + if api.transportCalls[0].body.FileType != wantFileType { + t.Fatalf("upload file_type = %d, want %d", api.transportCalls[0].body.FileType, wantFileType) + } +} + +func TestSendMedia_RemoteAudioFallsBackToFileUpload(t *testing.T) { + messageBus := bus.NewMessageBus() + api := &fakeQQAPI{ + transportResp: mustJSON(t, dto.Message{FileInfo: []byte("remote-file-info")}), + } + ch := &QQChannel{ + BaseChannel: channels.NewBaseChannel("qq", nil, messageBus, nil), + api: api, + dedup: make(map[string]time.Time), + done: make(chan struct{}), + ctx: context.Background(), + } + ch.SetRunning(true) + ch.chatType.Store("user-1", "direct") + + err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + ChatID: "user-1", + Parts: []bus.MediaPart{{ + Type: "audio", + Ref: "https://cdn.example.com/voice.ogg", + }}, + }) + if err != nil { + t.Fatalf("SendMedia() error = %v", err) + } + + if len(api.transportCalls) != 1 { + t.Fatalf("transportCalls = %d, want 1", len(api.transportCalls)) + } + if api.transportCalls[0].body.FileType != 4 { + t.Fatalf("upload file_type = %d, want 4", api.transportCalls[0].body.FileType) + } +} + +func TestSendMedia_LocalAudioWithUnknownDurationFallsBackToFileUpload(t *testing.T) { + messageBus := bus.NewMessageBus() + store := media.NewFileMediaStore() + + localPath := writeTempFile(t, t.TempDir(), "voice.mp3", []byte("not-a-real-mp3")) + ref, err := store.Store(localPath, media.MediaMeta{ + Filename: "voice.mp3", + ContentType: "audio/mpeg", + }, "qq:test") + if err != nil { + t.Fatalf("Store() error = %v", err) + } + + api := &fakeQQAPI{ + transportResp: mustJSON(t, dto.Message{FileInfo: []byte("file-info")}), + } + ch := &QQChannel{ + BaseChannel: channels.NewBaseChannel("qq", nil, messageBus, nil), + api: api, + dedup: make(map[string]time.Time), + done: make(chan struct{}), + ctx: context.Background(), + } + ch.SetRunning(true) + ch.SetMediaStore(store) + ch.chatType.Store("group-1", "group") + + err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + ChatID: "group-1", + Parts: []bus.MediaPart{{ + Type: "audio", + Ref: ref, + }}, + }) + if err != nil { + t.Fatalf("SendMedia() error = %v", err) + } + + if len(api.transportCalls) != 1 { + t.Fatalf("transportCalls = %d, want 1", len(api.transportCalls)) + } + if api.transportCalls[0].body.FileType != 4 { + t.Fatalf("upload file_type = %d, want 4", api.transportCalls[0].body.FileType) + } +} + func TestSendMedia_UsesRemoteURLUploadForC2C(t *testing.T) { messageBus := bus.NewMessageBus() api := &fakeQQAPI{ @@ -494,3 +632,53 @@ func writeTempFile(t *testing.T, dir, name string, content []byte) string { } return path } + +func writeWAVFile(t *testing.T, dir, name string, duration time.Duration) string { + t.Helper() + + const ( + sampleRate = 8000 + numChannels = 1 + bitsPerSample = 8 + ) + + dataSize := uint32(duration / time.Second * sampleRate * numChannels * (bitsPerSample / 8)) + byteRate := uint32(sampleRate * numChannels * (bitsPerSample / 8)) + blockAlign := uint16(numChannels * (bitsPerSample / 8)) + + var buf bytes.Buffer + buf.WriteString("RIFF") + if err := binary.Write(&buf, binary.LittleEndian, uint32(36)+dataSize); err != nil { + t.Fatalf("binary.Write(riff size) error = %v", err) + } + buf.WriteString("WAVE") + buf.WriteString("fmt ") + if err := binary.Write(&buf, binary.LittleEndian, uint32(16)); err != nil { + t.Fatalf("binary.Write(fmt chunk size) error = %v", err) + } + if err := binary.Write(&buf, binary.LittleEndian, uint16(1)); err != nil { + t.Fatalf("binary.Write(audio format) error = %v", err) + } + if err := binary.Write(&buf, binary.LittleEndian, uint16(numChannels)); err != nil { + t.Fatalf("binary.Write(channels) error = %v", err) + } + if err := binary.Write(&buf, binary.LittleEndian, uint32(sampleRate)); err != nil { + t.Fatalf("binary.Write(sample rate) error = %v", err) + } + if err := binary.Write(&buf, binary.LittleEndian, byteRate); err != nil { + t.Fatalf("binary.Write(byte rate) error = %v", err) + } + if err := binary.Write(&buf, binary.LittleEndian, blockAlign); err != nil { + t.Fatalf("binary.Write(block align) error = %v", err) + } + if err := binary.Write(&buf, binary.LittleEndian, uint16(bitsPerSample)); err != nil { + t.Fatalf("binary.Write(bits per sample) error = %v", err) + } + buf.WriteString("data") + if err := binary.Write(&buf, binary.LittleEndian, dataSize); err != nil { + t.Fatalf("binary.Write(data size) error = %v", err) + } + buf.Write(make([]byte, dataSize)) + + return writeTempFile(t, dir, name, buf.Bytes()) +} 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 89d89af04..7c7b79959 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -337,6 +337,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"` @@ -540,6 +541,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 28c1efb80..3397eb91c 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -176,6 +176,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" diff --git a/web/frontend/src/components/chat/user-message.tsx b/web/frontend/src/components/chat/user-message.tsx index b47806f49..84978e907 100644 --- a/web/frontend/src/components/chat/user-message.tsx +++ b/web/frontend/src/components/chat/user-message.tsx @@ -5,7 +5,7 @@ interface UserMessageProps { export function UserMessage({ content }: UserMessageProps) { return (
-
+
{content}
diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index a82ba2d83..0ff2beb25 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -17,7 +17,7 @@ "chat": { "welcome": "How can I help you today?", "welcomeDesc": "Ask me about weather, settings, or any other tasks. I'm here to assist you.", - "placeholder": "Start a new message...", + "placeholder": "Start a new message...\nPress Enter to send, Shift + Enter for a new line", "newChat": "New Chat", "notConnected": "Gateway is not running. Start it to chat.", "thinking": { diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json index 30d1b8b92..fc1f007ae 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -17,7 +17,7 @@ "chat": { "welcome": "今天我能为您做些什么?", "welcomeDesc": "您可以询问我天气、设置或其他任何任务,我随时为您效劳。", - "placeholder": "输入新消息...", + "placeholder": "输入新消息...\n按 Enter 发送,Shift + Enter 换行", "newChat": "新建对话", "notConnected": "服务未运行,请先启动以进行对话。", "thinking": {