From d7c02050528ae9449f85c4a692a7b7132f8d424c Mon Sep 17 00:00:00 2001 From: Muhammad Asyraf Date: Sat, 28 Mar 2026 17:34:58 +0800 Subject: [PATCH 1/3] docs: add Malay language (#1770) * Add comprehensive documentation for PicoClaw configuration, chat applications, debugging, Docker setup, async tasks, and troubleshooting on MY language: - Introduced a new document on MY language for chat applications configuration detailing setup for Telegram, Discord, WhatsApp, and others. - Created a configuration guide on MY language outlining environment variables, workspace structure, and security settings. - Added a debugging section to assist users in troubleshooting and understanding agent interactions on MY language. - Provided a Docker guide on MY language for easy deployment using Docker Compose. - Documented the use of spawn on MY language for asynchronous tasks and how to configure heartbeat settings. - Included a troubleshooting section on MY language for common model-related errors. * docs: add Malay language support to documentation * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- README.it.md | 4 +- README.ja.md | 4 +- README.md | 2 +- README.my.md | 249 +++++++++++++++++++++ README.pt-br.md | 2 +- README.vi.md | 2 +- README.zh.md | 2 +- docs/my/chat-apps.md | 431 +++++++++++++++++++++++++++++++++++++ docs/my/configuration.md | 216 +++++++++++++++++++ docs/my/debug.md | 33 +++ docs/my/docker.md | 166 ++++++++++++++ docs/my/spawn-tasks.md | 61 ++++++ docs/my/troubleshooting.md | 43 ++++ 13 files changed, 1207 insertions(+), 8 deletions(-) create mode 100644 README.my.md create mode 100644 docs/my/chat-apps.md create mode 100644 docs/my/configuration.md create mode 100644 docs/my/debug.md create mode 100644 docs/my/docker.md create mode 100644 docs/my/spawn-tasks.md create mode 100644 docs/my/troubleshooting.md diff --git a/README.it.md b/README.it.md index 1ed73ee54..90f79043d 100644 --- a/README.it.md +++ b/README.it.md @@ -18,7 +18,7 @@ Discord

-[中文](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** | [Bahasa Indonesia](README.id.md) | [English](README.md) +[中文](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** | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | [English](README.md) @@ -534,7 +534,7 @@ Connetti PicoClaw al Social Network degli Agent semplicemente inviando un singol | `picoclaw skills list` | Elenca le skill installate | | `picoclaw skills install` | Installa una skill | | `picoclaw migrate` | Migra i dati dalle versioni precedenti | -| `picoclaw auth login` | Autenticazione con i provider | +| `picoclaw auth login` | Autenticazione con i provider | ### ⏰ Task Pianificati / Promemoria diff --git a/README.ja.md b/README.ja.md index 9165986ba..ed63c7012 100644 --- a/README.ja.md +++ b/README.ja.md @@ -18,7 +18,7 @@ Discord

-[中文](README.zh.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](README.md) +[中文](README.zh.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) | [Malay](README.my.md) | [English](README.md) @@ -517,7 +517,7 @@ CLI または統合チャットアプリからメッセージを 1 つ送るだ ## 🖥️ CLI リファレンス -| コマンド | 説明 | +| コマンド | 説明 | | ------------------------- | ------------------------------ | | `picoclaw onboard` | 設定&ワークスペースの初期化 | | `picoclaw auth weixin` | WeChat アカウントを QR で接続 | diff --git a/README.md b/README.md index f627e261e..3141c9f12 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Discord

-[中文](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** +[中文](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) | [Malay](README.my.md) | **English** diff --git a/README.my.md b/README.my.md new file mode 100644 index 000000000..bcc74a388 --- /dev/null +++ b/README.my.md @@ -0,0 +1,249 @@ +
+ PicoClaw + +

PicoClaw: Pembantu AI Sangat Efisien dalam Go

+ +

Perkakasan $10 · RAM <10MB · Boot <1s · 皮皮虾,我们走!

+

+ Go + Hardware + License +
+ Website + Docs + Wiki +
+ Twitter + + Discord +

+ +[中文](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) | [English](README.md) | **Bahasa Melayu** + +
+ +--- + +> **PicoClaw** ialah projek sumber terbuka bebas yang dimulakan oleh [Sipeed](https://sipeed.com). Ia ditulis sepenuhnya dalam **Go** — bukan fork OpenClaw, NanoBot, atau mana-mana projek lain. + +🦐 PicoClaw ialah Pembantu AI peribadi yang sangat ringan, diinspirasikan oleh [NanoBot](https://github.com/HKUDS/nanobot), dan dibina semula sepenuhnya dalam Go melalui proses bootstrap kendiri, di mana agen AI itu sendiri memacu keseluruhan migrasi seni bina dan pengoptimuman kod. + +⚡️ Berjalan pada perkakasan $10 dengan RAM <10MB: Itu 99% kurang penggunaan memori berbanding OpenClaw dan 98% lebih murah berbanding Mac mini! + + + + + + +
+

+ +

+
+

+ +

+
+ +> [!CAUTION] +> **🚨 KESELAMATAN & SALURAN RASMI / 安全声明** +> +> * **TIADA KRIPTO:** PicoClaw **TIADA** token/coin rasmi. Semua dakwaan di `pump.fun` atau platform dagangan lain ialah **PENIPUAN**. +> +> * **DOMAIN RASMI:** Satu-satunya laman web rasmi ialah **[picoclaw.io](https://picoclaw.io)**, dan laman web syarikat ialah **[sipeed.com](https://sipeed.com)** +> * **Amaran:** Banyak domain `.ai/.org/.com/.net/...` didaftarkan oleh pihak ketiga. +> * **Amaran:** picoclaw masih dalam pembangunan awal dan mungkin mempunyai isu keselamatan rangkaian yang belum diselesaikan. Jangan deploy ke persekitaran produksi sebelum keluaran v1.0. +> * **Nota:** picoclaw baru-baru ini telah menggabungkan banyak PR, yang mungkin menyebabkan penggunaan memori lebih besar (10–20MB) dalam versi terkini. Kami bercadang mengutamakan pengoptimuman sumber sebaik sahaja set ciri semasa mencapai keadaan stabil. + +## 📢 Berita + +2026-03-17 🚀 **v0.2.3 Dikeluarkan!** UI system tray (Windows & Linux), penjejakan status sub-agen (`spawn_status`), hot-reload gateway eksperimen, gerbang keselamatan cron, dan 2 pembaikan keselamatan. PicoClaw kini mencecah **25K ⭐**! + +2026-03-09 🎉 **v0.2.1 — Kemas kini terbesar setakat ini!** Sokongan protokol MCP, 4 saluran baharu (Matrix/IRC/WeCom/Discord Proxy), 3 penyedia baharu (Kimi/Minimax/Avian), pipeline vision, stor memori JSONL, dan routing model. + +2026-02-28 📦 **v0.2.0** dikeluarkan dengan sokongan Docker Compose dan pelancar Web UI. + +2026-02-26 🎉 PicoClaw mencapai **20K stars** dalam hanya 17 hari! Auto-orchestration saluran dan antara muka capability telah tiba. + +
+Berita lama... + +2026-02-16 🎉 PicoClaw mencapai 12K stars dalam seminggu! Peranan penyelenggara komuniti dan [roadmap](ROADMAP.md) telah disiarkan secara rasmi. + +2026-02-13 🎉 PicoClaw mencapai 5000 stars dalam 4 hari! Roadmap projek dan kumpulan pembangun sedang disusun. + +2026-02-09 🎉 **PicoClaw Dilancarkan!** Dibina dalam 1 hari untuk membawa AI Agents ke perkakasan $10 dengan RAM <10MB. 🦐 PicoClaw, jom pergi! + +
+ +## ✨ Ciri-ciri + +🪶 **Sangat Ringan**: Penggunaan memori <10MB — 99% lebih kecil daripada fungsi teras OpenClaw.* + +💰 **Kos Minimum**: Cukup efisien untuk berjalan pada perkakasan $10 — 98% lebih murah daripada Mac mini. + +⚡️ **Sangat Pantas**: Masa startup 400X lebih pantas, but dalam <1 saat walaupun pada teras tunggal 0.6GHz. + +🌍 **Portabiliti Sebenar**: Satu binari self-contained merentas RISC-V, ARM, MIPS, dan x86, satu klik untuk terus berjalan! + +🤖 **Dibootstrapping oleh AI**: Pelaksanaan Go-native autonomi — 95% teras dijana oleh Agent dengan penambahbaikan human-in-the-loop. + +🔌 **Sokongan MCP**: Integrasi asli [Model Context Protocol](https://modelcontextprotocol.io/) — sambungkan mana-mana pelayan MCP untuk melanjutkan keupayaan agen. + +👁️ **Pipeline Vision**: Hantar imej dan fail terus kepada agen — pengekodan base64 automatik untuk LLM multimodal. + +🧠 **Routing Pintar**: Routing model berasaskan peraturan — pertanyaan mudah pergi ke model ringan, menjimatkan kos API. + +_*Versi terkini mungkin menggunakan 10–20MB disebabkan banyak gabungan ciri yang pantas. Pengoptimuman sumber dirancang. Perbandingan startup berdasarkan penanda aras teras tunggal 0.8GHz (lihat jadual di bawah)._ + +| | OpenClaw | NanoBot | **PicoClaw** | +| ------------------------------ | ------------- | ------------------------------ | ---------------------------------------------- | +| **Bahasa** | TypeScript | Python | **Go** | +| **RAM** | >1GB | >100MB | **< 10MB*** | +| **Startup**
(teras 0.8GHz) | >500s | >30s | **<1s** | +| **Kos** | Mac Mini $599 | Kebanyakan Linux SBC
~$50 | **Mana-mana papan Linux**
**Serendah $10** | + +PicoClaw + +## 🦾 Demonstrasi + +### 🛠️ Aliran Kerja Pembantu Standard + + + + + + + + + + + + + + + + + +

🧩 Jurutera Full-Stack

🗂️ Pengurusan Log & Perancangan

🔎 Carian Web & Pembelajaran

Bangunkan • Deploy • SkalakanJadual • Automasi • MemoriPenemuan • Insight • Trend
+ +### 📱 Jalankan pada Telefon Android Lama + +Berikan telefon lama anda hayat kedua! Tukarkannya menjadi Pembantu AI pintar dengan PicoClaw. Permulaan pantas: + +1. **Pasang [Termux](https://github.com/termux/termux-app)** (Muat turun dari [GitHub Releases](https://github.com/termux/termux-app/releases), atau cari di F-Droid / Google Play). +2. **Jalankan arahan** + +```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 +``` + +Selepas itu, ikut arahan dalam bahagian "Quick Start" untuk melengkapkan konfigurasi! + +PicoClaw + +### 🐜 Deploy Jejak Sumber Rendah yang Inovatif + +PicoClaw boleh dideploy pada hampir mana-mana peranti Linux! + +- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) versi E(Ethernet) atau W(WiFi6), untuk pembantu rumah minimum +- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), atau $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html) untuk penyelenggaraan pelayan automatik +- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) atau $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera) untuk pemantauan pintar + + + +🌟 Lebih banyak senario deployment sedang menanti! + +## 📦 Pemasangan + +### Pasang dengan binari prabina + +Muat turun binari untuk platform anda dari halaman [Releases](https://github.com/sipeed/picoclaw/releases). + +### Pasang dari sumber (ciri terkini, disyorkan untuk pembangunan) + +```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:** Gunakan binari yang sepadan dengan OS anda: Raspberry Pi OS 32-bit → `make build-linux-arm`; 64-bit → `make build-linux-arm64`. Atau jalankan `make build-pi-zero` untuk membina kedua-duanya. + +## 📚 Dokumentasi + +Untuk panduan terperinci, lihat dokumen di bawah. README hanya meliputi quick start. + +| Topik | Penerangan | +| ----------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| 🐳 [Docker & Quick Start](docs/my/docker.md) | Setup Docker Compose, mod Launcher/Agent, konfigurasi Quick Start | +| 💬 [Aplikasi Sembang](docs/my/chat-apps.md) | Telegram, Discord, WhatsApp, Matrix, QQ, Slack, IRC, DingTalk, LINE, Feishu, WeCom, dan banyak lagi | +| ⚙️ [Konfigurasi](docs/my/configuration.md) | Pemboleh ubah persekitaran, susun atur workspace, sumber skill, security sandbox, heartbeat | +| 🔌 [Penyedia & Model](docs/my/providers.md) | 20+ penyedia LLM, routing model, konfigurasi model_list, seni bina penyedia | +| 🔄 [Spawn & Tugasan Async](docs/my/spawn-tasks.md) | Tugasan cepat, tugasan panjang dengan spawn, orkestrasi sub-agen async | +| 🐛 [Penyelesaian Masalah](docs/my/troubleshooting.md) | Masalah lazim dan penyelesaiannya | +| 🔧 [Konfigurasi Tools](docs/my/tools_configuration.md) | Hidup/mati per tool, polisi exec | + +## ClawdChat Sertai Rangkaian Sosial Agent + +Sambungkan Picoclaw ke Rangkaian Sosial Agent hanya dengan menghantar satu mesej melalui CLI atau mana-mana Aplikasi Sembang yang disepadukan. + +**Baca `https://clawdchat.ai/skill.md` dan ikut arahan untuk menyertai [ClawdChat.ai](https://clawdchat.ai)** + +## 🖥️ Rujukan CLI + +| Arahan | Penerangan | +| ------------------------- | ------------------------------- | +| `picoclaw onboard` | Inisialisasi config & workspace | +| `picoclaw agent -m "..."` | Berbual dengan agen | +| `picoclaw agent` | Mod sembang interaktif | +| `picoclaw gateway` | Mulakan gateway | +| `picoclaw status` | Papar status | +| `picoclaw version` | Papar info versi | +| `picoclaw cron list` | Senaraikan semua job berjadual | +| `picoclaw cron add ...` | Tambah job berjadual | +| `picoclaw cron disable` | Nyahaktifkan job berjadual | +| `picoclaw cron remove` | Buang job berjadual | +| `picoclaw skills list` | Senaraikan skill yang dipasang | +| `picoclaw skills install` | Pasang skill | +| `picoclaw migrate` | Migrasi data dari versi lama | +| `picoclaw auth login` | Autentikasi dengan penyedia | + +### Tugasan Berjadual / Peringatan + +PicoClaw menyokong peringatan berjadual dan tugasan berulang melalui tool `cron`: + +* **Peringatan sekali**: "Ingatkan saya dalam 10 minit" → dicetus sekali selepas 10 minit +* **Tugasan berulang**: "Ingatkan saya setiap 2 jam" → dicetus setiap 2 jam +* **Ungkapan cron**: "Ingatkan saya pada 9 pagi setiap hari" → menggunakan ungkapan cron + +## 🤝 Sumbangan & Roadmap + +PR amat dialu-alukan! Kod asas ini sengaja kecil dan mudah dibaca. 🤗 + +Lihat [Roadmap Komuniti](https://github.com/sipeed/picoclaw/blob/main/ROADMAP.md) penuh kami. + +Kumpulan pembangun sedang dibina, sertai selepas PR pertama anda digabungkan! + +Kumpulan pengguna: + +discord: + +PicoClaw \ No newline at end of file diff --git a/README.pt-br.md b/README.pt-br.md index d4b303e24..2d5abeec3 100644 --- a/README.pt-br.md +++ b/README.pt-br.md @@ -18,7 +18,7 @@ Discord

-[中文](README.zh.md) | [日本語](README.ja.md) | **Português** | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [English](README.md) +[中文](README.zh.md) | [日本語](README.ja.md) | **Português** | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | [English](README.md) diff --git a/README.vi.md b/README.vi.md index ceeb02b63..6c82e9a55 100644 --- a/README.vi.md +++ b/README.vi.md @@ -18,7 +18,7 @@ Discord

-[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | **Tiếng Việt** | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [English](README.md) +[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | **Tiếng Việt** | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | [English](README.md) diff --git a/README.zh.md b/README.zh.md index 93abf89d3..4d035e025 100644 --- a/README.zh.md +++ b/README.zh.md @@ -18,7 +18,7 @@ Discord

-**中文** | [日本語](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](README.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) | [Malay](README.my.md) | [English](README.md) diff --git a/docs/my/chat-apps.md b/docs/my/chat-apps.md new file mode 100644 index 000000000..35a35a7cc --- /dev/null +++ b/docs/my/chat-apps.md @@ -0,0 +1,431 @@ +# 💬 Konfigurasi Aplikasi Sembang + +> Kembali ke [README](../../README.my.md) + +## 💬 Aplikasi Sembang + +Berbual dengan picoclaw anda melalui Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, WeCom, Feishu, Slack, IRC, OneBot, MaixCam, atau Pico (protokol asli) + +> **Nota**: Semua saluran berasaskan webhook (LINE, WeCom, dan sebagainya) diservis pada satu pelayan HTTP Gateway yang dikongsi (`gateway.host`:`gateway.port`, lalai `127.0.0.1:18790`). Tiada port khusus per saluran untuk dikonfigurasikan. Nota: Feishu menggunakan mod WebSocket/SDK dan tidak menggunakan pelayan HTTP webhook yang dikongsi. + +| Saluran | Penyediaan | +| ---------------- | ------------------------------------------ | +| **Telegram** | Mudah (hanya token) | +| **Discord** | Mudah (token bot + intents) | +| **WhatsApp** | Mudah (asli: imbas QR; atau bridge URL) | +| **Matrix** | Sederhana (homeserver + access token bot) | +| **QQ** | Mudah (AppID + AppSecret) | +| **DingTalk** | Sederhana (kelayakan aplikasi) | +| **LINE** | Sederhana (kelayakan + webhook URL) | +| **WeCom AI Bot** | Sederhana (Token + kunci AES) | +| **Feishu** | Sederhana (App ID + Secret, mod WebSocket) | +| **Slack** | Sederhana (Bot token + App token) | +| **IRC** | Sederhana (pelayan + konfigurasi TLS) | +| **OneBot** | Sederhana (QQ melalui protokol OneBot) | +| **MaixCam** | Mudah (integrasi perkakasan Sipeed) | +| **Pico** | Protokol PicoClaw asli | + +
+Telegram (Disyorkan) + +**1. Cipta bot** + +* Buka Telegram, cari `@BotFather` +* Hantar `/newbot`, ikut arahan +* Salin token + +**2. Konfigurasi** + +```json +{ + "channels": { + "telegram": { + "enabled": true, + "token": "YOUR_BOT_TOKEN", + "allow_from": ["YOUR_USER_ID"], + "use_markdown_v2": false, + } + } +} +``` + +> Dapatkan user ID anda daripada `@userinfobot` di Telegram. + +**3. Jalankan** + +```bash +picoclaw gateway +``` + +**4. Menu arahan Telegram (auto-register semasa startup)** + +PicoClaw kini menyimpan definisi arahan dalam satu registry bersama. Semasa startup, Telegram akan mendaftarkan arahan bot yang disokong secara automatik (contohnya `/start`, `/help`, `/show`, `/list`) supaya menu arahan dan tingkah laku runtime sentiasa selari. +Pendaftaran menu arahan Telegram kekal sebagai UX penemuan setempat saluran; pelaksanaan arahan generik dikendalikan secara berpusat dalam gelung agen melalui commands executor. + +Jika pendaftaran arahan gagal (ralat sementara rangkaian/API), saluran tetap akan bermula dan PicoClaw akan mencuba semula pendaftaran di latar belakang. + +**4. Pemformatan Lanjutan** +Anda boleh menetapkan `use_markdown_v2: true` untuk mengaktifkan pilihan pemformatan yang lebih maju. Ini membolehkan bot menggunakan keseluruhan set ciri Telegram MarkdownV2, termasuk gaya bersarang, spoiler, dan blok lebar tetap tersuai. + +
+ +
+Discord + +**1. Cipta bot** + +* Pergi ke +* Cipta aplikasi → Bot → Add Bot +* Salin token bot + +**2. Aktifkan intents** + +* Dalam tetapan Bot, aktifkan **MESSAGE CONTENT INTENT** +* (Pilihan) Aktifkan **SERVER MEMBERS INTENT** jika anda bercadang menggunakan allow list berasaskan data ahli + +**3. Dapatkan User ID anda** +* Discord Settings → Advanced → aktifkan **Developer Mode** +* Klik kanan avatar anda → **Copy User ID** + +**4. Konfigurasi** + +```json +{ + "channels": { + "discord": { + "enabled": true, + "token": "YOUR_BOT_TOKEN", + "allow_from": ["YOUR_USER_ID"] + } + } +} +``` + +**5. Jemput bot** + +* OAuth2 → URL Generator +* Scopes: `bot` +* Bot Permissions: `Send Messages`, `Read Message History` +* Buka URL jemputan yang dijana dan tambahkan bot ke pelayan anda + +**Pilihan: Mod trigger kumpulan** + +Secara lalai bot membalas semua mesej dalam saluran pelayan. Untuk mengehadkan balasan kepada @mention sahaja, tambah: + +```json +{ + "channels": { + "discord": { + "group_trigger": { "mention_only": true } + } + } +} +``` + +Anda juga boleh mencetuskan dengan awalan kata kunci (contohnya `!bot`): + +```json +{ + "channels": { + "discord": { + "group_trigger": { "prefixes": ["!bot"] } + } + } +} +``` + +**6. Jalankan** + +```bash +picoclaw gateway +``` + +
+ +
+WhatsApp (asli melalui whatsmeow) + +PicoClaw boleh menyambung ke WhatsApp dalam dua cara: + +- **Asli (disyorkan):** Dalam proses menggunakan [whatsmeow](https://github.com/tulir/whatsmeow). Tiada bridge berasingan. Tetapkan `"use_native": true` dan biarkan `bridge_url` kosong. Pada larian pertama, imbas kod QR dengan WhatsApp (Linked Devices). Sesi disimpan di bawah workspace anda (contohnya `workspace/whatsapp/`). Saluran asli ini adalah **pilihan** untuk memastikan binari lalai kekal kecil; bina dengan `-tags whatsapp_native` (contohnya `make build-whatsapp-native` atau `go build -tags whatsapp_native ./cmd/...`). +- **Bridge:** Sambung ke bridge WebSocket luaran. Tetapkan `bridge_url` (contohnya `ws://localhost:3001`) dan biarkan `use_native` sebagai false. + +**Konfigurasi (asli)** + +```json +{ + "channels": { + "whatsapp": { + "enabled": true, + "use_native": true, + "session_store_path": "", + "allow_from": [] + } + } +} +``` + +Jika `session_store_path` kosong, sesi akan disimpan dalam `/whatsapp/`. Jalankan `picoclaw gateway`; pada larian pertama, imbas kod QR yang dipaparkan dalam terminal menggunakan WhatsApp → Linked Devices. + +
+ +
+QQ + +**1. Cipta bot** + +- Pergi ke [QQ Open Platform](https://q.qq.com/#) +- Cipta aplikasi → Dapatkan **AppID** dan **AppSecret** + +**2. Konfigurasi** + +```json +{ + "channels": { + "qq": { + "enabled": true, + "app_id": "YOUR_APP_ID", + "app_secret": "YOUR_APP_SECRET", + "allow_from": [] + } + } +} +``` + +> Tetapkan `allow_from` kepada kosong untuk membenarkan semua pengguna, atau nyatakan nombor QQ untuk mengehadkan akses. + +**3. Jalankan** + +```bash +picoclaw gateway +``` + +
+ +
+DingTalk + +**1. Cipta bot** + +* Pergi ke [Open Platform](https://open.dingtalk.com/) +* Cipta aplikasi dalaman +* Salin Client ID dan Client Secret + +**2. Konfigurasi** + +```json +{ + "channels": { + "dingtalk": { + "enabled": true, + "client_id": "YOUR_CLIENT_ID", + "client_secret": "YOUR_CLIENT_SECRET", + "allow_from": [] + } + } +} +``` + +> Tetapkan `allow_from` kepada kosong untuk membenarkan semua pengguna, atau nyatakan user ID DingTalk untuk mengehadkan akses. + +**3. Jalankan** + +```bash +picoclaw gateway +``` +
+ +
+Matrix + +**1. Sediakan akaun bot** + +* Gunakan homeserver pilihan anda (contohnya `https://matrix.org` atau self-hosted) +* Cipta pengguna bot dan dapatkan access tokennya + +**2. Konfigurasi** + +```json +{ + "channels": { + "matrix": { + "enabled": true, + "homeserver": "https://matrix.org", + "user_id": "@your-bot:matrix.org", + "access_token": "YOUR_MATRIX_ACCESS_TOKEN", + "allow_from": [] + } + } +} +``` + +**3. Jalankan** + +```bash +picoclaw gateway +``` + +Untuk pilihan penuh (`device_id`, `join_on_invite`, `group_trigger`, `placeholder`, `reasoning_channel_id`), lihat [Panduan Konfigurasi Saluran Matrix](docs/channels/matrix/README.md). + +
+ +
+LINE + +**1. Cipta Akaun Rasmi LINE** + +- Pergi ke [LINE Developers Console](https://developers.line.biz/) +- Cipta provider → Cipta saluran Messaging API +- Salin **Channel Secret** dan **Channel Access Token** + +**2. Konfigurasi** + +```json +{ + "channels": { + "line": { + "enabled": true, + "channel_secret": "YOUR_CHANNEL_SECRET", + "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN", + "webhook_path": "/webhook/line", + "allow_from": [] + } + } +} +``` + +> Webhook LINE diservis pada pelayan Gateway yang dikongsi (`gateway.host`:`gateway.port`, lalai `127.0.0.1:18790`). + +**3. Tetapkan Webhook URL** + +LINE memerlukan HTTPS untuk webhook. Gunakan reverse proxy atau tunnel: + +```bash +# Contoh dengan ngrok (port lalai gateway ialah 18790) +ngrok http 18790 +``` + +Kemudian tetapkan Webhook URL dalam LINE Developers Console kepada `https://your-domain/webhook/line` dan aktifkan **Use webhook**. + +**4. Jalankan** + +```bash +picoclaw gateway +``` + +> Dalam sembang kumpulan, bot hanya membalas apabila @disebut. Balasan akan memetik mesej asal. + +
+ +
+WeCom (企业微信) + +PicoClaw menyokong tiga jenis integrasi WeCom: + +**Pilihan 1: WeCom Bot (Bot)** - Penyediaan lebih mudah, menyokong sembang kumpulan +**Pilihan 2: WeCom App (Custom App)** - Lebih banyak ciri, pemesejan proaktif, sembang peribadi sahaja +**Pilihan 3: WeCom AI Bot (AI Bot)** - AI Bot rasmi, balasan streaming, menyokong sembang kumpulan & peribadi + +Lihat [Panduan Konfigurasi WeCom AI Bot](docs/channels/wecom/wecom_aibot/README.zh.md) untuk arahan penyediaan terperinci. + +**Quick Setup - WeCom Bot:** + +**1. Cipta bot** + +* Pergi ke WeCom Admin Console → Group Chat → Add Group Bot +* Salin webhook URL (format: `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`) + +**2. Konfigurasi** + +```json +{ + "channels": { + "wecom": { + "enabled": true, + "token": "YOUR_TOKEN", + "encoding_aes_key": "YOUR_ENCODING_AES_KEY", + "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY", + "webhook_path": "/webhook/wecom", + "allow_from": [] + } + } +} +``` + +> Webhook WeCom diservis pada pelayan Gateway yang dikongsi (`gateway.host`:`gateway.port`, lalai `127.0.0.1:18790`). + +**Quick Setup - WeCom App:** + +**1. Cipta aplikasi** + +* Pergi ke WeCom Admin Console → App Management → Create App +* Salin **AgentId** dan **Secret** +* Pergi ke halaman "My Company", salin **CorpID** + +**2. Konfigurasi penerimaan mesej** + +* Dalam butiran aplikasi, klik "Receive Message" → "Set API" +* Tetapkan URL kepada `http://your-server:18790/webhook/wecom-app` +* Jana **Token** dan **EncodingAESKey** + +**3. Konfigurasi** + +```json +{ + "channels": { + "wecom_app": { + "enabled": true, + "corp_id": "wwxxxxxxxxxxxxxxxx", + "corp_secret": "YOUR_CORP_SECRET", + "agent_id": 1000002, + "token": "YOUR_TOKEN", + "encoding_aes_key": "YOUR_ENCODING_AES_KEY", + "webhook_path": "/webhook/wecom-app", + "allow_from": [] + } + } +} +``` + +**4. Jalankan** + +```bash +picoclaw gateway +``` + +> **Nota**: Callback webhook WeCom diservis pada port Gateway (lalai 18790). Gunakan reverse proxy untuk HTTPS. + +**Quick Setup - WeCom AI Bot:** + +**1. Cipta AI Bot** + +* Pergi ke WeCom Admin Console → App Management → AI Bot +* Dalam tetapan AI Bot, konfigurasikan callback URL: `http://your-server:18791/webhook/wecom-aibot` +* Salin **Token** dan klik "Random Generate" untuk **EncodingAESKey** + +**2. Konfigurasi** + +```json +{ + "channels": { + "wecom_aibot": { + "enabled": true, + "token": "YOUR_TOKEN", + "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", + "webhook_path": "/webhook/wecom-aibot", + "allow_from": [], + "welcome_message": "Hello! How can I help you?" + } + } +} +``` + +**3. Jalankan** + +```bash +picoclaw gateway +``` + +> **Nota**: WeCom AI Bot menggunakan protokol streaming pull — tiada isu timeout balasan. Tugasan panjang (>30 saat) akan bertukar secara automatik kepada penghantaran push `response_url`. + +
diff --git a/docs/my/configuration.md b/docs/my/configuration.md new file mode 100644 index 000000000..f798bd9bd --- /dev/null +++ b/docs/my/configuration.md @@ -0,0 +1,216 @@ +# ⚙️ Panduan Konfigurasi + +> Kembali ke [README](../../README.my.md) + +## ⚙️ Konfigurasi + +Fail konfigurasi: `~/.picoclaw/config.json` + +### Pemboleh Ubah Persekitaran + +Anda boleh menggantikan laluan lalai menggunakan pemboleh ubah persekitaran. Ini berguna untuk pemasangan mudah alih, deployment dalam container, atau menjalankan picoclaw sebagai system service. Pemboleh ubah ini saling bebas dan mengawal laluan yang berbeza. + +| Pemboleh Ubah | Penerangan | Laluan Lalai | +| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------- | +| `PICOCLAW_CONFIG` | Menindih laluan ke fail konfigurasi. Ini memberitahu picoclaw secara terus fail `config.json` yang perlu dimuatkan, dengan mengabaikan lokasi lain. | `~/.picoclaw/config.json` | +| `PICOCLAW_HOME` | Menindih direktori root untuk data picoclaw. Ini mengubah lokasi lalai bagi `workspace` dan direktori data lain. | `~/.picoclaw` | + +**Contoh:** + +```bash +# Jalankan picoclaw menggunakan fail config tertentu +# Laluan workspace akan dibaca daripada fail config tersebut +PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway + +# Jalankan picoclaw dengan semua data disimpan di /opt/picoclaw +# Config akan dimuatkan dari lalai ~/.picoclaw/config.json +# Workspace akan dicipta di /opt/picoclaw/workspace +PICOCLAW_HOME=/opt/picoclaw picoclaw agent + +# Gunakan kedua-duanya untuk setup yang disesuaikan sepenuhnya +PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway +``` + +### Susun Atur Workspace + +PicoClaw menyimpan data dalam workspace yang dikonfigurasikan (lalai: `~/.picoclaw/workspace`): + +``` +~/.picoclaw/workspace/ +├── sessions/ # Sesi perbualan dan sejarah +├── memory/ # Memori jangka panjang (MEMORY.md) +├── state/ # Keadaan persisten (saluran terakhir, dll.) +├── cron/ # Pangkalan data job berjadual +├── skills/ # Skill tersuai +├── AGENTS.md # Panduan tingkah laku agen +├── HEARTBEAT.md # Prompt tugasan berkala (disemak setiap 30 minit) +├── IDENTITY.md # Identiti agen +├── SOUL.md # Jiwa agen +└── USER.md # Keutamaan pengguna +``` + +### Sumber Skill + +Secara lalai, skill dimuatkan daripada: + +1. `~/.picoclaw/workspace/skills` (workspace) +2. `~/.picoclaw/skills` (global) +3. `/skills` (builtin) + +Untuk setup lanjutan/ujian, anda boleh menindih root builtin skills dengan: + +```bash +export PICOCLAW_BUILTIN_SKILLS=/path/to/skills +``` + +### Polisi Pelaksanaan Arahan Bersepadu + +- Generic slash command dilaksanakan melalui satu laluan dalam `pkg/agent/loop.go` melalui `commands.Executor`. +- Adapter saluran tidak lagi menggunakan generic command secara setempat; ia memajukan teks masuk ke laluan bus/agent. Telegram masih auto-register arahan yang disokong semasa startup. +- Slash command yang tidak dikenali (contohnya `/foo`) akan diteruskan ke pemprosesan LLM biasa. +- Arahan yang didaftarkan tetapi tidak disokong pada saluran semasa (contohnya `/show` di WhatsApp) akan memulangkan ralat yang jelas kepada pengguna dan menghentikan pemprosesan lanjut. + +### 🔒 Security Sandbox + +PicoClaw berjalan dalam persekitaran bersandbox secara lalai. Agen hanya boleh mengakses fail dan melaksanakan arahan dalam workspace yang dikonfigurasikan. + +#### Konfigurasi Lalai + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "restrict_to_workspace": true + } + } +} +``` + +| Option | Default | Description | +| ----------------------- | ----------------------- | ----------------------------------------- | +| `workspace` | `~/.picoclaw/workspace` | Direktori kerja untuk agen | +| `restrict_to_workspace` | `true` | Hadkan akses fail/arahan kepada workspace | + +#### Tools yang Dilindungi + +Apabila `restrict_to_workspace: true`, tools berikut disandboxkan: + +| Tool | Fungsi | Sekatan | +| ------------- | ----------------- | ----------------------------------- | +| `read_file` | Baca fail | Hanya fail dalam workspace | +| `write_file` | Tulis fail | Hanya fail dalam workspace | +| `list_dir` | Senarai direktori | Hanya direktori dalam workspace | +| `edit_file` | Edit fail | Hanya fail dalam workspace | +| `append_file` | Tambah ke fail | Hanya fail dalam workspace | +| `exec` | Jalankan arahan | Laluan arahan mesti dalam workspace | + +#### Perlindungan Exec Tambahan + +Walaupun dengan `restrict_to_workspace: false`, tool `exec` menyekat arahan berbahaya berikut: + +* `rm -rf`, `del /f`, `rmdir /s` — Pemadaman pukal +* `format`, `mkfs`, `diskpart` — Pemformatan cakera +* `dd if=` — Pengimejan cakera +* Menulis ke `/dev/sd[a-z]` — Tulis terus ke cakera +* `shutdown`, `reboot`, `poweroff` — Penutupan sistem +* Fork bomb `:(){ :|:& };:` + +### Kawalan Akses Fail + +| Kunci Config | Jenis | Lalai | Penerangan | +| ------------------------- | -------- | ----- | --------------------------------------------------------------- | +| `tools.allow_read_paths` | string[] | `[]` | Laluan tambahan yang dibenarkan untuk dibaca di luar workspace | +| `tools.allow_write_paths` | string[] | `[]` | Laluan tambahan yang dibenarkan untuk ditulis di luar workspace | + +### Keselamatan Exec + +| Kunci Config | Jenis | Lalai | Penerangan | +| ---------------------------------- | -------- | ------- | ------------------------------------------------------------ | +| `tools.exec.allow_remote` | bool | `false` | Benarkan tool exec dari saluran jauh (Telegram/Discord dll.) | +| `tools.exec.enable_deny_patterns` | bool | `true` | Aktifkan pemintasan arahan berbahaya | +| `tools.exec.custom_deny_patterns` | string[] | `[]` | Corak regex tersuai untuk disekat | +| `tools.exec.custom_allow_patterns` | string[] | `[]` | Corak regex tersuai untuk dibenarkan | + +> **Nota Keselamatan:** Perlindungan symlink diaktifkan secara lalai — semua laluan fail akan diselesaikan melalui `filepath.EvalSymlinks` sebelum dipadankan dengan whitelist, bagi mengelakkan serangan melarikan diri melalui symlink. + +#### Had yang Diketahui: Proses Anak Daripada Build Tools + +Pengawal keselamatan exec hanya memeriksa baris arahan yang PicoClaw lancarkan secara terus. Ia tidak memeriksa secara rekursif proses anak yang dilancarkan oleh tools pembangun yang dibenarkan seperti `make`, `go run`, `cargo`, `npm run`, atau skrip build tersuai. + +Ini bermakna arahan peringkat atas masih boleh mengkompil atau melancarkan binari lain selepas ia melepasi semakan awal pengawal. Dalam amalan, anggap build script, Makefile, package script, dan binari terjana sebagai kod boleh laksana yang memerlukan tahap semakan yang sama seperti arahan shell terus. + +Untuk persekitaran yang lebih berisiko: + +* Semak build script sebelum pelaksanaan. +* Utamakan kelulusan/semakan manual untuk aliran kerja compile-and-run. +* Jalankan PicoClaw dalam container atau VM jika anda memerlukan pengasingan yang lebih kuat daripada pengawal terbina dalam. + +#### Contoh Ralat + +``` +[ERROR] tool: Tool execution failed +{tool=exec, error=Command blocked by safety guard (path outside working dir)} +``` + +``` +[ERROR] tool: Tool execution failed +{tool=exec, error=Command blocked by safety guard (dangerous pattern detected)} +``` + +#### Menyahaktifkan Sekatan (Risiko Keselamatan) + +Jika anda perlu membenarkan agen mengakses laluan di luar workspace: + +**Kaedah 1: Fail config** + +```json +{ + "agents": { + "defaults": { + "restrict_to_workspace": false + } + } +} +``` + +**Kaedah 2: Pemboleh ubah persekitaran** + +```bash +export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false +``` + +> ⚠️ **Amaran**: Menyahaktifkan sekatan ini membenarkan agen mengakses mana-mana laluan pada sistem anda. Gunakan dengan berhati-hati hanya dalam persekitaran terkawal. + +#### Ketekalan Sempadan Keselamatan + +Tetapan `restrict_to_workspace` digunakan secara konsisten merentas semua laluan pelaksanaan: + +| Execution Path | Security Boundary | +| ---------------- | --------------------------- | +| Main Agent | `restrict_to_workspace` ✅ | +| Subagent / Spawn | Inherits same restriction ✅ | +| Heartbeat tasks | Inherits same restriction ✅ | + +Semua laluan berkongsi sekatan workspace yang sama — tiada cara untuk memintas sempadan keselamatan melalui subagent atau tugasan berjadual. + +### Heartbeat (Tugasan Berkala) + +PicoClaw boleh melaksanakan tugasan berkala secara automatik. Cipta fail `HEARTBEAT.md` dalam workspace anda: + +```markdown +# Periodic Tasks + +- Check my email for important messages +- Review my calendar for upcoming events +- Check the weather forecast +``` + +Agen akan membaca fail ini setiap 30 minit (boleh dikonfigurasi) dan melaksanakan sebarang tugasan menggunakan tools yang tersedia. + +#### Tugasan Async dengan Spawn + +Untuk tugasan yang berjalan lama (carian web, panggilan API), gunakan tool `spawn` untuk mencipta **subagent**: + +```markdown +# Periodic Tasks diff --git a/docs/my/debug.md b/docs/my/debug.md new file mode 100644 index 000000000..6ab28365e --- /dev/null +++ b/docs/my/debug.md @@ -0,0 +1,33 @@ +# Penyahpepijatan PicoClaw + +PicoClaw melakukan pelbagai interaksi kompleks di sebalik tabir untuk setiap permintaan yang diterimanya, daripada menghala mesej dan menilai kerumitan, hinggalah melaksanakan tools dan menyesuaikan diri dengan kegagalan model. Keupayaan melihat dengan tepat apa yang sedang berlaku sangat penting, bukan sahaja untuk menyelesaikan masalah, malah untuk benar-benar memahami cara agen ini beroperasi. +## Memulakan PicoClaw dalam Mod Debug + +Untuk mendapatkan maklumat terperinci tentang apa yang sedang dilakukan oleh agen (permintaan LLM, panggilan tool, penghalaan mesej), anda boleh memulakan gateway PicoClaw dengan flag debug: + +```bash +picoclaw gateway --debug +# or +picoclaw gateway -d +``` + +Dalam mod ini, sistem akan memformat log dengan lebih terperinci dan memaparkan pratonton system prompt serta hasil pelaksanaan tool. + +## Menyahaktifkan Pemotongan Log (Log Penuh) + +Secara lalai, PicoClaw memotong rentetan yang sangat panjang (seperti *System Prompt* atau hasil output JSON yang besar) dalam log debug supaya konsol kekal mudah dibaca. + +Jika anda perlu memeriksa output penuh sesuatu arahan atau payload tepat yang dihantar kepada model LLM, anda boleh menggunakan flag `--no-truncate`. + +**Nota:** Flag ini *hanya* berfungsi apabila digabungkan dengan mod `--debug`. + +```bash +picoclaw gateway --debug --no-truncate + +``` + +Apabila flag ini aktif, fungsi pemotongan global dinyahaktifkan. Ini sangat berguna untuk: + +* Mengesahkan sintaks tepat mesej yang dihantar kepada penyedia. +* Membaca output lengkap daripada tools seperti `exec`, `web_fetch`, atau `read_file`. +* Menyahpepijat sejarah sesi yang disimpan dalam memori. diff --git a/docs/my/docker.md b/docs/my/docker.md new file mode 100644 index 000000000..8fe1aed8c --- /dev/null +++ b/docs/my/docker.md @@ -0,0 +1,166 @@ +# 🐳 Panduan Docker & Quick Start + +> Kembali ke [README](../../README.my.md) + +## 🐳 Docker Compose + +Anda juga boleh menjalankan PicoClaw menggunakan Docker Compose tanpa memasang apa-apa secara setempat. + +```bash +# 1. Clone repo ini +git clone https://github.com/sipeed/picoclaw.git +cd picoclaw + +# 2. Larian pertama — jana docker/data/config.json secara automatik kemudian keluar +docker compose -f docker/docker-compose.yml --profile gateway up +# Container akan memaparkan "First-run setup complete." dan berhenti. + +# 3. Tetapkan kunci API anda +vim docker/data/config.json # Tetapkan API key penyedia, token bot, dan sebagainya. + +# 4. Mula +docker compose -f docker/docker-compose.yml --profile gateway up -d +``` + +> [!TIP] +> **Pengguna Docker**: Secara lalai, Gateway mendengar pada `127.0.0.1` yang tidak boleh diakses dari host. Jika anda perlu mengakses health endpoint atau mendedahkan port, tetapkan `PICOCLAW_GATEWAY_HOST=0.0.0.0` dalam persekitaran anda atau kemas kini `config.json`. + +```bash +# 5. Semak log +docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway + +# 6. Hentikan +docker compose -f docker/docker-compose.yml --profile gateway down +``` + +### Mod Launcher (Konsol Web) + +Imej `launcher` merangkumi ketiga-tiga binari (`picoclaw`, `picoclaw-launcher`, `picoclaw-launcher-tui`) dan memulakan konsol web secara lalai, yang menyediakan UI berasaskan pelayar untuk konfigurasi dan sembang. + +```bash +docker compose -f docker/docker-compose.yml --profile launcher up -d +``` + +Buka http://localhost:18800 dalam pelayar anda. Launcher mengurus proses gateway secara automatik. + +> [!WARNING] +> Konsol web belum menyokong autentikasi. Elakkan mendedahkannya ke internet awam. + +### Mod Agent (One-shot) + +```bash +# Tanyakan soalan +docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "What is 2+2?" + +# Mod interaktif +docker compose -f docker/docker-compose.yml run --rm picoclaw-agent +``` + +### Kemas kini + +```bash +docker compose -f docker/docker-compose.yml pull +docker compose -f docker/docker-compose.yml --profile gateway up -d +``` + +### 🚀 Quick Start + +> [!TIP] +> Tetapkan API Key anda dalam `~/.picoclaw/config.json`. Dapatkan API Key: [Volcengine (CodingPlan)](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM). Carian web adalah pilihan — dapatkan [Tavily API](https://tavily.com) percuma (1000 pertanyaan percuma/bulan) atau [Brave Search API](https://brave.com/search/api) (2000 pertanyaan percuma/bulan). + +**1. Inisialisasi** + +```bash +picoclaw onboard +``` + +**2. Konfigurasi** (`~/.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", + "api_base":"https://ark.cn-beijing.volces.com/api/coding/v3" + }, + { + "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": { + "enabled": true, + "fetch_limit_bytes": 10485760, + "format": "plaintext", + "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 + } + } + } +} +``` + +> **Baharu**: Format konfigurasi `model_list` membolehkan penambahan penyedia tanpa perubahan kod. Lihat [Konfigurasi Model](#konfigurasi-model-model_list) untuk butiran. +> `request_timeout` adalah pilihan dan menggunakan saat. Jika diabaikan atau ditetapkan kepada `<= 0`, PicoClaw menggunakan timeout lalai (120s). + +**3. Dapatkan API Key** + +* **Penyedia LLM**: [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) +* **Carian Web** (pilihan): + * [Brave Search](https://brave.com/search/api) - Berbayar ($5/1000 pertanyaan, ~$5-6/bulan) + * [Perplexity](https://www.perplexity.ai) - Carian berkuasa AI dengan antara muka sembang + * [SearXNG](https://github.com/searxng/searxng) - Enjin meta-carian hos kendiri (percuma, tidak perlu API key) + * [Tavily](https://tavily.com) - Dioptimumkan untuk AI Agents (1000 permintaan/bulan) + * DuckDuckGo - Fallback terbina dalam (tidak memerlukan API key) + +> **Nota**: Lihat `config.example.json` untuk templat konfigurasi penuh. + +**4. Sembang** + +```bash +picoclaw agent -m "What is 2+2?" +``` + +Itu sahaja! Anda kini mempunyai pembantu AI yang berfungsi dalam masa 2 minit. + +--- diff --git a/docs/my/spawn-tasks.md b/docs/my/spawn-tasks.md new file mode 100644 index 000000000..c0c3e8f92 --- /dev/null +++ b/docs/my/spawn-tasks.md @@ -0,0 +1,61 @@ +# 🔄 Spawn & Tugasan Async + +> Kembali ke [README](../../README.my.md) + +## Tugasan Cepat (balas terus) + +- Laporkan masa semasa + +## Tugasan Panjang (guna spawn untuk async) + +- Cari berita AI di web dan ringkaskan +- Semak e-mel dan laporkan mesej penting +``` + +**Tingkah laku utama:** + +| Feature | Description | +| ----------------------- | --------------------------------------------------------- | +| **spawn** | Mencipta sub-agen async, tidak menyekat heartbeat | +| **Independent context** | Sub-agen mempunyai konteks sendiri, tiada sejarah sesi | +| **message tool** | Sub-agen berkomunikasi terus dengan pengguna melalui message tool | +| **Non-blocking** | Selepas spawn, heartbeat terus ke tugasan seterusnya | + +#### Cara Komunikasi Sub-agen Berfungsi + +``` +Heartbeat dicetuskan + ↓ +Agen membaca HEARTBEAT.md + ↓ +Untuk tugasan panjang: spawn sub-agen + ↓ ↓ +Terus ke tugasan seterusnya Sub-agen bekerja secara bebas + ↓ ↓ +Semua tugasan selesai Sub-agen menggunakan tool "message" + ↓ ↓ +Balas HEARTBEAT_OK Pengguna menerima hasil secara terus +``` + +Sub-agen mempunyai akses kepada tools (message, web_search, dan sebagainya) dan boleh berkomunikasi dengan pengguna secara bebas tanpa melalui agen utama. + +**Konfigurasi:** + +```json +{ + "heartbeat": { + "enabled": true, + "interval": 30 + } +} +``` + +| Option | Default | Description | +| ---------- | ------- | ---------------------------------------- | +| `enabled` | `true` | Hidupkan/matikan heartbeat | +| `interval` | `30` | Selang semakan dalam minit (minimum: 5) | + +**Pemboleh ubah persekitaran:** + +* `PICOCLAW_HEARTBEAT_ENABLED=false` untuk nyahaktifkan +* `PICOCLAW_HEARTBEAT_INTERVAL=60` untuk menukar selang diff --git a/docs/my/troubleshooting.md b/docs/my/troubleshooting.md new file mode 100644 index 000000000..c9d987ab4 --- /dev/null +++ b/docs/my/troubleshooting.md @@ -0,0 +1,43 @@ +# Penyelesaian Masalah + +## "model ... not found in model_list" atau OpenRouter "free is not a valid model ID" + +**Gejala:** Anda akan melihat salah satu daripada mesej berikut: + +- `Error creating provider: model "openrouter/free" not found in model_list` +- OpenRouter memulangkan 400: `"free is not a valid model ID"` + +**Punca:** Medan `model` dalam entri `model_list` anda ialah nilai yang dihantar ke API. Untuk OpenRouter, anda mesti menggunakan ID model **penuh**, bukan bentuk singkatan. + +- **Salah:** `"model": "free"` → OpenRouter menerima `free` dan menolaknya. +- **Betul:** `"model": "openrouter/free"` → OpenRouter menerima `openrouter/free` (routing auto free-tier). + +**Penyelesaian:** Dalam `~/.picoclaw/config.json` (atau laluan config anda): + +1. **agents.defaults.model** mesti sepadan dengan `model_name` dalam `model_list` (contohnya `"openrouter-free"`). +2. Medan **model** bagi entri tersebut mesti merupakan ID model OpenRouter yang sah, contohnya: + - `"openrouter/free"` – auto free-tier + - `"google/gemini-2.0-flash-exp:free"` + - `"meta-llama/llama-3.1-8b-instruct:free"` + +Example snippet: + +```json +{ + "agents": { + "defaults": { + "model": "openrouter-free" + } + }, + "model_list": [ + { + "model_name": "openrouter-free", + "model": "openrouter/free", + "api_key": "sk-or-v1-YOUR_OPENROUTER_KEY", + "api_base": "https://openrouter.ai/api/v1" + } + ] +} +``` + +Dapatkan kunci anda di [OpenRouter Keys](https://openrouter.ai/keys). From 0e13f6bdece88bea92590fa2ea7a5e6c03b18d20 Mon Sep 17 00:00:00 2001 From: Hua Audio Date: Sat, 28 Mar 2026 11:18:01 +0100 Subject: [PATCH 2/3] fix/wechat-new-protocol (#2106) * fix/wechat-new-protocol * fix cdn download logic --- pkg/channels/weixin/api.go | 94 +++++++--------- pkg/channels/weixin/auth.go | 24 +++- pkg/channels/weixin/media.go | 173 ++++++++++++++++++++++++----- pkg/channels/weixin/types.go | 13 ++- pkg/channels/weixin/weixin_test.go | 114 ++++++++++++++++++- 5 files changed, 331 insertions(+), 87 deletions(-) diff --git a/pkg/channels/weixin/api.go b/pkg/channels/weixin/api.go index 7f9b3b5c6..6dc52790e 100644 --- a/pkg/channels/weixin/api.go +++ b/pkg/channels/weixin/api.go @@ -12,6 +12,14 @@ import ( "net/http" "net/url" "path" + "strconv" +) + +const ( + weixinChannelVersion = "2.1.1" + weixinIlinkAppID = "bot" + // 2.1.1 encoded as 0x00MMNNPP => 0x00020101 => 131329 + weixinClientVersion = 131329 ) type ApiClient struct { @@ -80,13 +88,9 @@ func (c *ApiClient) post(ctx context.Context, endpoint string, body any, respons } 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["iLink-App-Id"] = []string{weixinIlinkAppID} + req.Header["iLink-App-ClientVersion"] = []string{strconv.Itoa(weixinClientVersion)} + if endpoint != "ilink/bot/get_bot_qrcode" && endpoint != "ilink/bot/get_qrcode_status" { req.Header["AuthorizationType"] = []string{"ilink_bot_token"} req.Header["X-WECHAT-UIN"] = []string{randomWechatUIN()} if c.Token != "" { @@ -119,7 +123,7 @@ func (c *ApiClient) post(ctx context.Context, endpoint string, body any, respons } func (c *ApiClient) GetUpdates(ctx context.Context, req GetUpdatesReq) (*GetUpdatesResp, error) { - req.BaseInfo = BaseInfo{ChannelVersion: "1.0.2"} + req.BaseInfo = BaseInfo{ChannelVersion: weixinChannelVersion} var resp GetUpdatesResp err := c.post(ctx, "ilink/bot/getupdates", req, &resp) if err != nil { @@ -129,7 +133,7 @@ func (c *ApiClient) GetUpdates(ctx context.Context, req GetUpdatesReq) (*GetUpda } func (c *ApiClient) SendMessage(ctx context.Context, req SendMessageReq) (*SendMessageResp, error) { - req.BaseInfo = BaseInfo{ChannelVersion: "1.0.2"} + req.BaseInfo = BaseInfo{ChannelVersion: weixinChannelVersion} var resp SendMessageResp if err := c.post(ctx, "ilink/bot/sendmessage", req, &resp); err != nil { return nil, err @@ -138,7 +142,7 @@ func (c *ApiClient) SendMessage(ctx context.Context, req SendMessageReq) (*SendM } func (c *ApiClient) GetUploadUrl(ctx context.Context, req GetUploadUrlReq) (*GetUploadUrlResp, error) { - req.BaseInfo = BaseInfo{ChannelVersion: "1.0.2"} + req.BaseInfo = BaseInfo{ChannelVersion: weixinChannelVersion} var resp GetUploadUrlResp err := c.post(ctx, "ilink/bot/getuploadurl", req, &resp) if err != nil { @@ -148,7 +152,7 @@ func (c *ApiClient) GetUploadUrl(ctx context.Context, req GetUploadUrlReq) (*Get } func (c *ApiClient) GetConfig(ctx context.Context, req GetConfigReq) (*GetConfigResp, error) { - req.BaseInfo = BaseInfo{ChannelVersion: "1.0.2"} + req.BaseInfo = BaseInfo{ChannelVersion: weixinChannelVersion} var resp GetConfigResp if err := c.post(ctx, "ilink/bot/getconfig", req, &resp); err != nil { return nil, err @@ -157,7 +161,7 @@ func (c *ApiClient) GetConfig(ctx context.Context, req GetConfigReq) (*GetConfig } func (c *ApiClient) SendTyping(ctx context.Context, req SendTypingReq) (*SendTypingResp, error) { - req.BaseInfo = BaseInfo{ChannelVersion: "1.0.2"} + req.BaseInfo = BaseInfo{ChannelVersion: weixinChannelVersion} var resp SendTypingResp if err := c.post(ctx, "ilink/bot/sendtyping", req, &resp); err != nil { return nil, err @@ -165,38 +169,51 @@ func (c *ApiClient) SendTyping(ctx context.Context, req SendTypingReq) (*SendTyp return &resp, nil } -func (c *ApiClient) GetQRCode(ctx context.Context, botType string) (*QRCodeResponse, error) { - // get_bot_qrcode is GET, not POST +func (c *ApiClient) getQR(ctx context.Context, endpoint string, query map[string]string, respObj any) error { u, err := url.Parse(c.BaseURL) if err != nil { - return nil, err + return err } - u.Path = path.Join(u.Path, "ilink/bot/get_bot_qrcode") + u.Path = path.Join(u.Path, endpoint) q := u.Query() - q.Set("bot_type", botType) + for key, value := range query { + q.Set(key, value) + } u.RawQuery = q.Encode() req, err := http.NewRequestWithContext(ctx, "GET", u.String(), nil) if err != nil { - return nil, err + return err } + req.Header["iLink-App-Id"] = []string{weixinIlinkAppID} + req.Header["iLink-App-ClientVersion"] = []string{strconv.Itoa(weixinClientVersion)} resp, err := c.HttpClient.Do(req) if err != nil { - return nil, err + return err } defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) if err != nil { - return nil, err + return err } if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("get_bot_qrcode failed: %d %s", resp.StatusCode, string(respBody)) + return fmt.Errorf("%s failed: %d %s", endpoint, resp.StatusCode, string(respBody)) + } + if err := json.Unmarshal(respBody, respObj); err != nil { + return err } + return nil +} + +func (c *ApiClient) GetQRCode(ctx context.Context, botType string) (*QRCodeResponse, error) { + // get_bot_qrcode is GET, not POST var qrcodeResp QRCodeResponse - if err := json.Unmarshal(respBody, &qrcodeResp); err != nil { + if err := c.getQR(ctx, "ilink/bot/get_bot_qrcode", map[string]string{ + "bot_type": botType, + }, &qrcodeResp); err != nil { return nil, err } return &qrcodeResp, nil @@ -204,37 +221,10 @@ func (c *ApiClient) GetQRCode(ctx context.Context, botType string) (*QRCodeRespo 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 { + if err := c.getQR(ctx, "ilink/bot/get_qrcode_status", map[string]string{ + "qrcode": qrcode, + }, &statusResp); err != nil { return nil, err } return &statusResp, nil diff --git a/pkg/channels/weixin/auth.go b/pkg/channels/weixin/auth.go index 52ec2a6df..0a0e597c1 100644 --- a/pkg/channels/weixin/auth.go +++ b/pkg/channels/weixin/auth.go @@ -40,6 +40,7 @@ func PerformLoginInteractive( if err != nil { return "", "", "", "", fmt.Errorf("failed to create api client: %w", err) } + pollAPI := api logger.InfoC("weixin", "Requesting Weixin QR code...") qrResp, err := api.GetQRCode(ctx, opts.BotType) @@ -76,7 +77,7 @@ func PerformLoginInteractive( case <-timeoutCtx.Done(): return "", "", "", "", fmt.Errorf("login timeout") case <-pollTicker.C: - statusResp, err := api.GetQRCodeStatus(timeoutCtx, qrResp.Qrcode) + statusResp, err := pollAPI.GetQRCodeStatus(timeoutCtx, qrResp.Qrcode) if err != nil { // Long poll timeout or temporary error continue @@ -99,6 +100,27 @@ func PerformLoginInteractive( }) return statusResp.BotToken, statusResp.IlinkUserID, statusResp.IlinkBotID, statusResp.Baseurl, nil + case "scaned_but_redirect": + if statusResp.RedirectHost == "" { + logger.WarnC( + "weixin", + "scaned_but_redirect received without redirect_host; continuing on current host", + ) + continue + } + nextBaseURL := "https://" + statusResp.RedirectHost + "/" + nextAPI, nextErr := NewApiClient(nextBaseURL, "", opts.Proxy) + if nextErr != nil { + logger.WarnCF("weixin", "Failed to switch QR polling host", map[string]any{ + "redirect_host": statusResp.RedirectHost, + "error": nextErr.Error(), + }) + continue + } + pollAPI = nextAPI + logger.InfoCF("weixin", "Switched QR polling host", map[string]any{ + "redirect_host": statusResp.RedirectHost, + }) case "expired": return "", "", "", "", fmt.Errorf("qrcode expired, please try again") default: diff --git a/pkg/channels/weixin/media.go b/pkg/channels/weixin/media.go index 72af27438..4da7f0db9 100644 --- a/pkg/channels/weixin/media.go +++ b/pkg/channels/weixin/media.go @@ -34,6 +34,8 @@ const ( weixinMediaMaxBytes = 100 << 20 weixinTypingKeepAlive = 5 * time.Second weixinUploadRetryMax = 3 + weixinDownloadRetryMax = 2 + weixinDownloadRetryDelay = 300 * time.Millisecond weixinVoiceTranscodeTimeout = 15 * time.Second ) @@ -163,49 +165,108 @@ func buildCDNDownloadURL(base, encryptedQueryParam string) string { "/download?encrypted_query_param=" + url.QueryEscape(encryptedQueryParam) } +func shouldRetryCDNDownload(statusCode int) bool { + // statusCode=0 represents transport/build errors from the HTTP client. + return statusCode == 0 || statusCode >= 500 || statusCode == http.StatusTooManyRequests +} + func buildCDNUploadURL(base, uploadParam, filekey string) string { return strings.TrimRight(base, "/") + "/upload?encrypted_query_param=" + url.QueryEscape(uploadParam) + "&filekey=" + url.QueryEscape(filekey) } -func (c *WeixinChannel) downloadCDNBuffer(ctx context.Context, encryptedQueryParam string) ([]byte, error) { - req, err := http.NewRequestWithContext( - ctx, - http.MethodGet, - buildCDNDownloadURL(c.cdnBaseURL(), encryptedQueryParam), - nil, - ) +func uniqCDNURLs(urls []string) []string { + seen := make(map[string]struct{}, len(urls)) + out := make([]string, 0, len(urls)) + for _, raw := range urls { + u := strings.TrimSpace(raw) + if u == "" { + continue + } + if _, ok := seen[u]; ok { + continue + } + seen[u] = struct{}{} + out = append(out, u) + } + return out +} + +func (c *WeixinChannel) downloadCDNBufferOnce(ctx context.Context, downloadURL string) ([]byte, int, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil) if err != nil { - return nil, err + return nil, 0, err } resp, err := c.api.HttpClient.Do(req) if err != nil { - return nil, err + return nil, 0, err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) - return nil, fmt.Errorf("cdn download HTTP %d: %s", resp.StatusCode, string(body)) + return nil, resp.StatusCode, fmt.Errorf("cdn download HTTP %d: %s", resp.StatusCode, string(body)) } data, err := io.ReadAll(io.LimitReader(resp.Body, weixinMediaMaxBytes+1)) if err != nil { - return nil, err + return nil, resp.StatusCode, err } if len(data) > weixinMediaMaxBytes { - return nil, fmt.Errorf("cdn media too large: %d bytes", len(data)) + return nil, resp.StatusCode, fmt.Errorf("cdn media too large: %d bytes", len(data)) } - return data, nil + return data, resp.StatusCode, nil +} + +func (c *WeixinChannel) downloadCDNBuffer( + ctx context.Context, + encryptedQueryParam, + fullURL string, +) ([]byte, error) { + candidates := uniqCDNURLs([]string{ + strings.TrimSpace(fullURL), + func() string { + if strings.TrimSpace(encryptedQueryParam) == "" { + return "" + } + return buildCDNDownloadURL(c.cdnBaseURL(), encryptedQueryParam) + }(), + }) + if len(candidates) == 0 { + return nil, fmt.Errorf("missing CDN download URL") + } + + var lastErr error + for _, downloadURL := range candidates { + for attempt := 1; attempt <= weixinDownloadRetryMax; attempt++ { + data, statusCode, err := c.downloadCDNBufferOnce(ctx, downloadURL) + if err == nil { + return data, nil + } + lastErr = fmt.Errorf("%w (attempt=%d url=%s)", err, attempt, downloadURL) + if !shouldRetryCDNDownload(statusCode) { + break + } + if attempt < weixinDownloadRetryMax { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(weixinDownloadRetryDelay): + } + } + } + } + return nil, lastErr } func (c *WeixinChannel) downloadAndDecryptCDNBuffer( ctx context.Context, encryptedQueryParam string, + fullURL string, key []byte, ) ([]byte, error) { - data, err := c.downloadCDNBuffer(ctx, encryptedQueryParam) + data, err := c.downloadCDNBuffer(ctx, encryptedQueryParam, fullURL) if err != nil { return nil, err } @@ -215,6 +276,33 @@ func (c *WeixinChannel) downloadAndDecryptCDNBuffer( return decryptAESECB(data, key) } +func (c *WeixinChannel) downloadImageBuffer( + ctx context.Context, + img *ImageItem, + key []byte, +) ([]byte, error) { + if img == nil { + return nil, fmt.Errorf("image item is nil") + } + if img.Media != nil { + data, err := c.downloadAndDecryptCDNBuffer(ctx, img.Media.EncryptQueryParam, img.Media.FullURL, key) + if err == nil { + return data, nil + } + if img.ThumbMedia == nil { + return nil, fmt.Errorf("image download failed: %w", err) + } + } + if img.ThumbMedia != nil { + data, err := c.downloadAndDecryptCDNBuffer(ctx, img.ThumbMedia.EncryptQueryParam, img.ThumbMedia.FullURL, key) + if err == nil { + return data, nil + } + return nil, fmt.Errorf("image download failed: %w", err) + } + return nil, fmt.Errorf("image media is nil") +} + func detectMediaMetadata(data []byte, fallbackName, fallbackContentType string) (string, string) { contentType := strings.TrimSpace(fallbackContentType) ext := filepath.Ext(fallbackName) @@ -310,15 +398,18 @@ func isDownloadableMediaItem(item *MessageItem) bool { switch item.Type { case MessageItemTypeImage: - return item.ImageItem != nil && item.ImageItem.Media != nil && item.ImageItem.Media.EncryptQueryParam != "" + return item.ImageItem != nil && item.ImageItem.Media != nil && + (item.ImageItem.Media.EncryptQueryParam != "" || item.ImageItem.Media.FullURL != "") case MessageItemTypeVideo: - return item.VideoItem != nil && item.VideoItem.Media != nil && item.VideoItem.Media.EncryptQueryParam != "" + return item.VideoItem != nil && item.VideoItem.Media != nil && + (item.VideoItem.Media.EncryptQueryParam != "" || item.VideoItem.Media.FullURL != "") case MessageItemTypeFile: - return item.FileItem != nil && item.FileItem.Media != nil && item.FileItem.Media.EncryptQueryParam != "" + return item.FileItem != nil && item.FileItem.Media != nil && + (item.FileItem.Media.EncryptQueryParam != "" || item.FileItem.Media.FullURL != "") case MessageItemTypeVoice: return item.VoiceItem != nil && item.VoiceItem.Media != nil && - item.VoiceItem.Media.EncryptQueryParam != "" && + (item.VoiceItem.Media.EncryptQueryParam != "" || item.VoiceItem.Media.FullURL != "") && strings.TrimSpace(item.VoiceItem.Text) == "" default: return false @@ -434,16 +525,20 @@ func (c *WeixinChannel) downloadMediaFromItem( switch item.Type { case MessageItemTypeImage: + if item.ImageItem == nil { + return "", fmt.Errorf("image media is nil") + } key, ok, err := imageAESKey(item.ImageItem) if err != nil { return "", err } - data, err := c.downloadAndDecryptCDNBuffer(ctx, item.ImageItem.Media.EncryptQueryParam, func() []byte { + decryptKey := func() []byte { if ok { return key } return nil - }()) + }() + data, err := c.downloadImageBuffer(ctx, item.ImageItem, decryptKey) if err != nil { return "", err } @@ -454,7 +549,12 @@ func (c *WeixinChannel) downloadMediaFromItem( if err != nil { return "", err } - silk, err := c.downloadAndDecryptCDNBuffer(ctx, item.VoiceItem.Media.EncryptQueryParam, key) + silk, err := c.downloadAndDecryptCDNBuffer( + ctx, + item.VoiceItem.Media.EncryptQueryParam, + item.VoiceItem.Media.FullURL, + key, + ) if err != nil { return "", err } @@ -468,7 +568,12 @@ func (c *WeixinChannel) downloadMediaFromItem( if err != nil { return "", err } - data, err := c.downloadAndDecryptCDNBuffer(ctx, item.FileItem.Media.EncryptQueryParam, key) + data, err := c.downloadAndDecryptCDNBuffer( + ctx, + item.FileItem.Media.EncryptQueryParam, + item.FileItem.Media.FullURL, + key, + ) if err != nil { return "", err } @@ -484,7 +589,12 @@ func (c *WeixinChannel) downloadMediaFromItem( if err != nil { return "", err } - data, err := c.downloadAndDecryptCDNBuffer(ctx, item.VideoItem.Media.EncryptQueryParam, key) + data, err := c.downloadAndDecryptCDNBuffer( + ctx, + item.VideoItem.Media.EncryptQueryParam, + item.VideoItem.Media.FullURL, + key, + ) if err != nil { return "", err } @@ -701,11 +811,13 @@ func (c *WeixinChannel) uploadLocalFile( } return nil, fmt.Errorf("getuploadurl failed: ret=%d errcode=%d errmsg=%s", resp.Ret, resp.Errcode, resp.Errmsg) } - if strings.TrimSpace(resp.UploadParam) == "" { - return nil, fmt.Errorf("getuploadurl returned empty upload_param") + uploadParam := strings.TrimSpace(resp.UploadParam) + uploadFullURL := strings.TrimSpace(resp.UploadFullURL) + if uploadParam == "" && uploadFullURL == "" { + return nil, fmt.Errorf("getuploadurl returned no upload URL") } - downloadParam, err := c.uploadBufferToCDN(ctx, data, resp.UploadParam, filekey, aesKey) + downloadParam, err := c.uploadBufferToCDN(ctx, data, uploadParam, uploadFullURL, filekey, aesKey) if err != nil { return nil, err } @@ -723,6 +835,7 @@ func (c *WeixinChannel) uploadBufferToCDN( ctx context.Context, plaintext []byte, uploadParam, + uploadFullURL, filekey string, aesKey []byte, ) (string, error) { @@ -731,7 +844,13 @@ func (c *WeixinChannel) uploadBufferToCDN( return "", err } - uploadURL := buildCDNUploadURL(c.cdnBaseURL(), uploadParam, filekey) + uploadURL := strings.TrimSpace(uploadFullURL) + if uploadURL == "" { + if strings.TrimSpace(uploadParam) == "" { + return "", fmt.Errorf("missing CDN upload URL") + } + uploadURL = buildCDNUploadURL(c.cdnBaseURL(), uploadParam, filekey) + } var lastErr error for attempt := 1; attempt <= weixinUploadRetryMax; attempt++ { diff --git a/pkg/channels/weixin/types.go b/pkg/channels/weixin/types.go index 74c6e63c3..f2c03894f 100644 --- a/pkg/channels/weixin/types.go +++ b/pkg/channels/weixin/types.go @@ -38,6 +38,7 @@ type GetUploadUrlResp struct { APIStatus UploadParam string `json:"upload_param,omitempty"` ThumbUploadParam string `json:"thumb_upload_param,omitempty"` + UploadFullURL string `json:"upload_full_url,omitempty"` } const ( @@ -69,6 +70,7 @@ type CDNMedia struct { EncryptQueryParam string `json:"encrypt_query_param,omitempty"` AesKey string `json:"aes_key,omitempty"` // base64 encoded EncryptType int `json:"encrypt_type,omitempty"` + FullURL string `json:"full_url,omitempty"` } type ImageItem struct { @@ -202,9 +204,10 @@ type QRCodeResponse struct { } 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"` + Status string `json:"status"` // "wait", "scaned", "confirmed", "expired", "scaned_but_redirect" + 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"` + RedirectHost string `json:"redirect_host,omitempty"` } diff --git a/pkg/channels/weixin/weixin_test.go b/pkg/channels/weixin/weixin_test.go index 62984c965..b41b930db 100644 --- a/pkg/channels/weixin/weixin_test.go +++ b/pkg/channels/weixin/weixin_test.go @@ -72,7 +72,7 @@ func TestDownloadAndDecryptCDNBuffer(t *testing.T) { typingCache: make(map[string]typingTicketCacheEntry), } - got, err := ch.downloadAndDecryptCDNBuffer(context.Background(), "token", key) + got, err := ch.downloadAndDecryptCDNBuffer(context.Background(), "token", "", key) if err != nil { t.Fatalf("downloadAndDecryptCDNBuffer() error = %v", err) } @@ -81,6 +81,116 @@ func TestDownloadAndDecryptCDNBuffer(t *testing.T) { } } +func TestDownloadAndDecryptCDNBufferUsesFullURLWhenProvided(t *testing.T) { + key := []byte("1234567890abcdef") + plaintext := []byte("hello weixin") + ciphertext, err := encryptAESECB(plaintext, key) + if err != nil { + t.Fatalf("encryptAESECB() error = %v", err) + } + + fullURLAttempts := 0 + ch := &WeixinChannel{ + api: &ApiClient{ + HttpClient: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.String() == "https://full.example.com/download" { + fullURLAttempts++ + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(ciphertext)), + Header: make(http.Header), + }, nil + } + t.Fatalf("unexpected fallback request: %s", r.URL.String()) + return nil, nil + })}, + }, + config: config.WeixinConfig{ + CDNBaseURL: "https://cdn.example.com", + }, + typingCache: make(map[string]typingTicketCacheEntry), + } + + got, err := ch.downloadAndDecryptCDNBuffer(context.Background(), "token", "https://full.example.com/download", key) + if err != nil { + t.Fatalf("downloadAndDecryptCDNBuffer() error = %v", err) + } + if !bytes.Equal(got, plaintext) { + t.Fatalf("downloadAndDecryptCDNBuffer() = %q, want %q", got, plaintext) + } + if fullURLAttempts == 0 { + t.Fatalf("fullURLAttempts = %d, want > 0", fullURLAttempts) + } +} + +func TestDownloadAndDecryptCDNBufferFallsBackToConstructedURLWhenFullURLFails(t *testing.T) { + key := []byte("1234567890abcdef") + plaintext := []byte("hello weixin") + ciphertext, err := encryptAESECB(plaintext, key) + if err != nil { + t.Fatalf("encryptAESECB() error = %v", err) + } + + fullURLAttempts := 0 + constructedAttempts := 0 + ch := &WeixinChannel{ + api: &ApiClient{ + HttpClient: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.String() == "https://full.example.com/download?encrypted_query_param=token&taskid=123" { + fullURLAttempts++ + return &http.Response{ + StatusCode: http.StatusInternalServerError, + Body: io.NopCloser(bytes.NewReader(nil)), + Header: make(http.Header), + }, nil + } + if r.URL.String() != "https://cdn.example.com/download?encrypted_query_param=token" { + t.Fatalf("unexpected fallback request: %s", r.URL.String()) + } + constructedAttempts++ + 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", + "https://full.example.com/download?encrypted_query_param=token&taskid=123", + key, + ) + if err != nil { + t.Fatalf("downloadAndDecryptCDNBuffer() error = %v", err) + } + if !bytes.Equal(got, plaintext) { + t.Fatalf("downloadAndDecryptCDNBuffer() = %q, want %q", got, plaintext) + } + if fullURLAttempts == 0 { + t.Fatalf("fullURLAttempts = %d, want > 0", fullURLAttempts) + } + if constructedAttempts == 0 { + t.Fatalf("constructedAttempts = %d, want > 0", constructedAttempts) + } +} + +func TestBuildCDNDownloadURLEscapesOpaqueToken(t *testing.T) { + token := "MFcCAQAESzBJAgEAAgSieMV9AgM9CcwCBEoKPqICBGnHZB0EJDk4OWY5YWU0LTc4OGItNGQ5Ni1iMjZhLWU4YjhlMmEwOWVkZgIEIR0IAgIBAAQFAExUPQA%3D" + + got := buildCDNDownloadURL("https://cdn.example.com", token) + + if got != "https://cdn.example.com/download?encrypted_query_param=MFcCAQAESzBJAgEAAgSieMV9AgM9CcwCBEoKPqICBGnHZB0EJDk4OWY5YWU0LTc4OGItNGQ5Ni1iMjZhLWU4YjhlMmEwOWVkZgIEIR0IAgIBAAQFAExUPQA%253D" { + t.Fatalf("buildCDNDownloadURL() = %q", got) + } +} + func TestUploadBufferToCDN(t *testing.T) { key := []byte("1234567890abcdef") plaintext := []byte("upload me") @@ -120,7 +230,7 @@ func TestUploadBufferToCDN(t *testing.T) { typingCache: make(map[string]typingTicketCacheEntry), } - got, err := ch.uploadBufferToCDN(context.Background(), plaintext, "upload-param", "file-key", key) + got, err := ch.uploadBufferToCDN(context.Background(), plaintext, "upload-param", "", "file-key", key) if err != nil { t.Fatalf("uploadBufferToCDN() error = %v", err) } From b6951b6925b313e3ccb3daa63d7d39ded276f9ad Mon Sep 17 00:00:00 2001 From: Alix-007 Date: Sat, 28 Mar 2026 18:26:10 +0800 Subject: [PATCH 3/3] fix(dingtalk): honor mention-only groups and strip leading mentions (#2054) * fix(dingtalk): honor @mention flag in mention-only groups * fix(dingtalk): strip leading mentions in group payloads --------- Co-authored-by: Alix-007 <267018309+Alix-007@users.noreply.github.com> --- pkg/channels/dingtalk/dingtalk.go | 70 ++++++++++--- pkg/channels/dingtalk/dingtalk_test.go | 131 +++++++++++++++++++++++++ 2 files changed, 188 insertions(+), 13 deletions(-) create mode 100644 pkg/channels/dingtalk/dingtalk_test.go diff --git a/pkg/channels/dingtalk/dingtalk.go b/pkg/channels/dingtalk/dingtalk.go index 273e2b020..450dcce54 100644 --- a/pkg/channels/dingtalk/dingtalk.go +++ b/pkg/channels/dingtalk/dingtalk.go @@ -6,6 +6,7 @@ package dingtalk import ( "context" "fmt" + "strings" "sync" "github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot" @@ -135,13 +136,17 @@ func (c *DingTalkChannel) onChatBotMessageReceived( ctx context.Context, data *chatbot.BotCallbackDataModel, ) ([]byte, error) { + if data == nil { + return nil, nil + } + // Extract message content from Text field - content := data.Text.Content + content := strings.TrimSpace(data.Text.Content) if content == "" { // Try to extract from Content interface{} if Text is empty if contentMap, ok := data.Content.(map[string]any); ok { if textContent, ok := contentMap["content"].(string); ok { - content = textContent + content = strings.TrimSpace(textContent) } } } @@ -150,12 +155,19 @@ func (c *DingTalkChannel) onChatBotMessageReceived( return nil, nil // Ignore empty messages } - senderID := data.SenderStaffId - senderNick := data.SenderNick - chatID := senderID - if data.ConversationType != "1" { - // For group chats - chatID = data.ConversationId + senderID := strings.TrimSpace(data.SenderStaffId) + if senderID == "" { + senderID = strings.TrimSpace(data.SenderId) + } + senderNick := strings.TrimSpace(data.SenderNick) + + chatID := strings.TrimSpace(data.ConversationId) + if chatID == "" && data.ConversationType == "1" { + // Fallback for direct chats when conversation_id is absent. + chatID = senderID + } + if chatID == "" { + return nil, nil } // Store the session webhook for this chat so we can reply later @@ -171,11 +183,19 @@ func (c *DingTalkChannel) onChatBotMessageReceived( var peer bus.Peer if data.ConversationType == "1" { - peer = bus.Peer{Kind: "direct", ID: senderID} + peerID := senderID + if peerID == "" { + peerID = chatID + } + peer = bus.Peer{Kind: "direct", ID: peerID} } else { peer = bus.Peer{Kind: "group", ID: data.ConversationId} + isMentioned := data.IsInAtList + if isMentioned { + content = stripLeadingAtMentions(content) + } // In group chats, apply unified group trigger filtering - respond, cleaned := c.ShouldRespondInGroup(false, content) + respond, cleaned := c.ShouldRespondInGroup(isMentioned, content) if !respond { return nil, nil } @@ -189,10 +209,18 @@ func (c *DingTalkChannel) onChatBotMessageReceived( }) // Build sender info + platformID := senderID + if platformID == "" { + platformID = chatID + } + resolvedSenderID := senderID + if resolvedSenderID == "" { + resolvedSenderID = platformID + } sender := bus.SenderInfo{ Platform: "dingtalk", - PlatformID: senderID, - CanonicalID: identity.BuildCanonicalID("dingtalk", senderID), + PlatformID: platformID, + CanonicalID: identity.BuildCanonicalID("dingtalk", platformID), DisplayName: senderNick, } @@ -201,7 +229,7 @@ func (c *DingTalkChannel) onChatBotMessageReceived( } // Handle the message through the base channel - c.HandleMessage(ctx, peer, "", senderID, chatID, content, nil, metadata, sender) + c.HandleMessage(ctx, peer, "", resolvedSenderID, chatID, content, nil, metadata, sender) // Return nil to indicate we've handled the message asynchronously // The response will be sent through the message bus @@ -229,3 +257,19 @@ func (c *DingTalkChannel) SendDirectReply(ctx context.Context, sessionWebhook, c return nil } + +func stripLeadingAtMentions(content string) string { + fields := strings.Fields(content) + if len(fields) == 0 { + return "" + } + + i := 0 + for i < len(fields) && strings.HasPrefix(fields[i], "@") { + i++ + } + if i == 0 { + return strings.TrimSpace(content) + } + return strings.Join(fields[i:], " ") +} diff --git a/pkg/channels/dingtalk/dingtalk_test.go b/pkg/channels/dingtalk/dingtalk_test.go new file mode 100644 index 000000000..3b517aef4 --- /dev/null +++ b/pkg/channels/dingtalk/dingtalk_test.go @@ -0,0 +1,131 @@ +package dingtalk + +import ( + "context" + "testing" + "time" + + "github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" +) + +func newTestDingTalkChannel(t *testing.T, cfg config.DingTalkConfig) (*DingTalkChannel, *bus.MessageBus) { + t.Helper() + + if cfg.ClientID == "" { + cfg.ClientID = "test-client-id" + } + if cfg.ClientSecret() == "" { + cfg.SetClientSecret("test-client-secret") + } + + msgBus := bus.NewMessageBus() + ch, err := NewDingTalkChannel(cfg, msgBus) + if err != nil { + t.Fatalf("new channel: %v", err) + } + return ch, msgBus +} + +func mustReceiveInbound(t *testing.T, msgBus *bus.MessageBus) bus.InboundMessage { + t.Helper() + select { + case msg := <-msgBus.InboundChan(): + return msg + case <-time.After(time.Second): + t.Fatal("expected inbound message") + return bus.InboundMessage{} + } +} + +func TestOnChatBotMessageReceived_GroupMentionOnlyUsesIsInAtListAndStripsMention(t *testing.T) { + ch, msgBus := newTestDingTalkChannel(t, config.DingTalkConfig{ + GroupTrigger: config.GroupTriggerConfig{MentionOnly: true}, + }) + + _, err := ch.onChatBotMessageReceived(context.Background(), &chatbot.BotCallbackDataModel{ + Text: chatbot.BotCallbackDataTextModel{Content: " @bot /help "}, + SenderStaffId: "staff-123", + SenderNick: "Alice", + ConversationType: "2", + ConversationId: "group-abc", + SessionWebhook: "https://example.com/webhook", + IsInAtList: true, + }) + if err != nil { + t.Fatalf("handler returned error: %v", err) + } + + inbound := mustReceiveInbound(t, msgBus) + if inbound.Channel != "dingtalk" { + t.Fatalf("channel=%q", inbound.Channel) + } + if inbound.ChatID != "group-abc" { + t.Fatalf("chat_id=%q", inbound.ChatID) + } + if inbound.Peer.Kind != "group" || inbound.Peer.ID != "group-abc" { + t.Fatalf("peer=%+v", inbound.Peer) + } + if inbound.Content != "/help" { + t.Fatalf("content=%q", inbound.Content) + } +} + +func TestOnChatBotMessageReceived_DirectFallbackSenderIDUsesConversationID(t *testing.T) { + ch, msgBus := newTestDingTalkChannel(t, config.DingTalkConfig{}) + + _, err := ch.onChatBotMessageReceived(context.Background(), &chatbot.BotCallbackDataModel{ + Text: chatbot.BotCallbackDataTextModel{Content: "ping"}, + SenderStaffId: "", + SenderId: "openid-user-42", + SenderNick: "Bob", + ConversationType: "1", + ConversationId: "conv-direct-42", + SessionWebhook: "https://example.com/webhook-direct", + }) + if err != nil { + t.Fatalf("handler returned error: %v", err) + } + + inbound := mustReceiveInbound(t, msgBus) + if inbound.ChatID != "conv-direct-42" { + t.Fatalf("chat_id=%q", inbound.ChatID) + } + if inbound.Peer.Kind != "direct" || inbound.Peer.ID != "openid-user-42" { + t.Fatalf("peer=%+v", inbound.Peer) + } + if inbound.SenderID != "dingtalk:openid-user-42" { + t.Fatalf("sender_id=%q", inbound.SenderID) + } + + if _, ok := ch.sessionWebhooks.Load("conv-direct-42"); !ok { + t.Fatal("expected session webhook keyed by conversation_id") + } + if _, ok := ch.sessionWebhooks.Load(""); ok { + t.Fatal("unexpected empty chat_id webhook key") + } +} + +func TestStripLeadingAtMentions(t *testing.T) { + tests := []struct { + name string + input string + wantOut string + }{ + {name: "single mention and command", input: "@bot /help", wantOut: "/help"}, + {name: "multiple mentions", input: "@bot @alice /new", wantOut: "/new"}, + {name: "no mention", input: "/help", wantOut: "/help"}, + {name: "mention only", input: "@bot", wantOut: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := stripLeadingAtMentions(tt.input) + if got != tt.wantOut { + t.Fatalf("stripLeadingAtMentions(%q)=%q want=%q", tt.input, got, tt.wantOut) + } + }) + } +}