diff --git a/README.zh.md b/README.zh.md index 7a25cc204..88b4babd0 100644 --- a/README.zh.md +++ b/README.zh.md @@ -316,6 +316,66 @@ Telegram 侧保留的是命令菜单注册能力;通用命令的实际执行 如果注册因网络或 API 短暂异常失败,不会阻塞 channel 启动;系统会在后台自动重试。 +
+Zalo (Webhook) + +PicoClaw 通过官方 Zalo Bot API webhook 流程与 Zalo Bot 集成。 + +**1. 创建 Zalo Bot** + +* 在 Zalo Developer / Bot 控制台创建机器人 +* 复制您的 **Bot Token** (`BOT_TOKEN`) +* 选择一个强密码 `secret_token`(8–256 字符)。这是您自己的密钥,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:///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:///webhook/zalo","secret_token":"YOUR_SECRET_TOKEN"}' +``` + +之后,在 Zalo 上向您的机器人发送消息,PicoClaw 将回复。 + +
+ ## ClawdChat 加入 Agent 社交网络 只需通过 CLI 或任何集成的聊天应用发送一条消息,即可将 PicoClaw 连接到 Agent 社交网络。 diff --git a/config/config.example.json b/config/config.example.json index 9f114760d..f06066df5 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -485,9 +485,6 @@ "enabled": false, "monitor_usb": true }, - "voice": { - "echo_transcription": false - }, "gateway": { "host": "127.0.0.1", "port": 18790 diff --git a/pkg/channels/zalo/zalo.go b/pkg/channels/zalo/zalo.go index 1484d130f..7bccbcd2b 100644 --- a/pkg/channels/zalo/zalo.go +++ b/pkg/channels/zalo/zalo.go @@ -15,6 +15,8 @@ import ( "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" @@ -53,9 +55,13 @@ func NewZaloChannel(cfg *config.Config, messageBus *bus.MessageBus) (*ZaloChanne 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 } @@ -82,32 +88,40 @@ func (c *ZaloChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) { 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) - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) go func() { - defer cancel() - c.processPayload(ctx, payload) + 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 { @@ -175,11 +189,25 @@ func (c *ZaloChannel) processUpdate(ctx context.Context, upd map[string]any) { 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) } @@ -268,24 +296,56 @@ func (c *ZaloChannel) doPOST(ctx context.Context, method string, payload any) (i 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 { - 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 } 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, "text": text, }) - return err + 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 {