Merge branch 'main' into version

This commit is contained in:
Cytown
2026-03-22 19:58:33 +08:00
42 changed files with 3797 additions and 839 deletions
+3
View File
@@ -59,3 +59,6 @@ cmd/telegram/
!web/backend/dist/
web/backend/dist/*
!web/backend/dist/.gitkeep
docker/data
+37 -794
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>
@@ -713,6 +747,7 @@ Connect Picoclaw to the Agent Social Network simply by sending a single message
| Command | Description |
| ------------------------- | ----------------------------- |
| `picoclaw onboard` | Initialize config & workspace |
| `picoclaw onboard weixin` | Connect WeChat account via QR |
| `picoclaw agent -m "..."` | Chat with the agent |
| `picoclaw agent` | Interactive chat mode |
| `picoclaw gateway` | Start the gateway |
@@ -747,797 +782,5 @@ User Groups:
discord: <https://discord.gg/V4sAZ9XWpN>
<img src="assets/wechat.png" alt="PicoClaw" width="512">
center">
<img src="assets/logo.webp" alt="PicoClaw" width="512">
<h1>PicoClaw: Ultra-Efficient AI Assistant in Go</h1>
<h3>$10 Hardware · <10MB RAM · <1s Boot · 皮皮虾,我们走!</h3>
<p>
<img src="https://img.shields.io/badge/Go-1.25+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V%2C%20LoongArch-blue" alt="Hardware">
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
<br>
<a href="https://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
<a href="https://docs.picoclaw.io/"><img src="https://img.shields.io/badge/Docs-Official-007acc?style=flat&logo=read-the-docs&logoColor=white" alt="Docs"></a>
<a href="https://deepwiki.com/sipeed/picoclaw"><img src="https://img.shields.io/badge/Wiki-DeepWiki-FFA500?style=flat&logo=wikipedia&logoColor=white" alt="Wiki"></a>
<br>
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
<a href="./assets/wechat.png"><img src="https://img.shields.io/badge/WeChat-Group-41d56b?style=flat&logo=wechat&logoColor=white"></a>
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
</p>
[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | **English**
</div>
---
> **PicoClaw** is an independent open-source project initiated by [Sipeed](https://sipeed.com). It is written entirely in **Go** — not a fork of OpenClaw, NanoBot, or any other project.
🦐 PicoClaw is an ultra-lightweight personal AI Assistant inspired by [NanoBot](https://github.com/HKUDS/nanobot), refactored from the ground up in Go through a self-bootstrapping process, where the AI agent itself drove the entire architectural migration and code optimization.
⚡️ Runs on $10 hardware with <10MB RAM: That's 99% less memory than OpenClaw and 98% cheaper than a Mac mini!
<table align="center">
<tr align="center">
<td align="center" valign="top">
<p align="center">
<img src="assets/picoclaw_mem.gif" width="360" height="240">
</p>
</td>
<td align="center" valign="top">
<p align="center">
<img src="assets/licheervnano.png" width="400" height="240">
</p>
</td>
</tr>
</table>
> [!CAUTION]
> **🚨 SECURITY & OFFICIAL CHANNELS / 安全声明**
>
> * **NO CRYPTO:** PicoClaw has **NO** official token/coin. All claims on `pump.fun` or other trading platforms are **SCAMS**.
>
> * **OFFICIAL DOMAIN:** The **ONLY** official website is **[picoclaw.io](https://picoclaw.io)**, and company website is **[sipeed.com](https://sipeed.com)**
> * **Warning:** Many `.ai/.org/.com/.net/...` domains are registered by third parties.
> * **Warning:** picoclaw is in early development now and may have unresolved network security issues. Do not deploy to production environments before the v1.0 release.
> * **Note:** picoclaw has recently merged a lot of PRs, which may result in a larger memory footprint (1020MB) in the latest versions. We plan to prioritize resource optimization as soon as the current feature set reaches a stable state.
## 📢 News
2026-03-17 🚀 **v0.2.3 Released!** System tray UI (Windows & Linux), sub-agent status tracking (`spawn_status`), experimental gateway hot-reload, cron security gates, and 2 security fixes. PicoClaw now at **25K ⭐**!
2026-03-09 🎉 **v0.2.1 — Biggest update yet!** MCP protocol support, 4 new channels (Matrix/IRC/WeCom/Discord Proxy), 3 new providers (Kimi/Minimax/Avian), vision pipeline, JSONL memory store, and model routing.
2026-02-28 📦 **v0.2.0** released with Docker Compose support and Web UI launcher.
2026-02-26 🎉 PicoClaw hit **20K stars** in just 17 days! Channel auto-orchestration and capability interfaces landed.
<details>
<summary>Older news...</summary>
2026-02-16 🎉 PicoClaw hit 12K stars in one week! Community maintainer roles and [roadmap](ROADMAP.md) officially posted.
2026-02-13 🎉 PicoClaw hit 5000 stars in 4 days! Project Roadmap and Developer Group setup underway.
2026-02-09 🎉 **PicoClaw Launched!** Built in 1 day to bring AI Agents to $10 hardware with <10MB RAM. 🦐 PicoClawLet's Go
</details>
## ✨ Features
🪶 **Ultra-Lightweight**: <10MB Memory footprint — 99% smaller than OpenClaw core functionality.*
💰 **Minimal Cost**: Efficient enough to run on $10 Hardware — 98% cheaper than a Mac mini.
⚡️ **Lightning Fast**: 400X Faster startup time, boot in <1 second even on 0.6GHz single core.
🌍 **True Portability**: Single self-contained binary across RISC-V, ARM, MIPS, and x86, One-click to Go!
🤖 **AI-Bootstrapped**: Autonomous Go-native implementation — 95% Agent-generated core with human-in-the-loop refinement.
🔌 **MCP Support**: Native [Model Context Protocol](https://modelcontextprotocol.io/) integration — connect any MCP server to extend agent capabilities.
👁️ **Vision Pipeline**: Send images and files directly to the agent — automatic base64 encoding for multimodal LLMs.
🧠 **Smart Routing**: Rule-based model routing — simple queries go to lightweight models, saving API costs.
_*Recent versions may use 1020MB due to rapid feature merges. Resource optimization is planned. Startup comparison based on 0.8GHz single-core benchmarks (see table below)._
| | OpenClaw | NanoBot | **PicoClaw** |
| ----------------------------- | ------------- | ------------------------ | ----------------------------------------- |
| **Language** | TypeScript | Python | **Go** |
| **RAM** | >1GB | >100MB | **< 10MB*** |
| **Startup**</br>(0.8GHz core) | >500s | >30s | **<1s** |
| **Cost** | Mac Mini $599 | Most Linux SBC </br>~$50 | **Any Linux Board**</br>**As low as $10** |
<img src="assets/compare.jpg" alt="PicoClaw" width="512">
> 📋 **[Hardware Compatibility List](docs/hardware-compatibility.md)** — See all tested boards, from $5 RISC-V to Raspberry Pi to Android phones. Your board not listed? Submit a PR!
## 🦾 Demonstration
### 🛠️ Standard Assistant Workflows
<table align="center">
<tr align="center">
<th><p align="center">🧩 Full-Stack Engineer</p></th>
<th><p align="center">🗂️ Logging & Planning Management</p></th>
<th><p align="center">🔎 Web Search & Learning</p></th>
</tr>
<tr>
<td align="center"><p align="center"><img src="assets/picoclaw_code.gif" width="240" height="180"></p></td>
<td align="center"><p align="center"><img src="assets/picoclaw_memory.gif" width="240" height="180"></p></td>
<td align="center"><p align="center"><img src="assets/picoclaw_search.gif" width="240" height="180"></p></td>
</tr>
<tr>
<td align="center">Develop • Deploy • Scale</td>
<td align="center">Schedule • Automate • Memory</td>
<td align="center">Discovery • Insights • Trends</td>
</tr>
</table>
### 📱 Run on old Android Phones
Give your decade-old phone a second life! Turn it into a smart AI Assistant with PicoClaw. Quick Start:
1. **Install [Termux](https://github.com/termux/termux-app)** (Download from [GitHub Releases](https://github.com/termux/termux-app/releases), or search in F-Droid / Google Play).
2. **Execute cmds**
```bash
# Download the latest release from https://github.com/sipeed/picoclaw/releases
wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz
tar xzf picoclaw_Linux_arm64.tar.gz
pkg install proot
termux-chroot ./picoclaw onboard
```
And then follow the instructions in the "Quick Start" section to complete the configuration!
<img src="assets/termux.jpg" alt="PicoClaw" width="512">
### 🐜 Innovative Low-Footprint Deploy
PicoClaw can be deployed on almost any Linux device!
- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(Ethernet) or W(WiFi6) version, for Minimal Home Assistant
- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), or $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html) for Automated Server Maintenance
- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) or $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera) for Smart Monitoring
<https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4>
🌟 More Deployment Cases Await
## 📦 Install
### Install with precompiled binary
Download the binary for your platform from the [Releases](https://github.com/sipeed/picoclaw/releases) page.
### Install from source (latest features, recommended for development)
```bash
git clone https://github.com/sipeed/picoclaw.git
cd picoclaw
make deps
# Build, no need to install
make build
# Build for multiple platforms
make build-all
# Build for Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64)
make build-pi-zero
# Build And Install
make install
```
**Raspberry Pi Zero 2 W:** Use the binary that matches your OS: 32-bit Raspberry Pi OS → `make build-linux-arm`; 64-bit → `make build-linux-arm64`. Or run `make build-pi-zero` to build both.
## 📚 Documentation
For detailed guides, see the docs below. The README covers quick start only.
```bash
# 1. Clone this repo
git clone https://github.com/sipeed/picoclaw.git
cd picoclaw
# 2. First run — auto-generates docker/data/config.json then exits
docker compose -f docker/docker-compose.yml --profile gateway up
# The container prints "First-run setup complete." and stops.
# 3. Set your API keys
vim docker/data/config.json # Set provider API keys, bot tokens, etc.
# 4. Start
docker compose -f docker/docker-compose.yml --profile gateway up -d
```
> [!TIP]
> **Docker Users**: By default, the Gateway listens on `127.0.0.1` which is not accessible from the host. If you need to access the health endpoints or expose ports, set `PICOCLAW_GATEWAY_HOST=0.0.0.0` in your environment or update `config.json`.
```bash
# 5. Check logs
docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway
# 6. Stop
docker compose -f docker/docker-compose.yml --profile gateway down
```
### Launcher Mode (Web Console)
The `launcher` image includes all three binaries (`picoclaw`, `picoclaw-launcher`, `picoclaw-launcher-tui`) and starts the web console by default, which provides a browser-based UI for configuration and chat.
```bash
docker compose -f docker/docker-compose.yml --profile launcher up -d
```
Open http://localhost:18800 in your browser. The launcher manages the gateway process automatically.
> [!WARNING]
> The web console does not yet support authentication. Avoid exposing it to the public internet.
### Agent Mode (One-shot)
```bash
# Ask a question
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "What is 2+2?"
# Interactive mode
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent
```
### Update
```bash
docker compose -f docker/docker-compose.yml pull
docker compose -f docker/docker-compose.yml --profile gateway up -d
```
### 🚀 Quick Start
> [!TIP]
> Set your API Key in `~/.picoclaw/config.json`. Get API Keys: [Volcengine (CodingPlan)](https://console.volcengine.com) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM). Web search is optional — get a free [Tavily API](https://tavily.com) (1000 free queries/month) or [Brave Search API](https://brave.com/search/api) (2000 free queries/month).
**1. Initialize**
```bash
picoclaw onboard
```
**2. Configure** (`~/.picoclaw/config.json`)
```json
{
"agents": {
"defaults": {
"workspace": "~/.picoclaw/workspace",
"model_name": "gpt-5.4",
"max_tokens": 8192,
"temperature": 0.7,
"max_tool_iterations": 20
}
},
"model_list": [
{
"model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest",
"api_key": "sk-your-api-key"
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "your-api-key",
"request_timeout": 300
},
{
"model_name": "claude-sonnet-4.6",
"model": "anthropic/claude-sonnet-4.6",
"api_key": "your-anthropic-key"
}
],
"tools": {
"web": {
"brave": {
"enabled": false,
"api_key": "YOUR_BRAVE_API_KEY",
"max_results": 5
},
"tavily": {
"enabled": false,
"api_key": "YOUR_TAVILY_API_KEY",
"max_results": 5
},
"duckduckgo": {
"enabled": true,
"max_results": 5
},
"perplexity": {
"enabled": false,
"api_key": "YOUR_PERPLEXITY_API_KEY",
"max_results": 5
},
"searxng": {
"enabled": false,
"base_url": "http://your-searxng-instance:8888",
"max_results": 5
}
}
}
}
```
> **New**: The `model_list` configuration format allows zero-code provider addition. See [Model Configuration](#model-configuration-model_list) for details.
> `request_timeout` is optional and uses seconds. If omitted or set to `<= 0`, PicoClaw uses the default timeout (120s).
**3. Get API Keys**
* **LLM Provider**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
* **Web Search** (optional):
* [Brave Search](https://brave.com/search/api) - Paid ($5/1000 queries, ~$5-6/month)
* [Perplexity](https://www.perplexity.ai) - AI-powered search with chat interface
* [SearXNG](https://github.com/searxng/searxng) - Self-hosted metasearch engine (free, no API key needed)
* [Tavily](https://tavily.com) - Optimized for AI Agents (1000 requests/month)
* DuckDuckGo - Built-in fallback (no API key required)
> **Note**: See `config.example.json` for a complete configuration template.
**4. Chat**
```bash
picoclaw agent -m "What is 2+2?"
```
That's it! You have a working AI assistant in 2 minutes.
---
## 💬 Chat Apps
Talk to your picoclaw through Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, or WeCom
> **Note**: All webhook-based channels (LINE, WeCom, etc.) are served on a single shared Gateway HTTP server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). There are no per-channel ports to configure. Note: Feishu uses WebSocket/SDK mode and does not use the shared HTTP webhook server.
| Channel | Setup |
| ------------ | ---------------------------------- |
| **Telegram** | Easy (just a token) |
| **Discord** | Easy (bot token + intents) |
| **WhatsApp** | Easy (native: QR scan; or bridge URL) |
| **Matrix** | Medium (homeserver + bot access token) |
| **QQ** | Easy (AppID + AppSecret) |
| **DingTalk** | Medium (app credentials) |
| **LINE** | Medium (credentials + webhook URL) |
| **WeCom AI Bot** | Medium (Token + AES key) |
<details>
<summary><b>Telegram</b> (Recommended)</summary>
**1. Create a bot**
* Open Telegram, search `@BotFather`
* Send `/newbot`, follow prompts
* Copy the token
**2. Configure**
```json
{
"channels": {
"telegram": {
"enabled": true,
"token": "YOUR_BOT_TOKEN",
"allow_from": ["YOUR_USER_ID"]
}
}
}
```
> Get your user ID from `@userinfobot` on Telegram.
**3. Run**
```bash
picoclaw gateway
```
**4. Telegram command menu (auto-registered at startup)**
PicoClaw now keeps command definitions in one shared registry. On startup, Telegram will automatically register supported bot commands (for example `/start`, `/help`, `/show`, `/list`) so command menu and runtime behavior stay in sync.
Telegram command menu registration remains channel-local discovery UX; generic command execution is handled centrally in the agent loop via the commands executor.
If command registration fails (network/API transient errors), the channel still starts and PicoClaw retries registration in the background.
</details>
<details>
<summary><b>Discord</b></summary>
**1. Create a bot**
* Go to <https://discord.com/developers/applications>
* Create an application → Bot → Add Bot
* Copy the bot token
**2. Enable intents**
* In the Bot settings, enable **MESSAGE CONTENT INTENT**
* (Optional) Enable **SERVER MEMBERS INTENT** if you plan to use allow lists based on member data
**3. Get your User ID**
* Discord Settings → Advanced → enable **Developer Mode**
* Right-click your avatar → **Copy User ID**
**4. Configure**
```json
{
"channels": {
"discord": {
"enabled": true,
"token": "YOUR_BOT_TOKEN",
"allow_from": ["YOUR_USER_ID"]
}
}
}
```
**5. Invite the bot**
* OAuth2 → URL Generator
* Scopes: `bot`
* Bot Permissions: `Send Messages`, `Read Message History`
* Open the generated invite URL and add the bot to your server
**Optional: Group trigger mode**
By default the bot responds to all messages in a server channel. To restrict responses to @-mentions only, add:
```json
{
"channels": {
"discord": {
"group_trigger": { "mention_only": true }
}
}
}
```
You can also trigger by keyword prefixes (e.g. `!bot`):
```json
{
"channels": {
"discord": {
"group_trigger": { "prefixes": ["!bot"] }
}
}
}
```
**6. Run**
```bash
picoclaw gateway
```
</details>
<details>
<summary><b>WhatsApp</b> (native via whatsmeow)</summary>
PicoClaw can connect to WhatsApp in two ways:
- **Native (recommended):** In-process using [whatsmeow](https://github.com/tulir/whatsmeow). No separate bridge. Set `"use_native": true` and leave `bridge_url` empty. On first run, scan the QR code with WhatsApp (Linked Devices). Session is stored under your workspace (e.g. `workspace/whatsapp/`). The native channel is **optional** to keep the default binary small; build with `-tags whatsapp_native` (e.g. `make build-whatsapp-native` or `go build -tags whatsapp_native ./cmd/...`).
- **Bridge:** Connect to an external WebSocket bridge. Set `bridge_url` (e.g. `ws://localhost:3001`) and keep `use_native` false.
**Configure (native)**
```json
{
"channels": {
"whatsapp": {
"enabled": true,
"use_native": true,
"session_store_path": "",
"allow_from": []
}
}
}
```
If `session_store_path` is empty, the session is stored in `&lt;workspace&gt;/whatsapp/`. Run `picoclaw gateway`; on first run, scan the QR code printed in the terminal with WhatsApp → Linked Devices.
</details>
<details>
<summary><b>QQ</b></summary>
**1. Create a bot**
- Go to [QQ Open Platform](https://q.qq.com/#)
- Create an application → Get **AppID** and **AppSecret**
**2. Configure**
```json
{
"channels": {
"qq": {
"enabled": true,
"app_id": "YOUR_APP_ID",
"app_secret": "YOUR_APP_SECRET",
"allow_from": []
}
}
}
```
> Set `allow_from` to empty to allow all users, or specify QQ numbers to restrict access.
**3. Run**
```bash
picoclaw gateway
```
</details>
<details>
<summary><b>DingTalk</b></summary>
**1. Create a bot**
* Go to [Open Platform](https://open.dingtalk.com/)
* Create an internal app
* Copy Client ID and Client Secret
**2. Configure**
```json
{
"channels": {
"dingtalk": {
"enabled": true,
"client_id": "YOUR_CLIENT_ID",
"client_secret": "YOUR_CLIENT_SECRET",
"allow_from": []
}
}
}
```
> Set `allow_from` to empty to allow all users, or specify DingTalk user IDs to restrict access.
**3. Run**
```bash
picoclaw gateway
```
</details>
<details>
<summary><b>Matrix</b></summary>
**1. Prepare bot account**
* Use your preferred homeserver (e.g. `https://matrix.org` or self-hosted)
* Create a bot user and obtain its access token
**2. Configure**
```json
{
"channels": {
"matrix": {
"enabled": true,
"homeserver": "https://matrix.org",
"user_id": "@your-bot:matrix.org",
"access_token": "YOUR_MATRIX_ACCESS_TOKEN",
"allow_from": []
}
}
}
```
**3. Run**
```bash
picoclaw gateway
```
For full options (`device_id`, `join_on_invite`, `group_trigger`, `placeholder`, `reasoning_channel_id`), see [Matrix Channel Configuration Guide](docs/channels/matrix/README.md).
</details>
<details>
<summary><b>LINE</b></summary>
**1. Create a LINE Official Account**
- Go to [LINE Developers Console](https://developers.line.biz/)
- Create a provider → Create a Messaging API channel
- Copy **Channel Secret** and **Channel Access Token**
**2. Configure**
```json
{
"channels": {
"line": {
"enabled": true,
"channel_secret": "YOUR_CHANNEL_SECRET",
"channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
"webhook_path": "/webhook/line",
"allow_from": []
}
}
}
```
> LINE webhook is served on the shared Gateway server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`).
**3. Set up Webhook URL**
LINE requires HTTPS for webhooks. Use a reverse proxy or tunnel:
```bash
# Example with ngrok (gateway default port is 18790)
ngrok http 18790
```
Then set the Webhook URL in LINE Developers Console to `https://your-domain/webhook/line` and enable **Use webhook**.
**4. Run**
```bash
picoclaw gateway
```
> In group chats, the bot responds only when @mentioned. Replies quote the original message.
</details>
<details>
<summary><b>WeCom (企业微信)</b></summary>
PicoClaw supports three types of WeCom integration:
**Option 1: WeCom Bot (Bot)** - Easier setup, supports group chats
**Option 2: WeCom App (Custom App)** - More features, proactive messaging, private chat only
**Option 3: WeCom AI Bot (AI Bot)** - Official AI Bot, streaming replies, supports group & private chat
See [WeCom AI Bot Configuration Guide](docs/channels/wecom/wecom_aibot/README.zh.md) for detailed setup instructions.
**Quick Setup - WeCom AI Bot:**
**1. Create an AI Bot**
* Go to WeCom Admin Console → AI Bot
* Create a new AI Bot → Set name, avatar, etc.
* Copy **Bot ID** and **Secret**
**2. Configure**
```json
{
"channels": {
"wecom_aibot": {
"enabled": true,
"bot_id": "YOUR_BOT_ID",
"secret": "YOUR_SECRET",
"allow_from": [],
"welcome_message": "Hello! How can I help you?"
}
}
}
```
**3. Run**
```bash
picoclaw gateway
```
> **Note**: WeCom AI Bot uses streaming pull protocol — no reply timeout concerns. Long tasks (>30 seconds) automatically switch to `response_url` push delivery.
</details>
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Join the Agent Social Network
Connect Picoclaw to the Agent Social Network simply by sending a single message via the CLI or any integrated Chat App.
**Read `https://clawdchat.ai/skill.md` and follow the instructions to join [ClawdChat.ai](https://clawdchat.ai)**
## 🖥️ CLI Reference
| Command | Description |
| ------------------------- | ----------------------------- |
| `picoclaw onboard` | Initialize config & workspace |
| `picoclaw agent -m "..."` | Chat with the agent |
| `picoclaw agent` | Interactive chat mode |
| `picoclaw gateway` | Start the gateway |
| `picoclaw status` | Show status |
| `picoclaw version` | Show version info |
| `picoclaw cron list` | List all scheduled jobs |
| `picoclaw cron add ...` | Add a scheduled job |
| `picoclaw cron disable` | Disable a scheduled job |
| `picoclaw cron remove` | Remove a scheduled job |
| `picoclaw skills list` | List installed skills |
| `picoclaw skills install` | Install a skill |
| `picoclaw migrate` | Migrate data from older versions |
| `picoclaw auth login` | Authenticate with providers |
### Scheduled Tasks / Reminders
PicoClaw supports scheduled reminders and recurring tasks through the `cron` tool:
* **One-time reminders**: "Remind me in 10 minutes" → triggers once after 10min
* **Recurring tasks**: "Remind me every 2 hours" → triggers every 2 hours
* **Cron expressions**: "Remind me at 9am daily" → uses cron expression
## 🤝 Contribute & Roadmap
PRs welcome! The codebase is intentionally small and readable. 🤗
See our full [Community Roadmap](https://github.com/sipeed/picoclaw/blob/main/ROADMAP.md).
Developer group building, join after your first merged PR!
User Groups:
discord: <https://discord.gg/V4sAZ9XWpN>
<img src="assets/wechat.png" alt="PicoClaw" width="512">
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Join the Agent Social Network
Connect Picoclaw to the Agent Social Network simply by sending a single message via the CLI or any integrated Chat App.
**Read `https://clawdchat.ai/skill.md` and follow the instructions to join [ClawdChat.ai](https://clawdchat.ai)**
## 🖥️ CLI Reference
| Command | Description |
| ------------------------- | ----------------------------- |
| `picoclaw onboard` | Initialize config & workspace |
| `picoclaw agent -m "..."` | Chat with the agent |
| `picoclaw agent` | Interactive chat mode |
| `picoclaw gateway` | Start the gateway |
| `picoclaw status` | Show status |
| `picoclaw version` | Show version info |
| `picoclaw cron list` | List all scheduled jobs |
| `picoclaw cron add ...` | Add a scheduled job |
| `picoclaw cron disable` | Disable a scheduled job |
| `picoclaw cron remove` | Remove a scheduled job |
| `picoclaw skills list` | List installed skills |
| `picoclaw skills install` | Install a skill |
| `picoclaw migrate` | Migrate data from older versions |
| `picoclaw auth login` | Authenticate with providers |
| `picoclaw model` | View or switch the default model |
### Scheduled Tasks / Reminders
PicoClaw supports scheduled reminders and recurring tasks through the `cron` tool:
* **One-time reminders**: "Remind me in 10 minutes" → triggers once after 10min
* **Recurring tasks**: "Remind me every 2 hours" → triggers every 2 hours
* **Cron expressions**: "Remind me at 9am daily" → uses cron expression
## 🤝 Contribute & Roadmap
PRs welcome! The codebase is intentionally small and readable. 🤗
See our full [Community Roadmap](https://github.com/sipeed/picoclaw/blob/main/ROADMAP.md).
Developer group building, join after your first merged PR!
User Groups:
discord: <https://discord.gg/V4sAZ9XWpN>
WeChat:
<img src="assets/wechat.png" alt="WeChat group QR code" width="512">
+1
View File
@@ -218,6 +218,7 @@ make install
| 命令 | 说明 |
| ------------------------- | ---------------------- |
| `picoclaw onboard` | 初始化配置与工作区 |
| `picoclaw onboard weixin` | 扫码连接微信个人号 |
| `picoclaw agent -m "..."` | 与 Agent 对话 |
| `picoclaw agent` | 交互式对话模式 |
| `picoclaw gateway` | 启动网关 |
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

After

Width:  |  Height:  |  Size: 61 KiB

+5 -5
View File
@@ -23,16 +23,16 @@ func agentCmd(message, sessionKey, model string, debug bool) error {
sessionKey = "cli:default"
}
if debug {
logger.SetLevel(logger.DEBUG)
fmt.Println("🔍 Debug mode enabled")
}
cfg, err := internal.LoadConfig()
if err != nil {
return fmt.Errorf("error loading config: %w", err)
}
if debug {
logger.SetLevel(logger.DEBUG)
fmt.Println("🔍 Debug mode enabled")
}
if model != "" {
cfg.Agents.Defaults.ModelName = model
}
+7 -1
View File
@@ -6,6 +6,7 @@ import (
"github.com/sipeed/picoclaw/pkg"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
)
const Logo = pkg.Logo
@@ -28,7 +29,12 @@ func GetConfigPath() string {
}
func LoadConfig() (*config.Config, error) {
return config.LoadConfig(GetConfigPath())
cfg, err := config.LoadConfig(GetConfigPath())
if err != nil {
return nil, err
}
logger.SetLevelFromString(cfg.Agents.Defaults.LogLevel)
return cfg, nil
}
// FormatVersion returns the version string with optional git commit
+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.SetToken(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(` }`)
}
+1
View File
@@ -1,6 +1,7 @@
{
"agents": {
"defaults": {
"log_level": "fatal",
"workspace": "~/.picoclaw/workspace",
"restrict_to_workspace": true,
"model_name": "gpt-5.4",
+67
View File
@@ -0,0 +1,67 @@
# ============================================================
# Stage 1: Build the picoclaw binary
# ============================================================
FROM golang:1.26.0-alpine AS builder
RUN apk add --no-cache git make
WORKDIR /src
# Cache dependencies
COPY go.mod go.sum ./
RUN go mod download
# Copy source and build
COPY . .
RUN make build
# ============================================================
# Stage 2: Node.js runtime with Python + MCP support
# ============================================================
FROM node:24-alpine3.23
RUN apk add --no-cache \
ca-certificates \
curl \
git \
python3 \
py3-pip \
chromium \
jq
# Install Playwright browsers for agent-browser
ENV PLAYWRIGHT_BROWSERS_PATH=/opt/playwright-browsers
RUN npm install -g agent-browser && \
npx playwright install chromium && \
chmod -R o+rx $PLAYWRIGHT_BROWSERS_PATH
# Install uv
RUN curl -LsSf https://astral.sh/uv/install.sh | sh && \
ln -s /root/.local/bin/uv /usr/local/bin/uv && \
ln -s /root/.local/bin/uvx /usr/local/bin/uvx && \
uv --version
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -q --spider http://localhost:18790/health || exit 1
# Copy binary
COPY --from=builder /src/build/picoclaw /usr/local/bin/picoclaw
# Reuse existing node user (UID/GID 1000) — rename to picoclaw
RUN deluser node 2>/dev/null; delgroup node 2>/dev/null; \
addgroup -g 1000 picoclaw 2>/dev/null; \
adduser -D -u 1000 -G picoclaw -h /home/picoclaw picoclaw 2>/dev/null || true
USER picoclaw
# Run onboard to create initial directories and config
RUN /usr/local/bin/picoclaw onboard
# Copy default workspace
COPY --chown=picoclaw:picoclaw workspace/ /home/picoclaw/.picoclaw/workspace/
VOLUME /home/picoclaw/.picoclaw/workspace
ENTRYPOINT ["picoclaw"]
CMD ["gateway"]
+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
@@ -384,6 +384,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
}
+211
View File
@@ -0,0 +1,211 @@
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)
wxCfg := config.WeixinConfig{
BaseURL: "https://ilinkai.weixin.qq.com/",
}
wxCfg.SetToken("token-123")
got := buildWeixinSyncBufPath(wxCfg)
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)
}
}
+53 -19
View File
@@ -259,6 +259,7 @@ type AgentDefaults struct {
MaxMediaSize int `json:"max_media_size,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_MEDIA_SIZE"`
Routing *RoutingConfig `json:"routing,omitempty"`
ToolFeedback ToolFeedbackConfig `json:"tool_feedback,omitempty"`
LogLevel string `json:"log_level,omitempty" env:"PICOCLAW_LOG_LEVEL"`
}
const (
@@ -307,6 +308,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"`
@@ -751,6 +753,27 @@ func (c *WeComAIBotConfig) SetSecret(secret string) {
c.secDirty = true
}
type WeixinConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WEIXIN_ENABLED"`
token string
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"`
secDirty bool
}
func (c *WeixinConfig) Token() string {
return c.token
}
func (c *WeixinConfig) SetToken(token string) *WeixinConfig {
c.token = token
c.secDirty = true
return c
}
type PicoConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_PICO_ENABLED"`
token string
@@ -1391,82 +1414,87 @@ func applySecurityConfig(cfg *Config, sec *SecurityConfig) error {
// Handle Telegram token
if sec.Channels.Telegram != nil && sec.Channels.Telegram.Token != "" {
cfg.Channels.Telegram.SetToken(sec.Channels.Telegram.Token)
cfg.Channels.Telegram.token = sec.Channels.Telegram.Token
}
// Handle Feishu credentials
if sec.Channels.Feishu != nil {
if sec.Channels.Feishu.AppSecret != "" {
cfg.Channels.Feishu.SetAppSecret(sec.Channels.Feishu.AppSecret)
cfg.Channels.Feishu.appSecret = sec.Channels.Feishu.AppSecret
}
if sec.Channels.Feishu.EncryptKey != "" {
cfg.Channels.Feishu.SetEncryptKey(sec.Channels.Feishu.EncryptKey)
cfg.Channels.Feishu.encryptKey = sec.Channels.Feishu.EncryptKey
}
if sec.Channels.Feishu.VerificationToken != "" {
cfg.Channels.Feishu.SetVerificationToken(sec.Channels.Feishu.VerificationToken)
cfg.Channels.Feishu.verificationToken = sec.Channels.Feishu.VerificationToken
}
}
// Handle Discord token
if sec.Channels.Discord != nil && sec.Channels.Discord.Token != "" {
cfg.Channels.Discord.SetToken(sec.Channels.Discord.Token)
cfg.Channels.Discord.token = sec.Channels.Discord.Token
}
// Handle Weixin token
if sec.Channels.Weixin != nil && sec.Channels.Weixin.Token != "" {
cfg.Channels.Discord.token = sec.Channels.Discord.Token
}
// Handle DingTalk client secret
if sec.Channels.DingTalk != nil && sec.Channels.DingTalk.ClientSecret != "" {
cfg.Channels.DingTalk.SetClientSecret(sec.Channels.DingTalk.ClientSecret)
cfg.Channels.DingTalk.clientSecret = sec.Channels.DingTalk.ClientSecret
}
// Handle Slack tokens
if sec.Channels.Slack != nil {
if sec.Channels.Slack.BotToken != "" {
cfg.Channels.Slack.SetBotToken(sec.Channels.Slack.BotToken)
cfg.Channels.Slack.botToken = sec.Channels.Slack.BotToken
}
if sec.Channels.Slack.AppToken != "" {
cfg.Channels.Slack.SetAppToken(sec.Channels.Slack.AppToken)
cfg.Channels.Slack.appToken = sec.Channels.Slack.AppToken
}
}
// Handle Matrix access token
if sec.Channels.Matrix != nil && sec.Channels.Matrix.AccessToken != "" {
cfg.Channels.Matrix.SetAccessToken(sec.Channels.Matrix.AccessToken)
cfg.Channels.Matrix.accessToken = sec.Channels.Matrix.AccessToken
}
// Handle LINE credentials
if sec.Channels.LINE != nil {
if sec.Channels.LINE.ChannelSecret != "" {
cfg.Channels.LINE.SetChannelSecret(sec.Channels.LINE.ChannelSecret)
cfg.Channels.LINE.channelSecret = sec.Channels.LINE.ChannelSecret
}
if sec.Channels.LINE.ChannelAccessToken != "" {
cfg.Channels.LINE.SetChannelAccessToken(sec.Channels.LINE.ChannelAccessToken)
cfg.Channels.LINE.channelAccessToken = sec.Channels.LINE.ChannelAccessToken
}
}
// Handle OneBot access token
if sec.Channels.OneBot != nil && sec.Channels.OneBot.AccessToken != "" {
cfg.Channels.OneBot.SetAccessToken(sec.Channels.OneBot.AccessToken)
cfg.Channels.OneBot.accessToken = sec.Channels.OneBot.AccessToken
}
// Handle WeCom token and encoding key
if sec.Channels.WeCom != nil {
if sec.Channels.WeCom.Token != "" {
cfg.Channels.WeCom.SetToken(sec.Channels.WeCom.Token)
cfg.Channels.WeCom.token = sec.Channels.WeCom.Token
}
if sec.Channels.WeCom.EncodingAESKey != "" {
cfg.Channels.WeCom.SetEncodingAESKey(sec.Channels.WeCom.EncodingAESKey)
cfg.Channels.WeCom.encodingAESKey = sec.Channels.WeCom.EncodingAESKey
}
}
// Handle WeCom App credentials
if sec.Channels.WeComApp != nil {
if sec.Channels.WeComApp.CorpSecret != "" {
cfg.Channels.WeComApp.SetCorpSecret(sec.Channels.WeComApp.CorpSecret)
cfg.Channels.WeComApp.corpSecret = sec.Channels.WeComApp.CorpSecret
}
if sec.Channels.WeComApp.Token != "" {
cfg.Channels.WeComApp.SetToken(sec.Channels.WeComApp.Token)
cfg.Channels.WeComApp.token = sec.Channels.WeComApp.Token
}
if sec.Channels.WeComApp.EncodingAESKey != "" {
cfg.Channels.WeComApp.SetEncodingAESKey(sec.Channels.WeComApp.EncodingAESKey)
cfg.Channels.WeComApp.encodingAESKey = sec.Channels.WeComApp.EncodingAESKey
}
}
@@ -1485,7 +1513,7 @@ func applySecurityConfig(cfg *Config, sec *SecurityConfig) error {
// Handle Pico channel token
if sec.Channels.Pico != nil && sec.Channels.Pico.Token != "" {
cfg.Channels.Pico.SetToken(sec.Channels.Pico.Token)
cfg.Channels.Pico.token = sec.Channels.Pico.Token
}
// Handle IRC passwords
@@ -1503,7 +1531,7 @@ func applySecurityConfig(cfg *Config, sec *SecurityConfig) error {
// Handle QQ app secret
if sec.Channels.QQ != nil && sec.Channels.QQ.AppSecret != "" {
cfg.Channels.QQ.SetAppSecret(sec.Channels.QQ.AppSecret)
cfg.Channels.QQ.appSecret = sec.Channels.QQ.AppSecret
}
cfg.security = sec
@@ -1649,6 +1677,12 @@ func SaveConfig(path string, cfg *Config) error {
}
cfg.Channels.Discord.secDirty = false
}
if cfg.Channels.Weixin.secDirty {
cfg.security.Channels.Weixin = &WeixinSecurity{
Token: cfg.Channels.Weixin.Token(),
}
cfg.Channels.Discord.secDirty = false
}
if cfg.Channels.QQ.secDirty {
cfg.security.Channels.QQ = &QQSecurity{
AppSecret: cfg.Channels.QQ.AppSecret(),
+28
View File
@@ -88,6 +88,7 @@ type channelsConfigV0 struct {
Feishu feishuConfigV0 `json:"feishu"`
Discord discordConfigV0 `json:"discord"`
MaixCam maixcamConfigV0 `json:"maixcam"`
Weixin weixinConfigV0 `json:"weixin"`
QQ qqConfigV0 `json:"qq"`
DingTalk dingtalkConfigV0 `json:"dingtalk"`
Slack slackConfigV0 `json:"slack"`
@@ -107,6 +108,7 @@ func (v *channelsConfigV0) ToChannelsConfig() (ChannelsConfig, ChannelsSecurity)
discord, discordSecurity := v.Discord.ToDiscordConfig()
maixcam := v.MaixCam.ToMaixCamConfig()
qq, qqSecurity := v.QQ.ToQQConfig()
weixin, weixinSecurity := v.Weixin.ToWeiXinConfig()
dingtalk, dingtalkSecurity := v.DingTalk.ToDingTalkConfig()
slack, slackSecurity := v.Slack.ToSlackConfig()
matrix, matrixSecurity := v.Matrix.ToMatrixConfig()
@@ -125,6 +127,7 @@ func (v *channelsConfigV0) ToChannelsConfig() (ChannelsConfig, ChannelsSecurity)
Discord: discord,
MaixCam: maixcam,
QQ: qq,
Weixin: weixin,
DingTalk: dingtalk,
Slack: slack,
Matrix: matrix,
@@ -140,6 +143,7 @@ func (v *channelsConfigV0) ToChannelsConfig() (ChannelsConfig, ChannelsSecurity)
Feishu: &feishuSecurity,
Discord: &discordSecurity,
QQ: &qqSecurity,
Weixin: &weixinSecurity,
DingTalk: &dingtalkSecurity,
Slack: &slackSecurity,
Matrix: &matrixSecurity,
@@ -463,6 +467,30 @@ func (v *wecomConfigV0) ToWeComConfig() (WeComConfig, WeComSecurity) {
}
}
type weixinConfigV0 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"`
}
func (v *weixinConfigV0) ToWeiXinConfig() (WeixinConfig, WeixinSecurity) {
return WeixinConfig{
Enabled: v.Enabled,
token: v.Token,
BaseURL: v.BaseURL,
CDNBaseURL: v.CDNBaseURL,
Proxy: v.Proxy,
AllowFrom: v.AllowFrom,
ReasoningChannelID: v.ReasoningChannelID,
}, WeixinSecurity{
Token: v.Token,
}
}
type wecomappConfigV0 struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_APP_ENABLED"`
CorpID string `json:"corp_id" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_ID"`
+42
View File
@@ -443,6 +443,13 @@ func TestDefaultConfig_CronAllowCommandEnabled(t *testing.T) {
}
}
func TestDefaultConfig_LogLevel(t *testing.T) {
cfg := DefaultConfig()
if cfg.Agents.Defaults.LogLevel != "fatal" {
t.Errorf("LogLevel = %q, want \"fatal\"", cfg.Agents.Defaults.LogLevel)
}
}
func TestLoadConfig_ExecAllowRemoteDefaultsTrueWhenUnset(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
@@ -1052,3 +1059,38 @@ func TestLoadConfig_UsesPassphraseProvider(t *testing.T) {
t.Errorf("api_key = %q, want %q", cfg.ModelList[0].APIKey(), plainKey)
}
}
func TestConfigParsesLogLevel(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "config.json")
data := `{"version":1,"agents":{"defaults":{"log_level":"debug"}}}`
if err := os.WriteFile(cfgPath, []byte(data), 0o600); err != nil {
t.Fatalf("setup: %v", err)
}
cfg, err := LoadConfig(cfgPath)
if err != nil {
t.Fatalf("LoadConfig: %v", err)
}
if cfg.Agents.Defaults.LogLevel != "debug" {
t.Errorf("LogLevel = %q, want \"debug\"", cfg.Agents.Defaults.LogLevel)
}
}
func TestConfigLogLevelEmpty(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "config.json")
data := `{}`
if err := os.WriteFile(cfgPath, []byte(data), 0o600); err != nil {
t.Fatalf("setup: %v", err)
}
cfg, err := LoadConfig(cfgPath)
if err != nil {
t.Fatalf("LoadConfig: %v", err)
}
// When config omits log_level, the DefaultConfig value ("fatal") is preserved.
if cfg.Agents.Defaults.LogLevel != "fatal" {
t.Errorf("LogLevel = %q, want \"fatal\"", cfg.Agents.Defaults.LogLevel)
}
}
+8
View File
@@ -29,6 +29,7 @@ func DefaultConfig() *Config {
Version: CurrentVersion,
Agents: AgentsConfig{
Defaults: AgentDefaults{
LogLevel: "fatal",
Workspace: workspacePath,
RestrictToWorkspace: true,
Provider: "",
@@ -155,6 +156,13 @@ func DefaultConfig() *Config {
WelcomeMessage: "Hello! I'm your AI assistant. How can I help you today?",
ProcessingMessage: DefaultWeComAIBotProcessingMessage,
},
Weixin: WeixinConfig{
Enabled: false,
BaseURL: "https://ilinkai.weixin.qq.com/",
CDNBaseURL: "https://novac2c.cdn.weixin.qq.com/c2c",
AllowFrom: FlexibleStringSlice{},
Proxy: "",
},
Pico: PicoConfig{
Enabled: false,
PingInterval: 30,
+5
View File
@@ -47,6 +47,7 @@ type ChannelsSecurity struct {
Telegram *TelegramSecurity `yaml:"telegram,omitempty"`
Feishu *FeishuSecurity `yaml:"feishu,omitempty"`
Discord *DiscordSecurity `yaml:"discord,omitempty"`
Weixin *WeixinSecurity `yaml:"weixin,omitempty"`
QQ *QQSecurity `yaml:"qq,omitempty"`
DingTalk *DingTalkSecurity `yaml:"dingtalk,omitempty"`
Slack *SlackSecurity `yaml:"slack,omitempty"`
@@ -74,6 +75,10 @@ type DiscordSecurity struct {
Token string `yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"`
}
type WeixinSecurity struct {
Token string `yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_WEIXIN_TOKEN"`
}
type QQSecurity struct {
AppSecret string `yaml:"app_secret,omitempty" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"`
}
+8 -5
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"
@@ -79,16 +80,18 @@ func (p *startupBlockedProvider) GetDefaultModel() string {
// Run starts the gateway runtime using the configuration loaded from configPath.
func Run(debug bool, configPath string, allowEmptyStartup bool) error {
if debug {
logger.SetLevel(logger.DEBUG)
fmt.Println("🔍 Debug mode enabled")
}
cfg, err := config.LoadConfig(configPath)
if err != nil {
return fmt.Errorf("error loading config: %w", err)
}
logger.SetLevelFromString(cfg.Agents.Defaults.LogLevel)
if debug {
logger.SetLevel(logger.DEBUG)
fmt.Println("🔍 Debug mode enabled")
}
provider, modelID, err := createStartupProvider(cfg, allowEmptyStartup)
if err != nil {
return fmt.Errorf("error creating provider: %w", err)
+28 -1
View File
@@ -26,6 +26,7 @@ import (
const (
minIntervalMinutes = 5
defaultIntervalMinutes = 30
userTasksMarker = "Add your heartbeat tasks below this line:"
)
// HeartbeatHandler is the function type for handling heartbeat.
@@ -232,7 +233,7 @@ func (hs *HeartbeatService) buildPrompt() string {
}
content := string(data)
if len(content) == 0 {
if !heartbeatHasUserTasks(content) {
return ""
}
@@ -284,6 +285,32 @@ Add your heartbeat tasks below this line:
}
}
func heartbeatHasUserTasks(content string) bool {
trimmed := strings.TrimSpace(content)
if trimmed == "" {
return false
}
markerIdx := strings.Index(content, userTasksMarker)
if markerIdx < 0 {
return true
}
tasksSection := content[markerIdx+len(userTasksMarker):]
for _, line := range strings.Split(tasksSection, "\n") {
trimmedLine := strings.TrimSpace(line)
if trimmedLine == "" {
continue
}
if strings.HasPrefix(trimmedLine, "#") {
continue
}
return true
}
return false
}
// sendResponse sends the heartbeat response to the last channel
func (hs *HeartbeatService) sendResponse(response string) {
hs.mu.RLock()
+45
View File
@@ -3,6 +3,7 @@ package heartbeat
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -203,3 +204,47 @@ func TestHeartbeatFilePath(t *testing.T) {
t.Errorf("Expected HEARTBEAT.md at %s, but it doesn't exist", expectedPath)
}
}
func TestBuildPrompt_DefaultTemplateStaysIdle(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "heartbeat-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
hs := NewHeartbeatService(tmpDir, 30, true)
hs.createDefaultHeartbeatTemplate()
if prompt := hs.buildPrompt(); prompt != "" {
t.Fatalf("buildPrompt() = %q, want empty prompt for untouched default template", prompt)
}
}
func TestBuildPrompt_UserTasksAfterMarkerProducePrompt(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "heartbeat-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
hs := NewHeartbeatService(tmpDir, 30, true)
hs.createDefaultHeartbeatTemplate()
path := filepath.Join(tmpDir, "HEARTBEAT.md")
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("Failed to read HEARTBEAT.md: %v", err)
}
updated := string(data) + "\n- Check unread Feishu messages\n"
if err := os.WriteFile(path, []byte(updated), 0o644); err != nil {
t.Fatalf("Failed to update HEARTBEAT.md: %v", err)
}
prompt := hs.buildPrompt()
if prompt == "" {
t.Fatal("buildPrompt() = empty, want non-empty prompt when user tasks are present")
}
if !strings.Contains(prompt, "Check unread Feishu messages") {
t.Fatalf("prompt = %q, want user task content", prompt)
}
}
+8 -3
View File
@@ -94,13 +94,18 @@ func MatchAllowed(sender bus.SenderInfo, allowed string) bool {
return false
}
// isNumeric returns true if s consists entirely of digits.
// isNumeric returns true if s consists entirely of digits, allowing for an optional leading minus sign
// (required for Telegram group/channel IDs like -1001234567890).
func isNumeric(s string) bool {
if s == "" {
return false
}
for _, r := range s {
if r < '0' || r > '9' {
start := 0
if s[0] == '-' && len(s) > 1 {
start = 1
}
for i := start; i < len(s); i++ {
if s[i] < '0' || s[i] > '9' {
return false
}
}
+12
View File
@@ -97,6 +97,15 @@ func TestMatchAllowed(t *testing.T) {
allowed: "654321",
want: false,
},
{
name: "negative numeric ID matches PlatformID",
sender: bus.SenderInfo{
Platform: "telegram",
PlatformID: "-1001234567890",
},
allowed: "-1001234567890",
want: true,
},
// Username matching
{
name: "@username matches Username",
@@ -238,6 +247,9 @@ func TestIsNumeric(t *testing.T) {
{"abc", false},
{"12a34", false},
{"telegram", false},
{"-1001234567890", true},
{"-", false},
{"-12a34", false},
}
for _, tt := range tests {
+30
View File
@@ -106,6 +106,36 @@ func GetLevel() LogLevel {
return currentLevel
}
// ParseLevel converts a case-insensitive level name to a LogLevel.
// Returns the level and true if valid, or (INFO, false) if unrecognized.
func ParseLevel(s string) (LogLevel, bool) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "debug":
return DEBUG, true
case "info":
return INFO, true
case "warn", "warning":
return WARN, true
case "error":
return ERROR, true
case "fatal":
return FATAL, true
default:
return INFO, false
}
}
// SetLevelFromString sets the log level from a string value.
// If the string is empty or not a recognized level name, the current level is kept.
func SetLevelFromString(s string) {
if s == "" {
return
}
if level, ok := ParseLevel(s); ok {
SetLevel(level)
}
}
func EnableFileLogging(filePath string) error {
mu.Lock()
defer mu.Unlock()
+85
View File
@@ -252,3 +252,88 @@ func TestFormatFieldValue(t *testing.T) {
})
}
}
func TestDefaultLevelIsInfo(t *testing.T) {
// The package-level default (before any SetLevel call) should be INFO.
// Because earlier tests may have changed it, we just verify the constant is wired correctly.
if logLevelNames[INFO] != "INFO" {
t.Errorf("INFO constant mapped to %q, want \"INFO\"", logLevelNames[INFO])
}
}
func TestParseLevelValid(t *testing.T) {
tests := []struct {
input string
want LogLevel
}{
{"debug", DEBUG},
{"DEBUG", DEBUG},
{"Debug", DEBUG},
{"info", INFO},
{"INFO", INFO},
{"warn", WARN},
{"WARN", WARN},
{"warning", WARN},
{"WARNING", WARN},
{"error", ERROR},
{"ERROR", ERROR},
{"fatal", FATAL},
{"FATAL", FATAL},
{" info ", INFO},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got, ok := ParseLevel(tt.input)
if !ok {
t.Fatalf("ParseLevel(%q) returned ok=false, want true", tt.input)
}
if got != tt.want {
t.Errorf("ParseLevel(%q) = %v, want %v", tt.input, got, tt.want)
}
})
}
}
func TestParseLevelInvalid(t *testing.T) {
tests := []string{"", "garbage", "verbose", "trace", "critical"}
for _, input := range tests {
t.Run(input, func(t *testing.T) {
_, ok := ParseLevel(input)
if ok {
t.Errorf("ParseLevel(%q) returned ok=true, want false", input)
}
})
}
}
func TestSetLevelFromString(t *testing.T) {
initialLevel := GetLevel()
defer SetLevel(initialLevel)
// Valid string changes the level
SetLevel(INFO)
SetLevelFromString("error")
if got := GetLevel(); got != ERROR {
t.Errorf("after SetLevelFromString(\"error\"): GetLevel() = %v, want ERROR", got)
}
// Empty string is a no-op
SetLevelFromString("")
if got := GetLevel(); got != ERROR {
t.Errorf("after SetLevelFromString(\"\"): GetLevel() = %v, want ERROR (unchanged)", got)
}
// Invalid string is a no-op
SetLevelFromString("garbage")
if got := GetLevel(); got != ERROR {
t.Errorf("after SetLevelFromString(\"garbage\"): GetLevel() = %v, want ERROR (unchanged)", got)
}
// Case-insensitive
SetLevelFromString("FATAL")
if got := GetLevel(); got != FATAL {
t.Errorf("after SetLevelFromString(\"FATAL\"): GetLevel() = %v, want FATAL", got)
}
}
@@ -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": {
+129
View File
@@ -0,0 +1,129 @@
---
name: agent-browser
description: "Browser automation via agent-browser CLI. Use when the user needs to navigate websites, fill forms, click buttons, take screenshots, extract data, or test web apps."
metadata: {"nanobot":{"emoji":"🌐","requires":{"bins":["agent-browser"]},"install":[{"id":"npm","kind":"npm","package":"agent-browser","global":true,"bins":["agent-browser"],"label":"Install agent-browser (npm)"}]}}
---
# Agent Browser
CLI browser automation via Chrome/Chromium CDP. Install: `npm i -g agent-browser && agent-browser install`.
**Before using this skill**, verify the tool is available by running `which agent-browser`. If the command is not found, tell the user that browser automation requires the `agent-browser` CLI and Chromium, which are only available in the heavy container image. Do not attempt to install it at runtime.
## Core Workflow
1. `agent-browser open <url>` — navigate
2. `agent-browser snapshot -i` — get interactive elements with refs (`@e1`, `@e2`, ...)
3. Interact using refs — `click @e1`, `fill @e2 "text"`
4. Re-snapshot after any navigation or DOM change — refs are invalidated
```bash
agent-browser open https://example.com/form
agent-browser snapshot -i
# @e1 [input] "Email", @e2 [input] "Password", @e3 [button] "Submit"
agent-browser fill @e1 "user@example.com"
agent-browser fill @e2 "secret"
agent-browser click @e3
agent-browser wait --load networkidle
agent-browser snapshot -i
```
Chain commands with `&&` when you don't need intermediate output:
```bash
agent-browser open https://example.com && agent-browser wait --load networkidle && agent-browser snapshot -i
```
## Commands
```bash
# Navigation
agent-browser open <url>
agent-browser close
# Snapshot
agent-browser snapshot -i # Interactive elements with refs
agent-browser snapshot -s "#selector" # Scope to CSS selector
# Interaction (use @refs from snapshot)
agent-browser click @e1
agent-browser fill @e2 "text" # Clear + type
agent-browser type @e2 "text" # Type without clearing
agent-browser select @e1 "option"
agent-browser check @e1
agent-browser press Enter
agent-browser scroll down 500
# Get info
agent-browser get text @e1
agent-browser get url
agent-browser get title
# Wait
agent-browser wait @e1 # Wait for element
agent-browser wait --load networkidle # Wait for network idle
agent-browser wait --url "**/dashboard" # Wait for URL pattern
agent-browser wait --text "Welcome" # Wait for text
agent-browser wait 2000 # Wait ms
# Capture
agent-browser screenshot # Screenshot to temp dir
agent-browser screenshot --full # Full page
agent-browser screenshot --annotate # With numbered element labels ([N] -> @eN)
agent-browser pdf output.pdf
# Semantic locators (when refs unavailable)
agent-browser find text "Sign In" click
agent-browser find label "Email" fill "user@test.com"
agent-browser find role button click --name "Submit"
```
## Authentication
```bash
# Option 1: Import from user's running Chrome
agent-browser --auto-connect state save ./auth.json
agent-browser --state ./auth.json open https://app.example.com
# Option 2: Persistent profile
agent-browser --profile ~/.myapp open https://app.example.com/login
# ... login once, all future runs are authenticated
# Option 3: Session name (auto-save/restore)
agent-browser --session-name myapp open https://app.example.com/login
# ... login, close, next run state is restored
# Option 4: State file
agent-browser state save auth.json
agent-browser state load auth.json
```
## Iframes
Iframe content is inlined in snapshots. Interact with iframe refs directly — no frame switch needed.
## Parallel Sessions
```bash
agent-browser --session s1 open https://site-a.com
agent-browser --session s2 open https://site-b.com
agent-browser session list
```
## JavaScript Eval
```bash
agent-browser eval 'document.title'
# Complex JS — use --stdin to avoid shell quoting issues
agent-browser eval --stdin <<'EVALEOF'
JSON.stringify(Array.from(document.querySelectorAll("a")).map(a => a.href))
EVALEOF
```
## Cleanup
Always close sessions when done:
```bash
agent-browser close
agent-browser --session s1 close
```