mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
merge: sync main into refactor/agent
This commit is contained in:
@@ -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
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Weixin</b> (WeChat Personal)</summary>
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>QQ</b></summary>
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -219,6 +219,7 @@ make install
|
||||
| 命令 | 说明 |
|
||||
| ------------------------- | ---------------------- |
|
||||
| `picoclaw onboard` | 初始化配置与工作区 |
|
||||
| `picoclaw onboard weixin` | 扫码连接微信个人号 |
|
||||
| `picoclaw agent -m "..."` | 与 Agent 对话 |
|
||||
| `picoclaw agent` | 交互式对话模式 |
|
||||
| `picoclaw gateway` | 启动网关 |
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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(` }`)
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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 令牌通常与单个会话绑定。在其他地方重新扫码激活可能会导致旧令牌失效。
|
||||
- **频率控制**: 为避免触发微信的风控反垃圾机制,请避免设置死循环触发、高频广播等恶意行为。
|
||||
@@ -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 `<workspace>/whatsapp
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Weixin</b> (WeChat Personal)</summary>
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>QQ</b></summary>
|
||||
|
||||
|
||||
@@ -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 连接方式:
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Weixin</b> (微信个人号)</summary>
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Matrix</b></summary>
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+53
-4
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -5,7 +5,7 @@ interface UserMessageProps {
|
||||
export function UserMessage({ content }: UserMessageProps) {
|
||||
return (
|
||||
<div className="flex w-full flex-col items-end gap-1.5">
|
||||
<div className="max-w-[70%] rounded-2xl rounded-tr-sm bg-violet-500 px-5 py-3 text-[15px] leading-relaxed text-white shadow-sm">
|
||||
<div className="max-w-[70%] rounded-2xl rounded-tr-sm bg-violet-500 px-5 py-3 text-[15px] leading-relaxed text-white shadow-sm whitespace-pre-wrap">
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"chat": {
|
||||
"welcome": "今天我能为您做些什么?",
|
||||
"welcomeDesc": "您可以询问我天气、设置或其他任何任务,我随时为您效劳。",
|
||||
"placeholder": "输入新消息...",
|
||||
"placeholder": "输入新消息...\n按 Enter 发送,Shift + Enter 换行",
|
||||
"newChat": "新建对话",
|
||||
"notConnected": "服务未运行,请先启动以进行对话。",
|
||||
"thinking": {
|
||||
|
||||
Reference in New Issue
Block a user