add Zalo channel support and documentation

This commit is contained in:
trandangtrungduc 2026-03-18 10:26:08 +07:00
parent f12c09b767
commit e5b91ae4e6
13 changed files with 775 additions and 5 deletions

View file

@ -292,11 +292,12 @@ Et voilà ! Vous avez un assistant IA fonctionnel en 2 minutes.
## 💬 Applications de Chat ## 💬 Applications de Chat
Discutez avec votre PicoClaw via Telegram, Discord, DingTalk, LINE ou WeCom Discutez avec votre PicoClaw via Telegram, Zalo, Discord, DingTalk, LINE ou WeCom
| Canal | Configuration | | Canal | Configuration |
| ------------ | -------------------------------------- | | ------------ | -------------------------------------- |
| **Telegram** | Facile (juste un token) | | **Telegram** | Facile (juste un token) |
| **Zalo** | Moyen (token + secret_token + webhook URL) |
| **Discord** | Facile (token bot + intents) | | **Discord** | Facile (token bot + intents) |
| **QQ** | Facile (AppID + AppSecret) | | **QQ** | Facile (AppID + AppSecret) |
| **DingTalk** | Moyen (identifiants de l'application) | | **DingTalk** | Moyen (identifiants de l'application) |
@ -336,6 +337,55 @@ picoclaw gateway
</details> </details>
<details>
<summary><b>Zalo</b> (Webhook)</summary>
**1. Préparer**
* Récupérez votre `BOT_TOKEN` Zalo
* Choisissez un `secret_token` (8256 caractères). Zalo le renverra dans l'en-tête `X-Bot-Api-Secret-Token`.
* Pour plus de détails sur le webhook, voir la documentation officielle [`setWebhook`](https://bot.zapps.me/docs/apis/setWebhook/).
**2. Configurer**
```json
{
"channels": {
"zalo": {
"enabled": true,
"token": "YOUR_ZALO_BOT_TOKEN",
"secret_token": "YOUR_SECRET_TOKEN",
"webhook_path": "/webhook/zalo",
"allow_from": []
}
}
}
```
**3. Démarrer**
```bash
picoclaw gateway
```
**4. HTTPS local (ngrok)**
```bash
ngrok http 18790
```
Webhook URL: `https://<ngrok-domain>/webhook/zalo`
**5. Enregistrer le webhook**
```bash
curl -X POST "https://bot-api.zaloplatforms.com/bot${BOT_TOKEN}/setWebhook" \
-H "Content-Type: "application/json" \
-d '{"url":"https://<ngrok-domain>/webhook/zalo","secret_token":"YOUR_SECRET_TOKEN"}'
```
</details>
<details> <details>
<summary><b>Discord</b></summary> <summary><b>Discord</b></summary>

View file

@ -264,17 +264,67 @@ picoclaw agent -m "What is 2+2?"
## 💬 チャットアプリ ## 💬 チャットアプリ
Telegram、Discord、QQ、DingTalk、LINE、WeCom で PicoClaw と会話できます Telegram、Zalo、Discord、QQ、DingTalk、LINE、WeCom で PicoClaw と会話できます
| チャネル | セットアップ | | チャネル | セットアップ |
|---------|------------| |---------|------------|
| **Telegram** | 簡単(トークンのみ) | | **Telegram** | 簡単(トークンのみ) |
| **Zalo** | 普通token + secret_token + webhook URL |
| **Discord** | 簡単Bot トークン + Intents | | **Discord** | 簡単Bot トークン + Intents |
| **QQ** | 簡単AppID + AppSecret | | **QQ** | 簡単AppID + AppSecret |
| **DingTalk** | 普通(アプリ認証情報) | | **DingTalk** | 普通(アプリ認証情報) |
| **LINE** | 普通(認証情報 + Webhook URL | | **LINE** | 普通(認証情報 + Webhook URL |
| **WeCom AI Bot** | 普通Token + AES キー) | | **WeCom AI Bot** | 普通Token + AES キー) |
<details>
<summary><b>Zalo</b>Webhook</summary>
**1. 準備**
- Zalo の `BOT_TOKEN` を用意
- `secret_token`8256 文字を決めるZalo が `X-Bot-Api-Secret-Token` ヘッダーで返します)
- Webhook の詳細は公式ドキュメント [`setWebhook`](https://bot.zapps.me/docs/apis/setWebhook/) を参照してください。
**2. 設定**
```json
{
"channels": {
"zalo": {
"enabled": true,
"token": "YOUR_ZALO_BOT_TOKEN",
"secret_token": "YOUR_SECRET_TOKEN",
"webhook_path": "/webhook/zalo",
"allow_from": []
}
}
}
```
**3. 起動**
```bash
picoclaw gateway
```
**4. ローカルで HTTPSngrok**
```bash
ngrok http 18790
```
Webhook URL: `https://<ngrok-domain>/webhook/zalo`
**5. Webhook 登録**
```bash
curl -X POST "https://bot-api.zaloplatforms.com/bot${BOT_TOKEN}/setWebhook" \
-H "Content-Type: application/json" \
-d '{"url":"https://<ngrok-domain>/webhook/zalo","secret_token":"YOUR_SECRET_TOKEN"}'
```
</details>
<details> <details>
<summary><b>Telegram</b>(推奨)</summary> <summary><b>Telegram</b>(推奨)</summary>

View file

@ -331,13 +331,14 @@ That's it! You have a working AI assistant in 2 minutes.
## 💬 Chat Apps ## 💬 Chat Apps
Talk to your picoclaw through Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, or WeCom Talk to your picoclaw through Telegram, Zalo, 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. > **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 | | Channel | Setup |
| ------------ | ---------------------------------- | | ------------ | ---------------------------------- |
| **Telegram** | Easy (just a token) | | **Telegram** | Easy (just a token) |
| **Zalo** | Medium (token + secret_token + webhook URL) |
| **Discord** | Easy (bot token + intents) | | **Discord** | Easy (bot token + intents) |
| **WhatsApp** | Easy (native: QR scan; or bridge URL) | | **WhatsApp** | Easy (native: QR scan; or bridge URL) |
| **Matrix** | Medium (homeserver + bot access token) | | **Matrix** | Medium (homeserver + bot access token) |
@ -386,6 +387,66 @@ If command registration fails (network/API transient errors), the channel still
</details> </details>
<details>
<summary><b>Zalo</b> (Webhook)</summary>
PicoClaw integrates with Zalo Bot via the official Zalo Bot API webhook flow.
**1. Create a Zalo Bot**
* Create a bot in Zalo Developer / Bot console
* Copy your **Bot Token** (`BOT_TOKEN`)
* Choose a strong `secret_token` (8256 chars). This is your own secret and will be sent back by Zalo in the `X-Bot-Api-Secret-Token` header.
* For full webhook options, see Zalos official [`setWebhook` docs](https://bot.zapps.me/docs/apis/setWebhook/).
**2. Configure**
```json
{
"channels": {
"zalo": {
"enabled": true,
"token": "YOUR_ZALO_BOT_TOKEN",
"secret_token": "YOUR_SECRET_TOKEN",
"webhook_path": "/webhook/zalo",
"allow_from": []
}
}
}
```
**3. Run the Gateway**
```bash
picoclaw gateway
```
**4. Expose HTTPS locally (ngrok)**
Zalo requires an HTTPS webhook URL. For local development you can use ngrok:
```bash
ngrok http 18790
```
Then your webhook URL becomes:
`https://<your-ngrok-domain>/webhook/zalo`
**5. Register the webhook with Zalo**
Call Zalo `setWebhook` with your HTTPS URL and the same `secret_token` you configured above:
```bash
curl -X POST "https://bot-api.zaloplatforms.com/bot${BOT_TOKEN}/setWebhook" \
-H "Content-Type: application/json" \
-d '{"url":"https://<your-ngrok-domain>/webhook/zalo","secret_token":"YOUR_SECRET_TOKEN"}'
```
After that, send a message to your bot on Zalo and PicoClaw will reply.
</details>
<details> <details>
<summary><b>Discord</b></summary> <summary><b>Discord</b></summary>

View file

@ -286,17 +286,67 @@ Pronto! Você tem um assistente de IA funcionando em 2 minutos.
## 💬 Integração com Apps de Chat ## 💬 Integração com Apps de Chat
Converse com seu PicoClaw via Telegram, Discord, DingTalk, LINE ou WeCom. Converse com seu PicoClaw via Telegram, Zalo, Discord, DingTalk, LINE ou WeCom.
| Canal | Nível de Configuração | | Canal | Nível de Configuração |
| --- | --- | | --- | --- |
| **Telegram** | Fácil (apenas um token) | | **Telegram** | Fácil (apenas um token) |
| **Zalo** | Médio (token + secret_token + webhook URL) |
| **Discord** | Fácil (bot token + intents) | | **Discord** | Fácil (bot token + intents) |
| **QQ** | Fácil (AppID + AppSecret) | | **QQ** | Fácil (AppID + AppSecret) |
| **DingTalk** | Médio (credenciais do app) | | **DingTalk** | Médio (credenciais do app) |
| **LINE** | Médio (credenciais + webhook URL) | | **LINE** | Médio (credenciais + webhook URL) |
| **WeCom AI Bot** | Médio (Token + chave AES) | | **WeCom AI Bot** | Médio (Token + chave AES) |
<details>
<summary><b>Zalo</b> (Webhook)</summary>
**1. Preparar**
* Pegue seu `BOT_TOKEN` do Zalo
* Defina um `secret_token` (8256 chars). O Zalo enviará esse valor no header `X-Bot-Api-Secret-Token`.
* Para mais detalhes de webhook, veja a documentação oficial [`setWebhook`](https://bot.zapps.me/docs/apis/setWebhook/).
**2. Configurar**
```json
{
"channels": {
"zalo": {
"enabled": true,
"token": "YOUR_ZALO_BOT_TOKEN",
"secret_token": "YOUR_SECRET_TOKEN",
"webhook_path": "/webhook/zalo",
"allow_from": []
}
}
}
```
**3. Executar**
```bash
picoclaw gateway
```
**4. HTTPS local (ngrok)**
```bash
ngrok http 18790
```
Webhook URL: `https://<ngrok-domain>/webhook/zalo`
**5. Registrar webhook**
```bash
curl -X POST "https://bot-api.zaloplatforms.com/bot${BOT_TOKEN}/setWebhook" \
-H "Content-Type: application/json" \
-d '{"url":"https://<ngrok-domain>/webhook/zalo","secret_token":"YOUR_SECRET_TOKEN"}'
```
</details>
<details> <details>
<summary><b>Telegram</b> (Recomendado)</summary> <summary><b>Telegram</b> (Recomendado)</summary>

View file

@ -257,17 +257,67 @@ Vậy là xong! Bạn đã có một trợ lý AI hoạt động chỉ trong 2 p
## 💬 Tích hợp ứng dụng Chat ## 💬 Tích hợp ứng dụng Chat
Trò chuyện với PicoClaw qua Telegram, Discord, DingTalk, LINE hoặc WeCom. Trò chuyện với PicoClaw qua Telegram, Zalo, Discord, DingTalk, LINE hoặc WeCom.
| Kênh | Mức độ thiết lập | | Kênh | Mức độ thiết lập |
| --- | --- | | --- | --- |
| **Telegram** | Dễ (chỉ cần token) | | **Telegram** | Dễ (chỉ cần token) |
| **Zalo** | Trung bình (token + secret_token + webhook URL) |
| **Discord** | Dễ (bot token + intents) | | **Discord** | Dễ (bot token + intents) |
| **QQ** | Dễ (AppID + AppSecret) | | **QQ** | Dễ (AppID + AppSecret) |
| **DingTalk** | Trung bình (app credentials) | | **DingTalk** | Trung bình (app credentials) |
| **LINE** | Trung bình (credentials + webhook URL) | | **LINE** | Trung bình (credentials + webhook URL) |
| **WeCom AI Bot** | Trung bình (Token + khóa AES) | | **WeCom AI Bot** | Trung bình (Token + khóa AES) |
<details>
<summary><b>Zalo</b> (Webhook)</summary>
**1. Chuẩn bị**
* Lấy `BOT_TOKEN` của Zalo Bot
* Tạo `secret_token` (8256 ký tự). Zalo sẽ gửi lại trong header `X-Bot-Api-Secret-Token`.
* Xem thêm chi tiết webhook tại tài liệu chính thức [`setWebhook`](https://bot.zapps.me/docs/apis/setWebhook/).
**2. Cấu hình**
```json
{
"channels": {
"zalo": {
"enabled": true,
"token": "YOUR_ZALO_BOT_TOKEN",
"secret_token": "YOUR_SECRET_TOKEN",
"webhook_path": "/webhook/zalo",
"allow_from": []
}
}
}
```
**3. Chạy**
```bash
picoclaw gateway
```
**4. HTTPS local (ngrok)**
```bash
ngrok http 18790
```
Webhook URL: `https://<ngrok-domain>/webhook/zalo`
**5. Đăng ký webhook**
```bash
curl -X POST "https://bot-api.zaloplatforms.com/bot${BOT_TOKEN}/setWebhook" \
-H "Content-Type: application/json" \
-d '{"url":"https://<ngrok-domain>/webhook/zalo","secret_token":"YOUR_SECRET_TOKEN"}'
```
</details>
<details> <details>
<summary><b>Telegram</b> (Khuyên dùng)</summary> <summary><b>Telegram</b> (Khuyên dùng)</summary>

View file

@ -310,6 +310,7 @@ PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方
| 渠道 | 设置难度 | 特性说明 | 文档链接 | | 渠道 | 设置难度 | 特性说明 | 文档链接 |
| -------------------- | ----------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------- | | -------------------- | ----------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| **Telegram** | ⭐ 简单 | 推荐,支持语音转文字,长轮询无需公网 | [查看文档](docs/channels/telegram/README.zh.md) | | **Telegram** | ⭐ 简单 | 推荐,支持语音转文字,长轮询无需公网 | [查看文档](docs/channels/telegram/README.zh.md) |
| **Zalo** | ⭐⭐ 中等 | Webhook 模式(需要 HTTPS适合本地 ngrok 调试 | 参见主 README 中的 Zalo(Webhook) 章节 |
| **Discord** | ⭐ 简单 | Socket Mode支持群组/私信Bot 生态成熟 | [查看文档](docs/channels/discord/README.zh.md) | | **Discord** | ⭐ 简单 | Socket Mode支持群组/私信Bot 生态成熟 | [查看文档](docs/channels/discord/README.zh.md) |
| **Slack** | ⭐ 简单 | **Socket Mode** (无需公网 IP),企业级支持 | [查看文档](docs/channels/slack/README.zh.md) | | **Slack** | ⭐ 简单 | **Socket Mode** (无需公网 IP),企业级支持 | [查看文档](docs/channels/slack/README.zh.md) |
| **Matrix** | ⭐⭐ 中等 | 联邦协议,支持自建 homeserver 与公开服务器 | [查看文档](docs/channels/matrix/README.zh.md) | | **Matrix** | ⭐⭐ 中等 | 联邦协议,支持自建 homeserver 与公开服务器 | [查看文档](docs/channels/matrix/README.zh.md) |
@ -321,6 +322,66 @@ PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方
| **OneBot** | ⭐⭐ 中等 | 兼容 NapCat/Go-CQHTTP社区生态丰富 | [查看文档](docs/channels/onebot/README.zh.md) | | **OneBot** | ⭐⭐ 中等 | 兼容 NapCat/Go-CQHTTP社区生态丰富 | [查看文档](docs/channels/onebot/README.zh.md) |
| **MaixCam** | ⭐ 简单 | 专为 AI 摄像头设计的硬件集成通道 | [查看文档](docs/channels/maixcam/README.zh.md) | | **MaixCam** | ⭐ 简单 | 专为 AI 摄像头设计的硬件集成通道 | [查看文档](docs/channels/maixcam/README.zh.md) |
<details>
<summary><b>Zalo</b> (Webhook)</summary>
PicoClaw 通过官方 Zalo Bot API webhook 流程与 Zalo Bot 集成。
**1. 创建 Zalo Bot**
* 在 Zalo Developer / Bot 控制台创建机器人
* 复制您的 **Bot Token** (`BOT_TOKEN`)
* 选择一个强密码 `secret_token`8256 字符。这是您自己的密钥Zalo 会在 `X-Bot-Api-Secret-Token` 请求头中返回此值。
* 有关完整的 webhook 选项,请参阅 Zalo 官方 [`setWebhook` 文档](https://bot.zapps.me/docs/apis/setWebhook/)。
**2. 配置**
```json
{
"channels": {
"zalo": {
"enabled": true,
"token": "YOUR_ZALO_BOT_TOKEN",
"secret_token": "YOUR_SECRET_TOKEN",
"webhook_path": "/webhook/zalo",
"allow_from": []
}
}
}
```
**3. 运行 Gateway**
```bash
picoclaw gateway
```
**4. 本地暴露 HTTPS (ngrok)**
Zalo 需要 HTTPS webhook URL。对于本地开发您可以使用 ngrok
```bash
ngrok http 18790
```
然后您的 webhook URL 变为:
`https://<your-ngrok-domain>/webhook/zalo`
**5. 向 Zalo 注册 webhook**
使用您的 HTTPS URL 和上面配置的相同 `secret_token` 调用 Zalo `setWebhook`
```bash
curl -X POST "https://bot-api.zaloplatforms.com/bot${BOT_TOKEN}/setWebhook" \
-H "Content-Type: application/json" \
-d '{"url":"https://<your-ngrok-domain>/webhook/zalo","secret_token":"YOUR_SECRET_TOKEN"}'
```
之后,在 Zalo 上向您的机器人发送消息PicoClaw 将回复。
</details>
### Telegram 命令注册(启动时自动同步) ### Telegram 命令注册(启动时自动同步)
PicoClaw 现在使用统一的命令定义来源。启动时会自动将 Telegram 支持的命令(例如 `/start``/help``/show``/list`)注册到 Bot 命令菜单,确保菜单展示与实际行为一致。 PicoClaw 现在使用统一的命令定义来源。启动时会自动将 Telegram 支持的命令(例如 `/start``/help``/show``/list`)注册到 Bot 命令菜单,确保菜单展示与实际行为一致。

View file

@ -93,6 +93,14 @@
}, },
"reasoning_channel_id": "" "reasoning_channel_id": ""
}, },
"zalo": {
"enabled": false,
"token": "YOUR_ZALO_BOT_TOKEN",
"secret_token": "YOUR_SECRET_TOKEN",
"webhook_path": "/webhook/zalo",
"allow_from": [],
"reasoning_channel_id": ""
},
"qq": { "qq": {
"enabled": false, "enabled": false,
"app_id": "YOUR_QQ_APP_ID", "app_id": "YOUR_QQ_APP_ID",

View file

@ -59,6 +59,7 @@ type placeholderEntry struct {
// channelRateConfig maps channel name to per-second rate limit. // channelRateConfig maps channel name to per-second rate limit.
var channelRateConfig = map[string]float64{ var channelRateConfig = map[string]float64{
"telegram": 20, "telegram": 20,
"zalo": 10,
"discord": 1, "discord": 1,
"slack": 1, "slack": 1,
"matrix": 2, "matrix": 2,
@ -239,6 +240,11 @@ func (m *Manager) initChannels() error {
m.initChannel("telegram", "Telegram") m.initChannel("telegram", "Telegram")
} }
if m.config.Channels.Zalo.Enabled && m.config.Channels.Zalo.Token != "" &&
m.config.Channels.Zalo.SecretToken != "" {
m.initChannel("zalo", "Zalo")
}
if m.config.Channels.WhatsApp.Enabled { if m.config.Channels.WhatsApp.Enabled {
waCfg := m.config.Channels.WhatsApp waCfg := m.config.Channels.WhatsApp
if waCfg.UseNative { if waCfg.UseNative {

13
pkg/channels/zalo/init.go Normal file
View file

@ -0,0 +1,13 @@
package zalo
import (
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
)
func init() {
channels.RegisterFactory("zalo", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
return NewZaloChannel(cfg, b)
})
}

402
pkg/channels/zalo/zalo.go Normal file
View file

@ -0,0 +1,402 @@
package zalo
import (
"bytes"
"context"
"crypto/subtle"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"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"
"github.com/sipeed/picoclaw/pkg/utils"
)
const zaloAPIBase = "https://bot-api.zaloplatforms.com/bot"
type ZaloChannel struct {
*channels.BaseChannel
cfg config.ZaloConfig
httpClient *http.Client
}
func NewZaloChannel(cfg *config.Config, messageBus *bus.MessageBus) (*ZaloChannel, error) {
zc := cfg.Channels.Zalo
if strings.TrimSpace(zc.Token) == "" {
return nil, fmt.Errorf("zalo token is required")
}
if strings.TrimSpace(zc.SecretToken) == "" {
return nil, fmt.Errorf("zalo secret_token is required")
}
base := channels.NewBaseChannel(
"zalo",
zc,
messageBus,
zc.AllowFrom,
channels.WithMaxMessageLength(2000),
channels.WithGroupTrigger(zc.GroupTrigger),
channels.WithReasoningChannelID(zc.ReasoningChannelID),
)
return &ZaloChannel{
BaseChannel: base,
cfg: zc,
httpClient: &http.Client{Timeout: 30 * time.Second},
}, nil
}
func (c *ZaloChannel) Start(ctx context.Context) error {
if _, err := c.getMe(ctx); err != nil {
logger.ErrorCF("zalo", "Failed to start channel", map[string]any{
"error": err.Error(),
})
return err
}
c.SetRunning(true)
logger.InfoC("zalo", "Zalo channel started successfully")
return nil
}
func (c *ZaloChannel) Stop(ctx context.Context) error {
c.SetRunning(false)
return nil
}
func (c *ZaloChannel) WebhookPath() string {
if p := strings.TrimSpace(c.cfg.WebhookPath); p != "" {
if strings.HasPrefix(p, "/") {
return p
}
return "/" + p
}
return "/webhook/zalo"
}
func (c *ZaloChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
logger.ErrorCF("zalo", "Failed to read request body", map[string]any{
"error": err.Error(),
})
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
secret := r.Header.Get("X-Bot-Api-Secret-Token")
if subtle.ConstantTimeCompare([]byte(secret), []byte(c.cfg.SecretToken)) != 1 {
logger.WarnC("zalo", "Invalid webhook secret token")
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
var payload map[string]any
if err := json.Unmarshal(body, &payload); err != nil {
logger.ErrorCF("zalo", "Failed to parse webhook payload", map[string]any{
"error": err.Error(),
})
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusOK)
go func() {
c.processPayload(context.Background(), payload)
}()
}
func (c *ZaloChannel) processPayload(ctx context.Context, payload map[string]any) {
logger.DebugCF("zalo", "Processing webhook payload", map[string]any{
"has_result": payload["result"] != nil,
})
if v, ok := payload["result"]; ok {
if arr, ok := v.([]any); ok {
for _, item := range arr {
if m, ok := item.(map[string]any); ok {
c.processUpdate(ctx, m)
}
}
return
}
}
c.processUpdate(ctx, payload)
}
func (c *ZaloChannel) processUpdate(ctx context.Context, upd map[string]any) {
msgAny, _ := upd["message"]
msg, _ := msgAny.(map[string]any)
if msg == nil {
msg = upd
}
content := getString(msg, "text")
if strings.TrimSpace(content) == "" {
return
}
messageID := getString(msg, "message_id")
if messageID == "" {
messageID = getString(msg, "id")
}
chatID := getString(msg, "chat_id")
if chatID == "" {
chatID = getStringPath(msg, "chat", "id")
}
if chatID == "" {
return
}
senderID := getStringPath(msg, "from", "id")
if senderID == "" {
senderID = getString(msg, "from_id")
}
if senderID == "" {
return
}
peerKind := "direct"
if t := getStringPath(msg, "chat", "type"); t != "" {
if strings.Contains(strings.ToLower(t), "group") {
peerKind = "group"
}
}
if peerKind == "group" {
should, cleaned := c.ShouldRespondInGroup(false, content)
if !should {
return
}
content = cleaned
}
sender := bus.SenderInfo{
Platform: "zalo",
PlatformID: senderID,
CanonicalID: identity.BuildCanonicalID("zalo", senderID),
}
if !c.IsAllowedSender(sender) {
logger.DebugCF("zalo", "Message from disallowed sender", map[string]any{
"sender_id": senderID,
})
return
}
peer := bus.Peer{
Kind: peerKind,
ID: chatID,
}
logger.DebugCF("zalo", "Received message", map[string]any{
"sender_id": senderID,
"chat_id": chatID,
"peer_kind": peerKind,
"preview": utils.Truncate(content, 50),
})
c.HandleMessage(ctx, peer, messageID, senderID, chatID, content, nil, nil, sender)
}
func (c *ZaloChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
if !c.IsRunning() {
return channels.ErrNotRunning
}
return c.sendMessage(ctx, msg.ChatID, msg.Content)
}
func (c *ZaloChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {
if !c.IsRunning() {
return func() {}, channels.ErrNotRunning
}
err := c.sendChatAction(ctx, chatID, "typing")
return func() {}, err
}
func (c *ZaloChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
if !c.IsRunning() {
return channels.ErrNotRunning
}
for _, part := range msg.Parts {
if part.Type != "image" {
continue
}
ref := strings.TrimSpace(part.Ref)
if strings.HasPrefix(ref, "http://") || strings.HasPrefix(ref, "https://") {
if err := c.sendPhoto(ctx, msg.ChatID, ref, part.Caption); err != nil {
return err
}
continue
}
caption := strings.TrimSpace(part.Caption)
if caption == "" {
caption = part.Filename
}
if caption != "" {
if err := c.sendMessage(ctx, msg.ChatID, caption); err != nil {
return err
}
}
}
return nil
}
func (c *ZaloChannel) apiURL(method string) string {
return strings.TrimRight(zaloAPIBase, "/") + c.cfg.Token + "/" + strings.TrimLeft(method, "/")
}
func (c *ZaloChannel) doPOST(ctx context.Context, method string, payload any) (int, []byte, error) {
b, err := json.Marshal(payload)
if err != nil {
return 0, nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.apiURL(method), bytes.NewReader(b))
if err != nil {
return 0, nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return 0, nil, channels.ClassifyNetError(err)
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return resp.StatusCode, nil, channels.ClassifyNetError(err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return resp.StatusCode, data, channels.ClassifySendError(
resp.StatusCode,
fmt.Errorf("http %d", resp.StatusCode),
)
}
return resp.StatusCode, data, nil
}
func (c *ZaloChannel) getMe(ctx context.Context) (map[string]any, error) {
_, data, err := c.doPOST(ctx, "getMe", map[string]any{})
if err != nil {
logger.ErrorCF("zalo", "Failed to call getMe API", map[string]any{
"error": err.Error(),
})
return nil, err
}
var out map[string]any
if err := json.Unmarshal(data, &out); err != nil {
logger.ErrorCF("zalo", "Failed to parse getMe response", map[string]any{
"error": err.Error(),
})
return nil, err
}
if ok, _ := out["ok"].(bool); !ok {
err := fmt.Errorf("zalo getMe returned ok=false")
logger.ErrorCF("zalo", "getMe API returned error", map[string]any{
"response": out,
})
return out, err
}
return out, nil
}
func (c *ZaloChannel) sendMessage(ctx context.Context, chatID, text string) error {
_, data, err := c.doPOST(ctx, "sendMessage", map[string]any{
"chat_id": chatID,
"text": text,
})
if err != nil {
logger.ErrorCF("zalo", "Failed to send message", map[string]any{
"chat_id": chatID,
"error": err.Error(),
})
return err
}
var out map[string]any
if err := json.Unmarshal(data, &out); err != nil {
logger.ErrorCF("zalo", "Failed to parse sendMessage response", map[string]any{
"error": err.Error(),
})
return err
}
if ok, _ := out["ok"].(bool); !ok {
err := fmt.Errorf("zalo sendMessage returned ok=false")
logger.ErrorCF("zalo", "sendMessage API returned error", map[string]any{
"chat_id": chatID,
"response": out,
})
return err
}
return nil
}
func (c *ZaloChannel) sendPhoto(ctx context.Context, chatID, photoURL, caption string) error {
payload := map[string]any{
"chat_id": chatID,
"photo": photoURL,
}
if strings.TrimSpace(caption) != "" {
payload["caption"] = caption
}
_, _, err := c.doPOST(ctx, "sendPhoto", payload)
return err
}
func (c *ZaloChannel) sendChatAction(ctx context.Context, chatID, action string) error {
_, _, err := c.doPOST(ctx, "sendChatAction", map[string]any{
"chat_id": chatID,
"action": action,
})
return err
}
func getString(m map[string]any, key string) string {
if m == nil {
return ""
}
v, ok := m[key]
if !ok || v == nil {
return ""
}
switch t := v.(type) {
case string:
return t
case float64:
if t == float64(int64(t)) {
return fmt.Sprintf("%.0f", t)
}
return fmt.Sprintf("%v", t)
default:
return fmt.Sprintf("%v", t)
}
}
func getStringPath(m map[string]any, k1, k2 string) string {
v, ok := m[k1]
if !ok || v == nil {
return ""
}
m2, ok := v.(map[string]any)
if !ok {
return ""
}
return getString(m2, k2)
}

View file

@ -259,6 +259,7 @@ func (d *AgentDefaults) GetModelName() string {
type ChannelsConfig struct { type ChannelsConfig struct {
WhatsApp WhatsAppConfig `json:"whatsapp"` WhatsApp WhatsAppConfig `json:"whatsapp"`
Telegram TelegramConfig `json:"telegram"` Telegram TelegramConfig `json:"telegram"`
Zalo ZaloConfig `json:"zalo"`
Feishu FeishuConfig `json:"feishu"` Feishu FeishuConfig `json:"feishu"`
Discord DiscordConfig `json:"discord"` Discord DiscordConfig `json:"discord"`
MaixCam MaixCamConfig `json:"maixcam"` MaixCam MaixCamConfig `json:"maixcam"`
@ -313,6 +314,16 @@ type TelegramConfig struct {
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_TELEGRAM_REASONING_CHANNEL_ID"` ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_TELEGRAM_REASONING_CHANNEL_ID"`
} }
type ZaloConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_ZALO_ENABLED"`
Token string `json:"token" env:"PICOCLAW_CHANNELS_ZALO_TOKEN"`
SecretToken string `json:"secret_token" env:"PICOCLAW_CHANNELS_ZALO_SECRET_TOKEN"`
WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_ZALO_WEBHOOK_PATH"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ZALO_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_ZALO_REASONING_CHANNEL_ID"`
}
type FeishuConfig struct { type FeishuConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"` Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"`
AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"` AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"`

View file

@ -59,6 +59,13 @@ func DefaultConfig() *Config {
Text: "Thinking... 💭", Text: "Thinking... 💭",
}, },
}, },
Zalo: ZaloConfig{
Enabled: false,
Token: "",
SecretToken: "",
WebhookPath: "/webhook/zalo",
AllowFrom: FlexibleStringSlice{},
},
Feishu: FeishuConfig{ Feishu: FeishuConfig{
Enabled: false, Enabled: false,
AppID: "", AppID: "",

View file

@ -28,6 +28,7 @@ import (
_ "github.com/sipeed/picoclaw/pkg/channels/wecom" _ "github.com/sipeed/picoclaw/pkg/channels/wecom"
_ "github.com/sipeed/picoclaw/pkg/channels/whatsapp" _ "github.com/sipeed/picoclaw/pkg/channels/whatsapp"
_ "github.com/sipeed/picoclaw/pkg/channels/whatsapp_native" _ "github.com/sipeed/picoclaw/pkg/channels/whatsapp_native"
_ "github.com/sipeed/picoclaw/pkg/channels/zalo"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/cron" "github.com/sipeed/picoclaw/pkg/cron"
"github.com/sipeed/picoclaw/pkg/devices" "github.com/sipeed/picoclaw/pkg/devices"