mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
Merge branch 'main' into version
This commit is contained in:
@@ -59,3 +59,6 @@ cmd/telegram/
|
||||
!web/backend/dist/
|
||||
web/backend/dist/*
|
||||
!web/backend/dist/.gitkeep
|
||||
|
||||
|
||||
docker/data
|
||||
@@ -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 (10–20MB) 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. 🦐 PicoClaw,Let'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 10–20MB 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 `<workspace>/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">
|
||||
|
||||
@@ -218,6 +218,7 @@ make install
|
||||
| 命令 | 说明 |
|
||||
| ------------------------- | ---------------------- |
|
||||
| `picoclaw onboard` | 初始化配置与工作区 |
|
||||
| `picoclaw onboard weixin` | 扫码连接微信个人号 |
|
||||
| `picoclaw agent -m "..."` | 与 Agent 对话 |
|
||||
| `picoclaw agent` | 交互式对话模式 |
|
||||
| `picoclaw gateway` | 启动网关 |
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 158 KiB After Width: | Height: | Size: 61 KiB |
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -16,14 +16,22 @@ func NewOnboardCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "onboard",
|
||||
Aliases: []string{"o"},
|
||||
Short: "Initialize picoclaw configuration and workspace",
|
||||
Short: "Initialize picoclaw configuration, workspace, and channel accounts",
|
||||
// Run without subcommands → original onboard flow
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
onboard(encrypt)
|
||||
if len(args) == 0 {
|
||||
onboard(encrypt)
|
||||
} else {
|
||||
_ = cmd.Help()
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVar(&encrypt, "enc", false,
|
||||
"Enable credential encryption (generates SSH key and prompts for passphrase)")
|
||||
|
||||
// Channel onboarding subcommands
|
||||
cmd.AddCommand(newWeixinCommand())
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ func TestNewOnboardCommand(t *testing.T) {
|
||||
require.NotNil(t, cmd)
|
||||
|
||||
assert.Equal(t, "onboard", cmd.Use)
|
||||
assert.Equal(t, "Initialize picoclaw configuration and workspace", cmd.Short)
|
||||
assert.Equal(t, "Initialize picoclaw configuration, workspace, and channel accounts", cmd.Short)
|
||||
|
||||
assert.Len(t, cmd.Aliases, 1)
|
||||
assert.True(t, cmd.HasAlias("o"))
|
||||
@@ -28,5 +28,6 @@ func TestNewOnboardCommand(t *testing.T) {
|
||||
encFlag := cmd.Flags().Lookup("enc")
|
||||
require.NotNil(t, encFlag, "expected --enc flag to be registered")
|
||||
assert.Equal(t, "false", encFlag.DefValue, "--enc should default to false")
|
||||
assert.False(t, cmd.HasSubCommands())
|
||||
assert.True(t, cmd.HasSubCommands())
|
||||
assert.NotNil(t, cmd.Commands())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package onboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||
"github.com/sipeed/picoclaw/pkg/channels/weixin"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
func newWeixinCommand() *cobra.Command {
|
||||
var baseURL string
|
||||
var proxy string
|
||||
var timeout int
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "weixin",
|
||||
Short: "Connect a WeChat personal account via QR code",
|
||||
Long: `Start the interactive Weixin (WeChat personal) QR code login flow.
|
||||
|
||||
A QR code is displayed in the terminal. Scan it with the WeChat mobile app
|
||||
to authorize your account. On success, the bot token is saved to the picoclaw
|
||||
config so you can start the gateway immediately.
|
||||
|
||||
Example:
|
||||
picoclaw onboard weixin`,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return runWeixinOnboard(baseURL, proxy, time.Duration(timeout)*time.Second)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&baseURL, "base-url", "https://ilinkai.weixin.qq.com/", "iLink API base URL")
|
||||
cmd.Flags().StringVar(&proxy, "proxy", "", "HTTP proxy URL (e.g. http://localhost:7890)")
|
||||
cmd.Flags().IntVar(&timeout, "timeout", 300, "Login timeout in seconds")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runWeixinOnboard(baseURL, proxy string, timeout time.Duration) error {
|
||||
fmt.Println("Starting Weixin (WeChat personal) login...")
|
||||
fmt.Println()
|
||||
|
||||
botToken, userID, accountID, returnedBaseURL, err := weixin.PerformLoginInteractive(
|
||||
context.Background(),
|
||||
weixin.AuthFlowOpts{
|
||||
BaseURL: baseURL,
|
||||
Timeout: timeout,
|
||||
Proxy: proxy,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("login failed: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("✅ Login successful!")
|
||||
fmt.Printf(" Account ID : %s\n", accountID)
|
||||
if userID != "" {
|
||||
fmt.Printf(" User ID : %s\n", userID)
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
// Prefer the server-returned base URL (may be region-specific)
|
||||
effectiveBaseURL := returnedBaseURL
|
||||
if effectiveBaseURL == "" {
|
||||
effectiveBaseURL = baseURL
|
||||
}
|
||||
|
||||
if err := saveWeixinConfig(botToken, effectiveBaseURL, proxy); err != nil {
|
||||
fmt.Printf("⚠️ Could not auto-save to config: %v\n", err)
|
||||
printManualWeixinConfig(botToken, effectiveBaseURL)
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Println("✓ Config updated. Start the gateway with:")
|
||||
fmt.Println()
|
||||
fmt.Println(" picoclaw gateway")
|
||||
fmt.Println()
|
||||
fmt.Println("To restrict which WeChat users can send messages, add their user IDs")
|
||||
fmt.Println("to channels.weixin.allow_from in your config.")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// saveWeixinConfig patches channels.weixin in the config and saves it.
|
||||
func saveWeixinConfig(token, baseURL, proxy string) error {
|
||||
cfgPath := internal.GetConfigPath()
|
||||
|
||||
cfg, err := config.LoadConfig(cfgPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
|
||||
cfg.Channels.Weixin.Enabled = true
|
||||
cfg.Channels.Weixin.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,6 +1,7 @@
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"log_level": "fatal",
|
||||
"workspace": "~/.picoclaw/workspace",
|
||||
"restrict_to_workspace": true,
|
||||
"model_name": "gpt-5.4",
|
||||
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,58 @@
|
||||
# 💬 Weixin (WeChat Personal) Channel
|
||||
|
||||
PicoClaw supports connecting to your personal WeChat account using the official Tencent iLink API.
|
||||
|
||||
## 🚀 Quick Onboarding
|
||||
|
||||
The easiest way to set up the Weixin channel is using the interactive onboarding command:
|
||||
|
||||
```bash
|
||||
picoclaw onboard weixin
|
||||
```
|
||||
|
||||
This command will:
|
||||
1. Request a QR code from the iLink API and display it in your terminal.
|
||||
2. Wait for you to scan the QR code with your WeChat mobile app.
|
||||
3. Upon approval, automatically save the generated access token to your `~/.picoclaw/config.json`.
|
||||
|
||||
After onboarding, you can start the gateway:
|
||||
|
||||
```bash
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Configuration
|
||||
|
||||
You can also manually configure the filter rules in `config.json` under the `channels.weixin` section.
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"weixin": {
|
||||
"enabled": true,
|
||||
"token": "YOUR_WEIXIN_TOKEN",
|
||||
"allow_from": [
|
||||
"user_id_1",
|
||||
"user_id_2"
|
||||
],
|
||||
"proxy": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration Fields
|
||||
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| `enabled` | Set to `true` to enable the channel at startup. |
|
||||
| `token` | The authentication token obtained via QR login. |
|
||||
| `allow_from` | (Optional) List of WeChat User IDs permitted to interact with the bot. If empty, anyone who can send messages to the connected account can trigger the bot. |
|
||||
| `proxy` | (Optional) HTTP proxy address (e.g. `http://localhost:7890`) for environments where connection to `ilinkai.weixin.qq.com` is restricted. |
|
||||
|
||||
## ⚠️ Important Notes
|
||||
|
||||
- **One Account Only**: The iLink token binds to a single session. Starting a new interaction generally invalidates older tokens if another device authorizes.
|
||||
- **Message Rate Limits**: To avoid getting your account restricted by WeChat anti-spam systems, avoid loop triggers or high-frequency broadcasts.
|
||||
@@ -0,0 +1,58 @@
|
||||
# 💬 微信个人号渠道 (Weixin)
|
||||
|
||||
PicoClaw 支持使用腾讯官方 iLink API 连接您的个人微信账号。
|
||||
|
||||
## 🚀 快速激活
|
||||
|
||||
最简单的方法是使用交互式 onboarding 命令进行一键激活:
|
||||
|
||||
```bash
|
||||
picoclaw onboard weixin
|
||||
```
|
||||
|
||||
该命令将:
|
||||
1. 从 iLink API 获取二维码并在终端中打印。
|
||||
2. 等待您使用手机微信 App 扫码。
|
||||
3. 扫码确认后,自动将生成的 Access Token 保存至您的 `~/.picoclaw/config.json` 中。
|
||||
|
||||
配置完成后,即可启动网关:
|
||||
|
||||
```bash
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ 配置说明
|
||||
|
||||
您也可以在 `config.json` 的 `channels.weixin` 段目下进行手动维护。
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"weixin": {
|
||||
"enabled": true,
|
||||
"token": "YOUR_WEIXIN_TOKEN",
|
||||
"allow_from": [
|
||||
"user_id_1",
|
||||
"user_id_2"
|
||||
],
|
||||
"proxy": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 字段解析
|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| `enabled` | 设置为 `true` 以在启动时激活该频道。 |
|
||||
| `token` | 通过扫码获取的认证令牌。 |
|
||||
| `allow_from` | (可选) 允许与机器人交互的微信 User ID 列表。如果为空,任何能给此微信号发消息的人都可以触发机器人。 |
|
||||
| `proxy` | (可选) HTTP 代理地址(例如 `http://localhost:7890`),适合网络访问受限环境。 |
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
- **单端绑定**: iLink 令牌通常与单个会话绑定。在其他地方重新扫码激活可能会导致旧令牌失效。
|
||||
- **频率控制**: 为避免触发微信的风控反垃圾机制,请避免设置死循环触发、高频广播等恶意行为。
|
||||
@@ -13,6 +13,7 @@ Talk to your picoclaw through Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk,
|
||||
| **Telegram** | ⭐ Easy | Recommended, voice-to-text, long polling (no public IP needed) | [Docs](../channels/telegram/README.md) |
|
||||
| **Discord** | ⭐ Easy | Socket Mode, group/DM support, rich bot ecosystem | [Docs](../channels/discord/README.md) |
|
||||
| **WhatsApp** | ⭐ Easy | Native (QR scan) or Bridge URL | [Docs](#whatsapp) |
|
||||
| **Weixin** | ⭐ Easy | Native QR scan (Tencent iLink API) | [Docs](../channels/weixin/README.md) |
|
||||
| **Slack** | ⭐ Easy | **Socket Mode** (no public IP needed), enterprise | [Docs](../channels/slack/README.md) |
|
||||
| **Matrix** | ⭐⭐ Medium | Federated protocol, self-hosting supported | [Docs](../channels/matrix/README.md) |
|
||||
| **QQ** | ⭐⭐ Medium | Official bot API, Chinese community | [Docs](../channels/qq/README.md) |
|
||||
@@ -169,6 +170,39 @@ If `session_store_path` is empty, the session is stored in `<workspace>/whatsapp
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Weixin</b> (WeChat Personal)</summary>
|
||||
|
||||
PicoClaw supports connecting to your personal WeChat account using the official Tencent iLink API.
|
||||
|
||||
**1. Login**
|
||||
Run the interactive QR login flow:
|
||||
```bash
|
||||
picoclaw onboard weixin
|
||||
```
|
||||
Scan the printed QR code with your WeChat mobile app. On success, the token is saved to your config.
|
||||
|
||||
**2. Configure**
|
||||
(Optional) Update `allow_from` with your WeChat User ID to restrict who can message the bot:
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"weixin": {
|
||||
"enabled": true,
|
||||
"token": "YOUR_TOKEN",
|
||||
"allow_from": ["YOUR_USER_ID"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**3. Run**
|
||||
```bash
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>QQ</b></summary>
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方
|
||||
| **Telegram** | ⭐ 简单 | 推荐,支持语音转文字,长轮询无需公网 | [查看文档](../channels/telegram/README.zh.md) |
|
||||
| **Discord** | ⭐ 简单 | Socket Mode,支持群组/私信,Bot 生态成熟 | [查看文档](../channels/discord/README.zh.md) |
|
||||
| **WhatsApp** | ⭐ 简单 | 原生 (QR 扫码) 或 Bridge URL | [查看文档](#whatsapp) |
|
||||
| **Weixin** | ⭐ 简单 | 原生扫码登录 (腾讯 iLink API) | [查看文档](../channels/weixin/README.zh.md) |
|
||||
| **Slack** | ⭐ 简单 | **Socket Mode** (无需公网 IP),企业级支持 | [查看文档](../channels/slack/README.zh.md) |
|
||||
| **Matrix** | ⭐⭐ 中等 | 联邦协议,支持自建 homeserver 与公开服务器 | [查看文档](../channels/matrix/README.zh.md) |
|
||||
| **QQ** | ⭐⭐ 中等 | 官方机器人 API,适合国内社群 | [查看文档](../channels/qq/README.zh.md) |
|
||||
@@ -170,6 +171,39 @@ PicoClaw 支持两种 WhatsApp 连接方式:
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Weixin</b> (微信个人号)</summary>
|
||||
|
||||
PicoClaw 支持使用腾讯官方 iLink API 连接您的个人微信账号。
|
||||
|
||||
**1. 登录**
|
||||
运行交互式扫码登录流程:
|
||||
```bash
|
||||
picoclaw onboard weixin
|
||||
```
|
||||
在终端扫描打印出的二维码。登录成功后,Token 将自动保存到您的配置文件中。
|
||||
|
||||
**2. 配置**
|
||||
(可选)更新 `allow_from` 填写微信 User ID,以限制哪些用户可以给机器人发消息:
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"weixin": {
|
||||
"enabled": true,
|
||||
"token": "你的_TOKEN",
|
||||
"allow_from": ["你的_USER_ID"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**3. 运行**
|
||||
```bash
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Matrix</b></summary>
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
package qq
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const qqVoiceMaxDuration = 60 * time.Second
|
||||
|
||||
func qqAudioDuration(localPath, filename, contentType string) (time.Duration, bool, error) {
|
||||
if localPath == "" {
|
||||
return 0, false, nil
|
||||
}
|
||||
|
||||
switch qqAudioDurationFormat(localPath, filename, contentType) {
|
||||
case "wav":
|
||||
return qqWAVDuration(localPath)
|
||||
case "ogg":
|
||||
return qqOggDuration(localPath)
|
||||
default:
|
||||
return 0, false, nil
|
||||
}
|
||||
}
|
||||
|
||||
func qqAudioDurationFormat(localPath, filename, contentType string) string {
|
||||
contentType = strings.ToLower(contentType)
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(contentType, "audio/wav"), strings.HasPrefix(contentType, "audio/x-wav"):
|
||||
return "wav"
|
||||
case strings.HasPrefix(contentType, "audio/ogg"),
|
||||
contentType == "application/ogg",
|
||||
contentType == "application/x-ogg":
|
||||
return "ogg"
|
||||
}
|
||||
|
||||
switch filepath.Ext(strings.ToLower(filename)) {
|
||||
case ".wav":
|
||||
return "wav"
|
||||
case ".ogg", ".opus":
|
||||
return "ogg"
|
||||
}
|
||||
|
||||
switch filepath.Ext(strings.ToLower(localPath)) {
|
||||
case ".wav":
|
||||
return "wav"
|
||||
case ".ogg", ".opus":
|
||||
return "ogg"
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func qqWAVDuration(localPath string) (time.Duration, bool, error) {
|
||||
file, err := os.Open(localPath)
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var header [12]byte
|
||||
if _, err := io.ReadFull(file, header[:]); err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
|
||||
var order binary.ByteOrder
|
||||
switch string(header[:4]) {
|
||||
case "RIFF":
|
||||
order = binary.LittleEndian
|
||||
case "RIFX":
|
||||
order = binary.BigEndian
|
||||
default:
|
||||
return 0, false, nil
|
||||
}
|
||||
|
||||
if string(header[8:12]) != "WAVE" {
|
||||
return 0, false, nil
|
||||
}
|
||||
|
||||
var byteRate uint32
|
||||
var dataSize uint32
|
||||
var foundFmt bool
|
||||
var foundData bool
|
||||
|
||||
for {
|
||||
var chunkHeader [8]byte
|
||||
if _, err := io.ReadFull(file, chunkHeader[:]); err != nil {
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
return 0, false, err
|
||||
}
|
||||
|
||||
chunkSize := order.Uint32(chunkHeader[4:8])
|
||||
switch string(chunkHeader[:4]) {
|
||||
case "fmt ":
|
||||
chunkData := make([]byte, chunkSize)
|
||||
if _, err := io.ReadFull(file, chunkData); err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
if len(chunkData) >= 12 {
|
||||
byteRate = order.Uint32(chunkData[8:12])
|
||||
foundFmt = true
|
||||
}
|
||||
case "data":
|
||||
dataSize = chunkSize
|
||||
foundData = true
|
||||
if _, err := io.CopyN(io.Discard, file, int64(chunkSize)); err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
default:
|
||||
if _, err := io.CopyN(io.Discard, file, int64(chunkSize)); err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
}
|
||||
|
||||
if chunkSize%2 == 1 {
|
||||
if _, err := io.CopyN(io.Discard, file, 1); err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
}
|
||||
|
||||
if foundFmt && foundData {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !foundFmt || !foundData || byteRate == 0 {
|
||||
return 0, false, nil
|
||||
}
|
||||
|
||||
durationNS := int64(dataSize) * int64(time.Second) / int64(byteRate)
|
||||
return time.Duration(durationNS), true, nil
|
||||
}
|
||||
|
||||
func qqOggDuration(localPath string) (time.Duration, bool, error) {
|
||||
file, err := os.Open(localPath)
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var firstPacket []byte
|
||||
var codec string
|
||||
var sampleRate uint32
|
||||
var lastGranule uint64
|
||||
var haveGranule bool
|
||||
|
||||
for {
|
||||
var header [27]byte
|
||||
if _, err := io.ReadFull(file, header[:]); err != nil {
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
return 0, false, err
|
||||
}
|
||||
|
||||
if string(header[:4]) != "OggS" {
|
||||
return 0, false, nil
|
||||
}
|
||||
|
||||
pageSegments := int(header[26])
|
||||
segments := make([]byte, pageSegments)
|
||||
if _, err := io.ReadFull(file, segments); err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
|
||||
payloadLen := 0
|
||||
for _, segLen := range segments {
|
||||
payloadLen += int(segLen)
|
||||
}
|
||||
|
||||
payload := make([]byte, payloadLen)
|
||||
if _, err := io.ReadFull(file, payload); err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
|
||||
granule := binary.LittleEndian.Uint64(header[6:14])
|
||||
if granule != ^uint64(0) {
|
||||
lastGranule = granule
|
||||
haveGranule = true
|
||||
}
|
||||
|
||||
if codec == "" {
|
||||
offset := 0
|
||||
for _, segLen := range segments {
|
||||
firstPacket = append(firstPacket, payload[offset:offset+int(segLen)]...)
|
||||
offset += int(segLen)
|
||||
if segLen < 255 {
|
||||
codec, sampleRate = qqParseOggCodec(firstPacket)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !haveGranule || codec == "" {
|
||||
return 0, false, nil
|
||||
}
|
||||
|
||||
switch codec {
|
||||
case "opus":
|
||||
return time.Duration(lastGranule) * time.Second / 48000, true, nil
|
||||
case "vorbis":
|
||||
if sampleRate == 0 {
|
||||
return 0, false, nil
|
||||
}
|
||||
return time.Duration(lastGranule) * time.Second / time.Duration(sampleRate), true, nil
|
||||
default:
|
||||
return 0, false, nil
|
||||
}
|
||||
}
|
||||
|
||||
func qqParseOggCodec(packet []byte) (string, uint32) {
|
||||
if len(packet) >= 8 && string(packet[:8]) == "OpusHead" {
|
||||
return "opus", 48000
|
||||
}
|
||||
|
||||
if len(packet) >= 16 && packet[0] == 0x01 && string(packet[1:7]) == "vorbis" {
|
||||
sampleRate := binary.LittleEndian.Uint32(packet[12:16])
|
||||
if sampleRate > 0 {
|
||||
return "vorbis", sampleRate
|
||||
}
|
||||
}
|
||||
|
||||
return "", 0
|
||||
}
|
||||
+53
-4
@@ -387,12 +387,11 @@ func (c *QQChannel) uploadMedia(
|
||||
}
|
||||
|
||||
func (c *QQChannel) buildMediaUpload(part bus.MediaPart) (*qqMediaUpload, error) {
|
||||
payload := &qqMediaUpload{
|
||||
FileType: qqFileType(part.Type),
|
||||
}
|
||||
payload := &qqMediaUpload{}
|
||||
|
||||
mediaRef := part.Ref
|
||||
if isHTTPURL(mediaRef) {
|
||||
payload.FileType = qqFileType(c.outboundMediaType(part, ""))
|
||||
payload.URL = mediaRef
|
||||
return payload, nil
|
||||
}
|
||||
@@ -402,15 +401,23 @@ func (c *QQChannel) buildMediaUpload(part bus.MediaPart) (*qqMediaUpload, error)
|
||||
return nil, fmt.Errorf("no media store available: %w", channels.ErrSendFailed)
|
||||
}
|
||||
|
||||
resolved, err := store.Resolve(part.Ref)
|
||||
resolved, meta, err := store.ResolveWithMeta(part.Ref)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("qq resolve media ref %q: %v: %w", part.Ref, err, channels.ErrSendFailed)
|
||||
}
|
||||
if part.Filename == "" {
|
||||
part.Filename = meta.Filename
|
||||
}
|
||||
if part.ContentType == "" {
|
||||
part.ContentType = meta.ContentType
|
||||
}
|
||||
|
||||
if isHTTPURL(resolved) {
|
||||
payload.FileType = qqFileType(c.outboundMediaType(part, ""))
|
||||
payload.URL = resolved
|
||||
return payload, nil
|
||||
}
|
||||
payload.FileType = qqFileType(c.outboundMediaType(part, resolved))
|
||||
|
||||
if limitBytes := c.maxBase64FileSizeBytes(); limitBytes > 0 {
|
||||
info, statErr := os.Stat(resolved)
|
||||
@@ -437,6 +444,48 @@ func (c *QQChannel) buildMediaUpload(part bus.MediaPart) (*qqMediaUpload, error)
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func (c *QQChannel) outboundMediaType(part bus.MediaPart, localPath string) string {
|
||||
if part.Type != "audio" {
|
||||
return part.Type
|
||||
}
|
||||
|
||||
if localPath == "" {
|
||||
logger.InfoCF("qq", "Sending audio as file because duration is unavailable", map[string]any{
|
||||
"ref": part.Ref,
|
||||
"filename": part.Filename,
|
||||
})
|
||||
return "file"
|
||||
}
|
||||
|
||||
duration, ok, err := qqAudioDuration(localPath, part.Filename, part.ContentType)
|
||||
if err != nil {
|
||||
logger.WarnCF("qq", "Failed to detect audio duration, sending as file", map[string]any{
|
||||
"ref": part.Ref,
|
||||
"filename": part.Filename,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return "file"
|
||||
}
|
||||
if !ok {
|
||||
logger.InfoCF("qq", "Sending audio as file because duration is unavailable", map[string]any{
|
||||
"ref": part.Ref,
|
||||
"filename": part.Filename,
|
||||
})
|
||||
return "file"
|
||||
}
|
||||
if duration > qqVoiceMaxDuration {
|
||||
logger.InfoCF("qq", "Sending audio as file because it exceeds QQ voice limit", map[string]any{
|
||||
"ref": part.Ref,
|
||||
"filename": part.Filename,
|
||||
"duration_seconds": duration.Seconds(),
|
||||
"limit_seconds": qqVoiceMaxDuration.Seconds(),
|
||||
})
|
||||
return "file"
|
||||
}
|
||||
|
||||
return "audio"
|
||||
}
|
||||
|
||||
func (c *QQChannel) sendUploadedMedia(
|
||||
ctx context.Context,
|
||||
chatKind, chatID string,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package qq
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
@@ -264,6 +266,142 @@ func TestSendMedia_UploadsLocalFileAsBase64(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendMedia_AudioAt60SecondsUsesVoiceUpload(t *testing.T) {
|
||||
assertAudioWAVUploadType(t, 60*time.Second, 3)
|
||||
}
|
||||
|
||||
func TestSendMedia_AudioOver60SecondsFallsBackToFileUpload(t *testing.T) {
|
||||
assertAudioWAVUploadType(t, 61*time.Second, 4)
|
||||
}
|
||||
|
||||
func assertAudioWAVUploadType(t *testing.T, duration time.Duration, wantFileType uint64) {
|
||||
t.Helper()
|
||||
|
||||
messageBus := bus.NewMessageBus()
|
||||
store := media.NewFileMediaStore()
|
||||
|
||||
localPath := writeWAVFile(t, t.TempDir(), "voice.wav", duration)
|
||||
ref, err := store.Store(localPath, media.MediaMeta{
|
||||
Filename: "voice.wav",
|
||||
ContentType: "audio/wav",
|
||||
}, "qq:test")
|
||||
if err != nil {
|
||||
t.Fatalf("Store() error = %v", err)
|
||||
}
|
||||
|
||||
api := &fakeQQAPI{
|
||||
transportResp: mustJSON(t, dto.Message{FileInfo: []byte("file-info")}),
|
||||
}
|
||||
ch := &QQChannel{
|
||||
BaseChannel: channels.NewBaseChannel("qq", nil, messageBus, nil),
|
||||
api: api,
|
||||
dedup: make(map[string]time.Time),
|
||||
done: make(chan struct{}),
|
||||
ctx: context.Background(),
|
||||
}
|
||||
ch.SetRunning(true)
|
||||
ch.SetMediaStore(store)
|
||||
ch.chatType.Store("group-1", "group")
|
||||
|
||||
err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
|
||||
ChatID: "group-1",
|
||||
Parts: []bus.MediaPart{{
|
||||
Type: "audio",
|
||||
Ref: ref,
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendMedia() error = %v", err)
|
||||
}
|
||||
|
||||
if len(api.transportCalls) != 1 {
|
||||
t.Fatalf("transportCalls = %d, want 1", len(api.transportCalls))
|
||||
}
|
||||
if api.transportCalls[0].body.FileType != wantFileType {
|
||||
t.Fatalf("upload file_type = %d, want %d", api.transportCalls[0].body.FileType, wantFileType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendMedia_RemoteAudioFallsBackToFileUpload(t *testing.T) {
|
||||
messageBus := bus.NewMessageBus()
|
||||
api := &fakeQQAPI{
|
||||
transportResp: mustJSON(t, dto.Message{FileInfo: []byte("remote-file-info")}),
|
||||
}
|
||||
ch := &QQChannel{
|
||||
BaseChannel: channels.NewBaseChannel("qq", nil, messageBus, nil),
|
||||
api: api,
|
||||
dedup: make(map[string]time.Time),
|
||||
done: make(chan struct{}),
|
||||
ctx: context.Background(),
|
||||
}
|
||||
ch.SetRunning(true)
|
||||
ch.chatType.Store("user-1", "direct")
|
||||
|
||||
err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
|
||||
ChatID: "user-1",
|
||||
Parts: []bus.MediaPart{{
|
||||
Type: "audio",
|
||||
Ref: "https://cdn.example.com/voice.ogg",
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendMedia() error = %v", err)
|
||||
}
|
||||
|
||||
if len(api.transportCalls) != 1 {
|
||||
t.Fatalf("transportCalls = %d, want 1", len(api.transportCalls))
|
||||
}
|
||||
if api.transportCalls[0].body.FileType != 4 {
|
||||
t.Fatalf("upload file_type = %d, want 4", api.transportCalls[0].body.FileType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendMedia_LocalAudioWithUnknownDurationFallsBackToFileUpload(t *testing.T) {
|
||||
messageBus := bus.NewMessageBus()
|
||||
store := media.NewFileMediaStore()
|
||||
|
||||
localPath := writeTempFile(t, t.TempDir(), "voice.mp3", []byte("not-a-real-mp3"))
|
||||
ref, err := store.Store(localPath, media.MediaMeta{
|
||||
Filename: "voice.mp3",
|
||||
ContentType: "audio/mpeg",
|
||||
}, "qq:test")
|
||||
if err != nil {
|
||||
t.Fatalf("Store() error = %v", err)
|
||||
}
|
||||
|
||||
api := &fakeQQAPI{
|
||||
transportResp: mustJSON(t, dto.Message{FileInfo: []byte("file-info")}),
|
||||
}
|
||||
ch := &QQChannel{
|
||||
BaseChannel: channels.NewBaseChannel("qq", nil, messageBus, nil),
|
||||
api: api,
|
||||
dedup: make(map[string]time.Time),
|
||||
done: make(chan struct{}),
|
||||
ctx: context.Background(),
|
||||
}
|
||||
ch.SetRunning(true)
|
||||
ch.SetMediaStore(store)
|
||||
ch.chatType.Store("group-1", "group")
|
||||
|
||||
err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
|
||||
ChatID: "group-1",
|
||||
Parts: []bus.MediaPart{{
|
||||
Type: "audio",
|
||||
Ref: ref,
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendMedia() error = %v", err)
|
||||
}
|
||||
|
||||
if len(api.transportCalls) != 1 {
|
||||
t.Fatalf("transportCalls = %d, want 1", len(api.transportCalls))
|
||||
}
|
||||
if api.transportCalls[0].body.FileType != 4 {
|
||||
t.Fatalf("upload file_type = %d, want 4", api.transportCalls[0].body.FileType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendMedia_UsesRemoteURLUploadForC2C(t *testing.T) {
|
||||
messageBus := bus.NewMessageBus()
|
||||
api := &fakeQQAPI{
|
||||
@@ -494,3 +632,53 @@ func writeTempFile(t *testing.T, dir, name string, content []byte) string {
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func writeWAVFile(t *testing.T, dir, name string, duration time.Duration) string {
|
||||
t.Helper()
|
||||
|
||||
const (
|
||||
sampleRate = 8000
|
||||
numChannels = 1
|
||||
bitsPerSample = 8
|
||||
)
|
||||
|
||||
dataSize := uint32(duration / time.Second * sampleRate * numChannels * (bitsPerSample / 8))
|
||||
byteRate := uint32(sampleRate * numChannels * (bitsPerSample / 8))
|
||||
blockAlign := uint16(numChannels * (bitsPerSample / 8))
|
||||
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString("RIFF")
|
||||
if err := binary.Write(&buf, binary.LittleEndian, uint32(36)+dataSize); err != nil {
|
||||
t.Fatalf("binary.Write(riff size) error = %v", err)
|
||||
}
|
||||
buf.WriteString("WAVE")
|
||||
buf.WriteString("fmt ")
|
||||
if err := binary.Write(&buf, binary.LittleEndian, uint32(16)); err != nil {
|
||||
t.Fatalf("binary.Write(fmt chunk size) error = %v", err)
|
||||
}
|
||||
if err := binary.Write(&buf, binary.LittleEndian, uint16(1)); err != nil {
|
||||
t.Fatalf("binary.Write(audio format) error = %v", err)
|
||||
}
|
||||
if err := binary.Write(&buf, binary.LittleEndian, uint16(numChannels)); err != nil {
|
||||
t.Fatalf("binary.Write(channels) error = %v", err)
|
||||
}
|
||||
if err := binary.Write(&buf, binary.LittleEndian, uint32(sampleRate)); err != nil {
|
||||
t.Fatalf("binary.Write(sample rate) error = %v", err)
|
||||
}
|
||||
if err := binary.Write(&buf, binary.LittleEndian, byteRate); err != nil {
|
||||
t.Fatalf("binary.Write(byte rate) error = %v", err)
|
||||
}
|
||||
if err := binary.Write(&buf, binary.LittleEndian, blockAlign); err != nil {
|
||||
t.Fatalf("binary.Write(block align) error = %v", err)
|
||||
}
|
||||
if err := binary.Write(&buf, binary.LittleEndian, uint16(bitsPerSample)); err != nil {
|
||||
t.Fatalf("binary.Write(bits per sample) error = %v", err)
|
||||
}
|
||||
buf.WriteString("data")
|
||||
if err := binary.Write(&buf, binary.LittleEndian, dataSize); err != nil {
|
||||
t.Fatalf("binary.Write(data size) error = %v", err)
|
||||
}
|
||||
buf.Write(make([]byte, dataSize))
|
||||
|
||||
return writeTempFile(t, dir, name, buf.Bytes())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
package weixin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
)
|
||||
|
||||
type ApiClient struct {
|
||||
BaseURL string
|
||||
Token string
|
||||
HttpClient *http.Client
|
||||
}
|
||||
|
||||
func NewApiClient(baseURL, token string, proxy string) (*ApiClient, error) {
|
||||
if baseURL == "" {
|
||||
baseURL = "https://ilinkai.weixin.qq.com/"
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
// Default timeout; will be overridden per context
|
||||
}
|
||||
|
||||
if proxy != "" {
|
||||
proxyURL, err := url.Parse(proxy)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid proxy URL %q: %w", proxy, err)
|
||||
}
|
||||
|
||||
// Clone the default transport so we preserve all default settings (TLS, HTTP/2, timeouts, keep-alives)
|
||||
if defaultTransport, ok := http.DefaultTransport.(*http.Transport); ok {
|
||||
transport := defaultTransport.Clone()
|
||||
transport.Proxy = http.ProxyURL(proxyURL)
|
||||
client.Transport = transport
|
||||
} else {
|
||||
// Fallback: preserve previous behavior if DefaultTransport is not the expected type
|
||||
client.Transport = &http.Transport{
|
||||
Proxy: http.ProxyURL(proxyURL),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &ApiClient{
|
||||
BaseURL: baseURL,
|
||||
Token: token,
|
||||
HttpClient: client,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func randomWechatUIN() string {
|
||||
var b [4]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
uint32Val := binary.BigEndian.Uint32(b[:])
|
||||
return base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%d", uint32Val)))
|
||||
}
|
||||
|
||||
func (c *ApiClient) post(ctx context.Context, endpoint string, body any, responseObj any) error {
|
||||
u, err := url.Parse(c.BaseURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u.Path = path.Join(u.Path, endpoint)
|
||||
|
||||
jsonData, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal request body: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", u.String(), bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if endpoint == "ilink/bot/get_bot_qrcode" || endpoint == "ilink/bot/get_qrcode_status" {
|
||||
// QR routes have different headers sometimes, but let's stick to base ones
|
||||
if endpoint == "ilink/bot/get_qrcode_status" {
|
||||
// Use direct map assignment to send exact header name the Tencent API expects
|
||||
req.Header["iLink-App-ClientVersion"] = []string{"1"}
|
||||
}
|
||||
} else {
|
||||
req.Header["AuthorizationType"] = []string{"ilink_bot_token"}
|
||||
req.Header["X-WECHAT-UIN"] = []string{randomWechatUIN()}
|
||||
if c.Token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.Token)
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := c.HttpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("http POST %s failed: %w", endpoint, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("http %d %s: %s", resp.StatusCode, resp.Status, string(respBody))
|
||||
}
|
||||
|
||||
if responseObj != nil {
|
||||
if err := json.Unmarshal(respBody, responseObj); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal response: %w, body: %s", err, string(respBody))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *ApiClient) GetUpdates(ctx context.Context, req GetUpdatesReq) (*GetUpdatesResp, error) {
|
||||
req.BaseInfo = BaseInfo{ChannelVersion: "1.0.2"}
|
||||
var resp GetUpdatesResp
|
||||
err := c.post(ctx, "ilink/bot/getupdates", req, &resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
func (c *ApiClient) SendMessage(ctx context.Context, req SendMessageReq) (*SendMessageResp, error) {
|
||||
req.BaseInfo = BaseInfo{ChannelVersion: "1.0.2"}
|
||||
var resp SendMessageResp
|
||||
if err := c.post(ctx, "ilink/bot/sendmessage", req, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
func (c *ApiClient) GetUploadUrl(ctx context.Context, req GetUploadUrlReq) (*GetUploadUrlResp, error) {
|
||||
req.BaseInfo = BaseInfo{ChannelVersion: "1.0.2"}
|
||||
var resp GetUploadUrlResp
|
||||
err := c.post(ctx, "ilink/bot/getuploadurl", req, &resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
func (c *ApiClient) GetConfig(ctx context.Context, req GetConfigReq) (*GetConfigResp, error) {
|
||||
req.BaseInfo = BaseInfo{ChannelVersion: "1.0.2"}
|
||||
var resp GetConfigResp
|
||||
if err := c.post(ctx, "ilink/bot/getconfig", req, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
func (c *ApiClient) SendTyping(ctx context.Context, req SendTypingReq) (*SendTypingResp, error) {
|
||||
req.BaseInfo = BaseInfo{ChannelVersion: "1.0.2"}
|
||||
var resp SendTypingResp
|
||||
if err := c.post(ctx, "ilink/bot/sendtyping", req, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
func (c *ApiClient) GetQRCode(ctx context.Context, botType string) (*QRCodeResponse, error) {
|
||||
// get_bot_qrcode is GET, not POST
|
||||
u, err := url.Parse(c.BaseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.Path = path.Join(u.Path, "ilink/bot/get_bot_qrcode")
|
||||
q := u.Query()
|
||||
q.Set("bot_type", botType)
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", u.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := c.HttpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("get_bot_qrcode failed: %d %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var qrcodeResp QRCodeResponse
|
||||
if err := json.Unmarshal(respBody, &qrcodeResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &qrcodeResp, nil
|
||||
}
|
||||
|
||||
func (c *ApiClient) GetQRCodeStatus(ctx context.Context, qrcode string) (*StatusResponse, error) {
|
||||
// get_qrcode_status is GET
|
||||
u, err := url.Parse(c.BaseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.Path = path.Join(u.Path, "ilink/bot/get_qrcode_status")
|
||||
q := u.Query()
|
||||
q.Set("qrcode", qrcode)
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", u.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header["iLink-App-ClientVersion"] = []string{"1"}
|
||||
|
||||
resp, err := c.HttpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("get_qrcode_status failed: %d %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var statusResp StatusResponse
|
||||
if err := json.Unmarshal(respBody, &statusResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &statusResp, nil
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package weixin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/mdp/qrterminal/v3"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
)
|
||||
|
||||
// AuthFlowOpts configures the interactive QR login flow.
|
||||
type AuthFlowOpts struct {
|
||||
BaseURL string
|
||||
BotType string
|
||||
Timeout time.Duration
|
||||
Proxy string
|
||||
}
|
||||
|
||||
// PerformLoginInteractive starts the Weixin QR login flow and blocks until login is successful or times out.
|
||||
// It prints a QR code to the terminal for the user to scan.
|
||||
// Returns the BotToken, UserID, AccountID, and BaseUrl on success.
|
||||
func PerformLoginInteractive(
|
||||
ctx context.Context,
|
||||
opts AuthFlowOpts,
|
||||
) (botToken, userID, accountID, baseUrl string, err error) {
|
||||
if opts.BaseURL == "" {
|
||||
opts.BaseURL = "https://ilinkai.weixin.qq.com/"
|
||||
}
|
||||
if opts.BotType == "" {
|
||||
opts.BotType = "3" // Default iLink Bot Type
|
||||
}
|
||||
if opts.Timeout == 0 {
|
||||
opts.Timeout = 5 * time.Minute
|
||||
}
|
||||
|
||||
api, err := NewApiClient(opts.BaseURL, "", opts.Proxy)
|
||||
if err != nil {
|
||||
return "", "", "", "", fmt.Errorf("failed to create api client: %w", err)
|
||||
}
|
||||
|
||||
logger.InfoC("weixin", "Requesting Weixin QR code...")
|
||||
qrResp, err := api.GetQRCode(ctx, opts.BotType)
|
||||
if err != nil {
|
||||
return "", "", "", "", fmt.Errorf("failed to get qrcode: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("\n=======================================================")
|
||||
fmt.Println("Please scan the following QR code with WeChat to login:")
|
||||
fmt.Println("=======================================================")
|
||||
fmt.Println()
|
||||
|
||||
// Create Small QR
|
||||
qrconfig := qrterminal.Config{
|
||||
Level: qrterminal.L,
|
||||
Writer: os.Stdout,
|
||||
HalfBlocks: true,
|
||||
}
|
||||
qrterminal.GenerateWithConfig(qrResp.QrcodeImgContent, qrconfig)
|
||||
|
||||
fmt.Printf("\nQR Code Link: %s\n\n", qrResp.QrcodeImgContent)
|
||||
fmt.Println("Waiting for scan...")
|
||||
|
||||
timeoutCtx, cancel := context.WithTimeout(ctx, opts.Timeout)
|
||||
defer cancel()
|
||||
|
||||
pollTicker := time.NewTicker(2 * time.Second)
|
||||
defer pollTicker.Stop()
|
||||
|
||||
scannedPrinted := false
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-timeoutCtx.Done():
|
||||
return "", "", "", "", fmt.Errorf("login timeout")
|
||||
case <-pollTicker.C:
|
||||
statusResp, err := api.GetQRCodeStatus(timeoutCtx, qrResp.Qrcode)
|
||||
if err != nil {
|
||||
// Long poll timeout or temporary error
|
||||
continue
|
||||
}
|
||||
|
||||
switch statusResp.Status {
|
||||
case "wait":
|
||||
// still waiting
|
||||
case "scaned":
|
||||
if !scannedPrinted {
|
||||
fmt.Println("👀 QR Code scanned! Please confirm login on your WeChat app...")
|
||||
scannedPrinted = true
|
||||
}
|
||||
case "confirmed":
|
||||
if statusResp.BotToken == "" || statusResp.IlinkBotID == "" {
|
||||
return "", "", "", "", fmt.Errorf("login confirmed but missing bot_token or ilink_bot_id")
|
||||
}
|
||||
logger.InfoCF("weixin", "Login successful", map[string]any{
|
||||
"account_id": statusResp.IlinkBotID,
|
||||
})
|
||||
|
||||
return statusResp.BotToken, statusResp.IlinkUserID, statusResp.IlinkBotID, statusResp.Baseurl, nil
|
||||
case "expired":
|
||||
return "", "", "", "", fmt.Errorf("qrcode expired, please try again")
|
||||
default:
|
||||
logger.WarnCF("weixin", "Unknown QR code status", map[string]any{
|
||||
"status": statusResp.Status,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,226 @@
|
||||
package weixin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
basechannels "github.com/sipeed/picoclaw/pkg/channels"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/fileutil"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
weixinDefaultCDNBaseURL = "https://novac2c.cdn.weixin.qq.com/c2c"
|
||||
weixinConfigCacheTTL = 24 * time.Hour
|
||||
weixinConfigRetryInitial = 2 * time.Second
|
||||
weixinConfigRetryMax = time.Hour
|
||||
weixinSessionPauseDuration = time.Hour
|
||||
weixinSessionExpiredCode = -14
|
||||
)
|
||||
|
||||
type typingTicketCacheEntry struct {
|
||||
ticket string
|
||||
nextFetchAt time.Time
|
||||
retryDelay time.Duration
|
||||
}
|
||||
|
||||
type syncCursorFile struct {
|
||||
GetUpdatesBuf string `json:"get_updates_buf"`
|
||||
}
|
||||
|
||||
func picoclawHomeDir() string {
|
||||
if home := os.Getenv(config.EnvHome); home != "" {
|
||||
return home
|
||||
}
|
||||
userHome, _ := os.UserHomeDir()
|
||||
return filepath.Join(userHome, ".picoclaw")
|
||||
}
|
||||
|
||||
func buildWeixinSyncBufPath(cfg config.WeixinConfig) string {
|
||||
key := "default"
|
||||
token := strings.TrimSpace(cfg.Token())
|
||||
if token != "" {
|
||||
sum := sha256.Sum256([]byte(strings.TrimSpace(cfg.BaseURL) + "|" + token))
|
||||
key = hex.EncodeToString(sum[:8])
|
||||
}
|
||||
return filepath.Join(picoclawHomeDir(), "channels", "weixin", "sync", key+".json")
|
||||
}
|
||||
|
||||
func loadGetUpdatesBuf(path string) (string, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
|
||||
var decoded syncCursorFile
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return decoded.GetUpdatesBuf, nil
|
||||
}
|
||||
|
||||
func saveGetUpdatesBuf(path, cursor string) error {
|
||||
data, err := json.Marshal(syncCursorFile{GetUpdatesBuf: cursor})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return fileutil.WriteFileAtomic(path, data, 0o600)
|
||||
}
|
||||
|
||||
func (c *WeixinChannel) cdnBaseURL() string {
|
||||
if base := strings.TrimSpace(c.config.CDNBaseURL); base != "" {
|
||||
return strings.TrimRight(base, "/")
|
||||
}
|
||||
return weixinDefaultCDNBaseURL
|
||||
}
|
||||
|
||||
func isSessionExpiredStatus(ret, errcode int) bool {
|
||||
return ret == weixinSessionExpiredCode || errcode == weixinSessionExpiredCode
|
||||
}
|
||||
|
||||
func (c *WeixinChannel) pauseSession(operation string, ret, errcode int, errmsg string) time.Duration {
|
||||
c.pauseMu.Lock()
|
||||
defer c.pauseMu.Unlock()
|
||||
|
||||
until := time.Now().Add(weixinSessionPauseDuration)
|
||||
if until.After(c.pauseUntil) {
|
||||
c.pauseUntil = until
|
||||
}
|
||||
|
||||
remaining := time.Until(c.pauseUntil)
|
||||
logger.ErrorCF("weixin", "Session expired; pausing Weixin channel", map[string]any{
|
||||
"operation": operation,
|
||||
"ret": ret,
|
||||
"errcode": errcode,
|
||||
"errmsg": errmsg,
|
||||
"until": c.pauseUntil.Format(time.RFC3339),
|
||||
"minutes": int((remaining + time.Minute - 1) / time.Minute),
|
||||
})
|
||||
return remaining
|
||||
}
|
||||
|
||||
func (c *WeixinChannel) remainingPause() time.Duration {
|
||||
c.pauseMu.Lock()
|
||||
defer c.pauseMu.Unlock()
|
||||
|
||||
if c.pauseUntil.IsZero() {
|
||||
return 0
|
||||
}
|
||||
remaining := time.Until(c.pauseUntil)
|
||||
if remaining <= 0 {
|
||||
c.pauseUntil = time.Time{}
|
||||
return 0
|
||||
}
|
||||
return remaining
|
||||
}
|
||||
|
||||
func (c *WeixinChannel) waitWhileSessionPaused(ctx context.Context) error {
|
||||
remaining := c.remainingPause()
|
||||
if remaining <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
timer := time.NewTimer(remaining)
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (c *WeixinChannel) ensureSessionActive() error {
|
||||
remaining := c.remainingPause()
|
||||
if remaining <= 0 {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf(
|
||||
"weixin session paused (%d min remaining): %w",
|
||||
int((remaining+time.Minute-1)/time.Minute),
|
||||
basechannels.ErrSendFailed,
|
||||
)
|
||||
}
|
||||
|
||||
func (c *WeixinChannel) getTypingTicket(ctx context.Context, userID string) (string, error) {
|
||||
now := time.Now()
|
||||
|
||||
c.typingMu.Lock()
|
||||
entry, ok := c.typingCache[userID]
|
||||
if ok && now.Before(entry.nextFetchAt) {
|
||||
ticket := entry.ticket
|
||||
c.typingMu.Unlock()
|
||||
return ticket, nil
|
||||
}
|
||||
cachedTicket := entry.ticket
|
||||
retryDelay := entry.retryDelay
|
||||
c.typingMu.Unlock()
|
||||
|
||||
contextToken := ""
|
||||
if v, ok := c.contextTokens.Load(userID); ok {
|
||||
contextToken, _ = v.(string)
|
||||
}
|
||||
|
||||
resp, err := c.api.GetConfig(ctx, GetConfigReq{
|
||||
IlinkUserID: userID,
|
||||
ContextToken: contextToken,
|
||||
})
|
||||
if err == nil && resp != nil && resp.Ret == 0 && resp.Errcode == 0 {
|
||||
ticket := strings.TrimSpace(resp.TypingTicket)
|
||||
c.typingMu.Lock()
|
||||
c.typingCache[userID] = typingTicketCacheEntry{
|
||||
ticket: ticket,
|
||||
nextFetchAt: now.Add(weixinConfigCacheTTL),
|
||||
retryDelay: weixinConfigRetryInitial,
|
||||
}
|
||||
c.typingMu.Unlock()
|
||||
return ticket, nil
|
||||
}
|
||||
|
||||
if resp != nil && isSessionExpiredStatus(resp.Ret, resp.Errcode) {
|
||||
c.pauseSession("getconfig", resp.Ret, resp.Errcode, resp.Errmsg)
|
||||
}
|
||||
|
||||
if retryDelay <= 0 {
|
||||
retryDelay = weixinConfigRetryInitial
|
||||
} else {
|
||||
retryDelay *= 2
|
||||
if retryDelay > weixinConfigRetryMax {
|
||||
retryDelay = weixinConfigRetryMax
|
||||
}
|
||||
}
|
||||
|
||||
c.typingMu.Lock()
|
||||
c.typingCache[userID] = typingTicketCacheEntry{
|
||||
ticket: cachedTicket,
|
||||
nextFetchAt: now.Add(retryDelay),
|
||||
retryDelay: retryDelay,
|
||||
}
|
||||
c.typingMu.Unlock()
|
||||
|
||||
if err != nil {
|
||||
return cachedTicket, err
|
||||
}
|
||||
if resp == nil {
|
||||
return cachedTicket, fmt.Errorf("getconfig returned nil response")
|
||||
}
|
||||
return cachedTicket, fmt.Errorf(
|
||||
"getconfig failed: ret=%d errcode=%d errmsg=%s",
|
||||
resp.Ret,
|
||||
resp.Errcode,
|
||||
resp.Errmsg,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package weixin
|
||||
|
||||
// BaseInfo is attached to every outgoing CGI request
|
||||
type BaseInfo struct {
|
||||
ChannelVersion string `json:"channel_version,omitempty"`
|
||||
}
|
||||
|
||||
type APIStatus struct {
|
||||
Ret int `json:"ret,omitempty"`
|
||||
Errcode int `json:"errcode,omitempty"`
|
||||
Errmsg string `json:"errmsg,omitempty"`
|
||||
}
|
||||
|
||||
// UploadMediaType constants
|
||||
const (
|
||||
UploadMediaTypeImage = 1
|
||||
UploadMediaTypeVideo = 2
|
||||
UploadMediaTypeFile = 3
|
||||
UploadMediaTypeVoice = 4
|
||||
)
|
||||
|
||||
type GetUploadUrlReq struct {
|
||||
Filekey string `json:"filekey,omitempty"`
|
||||
MediaType int `json:"media_type,omitempty"`
|
||||
ToUserID string `json:"to_user_id,omitempty"`
|
||||
Rawsize int64 `json:"rawsize,omitempty"`
|
||||
RawfileMD5 string `json:"rawfilemd5,omitempty"`
|
||||
Filesize int64 `json:"filesize,omitempty"`
|
||||
ThumbRawsize int64 `json:"thumb_rawsize,omitempty"`
|
||||
ThumbRawfileMD5 string `json:"thumb_rawfilemd5,omitempty"`
|
||||
ThumbFilesize int64 `json:"thumb_filesize,omitempty"`
|
||||
NoNeedThumb bool `json:"no_need_thumb,omitempty"`
|
||||
Aeskey string `json:"aeskey,omitempty"` // hex-encoded 16-byte AES key
|
||||
BaseInfo BaseInfo `json:"base_info,omitempty"`
|
||||
}
|
||||
|
||||
type GetUploadUrlResp struct {
|
||||
APIStatus
|
||||
UploadParam string `json:"upload_param,omitempty"`
|
||||
ThumbUploadParam string `json:"thumb_upload_param,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
MessageTypeNone = 0
|
||||
MessageTypeUser = 1
|
||||
MessageTypeBot = 2
|
||||
)
|
||||
|
||||
const (
|
||||
MessageItemTypeNone = 0
|
||||
MessageItemTypeText = 1
|
||||
MessageItemTypeImage = 2
|
||||
MessageItemTypeVoice = 3
|
||||
MessageItemTypeFile = 4
|
||||
MessageItemTypeVideo = 5
|
||||
)
|
||||
|
||||
const (
|
||||
MessageStateNew = 0
|
||||
MessageStateGenerating = 1
|
||||
MessageStateFinish = 2
|
||||
)
|
||||
|
||||
type TextItem struct {
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
type CDNMedia struct {
|
||||
EncryptQueryParam string `json:"encrypt_query_param,omitempty"`
|
||||
AesKey string `json:"aes_key,omitempty"` // base64 encoded
|
||||
EncryptType int `json:"encrypt_type,omitempty"`
|
||||
}
|
||||
|
||||
type ImageItem struct {
|
||||
Media *CDNMedia `json:"media,omitempty"`
|
||||
ThumbMedia *CDNMedia `json:"thumb_media,omitempty"`
|
||||
Aeskey string `json:"aeskey,omitempty"`
|
||||
Url string `json:"url,omitempty"`
|
||||
MidSize int64 `json:"mid_size,omitempty"`
|
||||
ThumbSize int64 `json:"thumb_size,omitempty"`
|
||||
ThumbHeight int `json:"thumb_height,omitempty"`
|
||||
ThumbWidth int `json:"thumb_width,omitempty"`
|
||||
HDSize int64 `json:"hd_size,omitempty"`
|
||||
}
|
||||
|
||||
type VoiceItem struct {
|
||||
Media *CDNMedia `json:"media,omitempty"`
|
||||
EncodeType int `json:"encode_type,omitempty"`
|
||||
BitsPerSample int `json:"bits_per_sample,omitempty"`
|
||||
SampleRate int `json:"sample_rate,omitempty"`
|
||||
Playtime int `json:"playtime,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
type FileItem struct {
|
||||
Media *CDNMedia `json:"media,omitempty"`
|
||||
FileName string `json:"file_name,omitempty"`
|
||||
MD5 string `json:"md5,omitempty"`
|
||||
Len string `json:"len,omitempty"`
|
||||
}
|
||||
|
||||
type VideoItem struct {
|
||||
Media *CDNMedia `json:"media,omitempty"`
|
||||
VideoSize int64 `json:"video_size,omitempty"`
|
||||
PlayLength int `json:"play_length,omitempty"`
|
||||
VideoMD5 string `json:"video_md5,omitempty"`
|
||||
ThumbMedia *CDNMedia `json:"thumb_media,omitempty"`
|
||||
ThumbSize int64 `json:"thumb_size,omitempty"`
|
||||
ThumbHeight int `json:"thumb_height,omitempty"`
|
||||
ThumbWidth int `json:"thumb_width,omitempty"`
|
||||
}
|
||||
|
||||
type RefMessage struct {
|
||||
MessageItem *MessageItem `json:"message_item,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
}
|
||||
|
||||
type MessageItem struct {
|
||||
Type int `json:"type,omitempty"`
|
||||
CreateTimeMs int64 `json:"create_time_ms,omitempty"`
|
||||
UpdateTimeMs int64 `json:"update_time_ms,omitempty"`
|
||||
IsCompleted bool `json:"is_completed,omitempty"`
|
||||
MsgID string `json:"msg_id,omitempty"`
|
||||
RefMsg *RefMessage `json:"ref_msg,omitempty"`
|
||||
TextItem *TextItem `json:"text_item,omitempty"`
|
||||
ImageItem *ImageItem `json:"image_item,omitempty"`
|
||||
VoiceItem *VoiceItem `json:"voice_item,omitempty"`
|
||||
FileItem *FileItem `json:"file_item,omitempty"`
|
||||
VideoItem *VideoItem `json:"video_item,omitempty"`
|
||||
}
|
||||
|
||||
type WeixinMessage struct {
|
||||
Seq int `json:"seq,omitempty"`
|
||||
MessageID int64 `json:"message_id,omitempty"`
|
||||
FromUserID string `json:"from_user_id,omitempty"`
|
||||
ToUserID string `json:"to_user_id,omitempty"`
|
||||
ClientID string `json:"client_id,omitempty"`
|
||||
CreateTimeMs int64 `json:"create_time_ms,omitempty"`
|
||||
UpdateTimeMs int64 `json:"update_time_ms,omitempty"`
|
||||
DeleteTimeMs int64 `json:"delete_time_ms,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
GroupID string `json:"group_id,omitempty"`
|
||||
MessageType int `json:"message_type,omitempty"`
|
||||
MessageState int `json:"message_state,omitempty"`
|
||||
ItemList []MessageItem `json:"item_list,omitempty"`
|
||||
ContextToken string `json:"context_token,omitempty"`
|
||||
}
|
||||
|
||||
type GetUpdatesReq struct {
|
||||
SyncBuf string `json:"sync_buf,omitempty"`
|
||||
GetUpdatesBuf string `json:"get_updates_buf,omitempty"`
|
||||
BaseInfo BaseInfo `json:"base_info,omitempty"`
|
||||
}
|
||||
|
||||
type GetUpdatesResp struct {
|
||||
APIStatus
|
||||
Msgs []WeixinMessage `json:"msgs,omitempty"`
|
||||
SyncBuf string `json:"sync_buf,omitempty"`
|
||||
GetUpdatesBuf string `json:"get_updates_buf,omitempty"`
|
||||
LongpollingTimeoutMs int `json:"longpolling_timeout_ms,omitempty"`
|
||||
}
|
||||
|
||||
type SendMessageReq struct {
|
||||
Msg WeixinMessage `json:"msg,omitempty"`
|
||||
BaseInfo BaseInfo `json:"base_info,omitempty"`
|
||||
}
|
||||
|
||||
type SendMessageResp struct {
|
||||
APIStatus
|
||||
}
|
||||
|
||||
type GetConfigReq struct {
|
||||
IlinkUserID string `json:"ilink_user_id,omitempty"`
|
||||
ContextToken string `json:"context_token,omitempty"`
|
||||
BaseInfo BaseInfo `json:"base_info,omitempty"`
|
||||
}
|
||||
|
||||
type GetConfigResp struct {
|
||||
APIStatus
|
||||
TypingTicket string `json:"typing_ticket,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
TypingStatusTyping = 1
|
||||
TypingStatusCancel = 2
|
||||
)
|
||||
|
||||
type SendTypingReq struct {
|
||||
IlinkUserID string `json:"ilink_user_id,omitempty"`
|
||||
TypingTicket string `json:"typing_ticket,omitempty"`
|
||||
Status int `json:"status,omitempty"` // 1=typing, 2=cancel
|
||||
BaseInfo BaseInfo `json:"base_info,omitempty"`
|
||||
}
|
||||
|
||||
type SendTypingResp struct {
|
||||
APIStatus
|
||||
}
|
||||
|
||||
type QRCodeResponse struct {
|
||||
Qrcode string `json:"qrcode"`
|
||||
QrcodeImgContent string `json:"qrcode_img_content"`
|
||||
}
|
||||
|
||||
type StatusResponse struct {
|
||||
Status string `json:"status"` // "wait", "scaned", "confirmed", "expired"
|
||||
BotToken string `json:"bot_token,omitempty"`
|
||||
IlinkBotID string `json:"ilink_bot_id,omitempty"`
|
||||
Baseurl string `json:"baseurl,omitempty"`
|
||||
IlinkUserID string `json:"ilink_user_id,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
package weixin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/channels"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/identity"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
)
|
||||
|
||||
// WeixinChannel is the Weixin channel implementation over Tencent iLink REST API.
|
||||
type WeixinChannel struct {
|
||||
*channels.BaseChannel
|
||||
api *ApiClient
|
||||
config config.WeixinConfig
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
bus *bus.MessageBus
|
||||
// contextTokens stores the last context_token per user (from_user_id → context_token).
|
||||
// This is required by the iLink API to associate replies with the right chat session.
|
||||
contextTokens sync.Map
|
||||
typingMu sync.Mutex
|
||||
typingCache map[string]typingTicketCacheEntry
|
||||
pauseMu sync.Mutex
|
||||
pauseUntil time.Time
|
||||
syncBufPath string
|
||||
}
|
||||
|
||||
func init() {
|
||||
channels.RegisterFactory("weixin", func(cfg *config.Config, bus *bus.MessageBus) (channels.Channel, error) {
|
||||
return NewWeixinChannel(cfg.Channels.Weixin, bus)
|
||||
})
|
||||
}
|
||||
|
||||
// NewWeixinChannel creates a new WeixinChannel from config.
|
||||
func NewWeixinChannel(cfg config.WeixinConfig, messageBus *bus.MessageBus) (*WeixinChannel, error) {
|
||||
api, err := NewApiClient(cfg.BaseURL, cfg.Token(), cfg.Proxy)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("weixin: failed to create API client: %w", err)
|
||||
}
|
||||
|
||||
base := channels.NewBaseChannel(
|
||||
"weixin",
|
||||
cfg,
|
||||
messageBus,
|
||||
cfg.AllowFrom,
|
||||
channels.WithMaxMessageLength(4000),
|
||||
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
|
||||
)
|
||||
|
||||
return &WeixinChannel{
|
||||
BaseChannel: base,
|
||||
api: api,
|
||||
config: cfg,
|
||||
bus: messageBus,
|
||||
typingCache: make(map[string]typingTicketCacheEntry),
|
||||
syncBufPath: buildWeixinSyncBufPath(cfg),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *WeixinChannel) Start(ctx context.Context) error {
|
||||
logger.InfoC("weixin", "Starting Weixin channel")
|
||||
c.ctx, c.cancel = context.WithCancel(ctx)
|
||||
c.SetRunning(true)
|
||||
go c.pollLoop(c.ctx)
|
||||
logger.InfoC("weixin", "Weixin channel started")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *WeixinChannel) Stop(ctx context.Context) error {
|
||||
logger.InfoC("weixin", "Stopping Weixin channel")
|
||||
c.SetRunning(false)
|
||||
if c.cancel != nil {
|
||||
c.cancel()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pollLoop is the long-poll receive loop. It runs until ctx is canceled.
|
||||
func (c *WeixinChannel) pollLoop(ctx context.Context) {
|
||||
const (
|
||||
defaultPollTimeoutMs = 35_000
|
||||
retryDelay = 2 * time.Second
|
||||
backoffDelay = 30 * time.Second
|
||||
maxConsecutiveFails = 3
|
||||
)
|
||||
|
||||
consecutiveFails := 0
|
||||
getUpdatesBuf, err := loadGetUpdatesBuf(c.syncBufPath)
|
||||
if err != nil {
|
||||
logger.WarnCF("weixin", "Failed to load persisted get_updates_buf", map[string]any{
|
||||
"path": c.syncBufPath,
|
||||
"error": err.Error(),
|
||||
})
|
||||
getUpdatesBuf = ""
|
||||
} else if getUpdatesBuf != "" {
|
||||
logger.InfoCF("weixin", "Resuming persisted get_updates_buf", map[string]any{
|
||||
"path": c.syncBufPath,
|
||||
"bytes": len(getUpdatesBuf),
|
||||
"source": "disk",
|
||||
})
|
||||
}
|
||||
nextTimeoutMs := defaultPollTimeoutMs
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
logger.InfoC("weixin", "Weixin poll loop stopped")
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
if err := c.waitWhileSessionPaused(ctx); err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Build a context with timeout slightly longer than the long-poll
|
||||
pollCtx, pollCancel := context.WithTimeout(ctx, time.Duration(nextTimeoutMs+5000)*time.Millisecond)
|
||||
|
||||
resp, err := c.api.GetUpdates(pollCtx, GetUpdatesReq{
|
||||
GetUpdatesBuf: getUpdatesBuf,
|
||||
})
|
||||
pollCancel()
|
||||
|
||||
if err != nil {
|
||||
// Check if we're shutting down
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
consecutiveFails++
|
||||
logger.WarnCF("weixin", "getUpdates failed", map[string]any{
|
||||
"error": err.Error(),
|
||||
"attempt": consecutiveFails,
|
||||
})
|
||||
|
||||
if consecutiveFails >= maxConsecutiveFails {
|
||||
logger.ErrorCF("weixin", "Too many consecutive failures, backing off", map[string]any{
|
||||
"duration": backoffDelay,
|
||||
})
|
||||
consecutiveFails = 0
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(backoffDelay):
|
||||
}
|
||||
} else {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(retryDelay):
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if isSessionExpiredStatus(resp.Ret, resp.Errcode) {
|
||||
remaining := c.pauseSession("getupdates", resp.Ret, resp.Errcode, resp.Errmsg)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(remaining):
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if resp.Errcode != 0 || resp.Ret != 0 {
|
||||
consecutiveFails++
|
||||
logger.ErrorCF("weixin", "getUpdates API error", map[string]any{
|
||||
"ret": resp.Ret,
|
||||
"errcode": resp.Errcode,
|
||||
"errmsg": resp.Errmsg,
|
||||
})
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(retryDelay):
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
consecutiveFails = 0
|
||||
|
||||
// Update the long-poll timeout from server hint
|
||||
if resp.LongpollingTimeoutMs > 0 {
|
||||
nextTimeoutMs = resp.LongpollingTimeoutMs
|
||||
}
|
||||
|
||||
// Advance cursor
|
||||
if resp.GetUpdatesBuf != "" {
|
||||
getUpdatesBuf = resp.GetUpdatesBuf
|
||||
if err := saveGetUpdatesBuf(c.syncBufPath, getUpdatesBuf); err != nil {
|
||||
logger.WarnCF("weixin", "Failed to persist get_updates_buf", map[string]any{
|
||||
"path": c.syncBufPath,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Dispatch messages
|
||||
for _, msg := range resp.Msgs {
|
||||
c.handleInboundMessage(ctx, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleInboundMessage converts a WeixinMessage to a bus.InboundMessage.
|
||||
func (c *WeixinChannel) handleInboundMessage(ctx context.Context, msg WeixinMessage) {
|
||||
fromUserID := msg.FromUserID
|
||||
if fromUserID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
messageID := msg.ClientID
|
||||
if messageID == "" {
|
||||
messageID = uuid.New().String()
|
||||
}
|
||||
|
||||
// Build text content from item_list
|
||||
var parts []string
|
||||
for _, item := range msg.ItemList {
|
||||
switch item.Type {
|
||||
case MessageItemTypeText:
|
||||
if item.TextItem != nil && item.TextItem.Text != "" {
|
||||
parts = append(parts, item.TextItem.Text)
|
||||
}
|
||||
case MessageItemTypeVoice:
|
||||
if item.VoiceItem != nil && item.VoiceItem.Text != "" {
|
||||
// Use voice → text transcription from server
|
||||
parts = append(parts, item.VoiceItem.Text)
|
||||
} else {
|
||||
parts = append(parts, "[audio]")
|
||||
}
|
||||
case MessageItemTypeImage:
|
||||
parts = append(parts, "[image]")
|
||||
case MessageItemTypeFile:
|
||||
if item.FileItem != nil && item.FileItem.FileName != "" {
|
||||
parts = append(parts, fmt.Sprintf("[file: %s]", item.FileItem.FileName))
|
||||
} else {
|
||||
parts = append(parts, "[file]")
|
||||
}
|
||||
case MessageItemTypeVideo:
|
||||
parts = append(parts, "[video]")
|
||||
}
|
||||
}
|
||||
|
||||
var mediaRefs []string
|
||||
if mediaItem := selectInboundMediaItem(msg); mediaItem != nil {
|
||||
ref, err := c.downloadMediaFromItem(ctx, fromUserID, messageID, mediaItem)
|
||||
if err != nil {
|
||||
logger.ErrorCF("weixin", "Failed to download inbound media", map[string]any{
|
||||
"from_user_id": fromUserID,
|
||||
"message_id": messageID,
|
||||
"type": mediaItem.Type,
|
||||
"error": err.Error(),
|
||||
})
|
||||
} else if ref != "" {
|
||||
mediaRefs = append(mediaRefs, ref)
|
||||
}
|
||||
}
|
||||
|
||||
content := strings.Join(parts, "\n")
|
||||
if content == "" && len(mediaRefs) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
sender := bus.SenderInfo{
|
||||
Platform: "weixin",
|
||||
PlatformID: fromUserID,
|
||||
CanonicalID: identity.BuildCanonicalID("weixin", fromUserID),
|
||||
Username: fromUserID,
|
||||
DisplayName: fromUserID,
|
||||
}
|
||||
|
||||
if !c.IsAllowedSender(sender) {
|
||||
logger.DebugCF("weixin", "Message rejected by allowlist", map[string]any{
|
||||
"from_user_id": fromUserID,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
peer := bus.Peer{Kind: "direct", ID: fromUserID}
|
||||
|
||||
metadata := map[string]string{
|
||||
"from_user_id": fromUserID,
|
||||
"context_token": msg.ContextToken,
|
||||
"session_id": msg.SessionID,
|
||||
}
|
||||
|
||||
logger.DebugCF("weixin", "Received message", map[string]any{
|
||||
"from_user_id": fromUserID,
|
||||
"content_len": len(content),
|
||||
"media_count": len(mediaRefs),
|
||||
})
|
||||
|
||||
// Store context_token for outbound reply association
|
||||
if msg.ContextToken != "" {
|
||||
c.contextTokens.Store(fromUserID, msg.ContextToken)
|
||||
}
|
||||
|
||||
c.HandleMessage(ctx, peer, messageID, fromUserID, fromUserID, content, mediaRefs, metadata, sender)
|
||||
}
|
||||
|
||||
// Send implements channels.Channel by sending a text message to the WeChat user.
|
||||
func (c *WeixinChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||
if !c.IsRunning() {
|
||||
return channels.ErrNotRunning
|
||||
}
|
||||
if err := c.ensureSessionActive(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if msg.Content == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// We need a context_token to send a reply. It should be stored in the conversation metadata.
|
||||
// The chat_id is the weixin user_id (from_user_id).
|
||||
toUserID := msg.ChatID
|
||||
|
||||
// Retrieve context_token from our per-user map (stored on last inbound)
|
||||
contextToken := ""
|
||||
if ct, ok := c.contextTokens.Load(toUserID); ok {
|
||||
contextToken, _ = ct.(string)
|
||||
}
|
||||
|
||||
// If we don't have a context token for this user, we cannot send a valid reply.
|
||||
// Treat this as a non-temporary error so the manager doesn't keep retrying.
|
||||
if contextToken == "" {
|
||||
logger.ErrorCF("weixin", "Missing context token, cannot send message", map[string]any{
|
||||
"to_user_id": toUserID,
|
||||
})
|
||||
return fmt.Errorf("weixin send: %w: missing context token for chat %s", channels.ErrSendFailed, toUserID)
|
||||
}
|
||||
|
||||
if err := c.sendTextMessage(ctx, toUserID, contextToken, msg.Content); err != nil {
|
||||
logger.ErrorCF("weixin", "Failed to send message", map[string]any{
|
||||
"to_user_id": toUserID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
if c.remainingPause() > 0 {
|
||||
return fmt.Errorf("weixin send: %w", channels.ErrSendFailed)
|
||||
}
|
||||
return fmt.Errorf("weixin send: %w", channels.ErrTemporary)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,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
@@ -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(),
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"chat": {
|
||||
"welcome": "How can I help you today?",
|
||||
"welcomeDesc": "Ask me about weather, settings, or any other tasks. I'm here to assist you.",
|
||||
"placeholder": "Start a new message...",
|
||||
"placeholder": "Start a new message...\nPress Enter to send, Shift + Enter for a new line",
|
||||
"newChat": "New Chat",
|
||||
"notConnected": "Gateway is not running. Start it to chat.",
|
||||
"thinking": {
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"chat": {
|
||||
"welcome": "今天我能为您做些什么?",
|
||||
"welcomeDesc": "您可以询问我天气、设置或其他任何任务,我随时为您效劳。",
|
||||
"placeholder": "输入新消息...",
|
||||
"placeholder": "输入新消息...\n按 Enter 发送,Shift + Enter 换行",
|
||||
"newChat": "新建对话",
|
||||
"notConnected": "服务未运行,请先启动以进行对话。",
|
||||
"thinking": {
|
||||
|
||||
@@ -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
|
||||
```
|
||||
Reference in New Issue
Block a user