From e5b91ae4e65380d4ffa5e249df727b07dcbea62a Mon Sep 17 00:00:00 2001 From: trandangtrungduc Date: Wed, 18 Mar 2026 10:26:08 +0700 Subject: [PATCH] add Zalo channel support and documentation --- README.fr.md | 52 ++++- README.ja.md | 52 ++++- README.md | 63 +++++- README.pt-br.md | 52 ++++- README.vi.md | 52 ++++- README.zh.md | 61 ++++++ config/config.example.json | 8 + pkg/channels/manager.go | 6 + pkg/channels/zalo/init.go | 13 ++ pkg/channels/zalo/zalo.go | 402 +++++++++++++++++++++++++++++++++++++ pkg/config/config.go | 11 + pkg/config/defaults.go | 7 + pkg/gateway/gateway.go | 1 + 13 files changed, 775 insertions(+), 5 deletions(-) create mode 100644 pkg/channels/zalo/init.go create mode 100644 pkg/channels/zalo/zalo.go diff --git a/README.fr.md b/README.fr.md index 35e5e1e08..605988855 100644 --- a/README.fr.md +++ b/README.fr.md @@ -292,11 +292,12 @@ Et voilà ! Vous avez un assistant IA fonctionnel en 2 minutes. ## 💬 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 | | ------------ | -------------------------------------- | | **Telegram** | Facile (juste un token) | +| **Zalo** | Moyen (token + secret_token + webhook URL) | | **Discord** | Facile (token bot + intents) | | **QQ** | Facile (AppID + AppSecret) | | **DingTalk** | Moyen (identifiants de l'application) | @@ -336,6 +337,55 @@ picoclaw gateway +
+Zalo (Webhook) + +**1. Préparer** + +* Récupérez votre `BOT_TOKEN` Zalo +* Choisissez un `secret_token` (8–256 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:///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:///webhook/zalo","secret_token":"YOUR_SECRET_TOKEN"}' +``` + +
+
Discord diff --git a/README.ja.md b/README.ja.md index b1a784af9..435315feb 100644 --- a/README.ja.md +++ b/README.ja.md @@ -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** | 簡単(トークンのみ) | +| **Zalo** | 普通(token + secret_token + webhook URL) | | **Discord** | 簡単(Bot トークン + Intents) | | **QQ** | 簡単(AppID + AppSecret) | | **DingTalk** | 普通(アプリ認証情報) | | **LINE** | 普通(認証情報 + Webhook URL) | | **WeCom AI Bot** | 普通(Token + AES キー) | +
+Zalo(Webhook) + +**1. 準備** + +- Zalo の `BOT_TOKEN` を用意 +- `secret_token`(8–256 文字)を決める(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. ローカルで HTTPS(ngrok)** + +```bash +ngrok http 18790 +``` + +Webhook URL: `https:///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:///webhook/zalo","secret_token":"YOUR_SECRET_TOKEN"}' +``` + +
+
Telegram(推奨) diff --git a/README.md b/README.md index 98bc3e32e..238bd0148 100644 --- a/README.md +++ b/README.md @@ -331,13 +331,14 @@ That's it! You have a working AI assistant in 2 minutes. ## 💬 Chat Apps -Talk to your picoclaw through Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, or WeCom +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. | Channel | Setup | | ------------ | ---------------------------------- | | **Telegram** | Easy (just a token) | +| **Zalo** | Medium (token + secret_token + webhook URL) | | **Discord** | Easy (bot token + intents) | | **WhatsApp** | Easy (native: QR scan; or bridge URL) | | **Matrix** | Medium (homeserver + bot access token) | @@ -386,6 +387,66 @@ If command registration fails (network/API transient errors), the channel still
+
+Zalo (Webhook) + +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` (8–256 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 Zalo’s 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:///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:///webhook/zalo","secret_token":"YOUR_SECRET_TOKEN"}' +``` + +After that, send a message to your bot on Zalo and PicoClaw will reply. + +
+
Discord diff --git a/README.pt-br.md b/README.pt-br.md index 222755242..9b60cd537 100644 --- a/README.pt-br.md +++ b/README.pt-br.md @@ -286,17 +286,67 @@ Pronto! Você tem um assistente de IA funcionando em 2 minutos. ## 💬 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 | | --- | --- | | **Telegram** | Fácil (apenas um token) | +| **Zalo** | Médio (token + secret_token + webhook URL) | | **Discord** | Fácil (bot token + intents) | | **QQ** | Fácil (AppID + AppSecret) | | **DingTalk** | Médio (credenciais do app) | | **LINE** | Médio (credenciais + webhook URL) | | **WeCom AI Bot** | Médio (Token + chave AES) | +
+Zalo (Webhook) + +**1. Preparar** + +* Pegue seu `BOT_TOKEN` do Zalo +* Defina um `secret_token` (8–256 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:///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:///webhook/zalo","secret_token":"YOUR_SECRET_TOKEN"}' +``` + +
+
Telegram (Recomendado) diff --git a/README.vi.md b/README.vi.md index da77d0bf5..df4b4d963 100644 --- a/README.vi.md +++ b/README.vi.md @@ -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 -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 | | --- | --- | | **Telegram** | Dễ (chỉ cần token) | +| **Zalo** | Trung bình (token + secret_token + webhook URL) | | **Discord** | Dễ (bot token + intents) | | **QQ** | Dễ (AppID + AppSecret) | | **DingTalk** | Trung bình (app credentials) | | **LINE** | Trung bình (credentials + webhook URL) | | **WeCom AI Bot** | Trung bình (Token + khóa AES) | +
+Zalo (Webhook) + +**1. Chuẩn bị** + +* Lấy `BOT_TOKEN` của Zalo Bot +* Tạo `secret_token` (8–256 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:///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:///webhook/zalo","secret_token":"YOUR_SECRET_TOKEN"}' +``` + +
+
Telegram (Khuyên dùng) diff --git a/README.zh.md b/README.zh.md index 800e7ada7..824e439fa 100644 --- a/README.zh.md +++ b/README.zh.md @@ -310,6 +310,7 @@ PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方 | 渠道 | 设置难度 | 特性说明 | 文档链接 | | -------------------- | ----------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------- | | **Telegram** | ⭐ 简单 | 推荐,支持语音转文字,长轮询无需公网 | [查看文档](docs/channels/telegram/README.zh.md) | +| **Zalo** | ⭐⭐ 中等 | Webhook 模式(需要 HTTPS),适合本地 ngrok 调试 | 参见主 README 中的 Zalo(Webhook) 章节 | | **Discord** | ⭐ 简单 | Socket Mode,支持群组/私信,Bot 生态成熟 | [查看文档](docs/channels/discord/README.zh.md) | | **Slack** | ⭐ 简单 | **Socket Mode** (无需公网 IP),企业级支持 | [查看文档](docs/channels/slack/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) | | **MaixCam** | ⭐ 简单 | 专为 AI 摄像头设计的硬件集成通道 | [查看文档](docs/channels/maixcam/README.zh.md) | +
+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 将回复。 + +
+ ### Telegram 命令注册(启动时自动同步) PicoClaw 现在使用统一的命令定义来源。启动时会自动将 Telegram 支持的命令(例如 `/start`、`/help`、`/show`、`/list`)注册到 Bot 命令菜单,确保菜单展示与实际行为一致。 diff --git a/config/config.example.json b/config/config.example.json index 9a92ff0c2..9f4b1e791 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -93,6 +93,14 @@ }, "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": { "enabled": false, "app_id": "YOUR_QQ_APP_ID", diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index aed815399..34c7e174f 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -59,6 +59,7 @@ type placeholderEntry struct { // channelRateConfig maps channel name to per-second rate limit. var channelRateConfig = map[string]float64{ "telegram": 20, + "zalo": 10, "discord": 1, "slack": 1, "matrix": 2, @@ -239,6 +240,11 @@ func (m *Manager) initChannels() error { 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 { waCfg := m.config.Channels.WhatsApp if waCfg.UseNative { diff --git a/pkg/channels/zalo/init.go b/pkg/channels/zalo/init.go new file mode 100644 index 000000000..98ecb7673 --- /dev/null +++ b/pkg/channels/zalo/init.go @@ -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) + }) +} diff --git a/pkg/channels/zalo/zalo.go b/pkg/channels/zalo/zalo.go new file mode 100644 index 000000000..7bccbcd2b --- /dev/null +++ b/pkg/channels/zalo/zalo.go @@ -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) +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 7a47fccae..3ed63f6ac 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -259,6 +259,7 @@ func (d *AgentDefaults) GetModelName() string { type ChannelsConfig struct { WhatsApp WhatsAppConfig `json:"whatsapp"` Telegram TelegramConfig `json:"telegram"` + Zalo ZaloConfig `json:"zalo"` Feishu FeishuConfig `json:"feishu"` Discord DiscordConfig `json:"discord"` MaixCam MaixCamConfig `json:"maixcam"` @@ -313,6 +314,16 @@ type TelegramConfig struct { 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 { Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"` AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"` diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index eebb1dce3..93ba8dbb4 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -59,6 +59,13 @@ func DefaultConfig() *Config { Text: "Thinking... 💭", }, }, + Zalo: ZaloConfig{ + Enabled: false, + Token: "", + SecretToken: "", + WebhookPath: "/webhook/zalo", + AllowFrom: FlexibleStringSlice{}, + }, Feishu: FeishuConfig{ Enabled: false, AppID: "", diff --git a/pkg/gateway/gateway.go b/pkg/gateway/gateway.go index 6745d1748..135f2305f 100644 --- a/pkg/gateway/gateway.go +++ b/pkg/gateway/gateway.go @@ -28,6 +28,7 @@ import ( _ "github.com/sipeed/picoclaw/pkg/channels/wecom" _ "github.com/sipeed/picoclaw/pkg/channels/whatsapp" _ "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/cron" "github.com/sipeed/picoclaw/pkg/devices"