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"