merge: sync main into refactor/agent

This commit is contained in:
yinwm
2026-03-22 21:44:17 +08:00
26 changed files with 3250 additions and 11 deletions
+35
View File
@@ -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 |
+1
View File
@@ -219,6 +219,7 @@ make install
| 命令 | 说明 |
| ------------------------- | ---------------------- |
| `picoclaw onboard` | 初始化配置与工作区 |
| `picoclaw onboard weixin` | 扫码连接微信个人号 |
| `picoclaw agent -m "..."` | 与 Agent 对话 |
| `picoclaw agent` | 交互式对话模式 |
| `picoclaw gateway` | 启动网关 |
+10 -2
View File
@@ -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())
}
+124
View File
@@ -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(` }`)
}
+58
View File
@@ -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.
+58
View File
@@ -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 令牌通常与单个会话绑定。在其他地方重新扫码激活可能会导致旧令牌失效。
- **频率控制**: 为避免触发微信的风控反垃圾机制,请避免设置死循环触发、高频广播等恶意行为。
+34
View File
@@ -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>
+34
View File
@@ -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>
+4
View File
@@ -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")
}
+231
View File
@@ -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
View File
@@ -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,
+188
View File
@@ -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())
}
+241
View File
@@ -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
}
+111
View File
@@ -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
+226
View File
@@ -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,
)
}
+210
View File
@@ -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"`
}
+359
View File
@@ -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
}
+210
View File
@@ -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)
}
}
+11
View File
@@ -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"`
+8
View File
@@ -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: "",
+1
View File
@@ -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>
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -17,7 +17,7 @@
"chat": {
"welcome": "今天我能为您做些什么?",
"welcomeDesc": "您可以询问我天气、设置或其他任何任务,我随时为您效劳。",
"placeholder": "输入新消息...",
"placeholder": "输入新消息...\n按 Enter 发送,Shift + Enter 换行",
"newChat": "新建对话",
"notConnected": "服务未运行,请先启动以进行对话。",
"thinking": {