fix(zalo): address PR review comments

- Remove unrelated voice config from config.example.json
- Add IsAllowedSender check in processUpdate
- Add comprehensive logging throughout zalo.go
- Fix context handling in ServeHTTP
- Validate API response body in sendMessage
- Complete Chinese documentation in README.zh.md
This commit is contained in:
trandangtrungduc 2026-03-12 09:15:12 +07:00
parent 5d71c2d946
commit 7d4c009d0a
3 changed files with 126 additions and 9 deletions

View file

@ -316,6 +316,66 @@ Telegram 侧保留的是命令菜单注册能力;通用命令的实际执行
如果注册因网络或 API 短暂异常失败,不会阻塞 channel 启动;系统会在后台自动重试。 如果注册因网络或 API 短暂异常失败,不会阻塞 channel 启动;系统会在后台自动重试。
<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>
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> 加入 Agent 社交网络 ## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> 加入 Agent 社交网络
只需通过 CLI 或任何集成的聊天应用发送一条消息,即可将 PicoClaw 连接到 Agent 社交网络。 只需通过 CLI 或任何集成的聊天应用发送一条消息,即可将 PicoClaw 连接到 Agent 社交网络。

View file

@ -485,9 +485,6 @@
"enabled": false, "enabled": false,
"monitor_usb": true "monitor_usb": true
}, },
"voice": {
"echo_transcription": false
},
"gateway": { "gateway": {
"host": "127.0.0.1", "host": "127.0.0.1",
"port": 18790 "port": 18790

View file

@ -15,6 +15,8 @@ import (
"github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/identity" "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" const zaloAPIBase = "https://bot-api.zaloplatforms.com/bot"
@ -53,9 +55,13 @@ func NewZaloChannel(cfg *config.Config, messageBus *bus.MessageBus) (*ZaloChanne
func (c *ZaloChannel) Start(ctx context.Context) error { func (c *ZaloChannel) Start(ctx context.Context) error {
if _, err := c.getMe(ctx); err != nil { if _, err := c.getMe(ctx); err != nil {
logger.ErrorCF("zalo", "Failed to start channel", map[string]any{
"error": err.Error(),
})
return err return err
} }
c.SetRunning(true) c.SetRunning(true)
logger.InfoC("zalo", "Zalo channel started successfully")
return nil return nil
} }
@ -82,32 +88,40 @@ func (c *ZaloChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body) body, err := io.ReadAll(r.Body)
if err != nil { if err != nil {
logger.ErrorCF("zalo", "Failed to read request body", map[string]any{
"error": err.Error(),
})
http.Error(w, "Bad request", http.StatusBadRequest) http.Error(w, "Bad request", http.StatusBadRequest)
return return
} }
secret := r.Header.Get("X-Bot-Api-Secret-Token") secret := r.Header.Get("X-Bot-Api-Secret-Token")
if subtle.ConstantTimeCompare([]byte(secret), []byte(c.cfg.SecretToken)) != 1 { if subtle.ConstantTimeCompare([]byte(secret), []byte(c.cfg.SecretToken)) != 1 {
logger.WarnC("zalo", "Invalid webhook secret token")
http.Error(w, "Forbidden", http.StatusForbidden) http.Error(w, "Forbidden", http.StatusForbidden)
return return
} }
var payload map[string]any var payload map[string]any
if err := json.Unmarshal(body, &payload); err != nil { 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) http.Error(w, "Bad request", http.StatusBadRequest)
return return
} }
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
go func() { go func() {
defer cancel() c.processPayload(context.Background(), payload)
c.processPayload(ctx, payload)
}() }()
} }
func (c *ZaloChannel) processPayload(ctx context.Context, payload map[string]any) { 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 v, ok := payload["result"]; ok {
if arr, ok := v.([]any); ok { if arr, ok := v.([]any); ok {
for _, item := range arr { for _, item := range arr {
@ -175,11 +189,25 @@ func (c *ZaloChannel) processUpdate(ctx context.Context, upd map[string]any) {
CanonicalID: identity.BuildCanonicalID("zalo", 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{ peer := bus.Peer{
Kind: peerKind, Kind: peerKind,
ID: chatID, 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) c.HandleMessage(ctx, peer, messageID, senderID, chatID, content, nil, nil, sender)
} }
@ -268,25 +296,57 @@ func (c *ZaloChannel) doPOST(ctx context.Context, method string, payload any) (i
func (c *ZaloChannel) getMe(ctx context.Context) (map[string]any, error) { func (c *ZaloChannel) getMe(ctx context.Context) (map[string]any, error) {
_, data, err := c.doPOST(ctx, "getMe", map[string]any{}) _, data, err := c.doPOST(ctx, "getMe", map[string]any{})
if err != nil { if err != nil {
logger.ErrorCF("zalo", "Failed to call getMe API", map[string]any{
"error": err.Error(),
})
return nil, err return nil, err
} }
var out map[string]any var out map[string]any
if err := json.Unmarshal(data, &out); err != nil { 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 return nil, err
} }
if ok, _ := out["ok"].(bool); !ok { if ok, _ := out["ok"].(bool); !ok {
return out, fmt.Errorf("zalo getMe returned ok=false") 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 return out, nil
} }
func (c *ZaloChannel) sendMessage(ctx context.Context, chatID, text string) error { func (c *ZaloChannel) sendMessage(ctx context.Context, chatID, text string) error {
_, _, err := c.doPOST(ctx, "sendMessage", map[string]any{ _, data, err := c.doPOST(ctx, "sendMessage", map[string]any{
"chat_id": chatID, "chat_id": chatID,
"text": text, "text": text,
}) })
if err != nil {
logger.ErrorCF("zalo", "Failed to send message", map[string]any{
"chat_id": chatID,
"error": err.Error(),
})
return err 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 { func (c *ZaloChannel) sendPhoto(ctx context.Context, chatID, photoURL, caption string) error {
payload := map[string]any{ payload := map[string]any{