Merge PR #1351
This commit is contained in:
commit
f712d803f4
13 changed files with 776 additions and 6 deletions
52
README.fr.md
52
README.fr.md
|
|
@ -283,11 +283,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) |
|
||||
|
|
@ -327,6 +328,55 @@ picoclaw gateway
|
|||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Zalo</b> (Webhook)</summary>
|
||||
|
||||
**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://<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>
|
||||
<summary><b>Discord</b></summary>
|
||||
|
||||
|
|
|
|||
52
README.ja.md
52
README.ja.md
|
|
@ -252,11 +252,12 @@ 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** | 普通(アプリ認証情報) |
|
||||
|
|
@ -295,6 +296,55 @@ picoclaw gateway
|
|||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Zalo</b>(Webhook)</summary>
|
||||
|
||||
**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://<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>
|
||||
<summary><b>Discord</b></summary>
|
||||
|
|
|
|||
63
README.md
63
README.md
|
|
@ -325,13 +325,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) |
|
||||
|
|
@ -380,6 +381,66 @@ If command registration fails (network/API transient errors), the channel still
|
|||
|
||||
</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` (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://<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>
|
||||
<summary><b>Discord</b></summary>
|
||||
|
||||
|
|
|
|||
|
|
@ -277,11 +277,12 @@ 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) |
|
||||
|
|
@ -321,6 +322,55 @@ picoclaw gateway
|
|||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Zalo</b> (Webhook)</summary>
|
||||
|
||||
**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://<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>
|
||||
<summary><b>Discord</b></summary>
|
||||
|
||||
|
|
|
|||
52
README.vi.md
52
README.vi.md
|
|
@ -251,11 +251,12 @@ 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) |
|
||||
|
|
@ -295,6 +296,55 @@ picoclaw gateway
|
|||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Zalo</b> (Webhook)</summary>
|
||||
|
||||
**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://<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>
|
||||
<summary><b>Discord</b></summary>
|
||||
|
||||
|
|
|
|||
61
README.zh.md
61
README.zh.md
|
|
@ -301,6 +301,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) |
|
||||
|
|
@ -319,6 +320,66 @@ Telegram 侧保留的是命令菜单注册能力;通用命令的实际执行
|
|||
|
||||
如果注册因网络或 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`(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://<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 社交网络
|
||||
|
||||
只需通过 CLI 或任何集成的聊天应用发送一条消息,即可将 PicoClaw 连接到 Agent 社交网络。
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -77,6 +77,14 @@
|
|||
],
|
||||
"reasoning_channel_id": ""
|
||||
},
|
||||
"zalo": {
|
||||
"enabled": true,
|
||||
"token": "YOUR_ZALO_BOT_TOKEN",
|
||||
"secret_token": "YOUR_SECRET_TOKEN",
|
||||
"webhook_path": "/webhook/zalo",
|
||||
"allow_from": [],
|
||||
"reasoning_channel_id": ""
|
||||
},
|
||||
"discord": {
|
||||
"enabled": false,
|
||||
"token": "YOUR_DISCORD_BOT_TOKEN",
|
||||
|
|
@ -514,4 +522,4 @@
|
|||
"host": "127.0.0.1",
|
||||
"port": 18790
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
13
pkg/channels/zalo/init.go
Normal file
13
pkg/channels/zalo/init.go
Normal 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
402
pkg/channels/zalo/zalo.go
Normal 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)
|
||||
}
|
||||
|
|
@ -257,6 +257,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"`
|
||||
|
|
@ -311,6 +312,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"`
|
||||
|
|
|
|||
|
|
@ -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: "",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue