Merge pull request #15 from hobbyistlabs-coder/remove-feishu-channel-2551191560100982816
remove feishu channel
This commit is contained in:
commit
7074e824b9
38 changed files with 14 additions and 1940 deletions
|
|
@ -9,10 +9,6 @@
|
|||
# ── Chat Channel ──────────────────────────
|
||||
# TELEGRAM_BOT_TOKEN=123456:ABC...
|
||||
# DISCORD_BOT_TOKEN=xxx
|
||||
# Feishu (飞书)
|
||||
# PICOCLAW_CHANNELS_FEISHU_APP_ID=cli_xxx
|
||||
# PICOCLAW_CHANNELS_FEISHU_APP_SECRET=xxx
|
||||
# PICOCLAW_CHANNELS_FEISHU_RANDOM_REACTION_EMOJI=Typing,OneSecond
|
||||
|
||||
# ── Web Search (optional) ────────────────
|
||||
# BRAVE_SEARCH_API_KEY=BSA...
|
||||
|
|
|
|||
|
|
@ -913,14 +913,6 @@ picoclaw agent -m "Bonjour, comment ça va ?"
|
|||
"whatsapp": {
|
||||
"enabled": false
|
||||
},
|
||||
"feishu": {
|
||||
"enabled": false,
|
||||
"app_id": "cli_xxx",
|
||||
"app_secret": "xxx",
|
||||
"encrypt_key": "",
|
||||
"verification_token": "",
|
||||
"allow_from": []
|
||||
},
|
||||
"qq": {
|
||||
"enabled": false,
|
||||
"app_id": "",
|
||||
|
|
|
|||
|
|
@ -866,14 +866,6 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
|
|||
"whatsapp": {
|
||||
"enabled": false
|
||||
},
|
||||
"feishu": {
|
||||
"enabled": false,
|
||||
"app_id": "cli_xxx",
|
||||
"app_secret": "xxx",
|
||||
"encrypt_key": "",
|
||||
"verification_token": "",
|
||||
"allow_from": []
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"web": {
|
||||
|
|
|
|||
10
README.md
10
README.md
|
|
@ -326,7 +326,7 @@ That's it! You have a working AI assistant in 2 minutes.
|
|||
|
||||
Talk to your picoclaw through Telegram, 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.
|
||||
> **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.
|
||||
|
||||
| Channel | Setup |
|
||||
| ------------ | ---------------------------------- |
|
||||
|
|
@ -1318,14 +1318,6 @@ picoclaw agent -m "Hello"
|
|||
"session_store_path": "",
|
||||
"allow_from": []
|
||||
},
|
||||
"feishu": {
|
||||
"enabled": false,
|
||||
"app_id": "cli_xxx",
|
||||
"app_secret": "xxx",
|
||||
"encrypt_key": "",
|
||||
"verification_token": "",
|
||||
"allow_from": []
|
||||
},
|
||||
"qq": {
|
||||
"enabled": false,
|
||||
"app_id": "",
|
||||
|
|
|
|||
|
|
@ -909,14 +909,6 @@ picoclaw agent -m "Ola, como vai?"
|
|||
"whatsapp": {
|
||||
"enabled": false
|
||||
},
|
||||
"feishu": {
|
||||
"enabled": false,
|
||||
"app_id": "cli_xxx",
|
||||
"app_secret": "xxx",
|
||||
"encrypt_key": "",
|
||||
"verification_token": "",
|
||||
"allow_from": []
|
||||
},
|
||||
"qq": {
|
||||
"enabled": false,
|
||||
"app_id": "",
|
||||
|
|
|
|||
|
|
@ -881,14 +881,6 @@ picoclaw agent -m "Xin chào"
|
|||
"whatsapp": {
|
||||
"enabled": false
|
||||
},
|
||||
"feishu": {
|
||||
"enabled": false,
|
||||
"app_id": "cli_xxx",
|
||||
"app_secret": "xxx",
|
||||
"encrypt_key": "",
|
||||
"verification_token": "",
|
||||
"allow_from": []
|
||||
},
|
||||
"qq": {
|
||||
"enabled": false,
|
||||
"app_id": "",
|
||||
|
|
|
|||
11
README.zh.md
11
README.zh.md
|
|
@ -293,7 +293,7 @@ picoclaw agent -m "2+2 等于几?"
|
|||
|
||||
PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方。
|
||||
|
||||
> **注意**: 所有 Webhook 类渠道(LINE、WeCom 等)均挂载在同一个 Gateway HTTP 服务器上(`gateway.host`:`gateway.port`,默认 `127.0.0.1:18790`),无需为每个渠道单独配置端口。注意:飞书(Feishu)使用 WebSocket/SDK 模式,不通过该共享 HTTP webhook 服务器接收消息。
|
||||
> **注意**: 所有 Webhook 类渠道(LINE、WeCom 等)均挂载在同一个 Gateway HTTP 服务器上(`gateway.host`:`gateway.port`,默认 `127.0.0.1:18790`),无需为每个渠道单独配置端口。
|
||||
|
||||
### 核心渠道
|
||||
|
||||
|
|
@ -306,7 +306,6 @@ PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方
|
|||
| **QQ** | ⭐⭐ 中等 | 官方机器人 API,适合国内社群 | [查看文档](docs/channels/qq/README.zh.md) |
|
||||
| **钉钉 (DingTalk)** | ⭐⭐ 中等 | Stream 模式无需公网,企业办公首选 | [查看文档](docs/channels/dingtalk/README.zh.md) |
|
||||
| **企业微信 (WeCom)** | ⭐⭐⭐ 较难 | 支持群机器人(Webhook)、自建应用(API)和智能机器人(AI Bot) | [Bot 文档](docs/channels/wecom/wecom_bot/README.zh.md) / [App 文档](docs/channels/wecom/wecom_app/README.zh.md) / [AI Bot 文档](docs/channels/wecom/wecom_aibot/README.zh.md) |
|
||||
| **飞书 (Feishu)** | ⭐⭐⭐ 较难 | 企业级协作,功能丰富 | [查看文档](docs/channels/feishu/README.zh.md) |
|
||||
| **Line** | ⭐⭐⭐ 较难 | 需要 HTTPS Webhook | [查看文档](docs/channels/line/README.zh.md) |
|
||||
| **OneBot** | ⭐⭐ 中等 | 兼容 NapCat/Go-CQHTTP,社区生态丰富 | [查看文档](docs/channels/onebot/README.zh.md) |
|
||||
| **MaixCam** | ⭐ 简单 | 专为 AI 摄像头设计的硬件集成通道 | [查看文档](docs/channels/maixcam/README.zh.md) |
|
||||
|
|
@ -775,14 +774,6 @@ picoclaw agent -m "你好"
|
|||
"whatsapp": {
|
||||
"enabled": false
|
||||
},
|
||||
"feishu": {
|
||||
"enabled": false,
|
||||
"app_id": "cli_xxx",
|
||||
"app_secret": "xxx",
|
||||
"encrypt_key": "",
|
||||
"verification_token": "",
|
||||
"allow_from": []
|
||||
},
|
||||
"qq": {
|
||||
"enabled": false,
|
||||
"app_id": "",
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@
|
|||
|
||||
|
||||
* **Channel**
|
||||
* **IM Matrix**: QQ, WeChat (Work), DingTalk, Feishu (Lark), Telegram, Discord, WhatsApp, LINE, Slack, Email, KOOK, Signal, ...
|
||||
* **IM Matrix**: QQ, WeChat (Work), DingTalk, Telegram, Discord, WhatsApp, LINE, Slack, Email, KOOK, Signal, ...
|
||||
* **Standards**: Support for the **OneBot** protocol.
|
||||
* [**attachment**](https://github.com/sipeed/picoclaw/issues/348): Native handling of images, audio, and video attachments.
|
||||
|
||||
|
|
|
|||
|
|
@ -138,7 +138,6 @@ func (s *appState) countChannels() (enabled int, total int) {
|
|||
c.QQ.Enabled,
|
||||
c.MaixCam.Enabled,
|
||||
c.WhatsApp.Enabled,
|
||||
c.Feishu.Enabled,
|
||||
c.DingTalk.Enabled,
|
||||
c.Slack.Enabled,
|
||||
c.Matrix.Enabled,
|
||||
|
|
@ -438,7 +437,7 @@ func (s *appState) isActiveModelValid() bool {
|
|||
func (s *appState) hasEnabledChannel() bool {
|
||||
c := s.config.Channels
|
||||
return c.Telegram.Enabled || c.Discord.Enabled || c.QQ.Enabled || c.MaixCam.Enabled ||
|
||||
c.WhatsApp.Enabled || c.Feishu.Enabled || c.DingTalk.Enabled || c.Slack.Enabled ||
|
||||
c.WhatsApp.Enabled || c.DingTalk.Enabled || c.Slack.Enabled ||
|
||||
c.Matrix.Enabled || c.LINE.Enabled || c.OneBot.Enabled || c.WeCom.Enabled || c.WeComApp.Enabled
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -42,12 +42,6 @@ func (s *appState) buildChannelMenuItems() []MenuItem {
|
|||
s.config.Channels.WhatsApp.Enabled,
|
||||
func() { s.push("channel-whatsapp", s.whatsappForm()) },
|
||||
),
|
||||
channelItem(
|
||||
"Feishu",
|
||||
"Feishu bot settings",
|
||||
s.config.Channels.Feishu.Enabled,
|
||||
func() { s.push("channel-feishu", s.feishuForm()) },
|
||||
),
|
||||
channelItem(
|
||||
"DingTalk",
|
||||
"DingTalk bot settings",
|
||||
|
|
@ -169,25 +163,6 @@ func (s *appState) whatsappForm() tview.Primitive {
|
|||
return wrapWithBack(form, s)
|
||||
}
|
||||
|
||||
func (s *appState) feishuForm() tview.Primitive {
|
||||
cfg := &s.config.Channels.Feishu
|
||||
form := baseChannelForm("Feishu", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
||||
form.AddInputField("App ID", cfg.AppID, 64, nil, func(text string) {
|
||||
cfg.AppID = strings.TrimSpace(text)
|
||||
})
|
||||
form.AddInputField("App Secret", cfg.AppSecret, 128, nil, func(text string) {
|
||||
cfg.AppSecret = strings.TrimSpace(text)
|
||||
})
|
||||
form.AddInputField("Encrypt Key", cfg.EncryptKey, 128, nil, func(text string) {
|
||||
cfg.EncryptKey = strings.TrimSpace(text)
|
||||
})
|
||||
form.AddInputField("Verification Token", cfg.VerificationToken, 128, nil, func(text string) {
|
||||
cfg.VerificationToken = strings.TrimSpace(text)
|
||||
})
|
||||
addAllowFromField(form, &cfg.AllowFrom)
|
||||
return wrapWithBack(form, s)
|
||||
}
|
||||
|
||||
func (s *appState) dingtalkForm() tview.Primitive {
|
||||
cfg := &s.config.Channels.DingTalk
|
||||
form := baseChannelForm("DingTalk", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ import (
|
|||
"jane/pkg/channels"
|
||||
_ "jane/pkg/channels/dingtalk"
|
||||
_ "jane/pkg/channels/discord"
|
||||
_ "jane/pkg/channels/feishu"
|
||||
_ "jane/pkg/channels/irc"
|
||||
_ "jane/pkg/channels/line"
|
||||
_ "jane/pkg/channels/maixcam"
|
||||
|
|
|
|||
|
|
@ -96,16 +96,6 @@
|
|||
"allow_from": [],
|
||||
"reasoning_channel_id": ""
|
||||
},
|
||||
"feishu": {
|
||||
"enabled": false,
|
||||
"app_id": "",
|
||||
"app_secret": "",
|
||||
"encrypt_key": "",
|
||||
"verification_token": "",
|
||||
"allow_from": [],
|
||||
"reasoning_channel_id": "",
|
||||
"random_reaction_emoji": []
|
||||
},
|
||||
"dingtalk": {
|
||||
"enabled": false,
|
||||
"client_id": "YOUR_CLIENT_ID",
|
||||
|
|
|
|||
|
|
@ -1,39 +0,0 @@
|
|||
# 飞书
|
||||
|
||||
飞书(国际版名称:Lark)是字节跳动旗下的企业协作平台。它通过事件驱动的 Webhook 同时支持中国和全球市场。
|
||||
|
||||
## 配置
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"feishu": {
|
||||
"enabled": true,
|
||||
"app_id": "cli_xxx",
|
||||
"app_secret": "xxx",
|
||||
"encrypt_key": "",
|
||||
"verification_token": "",
|
||||
"allow_from": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 必填 | 描述 |
|
||||
| ------------------ | ------ | ---- | -------------------------------- |
|
||||
| enabled | bool | 是 | 是否启用飞书频道 |
|
||||
| app_id | string | 是 | 飞书应用的 App ID(以cli\_开头) |
|
||||
| app_secret | string | 是 | 飞书应用的 App Secret |
|
||||
| encrypt_key | string | 否 | 事件回调加密密钥 |
|
||||
| verification_token | string | 否 | 用于Webhook事件验证的Token |
|
||||
| allow_from | array | 否 | 用户ID白名单,空表示所有用户 |
|
||||
| random_reaction_emoji | array | 否 | 随机添加的表情列表,空则使用默认 "Pin" |
|
||||
|
||||
## 设置流程
|
||||
|
||||
1. 前往 [飞书开放平台](https://open.feishu.cn/)创建应用程序
|
||||
2. 获取 App ID 和 App Secret
|
||||
3. 配置事件订阅和Webhook URL
|
||||
4. 设置加密(可选,生产环境建议启用)
|
||||
5. 将 App ID、App Secret、Encrypt Key 和 Verification Token(如果启用加密) 填入配置文件中
|
||||
6. 自定义你希望 PicoClaw react 你消息时的表情(可选, Reference URL: [Feishu Emoji List](https://open.larkoffice.com/document/server-docs/im-v1/message-reaction/emojis-introduce))
|
||||
2
go.mod
2
go.mod
|
|
@ -15,7 +15,6 @@ require (
|
|||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/h2non/filetype v1.1.3
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
|
||||
github.com/mdp/qrterminal/v3 v3.2.1
|
||||
github.com/modelcontextprotocol/go-sdk v1.3.1
|
||||
github.com/mymmrac/telego v1.6.0
|
||||
|
|
@ -80,7 +79,6 @@ require (
|
|||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/github/copilot-sdk/go v0.1.23
|
||||
github.com/go-resty/resty/v2 v2.17.1 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/google/jsonschema-go v0.4.2 // indirect
|
||||
github.com/grbit/go-json v0.11.0 // indirect
|
||||
github.com/klauspost/compress v1.18.4 // indirect
|
||||
|
|
|
|||
|
|
@ -846,7 +846,6 @@ func TestTargetReasoningChannelID_AllChannels(t *testing.T) {
|
|||
for name, id := range map[string]string{
|
||||
"whatsapp": "rid-whatsapp",
|
||||
"telegram": "rid-telegram",
|
||||
"feishu": "rid-feishu",
|
||||
"discord": "rid-discord",
|
||||
"maixcam": "rid-maixcam",
|
||||
"qq": "rid-qq",
|
||||
|
|
@ -866,7 +865,6 @@ func TestTargetReasoningChannelID_AllChannels(t *testing.T) {
|
|||
}{
|
||||
{channel: "whatsapp", wantID: "rid-whatsapp"},
|
||||
{channel: "telegram", wantID: "rid-telegram"},
|
||||
{channel: "feishu", wantID: "rid-feishu"},
|
||||
{channel: "discord", wantID: "rid-discord"},
|
||||
{channel: "maixcam", wantID: "rid-maixcam"},
|
||||
{channel: "qq", wantID: "rid-qq"},
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ pkg/channels/
|
|||
├── discord/
|
||||
│ ├── init.go
|
||||
│ └── discord.go
|
||||
├── slack/ line/ onebot/ dingtalk/ feishu/ wecom/ qq/ whatsapp/ whatsapp_native/ maixcam/ pico/
|
||||
├── slack/ line/ onebot/ dingtalk/ wecom/ qq/ whatsapp/ whatsapp_native/ maixcam/ pico/
|
||||
│ └── ...
|
||||
|
||||
pkg/bus/
|
||||
|
|
@ -1254,7 +1254,6 @@ make test # Full test suite
|
|||
| `pkg/channels/line/` | `"line"` | TypingCapable, MediaSender, WebhookHandler |
|
||||
| `pkg/channels/onebot/` | `"onebot"` | ReactionCapable, MediaSender |
|
||||
| `pkg/channels/dingtalk/` | `"dingtalk"` | — |
|
||||
| `pkg/channels/feishu/` | `"feishu"` | — (architecture-specific build tags: `feishu_32.go` / `feishu_64.go`) |
|
||||
| `pkg/channels/wecom/` | `"wecom"` | WebhookHandler, HealthChecker |
|
||||
| `pkg/channels/wecom/` | `"wecom_app"` | MediaSender, WebhookHandler, HealthChecker |
|
||||
| `pkg/channels/qq/` | `"qq"` | — |
|
||||
|
|
@ -1369,7 +1368,6 @@ agentLoop.Stop() // Stop Agent
|
|||
|
||||
1. **Media cleanup temporarily disabled**: The `ReleaseAll` call in the Agent loop is commented out (`refactor(loop): disable media cleanup to prevent premature file deletion`) because session boundaries are not yet clearly defined. TTL cleanup remains active.
|
||||
|
||||
2. **Feishu architecture-specific compilation**: The Feishu channel uses build tags to distinguish 32-bit and 64-bit architectures (`feishu_32.go` / `feishu_64.go`). Feishu uses the SDK's WebSocket mode (not HTTP webhook), so it does not implement `WebhookHandler`.
|
||||
|
||||
3. **WeCom has two factories**: `"wecom"` (Bot mode, webhook only) and `"wecom_app"` (App mode, supports MediaSender) are registered separately. Both implement `WebhookHandler` and `HealthChecker`.
|
||||
|
||||
|
|
@ -1381,4 +1379,4 @@ agentLoop.Stop() // Stop Agent
|
|||
|
||||
7. **PlaceholderConfig vs implementation**: `PlaceholderConfig` appears in 6 channel configs (Telegram, Discord, Slack, LINE, OneBot, Pico), but only channels that implement both `PlaceholderCapable` + `MessageEditor` (Telegram, Discord, Pico) can actually use placeholder message editing. The rest are reserved fields.
|
||||
|
||||
8. **ReasoningChannelID**: Most channel configs include a `reasoning_channel_id` field to route LLM reasoning/thinking output to a designated channel (WhatsApp, Telegram, Feishu, Discord, MaixCam, QQ, DingTalk, Slack, LINE, OneBot, WeCom, WeComApp). Note: `PicoConfig` does not currently expose this field. `BaseChannel` exposes this via the `WithReasoningChannelID` option and `ReasoningChannelID()` method.
|
||||
8. **ReasoningChannelID**: Most channel configs include a `reasoning_channel_id` field to route LLM reasoning/thinking output to a designated channel (WhatsApp, Telegram, Discord, MaixCam, QQ, DingTalk, Slack, LINE, OneBot, WeCom, WeComApp). Note: `PicoConfig` does not currently expose this field. `BaseChannel` exposes this via the `WithReasoningChannelID` option and `ReasoningChannelID()` method.
|
||||
|
|
@ -60,7 +60,7 @@ pkg/channels/
|
|||
├── discord/
|
||||
│ ├── init.go
|
||||
│ └── discord.go
|
||||
├── slack/ line/ onebot/ dingtalk/ feishu/ wecom/ qq/ whatsapp/ whatsapp_native/ maixcam/ pico/
|
||||
├── slack/ line/ onebot/ dingtalk/ wecom/ qq/ whatsapp/ whatsapp_native/ maixcam/ pico/
|
||||
│ └── ...
|
||||
|
||||
pkg/bus/
|
||||
|
|
@ -1253,7 +1253,6 @@ make test # 全量测试
|
|||
| `pkg/channels/line/` | `"line"` | TypingCapable, MediaSender, WebhookHandler |
|
||||
| `pkg/channels/onebot/` | `"onebot"` | ReactionCapable, MediaSender |
|
||||
| `pkg/channels/dingtalk/` | `"dingtalk"` | — |
|
||||
| `pkg/channels/feishu/` | `"feishu"` | — (架构特定 build tags: `feishu_32.go` / `feishu_64.go`) |
|
||||
| `pkg/channels/wecom/` | `"wecom"` | WebhookHandler, HealthChecker |
|
||||
| `pkg/channels/wecom/` | `"wecom_app"` | MediaSender, WebhookHandler, HealthChecker |
|
||||
| `pkg/channels/qq/` | `"qq"` | — |
|
||||
|
|
@ -1368,7 +1367,6 @@ agentLoop.Stop() // 停止 Agent
|
|||
|
||||
1. **媒体清理暂时禁用**:Agent loop 中的 `ReleaseAll` 调用被注释掉了(`refactor(loop): disable media cleanup to prevent premature file deletion`),因为会话边界尚未明确定义。TTL 清理仍然有效。
|
||||
|
||||
2. **Feishu 架构特定编译**:Feishu channel 使用 build tags 区分 32 位和 64 位架构(`feishu_32.go` / `feishu_64.go`)。Feishu 使用 SDK 的 WebSocket 模式(非 HTTP webhook),因此不实现 `WebhookHandler`。
|
||||
|
||||
3. **WeCom 有两个工厂**:`"wecom"`(Bot 模式,纯 webhook)和 `"wecom_app"`(应用模式,支持 MediaSender)分别注册。两者都实现了 `WebhookHandler` 和 `HealthChecker`。
|
||||
|
||||
|
|
@ -1380,4 +1378,4 @@ agentLoop.Stop() // 停止 Agent
|
|||
|
||||
7. **PlaceholderConfig 的配置与实现**:`PlaceholderConfig` 出现在 6 个 channel config 中(Telegram、Discord、Slack、LINE、OneBot、Pico),但只有实现了 `PlaceholderCapable` + `MessageEditor` 的 channel(Telegram、Discord、Pico)能真正使用占位消息编辑功能。其余 channel 的 `PlaceholderConfig` 为预留字段。
|
||||
|
||||
8. **ReasoningChannelID**:大多数 channel config 都包含 `reasoning_channel_id` 字段,用于将 LLM 的思维链(reasoning/thinking)路由到指定 channel(WhatsApp、Telegram、Feishu、Discord、MaixCam、QQ、DingTalk、Slack、LINE、OneBot、WeCom、WeComApp)。注意:`PicoConfig` 目前不包含该字段。`BaseChannel` 通过 `WithReasoningChannelID` 选项和 `ReasoningChannelID()` 方法暴露此配置。
|
||||
8. **ReasoningChannelID**:大多数 channel config 都包含 `reasoning_channel_id` 字段,用于将 LLM 的思维链(reasoning/thinking)路由到指定 channel(WhatsApp、Telegram、Discord、MaixCam、QQ、DingTalk、Slack、LINE、OneBot、WeCom、WeComApp)。注意:`PicoConfig` 目前不包含该字段。`BaseChannel` 通过 `WithReasoningChannelID` 选项和 `ReasoningChannelID()` 方法暴露此配置。
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
package feishu
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
|
||||
)
|
||||
|
||||
// mentionPlaceholderRegex matches @_user_N placeholders inserted by Feishu for mentions.
|
||||
var mentionPlaceholderRegex = regexp.MustCompile(`@_user_\d+`)
|
||||
|
||||
// stringValue safely dereferences a *string pointer.
|
||||
func stringValue(v *string) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
return *v
|
||||
}
|
||||
|
||||
// buildMarkdownCard builds a Feishu Interactive Card JSON 2.0 string with markdown content.
|
||||
// JSON 2.0 cards support full CommonMark standard markdown syntax.
|
||||
func buildMarkdownCard(content string) (string, error) {
|
||||
card := map[string]any{
|
||||
"schema": "2.0",
|
||||
"body": map[string]any{
|
||||
"elements": []map[string]any{
|
||||
{
|
||||
"tag": "markdown",
|
||||
"content": content,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
data, err := json.Marshal(card)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// extractJSONStringField unmarshals content as JSON and returns the value of the given string field.
|
||||
// Returns "" if the content is invalid JSON or the field is missing/empty.
|
||||
func extractJSONStringField(content, field string) string {
|
||||
var m map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(content), &m); err != nil {
|
||||
return ""
|
||||
}
|
||||
raw, ok := m[field]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
var s string
|
||||
if err := json.Unmarshal(raw, &s); err != nil {
|
||||
return ""
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// extractImageKey extracts the image_key from a Feishu image message content JSON.
|
||||
// Format: {"image_key": "img_xxx"}
|
||||
func extractImageKey(content string) string { return extractJSONStringField(content, "image_key") }
|
||||
|
||||
// extractFileKey extracts the file_key from a Feishu file/audio message content JSON.
|
||||
// Format: {"file_key": "file_xxx", "file_name": "...", ...}
|
||||
func extractFileKey(content string) string { return extractJSONStringField(content, "file_key") }
|
||||
|
||||
// extractFileName extracts the file_name from a Feishu file message content JSON.
|
||||
func extractFileName(content string) string { return extractJSONStringField(content, "file_name") }
|
||||
|
||||
// stripMentionPlaceholders removes @_user_N placeholders from the text content.
|
||||
// These are inserted by Feishu when users @mention someone in a message.
|
||||
func stripMentionPlaceholders(content string, mentions []*larkim.MentionEvent) string {
|
||||
if len(mentions) == 0 {
|
||||
return content
|
||||
}
|
||||
for _, m := range mentions {
|
||||
if m.Key != nil && *m.Key != "" {
|
||||
content = strings.ReplaceAll(content, *m.Key, "")
|
||||
}
|
||||
}
|
||||
// Also clean up any remaining @_user_N patterns
|
||||
content = mentionPlaceholderRegex.ReplaceAllString(content, "")
|
||||
return strings.TrimSpace(content)
|
||||
}
|
||||
|
|
@ -1,292 +0,0 @@
|
|||
package feishu
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
|
||||
)
|
||||
|
||||
func TestExtractJSONStringField(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
field string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "valid field",
|
||||
content: `{"image_key": "img_v2_xxx"}`,
|
||||
field: "image_key",
|
||||
want: "img_v2_xxx",
|
||||
},
|
||||
{
|
||||
name: "missing field",
|
||||
content: `{"image_key": "img_v2_xxx"}`,
|
||||
field: "file_key",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "invalid JSON",
|
||||
content: `not json at all`,
|
||||
field: "image_key",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "empty content",
|
||||
content: "",
|
||||
field: "image_key",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "non-string field value",
|
||||
content: `{"count": 42}`,
|
||||
field: "count",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "empty string value",
|
||||
content: `{"image_key": ""}`,
|
||||
field: "image_key",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "multiple fields",
|
||||
content: `{"file_key": "file_xxx", "file_name": "test.pdf"}`,
|
||||
field: "file_name",
|
||||
want: "test.pdf",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := extractJSONStringField(tt.content, tt.field)
|
||||
if got != tt.want {
|
||||
t.Errorf("extractJSONStringField(%q, %q) = %q, want %q", tt.content, tt.field, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractImageKey(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "normal",
|
||||
content: `{"image_key": "img_v2_abc123"}`,
|
||||
want: "img_v2_abc123",
|
||||
},
|
||||
{
|
||||
name: "missing key",
|
||||
content: `{"file_key": "file_xxx"}`,
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "malformed JSON",
|
||||
content: `{broken`,
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := extractImageKey(tt.content)
|
||||
if got != tt.want {
|
||||
t.Errorf("extractImageKey(%q) = %q, want %q", tt.content, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractFileKey(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "normal",
|
||||
content: `{"file_key": "file_v2_abc123", "file_name": "test.doc"}`,
|
||||
want: "file_v2_abc123",
|
||||
},
|
||||
{
|
||||
name: "missing key",
|
||||
content: `{"image_key": "img_xxx"}`,
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "malformed JSON",
|
||||
content: `not json`,
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := extractFileKey(tt.content)
|
||||
if got != tt.want {
|
||||
t.Errorf("extractFileKey(%q) = %q, want %q", tt.content, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractFileName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "normal",
|
||||
content: `{"file_key": "file_xxx", "file_name": "report.pdf"}`,
|
||||
want: "report.pdf",
|
||||
},
|
||||
{
|
||||
name: "missing name",
|
||||
content: `{"file_key": "file_xxx"}`,
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "malformed JSON",
|
||||
content: `{bad`,
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := extractFileName(tt.content)
|
||||
if got != tt.want {
|
||||
t.Errorf("extractFileName(%q) = %q, want %q", tt.content, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMarkdownCard(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
}{
|
||||
{
|
||||
name: "normal content",
|
||||
content: "Hello **world**",
|
||||
},
|
||||
{
|
||||
name: "empty content",
|
||||
content: "",
|
||||
},
|
||||
{
|
||||
name: "special characters",
|
||||
content: `Code: "foo" & <bar> 'baz'`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := buildMarkdownCard(tt.content)
|
||||
if err != nil {
|
||||
t.Fatalf("buildMarkdownCard(%q) unexpected error: %v", tt.content, err)
|
||||
}
|
||||
|
||||
// Verify valid JSON
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(result), &parsed); err != nil {
|
||||
t.Fatalf("buildMarkdownCard(%q) produced invalid JSON: %v", tt.content, err)
|
||||
}
|
||||
|
||||
// Verify schema
|
||||
if parsed["schema"] != "2.0" {
|
||||
t.Errorf("schema = %v, want %q", parsed["schema"], "2.0")
|
||||
}
|
||||
|
||||
// Verify body.elements[0].content == input
|
||||
body, ok := parsed["body"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("missing body in card JSON")
|
||||
}
|
||||
elements, ok := body["elements"].([]any)
|
||||
if !ok || len(elements) == 0 {
|
||||
t.Fatal("missing or empty elements in card JSON")
|
||||
}
|
||||
elem, ok := elements[0].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("first element is not an object")
|
||||
}
|
||||
if elem["tag"] != "markdown" {
|
||||
t.Errorf("tag = %v, want %q", elem["tag"], "markdown")
|
||||
}
|
||||
if elem["content"] != tt.content {
|
||||
t.Errorf("content = %v, want %q", elem["content"], tt.content)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripMentionPlaceholders(t *testing.T) {
|
||||
strPtr := func(s string) *string { return &s }
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
mentions []*larkim.MentionEvent
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "no mentions",
|
||||
content: "Hello world",
|
||||
mentions: nil,
|
||||
want: "Hello world",
|
||||
},
|
||||
{
|
||||
name: "single mention",
|
||||
content: "@_user_1 hello",
|
||||
mentions: []*larkim.MentionEvent{
|
||||
{Key: strPtr("@_user_1")},
|
||||
},
|
||||
want: "hello",
|
||||
},
|
||||
{
|
||||
name: "multiple mentions",
|
||||
content: "@_user_1 @_user_2 hey",
|
||||
mentions: []*larkim.MentionEvent{
|
||||
{Key: strPtr("@_user_1")},
|
||||
{Key: strPtr("@_user_2")},
|
||||
},
|
||||
want: "hey",
|
||||
},
|
||||
{
|
||||
name: "empty content",
|
||||
content: "",
|
||||
mentions: []*larkim.MentionEvent{{Key: strPtr("@_user_1")}},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "empty mentions slice",
|
||||
content: "@_user_1 test",
|
||||
mentions: []*larkim.MentionEvent{},
|
||||
want: "@_user_1 test",
|
||||
},
|
||||
{
|
||||
name: "mention with nil key",
|
||||
content: "@_user_1 test",
|
||||
mentions: []*larkim.MentionEvent{
|
||||
{Key: nil},
|
||||
},
|
||||
want: "test",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := stripMentionPlaceholders(tt.content, tt.mentions)
|
||||
if got != tt.want {
|
||||
t.Errorf("stripMentionPlaceholders(%q, ...) = %q, want %q", tt.content, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
//go:build !amd64 && !arm64 && !riscv64 && !mips64 && !ppc64
|
||||
|
||||
package feishu
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"jane/pkg/bus"
|
||||
"jane/pkg/channels"
|
||||
"jane/pkg/config"
|
||||
)
|
||||
|
||||
// FeishuChannel is a stub implementation for 32-bit architectures
|
||||
type FeishuChannel struct {
|
||||
*channels.BaseChannel
|
||||
}
|
||||
|
||||
var errUnsupported = errors.New("feishu channel is not supported on 32-bit architectures")
|
||||
|
||||
// NewFeishuChannel returns an error on 32-bit architectures where the Feishu SDK is not supported
|
||||
func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChannel, error) {
|
||||
return nil, errors.New(
|
||||
"feishu channel is not supported on 32-bit architectures (armv7l, 386, etc.). Please use a 64-bit system or disable feishu in your config",
|
||||
)
|
||||
}
|
||||
|
||||
// Start is a stub method to satisfy the Channel interface
|
||||
func (c *FeishuChannel) Start(ctx context.Context) error {
|
||||
return errUnsupported
|
||||
}
|
||||
|
||||
// Stop is a stub method to satisfy the Channel interface
|
||||
func (c *FeishuChannel) Stop(ctx context.Context) error {
|
||||
return errUnsupported
|
||||
}
|
||||
|
||||
// Send is a stub method to satisfy the Channel interface
|
||||
func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||
return errUnsupported
|
||||
}
|
||||
|
||||
// EditMessage is a stub method to satisfy MessageEditor
|
||||
func (c *FeishuChannel) EditMessage(ctx context.Context, chatID, messageID, content string) error {
|
||||
return errUnsupported
|
||||
}
|
||||
|
||||
// SendPlaceholder is a stub method to satisfy PlaceholderCapable
|
||||
func (c *FeishuChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) {
|
||||
return "", errUnsupported
|
||||
}
|
||||
|
||||
// ReactToMessage is a stub method to satisfy ReactionCapable
|
||||
func (c *FeishuChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (func(), error) {
|
||||
return func() {}, errUnsupported
|
||||
}
|
||||
|
||||
// SendMedia is a stub method to satisfy MediaSender
|
||||
func (c *FeishuChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
|
||||
return errUnsupported
|
||||
}
|
||||
|
|
@ -1,832 +0,0 @@
|
|||
//go:build amd64 || arm64 || riscv64 || mips64 || ppc64
|
||||
|
||||
package feishu
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
larkdispatcher "github.com/larksuite/oapi-sdk-go/v3/event/dispatcher"
|
||||
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
|
||||
larkws "github.com/larksuite/oapi-sdk-go/v3/ws"
|
||||
|
||||
"jane/pkg/bus"
|
||||
"jane/pkg/channels"
|
||||
"jane/pkg/config"
|
||||
"jane/pkg/identity"
|
||||
"jane/pkg/logger"
|
||||
"jane/pkg/media"
|
||||
"jane/pkg/utils"
|
||||
)
|
||||
|
||||
type FeishuChannel struct {
|
||||
*channels.BaseChannel
|
||||
config config.FeishuConfig
|
||||
client *lark.Client
|
||||
wsClient *larkws.Client
|
||||
|
||||
botOpenID atomic.Value // stores string; populated lazily for @mention detection
|
||||
|
||||
mu sync.Mutex
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChannel, error) {
|
||||
base := channels.NewBaseChannel("feishu", cfg, bus, cfg.AllowFrom,
|
||||
channels.WithGroupTrigger(cfg.GroupTrigger),
|
||||
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
|
||||
)
|
||||
|
||||
ch := &FeishuChannel{
|
||||
BaseChannel: base,
|
||||
config: cfg,
|
||||
client: lark.NewClient(cfg.AppID, cfg.AppSecret),
|
||||
}
|
||||
ch.SetOwner(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (c *FeishuChannel) Start(ctx context.Context) error {
|
||||
if c.config.AppID == "" || c.config.AppSecret == "" {
|
||||
return fmt.Errorf("feishu app_id or app_secret is empty")
|
||||
}
|
||||
|
||||
// Fetch bot open_id via API for reliable @mention detection.
|
||||
if err := c.fetchBotOpenID(ctx); err != nil {
|
||||
logger.ErrorCF("feishu", "Failed to fetch bot open_id, @mention detection may not work", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
dispatcher := larkdispatcher.NewEventDispatcher(c.config.VerificationToken, c.config.EncryptKey).
|
||||
OnP2MessageReceiveV1(c.handleMessageReceive)
|
||||
|
||||
runCtx, cancel := context.WithCancel(ctx)
|
||||
|
||||
c.mu.Lock()
|
||||
c.cancel = cancel
|
||||
c.wsClient = larkws.NewClient(
|
||||
c.config.AppID,
|
||||
c.config.AppSecret,
|
||||
larkws.WithEventHandler(dispatcher),
|
||||
)
|
||||
wsClient := c.wsClient
|
||||
c.mu.Unlock()
|
||||
|
||||
c.SetRunning(true)
|
||||
logger.InfoC("feishu", "Feishu channel started (websocket mode)")
|
||||
|
||||
go func() {
|
||||
if err := wsClient.Start(runCtx); err != nil {
|
||||
logger.ErrorCF("feishu", "Feishu websocket stopped with error", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *FeishuChannel) Stop(ctx context.Context) error {
|
||||
c.mu.Lock()
|
||||
if c.cancel != nil {
|
||||
c.cancel()
|
||||
c.cancel = nil
|
||||
}
|
||||
c.wsClient = nil
|
||||
c.mu.Unlock()
|
||||
|
||||
c.SetRunning(false)
|
||||
logger.InfoC("feishu", "Feishu channel stopped")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Send sends a message using Interactive Card format for markdown rendering.
|
||||
func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||
if !c.IsRunning() {
|
||||
return channels.ErrNotRunning
|
||||
}
|
||||
|
||||
if msg.ChatID == "" {
|
||||
return fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed)
|
||||
}
|
||||
|
||||
// Build interactive card with markdown content
|
||||
cardContent, err := buildMarkdownCard(msg.Content)
|
||||
if err != nil {
|
||||
return fmt.Errorf("feishu send: card build failed: %w", err)
|
||||
}
|
||||
return c.sendCard(ctx, msg.ChatID, cardContent)
|
||||
}
|
||||
|
||||
// EditMessage implements channels.MessageEditor.
|
||||
// Uses Message.Patch to update an interactive card message.
|
||||
func (c *FeishuChannel) EditMessage(ctx context.Context, chatID, messageID, content string) error {
|
||||
cardContent, err := buildMarkdownCard(content)
|
||||
if err != nil {
|
||||
return fmt.Errorf("feishu edit: card build failed: %w", err)
|
||||
}
|
||||
|
||||
req := larkim.NewPatchMessageReqBuilder().
|
||||
MessageId(messageID).
|
||||
Body(larkim.NewPatchMessageReqBodyBuilder().Content(cardContent).Build()).
|
||||
Build()
|
||||
|
||||
resp, err := c.client.Im.V1.Message.Patch(ctx, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("feishu edit: %w", err)
|
||||
}
|
||||
if !resp.Success() {
|
||||
return fmt.Errorf("feishu edit api error (code=%d msg=%s)", resp.Code, resp.Msg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendPlaceholder implements channels.PlaceholderCapable.
|
||||
// Sends an interactive card with placeholder text and returns its message ID.
|
||||
func (c *FeishuChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) {
|
||||
if !c.config.Placeholder.Enabled {
|
||||
logger.DebugCF("feishu", "Placeholder disabled, skipping", map[string]any{
|
||||
"chat_id": chatID,
|
||||
})
|
||||
return "", nil
|
||||
}
|
||||
|
||||
text := c.config.Placeholder.Text
|
||||
if text == "" {
|
||||
text = "Thinking..."
|
||||
}
|
||||
|
||||
cardContent, err := buildMarkdownCard(text)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("feishu placeholder: card build failed: %w", err)
|
||||
}
|
||||
|
||||
req := larkim.NewCreateMessageReqBuilder().
|
||||
ReceiveIdType(larkim.ReceiveIdTypeChatId).
|
||||
Body(larkim.NewCreateMessageReqBodyBuilder().
|
||||
ReceiveId(chatID).
|
||||
MsgType(larkim.MsgTypeInteractive).
|
||||
Content(cardContent).
|
||||
Build()).
|
||||
Build()
|
||||
|
||||
resp, err := c.client.Im.V1.Message.Create(ctx, req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("feishu placeholder send: %w", err)
|
||||
}
|
||||
if !resp.Success() {
|
||||
return "", fmt.Errorf("feishu placeholder api error (code=%d msg=%s)", resp.Code, resp.Msg)
|
||||
}
|
||||
|
||||
if resp.Data != nil && resp.Data.MessageId != nil {
|
||||
return *resp.Data.MessageId, nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// ReactToMessage implements channels.ReactionCapable.
|
||||
// Adds a reaction (randomly chosen from config) and returns an undo function to remove it.
|
||||
func (c *FeishuChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (func(), error) {
|
||||
// Get emoji list from config
|
||||
emojiList := c.config.RandomReactionEmoji
|
||||
var chosenEmoji string
|
||||
if len(emojiList) == 0 {
|
||||
// Default to "Pin" if no config
|
||||
chosenEmoji = "Pin"
|
||||
} else {
|
||||
idx := rand.Intn(len(emojiList))
|
||||
chosenEmoji = emojiList[idx]
|
||||
}
|
||||
|
||||
req := larkim.NewCreateMessageReactionReqBuilder().
|
||||
MessageId(messageID).
|
||||
Body(larkim.NewCreateMessageReactionReqBodyBuilder().
|
||||
ReactionType(larkim.NewEmojiBuilder().EmojiType(chosenEmoji).Build()).
|
||||
Build()).
|
||||
Build()
|
||||
|
||||
resp, err := c.client.Im.V1.MessageReaction.Create(ctx, req)
|
||||
if err != nil {
|
||||
logger.ErrorCF("feishu", "Failed to add reaction", map[string]any{
|
||||
"emoji": chosenEmoji,
|
||||
"message_id": messageID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return func() {}, fmt.Errorf("feishu react: %w", err)
|
||||
}
|
||||
if !resp.Success() {
|
||||
logger.ErrorCF("feishu", "Reaction API error", map[string]any{
|
||||
"emoji": chosenEmoji,
|
||||
"message_id": messageID,
|
||||
"code": resp.Code,
|
||||
"msg": resp.Msg,
|
||||
})
|
||||
return func() {}, fmt.Errorf("feishu react api error (code=%d msg=%s)", resp.Code, resp.Msg)
|
||||
}
|
||||
|
||||
var reactionID string
|
||||
if resp.Data != nil && resp.Data.ReactionId != nil {
|
||||
reactionID = *resp.Data.ReactionId
|
||||
}
|
||||
if reactionID == "" {
|
||||
return func() {}, nil
|
||||
}
|
||||
|
||||
var undone atomic.Bool
|
||||
undo := func() {
|
||||
if !undone.CompareAndSwap(false, true) {
|
||||
return
|
||||
}
|
||||
delReq := larkim.NewDeleteMessageReactionReqBuilder().
|
||||
MessageId(messageID).
|
||||
ReactionId(reactionID).
|
||||
Build()
|
||||
_, _ = c.client.Im.V1.MessageReaction.Delete(context.Background(), delReq)
|
||||
}
|
||||
return undo, nil
|
||||
}
|
||||
|
||||
// SendMedia implements channels.MediaSender.
|
||||
// Uploads images/files via Feishu API then sends as messages.
|
||||
func (c *FeishuChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
|
||||
if !c.IsRunning() {
|
||||
return channels.ErrNotRunning
|
||||
}
|
||||
|
||||
if msg.ChatID == "" {
|
||||
return fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed)
|
||||
}
|
||||
|
||||
store := c.GetMediaStore()
|
||||
if store == nil {
|
||||
return fmt.Errorf("no media store available: %w", channels.ErrSendFailed)
|
||||
}
|
||||
|
||||
for _, part := range msg.Parts {
|
||||
if err := c.sendMediaPart(ctx, msg.ChatID, part, store); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendMediaPart resolves and sends a single media part.
|
||||
func (c *FeishuChannel) sendMediaPart(
|
||||
ctx context.Context,
|
||||
chatID string,
|
||||
part bus.MediaPart,
|
||||
store media.MediaStore,
|
||||
) error {
|
||||
localPath, err := store.Resolve(part.Ref)
|
||||
if err != nil {
|
||||
logger.ErrorCF("feishu", "Failed to resolve media ref", map[string]any{
|
||||
"ref": part.Ref,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return nil // skip this part
|
||||
}
|
||||
|
||||
file, err := os.Open(localPath)
|
||||
if err != nil {
|
||||
logger.ErrorCF("feishu", "Failed to open media file", map[string]any{
|
||||
"path": localPath,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return nil // skip this part
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
switch part.Type {
|
||||
case "image":
|
||||
err = c.sendImage(ctx, chatID, file)
|
||||
default:
|
||||
filename := part.Filename
|
||||
if filename == "" {
|
||||
filename = "file"
|
||||
}
|
||||
err = c.sendFile(ctx, chatID, file, filename, part.Type)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
logger.ErrorCF("feishu", "Failed to send media", map[string]any{
|
||||
"type": part.Type,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return fmt.Errorf("feishu send media: %w", channels.ErrTemporary)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Inbound message handling ---
|
||||
|
||||
func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim.P2MessageReceiveV1) error {
|
||||
if event == nil || event.Event == nil || event.Event.Message == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
message := event.Event.Message
|
||||
sender := event.Event.Sender
|
||||
|
||||
chatID := stringValue(message.ChatId)
|
||||
if chatID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
senderID := extractFeishuSenderID(sender)
|
||||
if senderID == "" {
|
||||
senderID = "unknown"
|
||||
}
|
||||
|
||||
messageType := stringValue(message.MessageType)
|
||||
messageID := stringValue(message.MessageId)
|
||||
rawContent := stringValue(message.Content)
|
||||
|
||||
// Check allowlist early to avoid downloading media for rejected senders.
|
||||
// BaseChannel.HandleMessage will check again, but this avoids wasted network I/O.
|
||||
senderInfo := bus.SenderInfo{
|
||||
Platform: "feishu",
|
||||
PlatformID: senderID,
|
||||
CanonicalID: identity.BuildCanonicalID("feishu", senderID),
|
||||
}
|
||||
if !c.IsAllowedSender(senderInfo) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Extract content based on message type
|
||||
content := extractContent(messageType, rawContent)
|
||||
|
||||
// Handle media messages (download and store)
|
||||
var mediaRefs []string
|
||||
if store := c.GetMediaStore(); store != nil && messageID != "" {
|
||||
mediaRefs = c.downloadInboundMedia(ctx, chatID, messageID, messageType, rawContent, store)
|
||||
}
|
||||
|
||||
// Append media tags to content (like Telegram does)
|
||||
content = appendMediaTags(content, messageType, mediaRefs)
|
||||
|
||||
if content == "" {
|
||||
content = "[empty message]"
|
||||
}
|
||||
|
||||
metadata := map[string]string{}
|
||||
if messageID != "" {
|
||||
metadata["message_id"] = messageID
|
||||
}
|
||||
if messageType != "" {
|
||||
metadata["message_type"] = messageType
|
||||
}
|
||||
chatType := stringValue(message.ChatType)
|
||||
if chatType != "" {
|
||||
metadata["chat_type"] = chatType
|
||||
}
|
||||
if sender != nil && sender.TenantKey != nil {
|
||||
metadata["tenant_key"] = *sender.TenantKey
|
||||
}
|
||||
|
||||
var peer bus.Peer
|
||||
if chatType == "p2p" {
|
||||
peer = bus.Peer{Kind: "direct", ID: senderID}
|
||||
} else {
|
||||
peer = bus.Peer{Kind: "group", ID: chatID}
|
||||
|
||||
// Check if bot was mentioned
|
||||
isMentioned := c.isBotMentioned(message)
|
||||
|
||||
// Strip mention placeholders from content before group trigger check
|
||||
if len(message.Mentions) > 0 {
|
||||
content = stripMentionPlaceholders(content, message.Mentions)
|
||||
}
|
||||
|
||||
// In group chats, apply unified group trigger filtering
|
||||
respond, cleaned := c.ShouldRespondInGroup(isMentioned, content)
|
||||
if !respond {
|
||||
return nil
|
||||
}
|
||||
content = cleaned
|
||||
}
|
||||
|
||||
logger.InfoCF("feishu", "Feishu message received", map[string]any{
|
||||
"sender_id": senderID,
|
||||
"chat_id": chatID,
|
||||
"message_id": messageID,
|
||||
"preview": utils.Truncate(content, 80),
|
||||
})
|
||||
|
||||
c.HandleMessage(ctx, peer, messageID, senderID, chatID, content, mediaRefs, metadata, senderInfo)
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Internal helpers ---
|
||||
|
||||
// fetchBotOpenID calls the Feishu bot info API to retrieve and store the bot's open_id.
|
||||
func (c *FeishuChannel) fetchBotOpenID(ctx context.Context) error {
|
||||
resp, err := c.client.Do(ctx, &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: "/open-apis/bot/v3/info",
|
||||
SupportedAccessTokenTypes: []larkcore.AccessTokenType{larkcore.AccessTokenTypeTenant},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("bot info request: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Bot struct {
|
||||
OpenID string `json:"open_id"`
|
||||
} `json:"bot"`
|
||||
}
|
||||
if err := json.Unmarshal(resp.RawBody, &result); err != nil {
|
||||
return fmt.Errorf("bot info parse: %w", err)
|
||||
}
|
||||
if result.Code != 0 {
|
||||
return fmt.Errorf("bot info api error (code=%d)", result.Code)
|
||||
}
|
||||
if result.Bot.OpenID == "" {
|
||||
return fmt.Errorf("bot info: empty open_id")
|
||||
}
|
||||
|
||||
c.botOpenID.Store(result.Bot.OpenID)
|
||||
logger.InfoCF("feishu", "Fetched bot open_id from API", map[string]any{
|
||||
"open_id": result.Bot.OpenID,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// isBotMentioned checks if the bot was @mentioned in the message.
|
||||
func (c *FeishuChannel) isBotMentioned(message *larkim.EventMessage) bool {
|
||||
if message.Mentions == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
knownID, _ := c.botOpenID.Load().(string)
|
||||
if knownID == "" {
|
||||
logger.DebugCF("feishu", "Bot open_id unknown, cannot detect @mention", nil)
|
||||
return false
|
||||
}
|
||||
|
||||
for _, m := range message.Mentions {
|
||||
if m.Id == nil {
|
||||
continue
|
||||
}
|
||||
if m.Id.OpenId != nil && *m.Id.OpenId == knownID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// extractContent extracts text content from different message types.
|
||||
func extractContent(messageType, rawContent string) string {
|
||||
if rawContent == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
switch messageType {
|
||||
case larkim.MsgTypeText:
|
||||
var textPayload struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(rawContent), &textPayload); err == nil {
|
||||
return textPayload.Text
|
||||
}
|
||||
return rawContent
|
||||
|
||||
case larkim.MsgTypePost:
|
||||
// Pass raw JSON to LLM — structured rich text is more informative than flattened plain text
|
||||
return rawContent
|
||||
|
||||
case larkim.MsgTypeImage:
|
||||
// Image messages don't have text content
|
||||
return ""
|
||||
|
||||
case larkim.MsgTypeFile, larkim.MsgTypeAudio, larkim.MsgTypeMedia:
|
||||
// File/audio/video messages may have a filename
|
||||
name := extractFileName(rawContent)
|
||||
if name != "" {
|
||||
return name
|
||||
}
|
||||
return ""
|
||||
|
||||
default:
|
||||
return rawContent
|
||||
}
|
||||
}
|
||||
|
||||
// downloadInboundMedia downloads media from inbound messages and stores in MediaStore.
|
||||
func (c *FeishuChannel) downloadInboundMedia(
|
||||
ctx context.Context,
|
||||
chatID, messageID, messageType, rawContent string,
|
||||
store media.MediaStore,
|
||||
) []string {
|
||||
var refs []string
|
||||
scope := channels.BuildMediaScope("feishu", chatID, messageID)
|
||||
|
||||
switch messageType {
|
||||
case larkim.MsgTypeImage:
|
||||
imageKey := extractImageKey(rawContent)
|
||||
if imageKey == "" {
|
||||
return nil
|
||||
}
|
||||
ref := c.downloadResource(ctx, messageID, imageKey, "image", ".jpg", store, scope)
|
||||
if ref != "" {
|
||||
refs = append(refs, ref)
|
||||
}
|
||||
|
||||
case larkim.MsgTypeFile, larkim.MsgTypeAudio, larkim.MsgTypeMedia:
|
||||
fileKey := extractFileKey(rawContent)
|
||||
if fileKey == "" {
|
||||
return nil
|
||||
}
|
||||
// Derive a fallback extension from the message type.
|
||||
var ext string
|
||||
switch messageType {
|
||||
case larkim.MsgTypeAudio:
|
||||
ext = ".ogg"
|
||||
case larkim.MsgTypeMedia:
|
||||
ext = ".mp4"
|
||||
default:
|
||||
ext = "" // generic file — rely on resp.FileName
|
||||
}
|
||||
ref := c.downloadResource(ctx, messageID, fileKey, "file", ext, store, scope)
|
||||
if ref != "" {
|
||||
refs = append(refs, ref)
|
||||
}
|
||||
}
|
||||
|
||||
return refs
|
||||
}
|
||||
|
||||
// downloadResource downloads a message resource (image/file) from Feishu,
|
||||
// writes it to the project media directory, and stores the reference in MediaStore.
|
||||
// fallbackExt (e.g. ".jpg") is appended when the resolved filename has no extension.
|
||||
func (c *FeishuChannel) downloadResource(
|
||||
ctx context.Context,
|
||||
messageID, fileKey, resourceType, fallbackExt string,
|
||||
store media.MediaStore,
|
||||
scope string,
|
||||
) string {
|
||||
req := larkim.NewGetMessageResourceReqBuilder().
|
||||
MessageId(messageID).
|
||||
FileKey(fileKey).
|
||||
Type(resourceType).
|
||||
Build()
|
||||
|
||||
resp, err := c.client.Im.V1.MessageResource.Get(ctx, req)
|
||||
if err != nil {
|
||||
logger.ErrorCF("feishu", "Failed to download resource", map[string]any{
|
||||
"message_id": messageID,
|
||||
"file_key": fileKey,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return ""
|
||||
}
|
||||
if !resp.Success() {
|
||||
logger.ErrorCF("feishu", "Resource download api error", map[string]any{
|
||||
"code": resp.Code,
|
||||
"msg": resp.Msg,
|
||||
})
|
||||
return ""
|
||||
}
|
||||
|
||||
if resp.File == nil {
|
||||
return ""
|
||||
}
|
||||
// Safely close the underlying reader if it implements io.Closer (e.g. HTTP response body).
|
||||
if closer, ok := resp.File.(io.Closer); ok {
|
||||
defer closer.Close()
|
||||
}
|
||||
|
||||
filename := resp.FileName
|
||||
if filename == "" {
|
||||
filename = fileKey
|
||||
}
|
||||
// If filename still has no extension, append the fallback (like Telegram's ext parameter).
|
||||
if filepath.Ext(filename) == "" && fallbackExt != "" {
|
||||
filename += fallbackExt
|
||||
}
|
||||
|
||||
// Write to the shared picoclaw_media directory using a unique name to avoid collisions.
|
||||
mediaDir := filepath.Join(os.TempDir(), "picoclaw_media")
|
||||
if mkdirErr := os.MkdirAll(mediaDir, 0o700); mkdirErr != nil {
|
||||
logger.ErrorCF("feishu", "Failed to create media directory", map[string]any{
|
||||
"error": mkdirErr.Error(),
|
||||
})
|
||||
return ""
|
||||
}
|
||||
ext := filepath.Ext(filename)
|
||||
localPath := filepath.Join(mediaDir, utils.SanitizeFilename(messageID+"-"+fileKey+ext))
|
||||
|
||||
out, err := os.Create(localPath)
|
||||
if err != nil {
|
||||
logger.ErrorCF("feishu", "Failed to create local file for resource", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return ""
|
||||
}
|
||||
|
||||
if _, copyErr := io.Copy(out, resp.File); copyErr != nil {
|
||||
out.Close()
|
||||
os.Remove(localPath)
|
||||
logger.ErrorCF("feishu", "Failed to write resource to file", map[string]any{
|
||||
"error": copyErr.Error(),
|
||||
})
|
||||
return ""
|
||||
}
|
||||
out.Close()
|
||||
|
||||
ref, err := store.Store(localPath, media.MediaMeta{
|
||||
Filename: filename,
|
||||
Source: "feishu",
|
||||
}, scope)
|
||||
if err != nil {
|
||||
logger.ErrorCF("feishu", "Failed to store downloaded resource", map[string]any{
|
||||
"file_key": fileKey,
|
||||
"error": err.Error(),
|
||||
})
|
||||
os.Remove(localPath)
|
||||
return ""
|
||||
}
|
||||
|
||||
return ref
|
||||
}
|
||||
|
||||
// appendMediaTags appends media type tags to content (like Telegram's "[image: photo]").
|
||||
func appendMediaTags(content, messageType string, mediaRefs []string) string {
|
||||
if len(mediaRefs) == 0 {
|
||||
return content
|
||||
}
|
||||
|
||||
var tag string
|
||||
switch messageType {
|
||||
case larkim.MsgTypeImage:
|
||||
tag = "[image: photo]"
|
||||
case larkim.MsgTypeAudio:
|
||||
tag = "[audio]"
|
||||
case larkim.MsgTypeMedia:
|
||||
tag = "[video]"
|
||||
case larkim.MsgTypeFile:
|
||||
tag = "[file]"
|
||||
default:
|
||||
tag = "[attachment]"
|
||||
}
|
||||
|
||||
if content == "" {
|
||||
return tag
|
||||
}
|
||||
return content + " " + tag
|
||||
}
|
||||
|
||||
// sendCard sends an interactive card message to a chat.
|
||||
func (c *FeishuChannel) sendCard(ctx context.Context, chatID, cardContent string) error {
|
||||
req := larkim.NewCreateMessageReqBuilder().
|
||||
ReceiveIdType(larkim.ReceiveIdTypeChatId).
|
||||
Body(larkim.NewCreateMessageReqBodyBuilder().
|
||||
ReceiveId(chatID).
|
||||
MsgType(larkim.MsgTypeInteractive).
|
||||
Content(cardContent).
|
||||
Build()).
|
||||
Build()
|
||||
|
||||
resp, err := c.client.Im.V1.Message.Create(ctx, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("feishu send card: %w", channels.ErrTemporary)
|
||||
}
|
||||
|
||||
if !resp.Success() {
|
||||
return fmt.Errorf("feishu api error (code=%d msg=%s): %w", resp.Code, resp.Msg, channels.ErrTemporary)
|
||||
}
|
||||
|
||||
logger.DebugCF("feishu", "Feishu card message sent", map[string]any{
|
||||
"chat_id": chatID,
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendImage uploads an image and sends it as a message.
|
||||
func (c *FeishuChannel) sendImage(ctx context.Context, chatID string, file *os.File) error {
|
||||
// Upload image to get image_key
|
||||
uploadReq := larkim.NewCreateImageReqBuilder().
|
||||
Body(larkim.NewCreateImageReqBodyBuilder().
|
||||
ImageType("message").
|
||||
Image(file).
|
||||
Build()).
|
||||
Build()
|
||||
|
||||
uploadResp, err := c.client.Im.V1.Image.Create(ctx, uploadReq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("feishu image upload: %w", err)
|
||||
}
|
||||
if !uploadResp.Success() {
|
||||
return fmt.Errorf("feishu image upload api error (code=%d msg=%s)", uploadResp.Code, uploadResp.Msg)
|
||||
}
|
||||
if uploadResp.Data == nil || uploadResp.Data.ImageKey == nil {
|
||||
return fmt.Errorf("feishu image upload: no image_key returned")
|
||||
}
|
||||
|
||||
imageKey := *uploadResp.Data.ImageKey
|
||||
|
||||
// Send image message
|
||||
content, _ := json.Marshal(map[string]string{"image_key": imageKey})
|
||||
req := larkim.NewCreateMessageReqBuilder().
|
||||
ReceiveIdType(larkim.ReceiveIdTypeChatId).
|
||||
Body(larkim.NewCreateMessageReqBodyBuilder().
|
||||
ReceiveId(chatID).
|
||||
MsgType(larkim.MsgTypeImage).
|
||||
Content(string(content)).
|
||||
Build()).
|
||||
Build()
|
||||
|
||||
resp, err := c.client.Im.V1.Message.Create(ctx, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("feishu image send: %w", err)
|
||||
}
|
||||
if !resp.Success() {
|
||||
return fmt.Errorf("feishu image send api error (code=%d msg=%s)", resp.Code, resp.Msg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendFile uploads a file and sends it as a message.
|
||||
func (c *FeishuChannel) sendFile(ctx context.Context, chatID string, file *os.File, filename, fileType string) error {
|
||||
// Map part type to Feishu file type
|
||||
feishuFileType := "stream"
|
||||
switch fileType {
|
||||
case "audio":
|
||||
feishuFileType = "opus"
|
||||
case "video":
|
||||
feishuFileType = "mp4"
|
||||
}
|
||||
|
||||
// Upload file to get file_key
|
||||
uploadReq := larkim.NewCreateFileReqBuilder().
|
||||
Body(larkim.NewCreateFileReqBodyBuilder().
|
||||
FileType(feishuFileType).
|
||||
FileName(filename).
|
||||
File(file).
|
||||
Build()).
|
||||
Build()
|
||||
|
||||
uploadResp, err := c.client.Im.V1.File.Create(ctx, uploadReq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("feishu file upload: %w", err)
|
||||
}
|
||||
if !uploadResp.Success() {
|
||||
return fmt.Errorf("feishu file upload api error (code=%d msg=%s)", uploadResp.Code, uploadResp.Msg)
|
||||
}
|
||||
if uploadResp.Data == nil || uploadResp.Data.FileKey == nil {
|
||||
return fmt.Errorf("feishu file upload: no file_key returned")
|
||||
}
|
||||
|
||||
fileKey := *uploadResp.Data.FileKey
|
||||
|
||||
// Send file message
|
||||
content, _ := json.Marshal(map[string]string{"file_key": fileKey})
|
||||
req := larkim.NewCreateMessageReqBuilder().
|
||||
ReceiveIdType(larkim.ReceiveIdTypeChatId).
|
||||
Body(larkim.NewCreateMessageReqBodyBuilder().
|
||||
ReceiveId(chatID).
|
||||
MsgType(larkim.MsgTypeFile).
|
||||
Content(string(content)).
|
||||
Build()).
|
||||
Build()
|
||||
|
||||
resp, err := c.client.Im.V1.Message.Create(ctx, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("feishu file send: %w", err)
|
||||
}
|
||||
if !resp.Success() {
|
||||
return fmt.Errorf("feishu file send api error (code=%d msg=%s)", resp.Code, resp.Msg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func extractFeishuSenderID(sender *larkim.EventSender) string {
|
||||
if sender == nil || sender.SenderId == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if sender.SenderId.UserId != nil && *sender.SenderId.UserId != "" {
|
||||
return *sender.SenderId.UserId
|
||||
}
|
||||
if sender.SenderId.OpenId != nil && *sender.SenderId.OpenId != "" {
|
||||
return *sender.SenderId.OpenId
|
||||
}
|
||||
if sender.SenderId.UnionId != nil && *sender.SenderId.UnionId != "" {
|
||||
return *sender.SenderId.UnionId
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
|
@ -1,256 +0,0 @@
|
|||
//go:build amd64 || arm64 || riscv64 || mips64 || ppc64
|
||||
|
||||
package feishu
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
|
||||
)
|
||||
|
||||
func TestExtractContent(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
messageType string
|
||||
rawContent string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "text message",
|
||||
messageType: "text",
|
||||
rawContent: `{"text": "hello world"}`,
|
||||
want: "hello world",
|
||||
},
|
||||
{
|
||||
name: "text message invalid JSON",
|
||||
messageType: "text",
|
||||
rawContent: `not json`,
|
||||
want: "not json",
|
||||
},
|
||||
{
|
||||
name: "post message returns raw JSON",
|
||||
messageType: "post",
|
||||
rawContent: `{"title": "test post"}`,
|
||||
want: `{"title": "test post"}`,
|
||||
},
|
||||
{
|
||||
name: "image message returns empty",
|
||||
messageType: "image",
|
||||
rawContent: `{"image_key": "img_xxx"}`,
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "file message with filename",
|
||||
messageType: "file",
|
||||
rawContent: `{"file_key": "file_xxx", "file_name": "report.pdf"}`,
|
||||
want: "report.pdf",
|
||||
},
|
||||
{
|
||||
name: "file message without filename",
|
||||
messageType: "file",
|
||||
rawContent: `{"file_key": "file_xxx"}`,
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "audio message with filename",
|
||||
messageType: "audio",
|
||||
rawContent: `{"file_key": "file_xxx", "file_name": "recording.ogg"}`,
|
||||
want: "recording.ogg",
|
||||
},
|
||||
{
|
||||
name: "media message with filename",
|
||||
messageType: "media",
|
||||
rawContent: `{"file_key": "file_xxx", "file_name": "video.mp4"}`,
|
||||
want: "video.mp4",
|
||||
},
|
||||
{
|
||||
name: "unknown message type returns raw",
|
||||
messageType: "sticker",
|
||||
rawContent: `{"sticker_id": "sticker_xxx"}`,
|
||||
want: `{"sticker_id": "sticker_xxx"}`,
|
||||
},
|
||||
{
|
||||
name: "empty raw content",
|
||||
messageType: "text",
|
||||
rawContent: "",
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := extractContent(tt.messageType, tt.rawContent)
|
||||
if got != tt.want {
|
||||
t.Errorf("extractContent(%q, %q) = %q, want %q", tt.messageType, tt.rawContent, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendMediaTags(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
messageType string
|
||||
mediaRefs []string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "no refs returns content unchanged",
|
||||
content: "hello",
|
||||
messageType: "image",
|
||||
mediaRefs: nil,
|
||||
want: "hello",
|
||||
},
|
||||
{
|
||||
name: "empty refs returns content unchanged",
|
||||
content: "hello",
|
||||
messageType: "image",
|
||||
mediaRefs: []string{},
|
||||
want: "hello",
|
||||
},
|
||||
{
|
||||
name: "image with content",
|
||||
content: "check this",
|
||||
messageType: "image",
|
||||
mediaRefs: []string{"ref1"},
|
||||
want: "check this [image: photo]",
|
||||
},
|
||||
{
|
||||
name: "image empty content",
|
||||
content: "",
|
||||
messageType: "image",
|
||||
mediaRefs: []string{"ref1"},
|
||||
want: "[image: photo]",
|
||||
},
|
||||
{
|
||||
name: "audio",
|
||||
content: "listen",
|
||||
messageType: "audio",
|
||||
mediaRefs: []string{"ref1"},
|
||||
want: "listen [audio]",
|
||||
},
|
||||
{
|
||||
name: "media/video",
|
||||
content: "watch",
|
||||
messageType: "media",
|
||||
mediaRefs: []string{"ref1"},
|
||||
want: "watch [video]",
|
||||
},
|
||||
{
|
||||
name: "file",
|
||||
content: "report.pdf",
|
||||
messageType: "file",
|
||||
mediaRefs: []string{"ref1"},
|
||||
want: "report.pdf [file]",
|
||||
},
|
||||
{
|
||||
name: "unknown type",
|
||||
content: "something",
|
||||
messageType: "sticker",
|
||||
mediaRefs: []string{"ref1"},
|
||||
want: "something [attachment]",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := appendMediaTags(tt.content, tt.messageType, tt.mediaRefs)
|
||||
if got != tt.want {
|
||||
t.Errorf(
|
||||
"appendMediaTags(%q, %q, %v) = %q, want %q",
|
||||
tt.content,
|
||||
tt.messageType,
|
||||
tt.mediaRefs,
|
||||
got,
|
||||
tt.want,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractFeishuSenderID(t *testing.T) {
|
||||
strPtr := func(s string) *string { return &s }
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
sender *larkim.EventSender
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "nil sender",
|
||||
sender: nil,
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "nil sender ID",
|
||||
sender: &larkim.EventSender{SenderId: nil},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "userId preferred",
|
||||
sender: &larkim.EventSender{
|
||||
SenderId: &larkim.UserId{
|
||||
UserId: strPtr("u_abc123"),
|
||||
OpenId: strPtr("ou_def456"),
|
||||
UnionId: strPtr("on_ghi789"),
|
||||
},
|
||||
},
|
||||
want: "u_abc123",
|
||||
},
|
||||
{
|
||||
name: "openId fallback",
|
||||
sender: &larkim.EventSender{
|
||||
SenderId: &larkim.UserId{
|
||||
UserId: strPtr(""),
|
||||
OpenId: strPtr("ou_def456"),
|
||||
UnionId: strPtr("on_ghi789"),
|
||||
},
|
||||
},
|
||||
want: "ou_def456",
|
||||
},
|
||||
{
|
||||
name: "unionId fallback",
|
||||
sender: &larkim.EventSender{
|
||||
SenderId: &larkim.UserId{
|
||||
UserId: strPtr(""),
|
||||
OpenId: strPtr(""),
|
||||
UnionId: strPtr("on_ghi789"),
|
||||
},
|
||||
},
|
||||
want: "on_ghi789",
|
||||
},
|
||||
{
|
||||
name: "all empty strings",
|
||||
sender: &larkim.EventSender{
|
||||
SenderId: &larkim.UserId{
|
||||
UserId: strPtr(""),
|
||||
OpenId: strPtr(""),
|
||||
UnionId: strPtr(""),
|
||||
},
|
||||
},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "nil userId pointer falls through",
|
||||
sender: &larkim.EventSender{
|
||||
SenderId: &larkim.UserId{
|
||||
UserId: nil,
|
||||
OpenId: strPtr("ou_def456"),
|
||||
UnionId: nil,
|
||||
},
|
||||
},
|
||||
want: "ou_def456",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := extractFeishuSenderID(tt.sender)
|
||||
if got != tt.want {
|
||||
t.Errorf("extractFeishuSenderID() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
package feishu
|
||||
|
||||
import (
|
||||
"jane/pkg/bus"
|
||||
"jane/pkg/channels"
|
||||
"jane/pkg/config"
|
||||
)
|
||||
|
||||
func init() {
|
||||
channels.RegisterFactory("feishu", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
|
||||
return NewFeishuChannel(cfg.Channels.Feishu, b)
|
||||
})
|
||||
}
|
||||
|
|
@ -248,10 +248,6 @@ func (m *Manager) initChannels() error {
|
|||
}
|
||||
}
|
||||
|
||||
if m.config.Channels.Feishu.Enabled {
|
||||
m.initChannel("feishu", "Feishu")
|
||||
}
|
||||
|
||||
if m.config.Channels.Discord.Enabled && m.config.Channels.Discord.Token != "" {
|
||||
m.initChannel("discord", "Discord")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -257,7 +257,6 @@ func (d *AgentDefaults) GetModelName() string {
|
|||
type ChannelsConfig struct {
|
||||
WhatsApp WhatsAppConfig `json:"whatsapp"`
|
||||
Telegram TelegramConfig `json:"telegram"`
|
||||
Feishu FeishuConfig `json:"feishu"`
|
||||
Discord DiscordConfig `json:"discord"`
|
||||
MaixCam MaixCamConfig `json:"maixcam"`
|
||||
QQ QQConfig `json:"qq"`
|
||||
|
|
@ -311,19 +310,6 @@ type TelegramConfig struct {
|
|||
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_TELEGRAM_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"`
|
||||
AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"`
|
||||
EncryptKey string `json:"encrypt_key" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"`
|
||||
VerificationToken string `json:"verification_token" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"`
|
||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"`
|
||||
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
|
||||
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
|
||||
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_FEISHU_REASONING_CHANNEL_ID"`
|
||||
RandomReactionEmoji FlexibleStringSlice `json:"random_reaction_emoji" env:"PICOCLAW_CHANNELS_FEISHU_RANDOM_REACTION_EMOJI"`
|
||||
}
|
||||
|
||||
type DiscordConfig struct {
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"`
|
||||
Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"`
|
||||
|
|
|
|||
|
|
@ -66,14 +66,6 @@ func DefaultConfig() *Config {
|
|||
Text: "Thinking... 💭",
|
||||
},
|
||||
},
|
||||
Feishu: FeishuConfig{
|
||||
Enabled: false,
|
||||
AppID: "",
|
||||
AppSecret: "",
|
||||
EncryptKey: "",
|
||||
VerificationToken: "",
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
},
|
||||
Discord: DiscordConfig{
|
||||
Enabled: false,
|
||||
Token: "",
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ var migrateableDirs = []string{
|
|||
var supportedChannels = map[string]bool{
|
||||
"whatsapp": true,
|
||||
"telegram": true,
|
||||
"feishu": true,
|
||||
"discord": true,
|
||||
"maixcam": true,
|
||||
"qq": true,
|
||||
|
|
|
|||
|
|
@ -123,7 +123,6 @@ type OpenClawChannels struct {
|
|||
Teams *OpenClawTeamsConfig `json:"msteams"`
|
||||
IRC *OpenClawIrcConfig `json:"irc"`
|
||||
Mattermost *OpenClawMattermostConfig `json:"mattermost"`
|
||||
Feishu *OpenClawFeishuConfig `json:"feishu"`
|
||||
IMessage *OpenClawIMessageConfig `json:"imessage"`
|
||||
BlueBubbles *OpenClawBlueBubblesConfig `json:"bluebubbles"`
|
||||
QQ *OpenClawQQConfig `json:"qq"`
|
||||
|
|
@ -223,17 +222,6 @@ type OpenClawMattermostConfig struct {
|
|||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type OpenClawFeishuConfig struct {
|
||||
AppID *string `json:"appId"`
|
||||
AppSecret *string `json:"appSecret"`
|
||||
Domain *string `json:"domain"`
|
||||
DmPolicy *string `json:"dmPolicy"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
VerificationToken *string `json:"verificationToken"`
|
||||
EncryptKey *string `json:"encryptKey"`
|
||||
AllowFrom []string `json:"allowFrom"`
|
||||
}
|
||||
|
||||
type OpenClawIMessageConfig struct {
|
||||
CliPath *string `json:"cliPath"`
|
||||
DbPath *string `json:"dbPath"`
|
||||
|
|
@ -375,8 +363,6 @@ func (c *OpenClawConfig) IsChannelEnabled(name string) bool {
|
|||
return c.Channels.Matrix == nil || c.Channels.Matrix.Enabled == nil || *c.Channels.Matrix.Enabled
|
||||
case "whatsapp":
|
||||
return c.Channels.WhatsApp == nil || c.Channels.WhatsApp.Enabled == nil || *c.Channels.WhatsApp.Enabled
|
||||
case "feishu":
|
||||
return c.Channels.Feishu == nil || c.Channels.Feishu.Enabled == nil || *c.Channels.Feishu.Enabled
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
|
@ -409,11 +395,6 @@ func GetChannelAllowFrom(ch any) []string {
|
|||
return nil
|
||||
}
|
||||
return c.AllowFrom
|
||||
case *OpenClawFeishuConfig:
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
return c.AllowFrom
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
|
@ -628,7 +609,6 @@ type PeerMatch struct {
|
|||
type ChannelsConfig struct {
|
||||
WhatsApp WhatsAppConfig `json:"whatsapp"`
|
||||
Telegram TelegramConfig `json:"telegram"`
|
||||
Feishu FeishuConfig `json:"feishu"`
|
||||
Discord DiscordConfig `json:"discord"`
|
||||
MaixCam MaixCamConfig `json:"maixcam"`
|
||||
QQ QQConfig `json:"qq"`
|
||||
|
|
@ -651,15 +631,6 @@ type TelegramConfig struct {
|
|||
AllowFrom []string `json:"allow_from"`
|
||||
}
|
||||
|
||||
type FeishuConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
AppID string `json:"app_id"`
|
||||
AppSecret string `json:"app_secret"`
|
||||
EncryptKey string `json:"encrypt_key"`
|
||||
VerificationToken string `json:"verification_token"`
|
||||
AllowFrom []string `json:"allow_from"`
|
||||
}
|
||||
|
||||
type DiscordConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Token string `json:"token"`
|
||||
|
|
@ -822,26 +793,6 @@ func (c *OpenClawConfig) convertChannels(warnings *[]string) ChannelsConfig {
|
|||
}
|
||||
}
|
||||
|
||||
if c.Channels.Feishu != nil {
|
||||
enabled := c.Channels.Feishu.Enabled == nil || *c.Channels.Feishu.Enabled
|
||||
channels.Feishu = FeishuConfig{
|
||||
Enabled: enabled,
|
||||
AllowFrom: c.Channels.Feishu.AllowFrom,
|
||||
}
|
||||
if c.Channels.Feishu.AppID != nil {
|
||||
channels.Feishu.AppID = *c.Channels.Feishu.AppID
|
||||
}
|
||||
if c.Channels.Feishu.AppSecret != nil {
|
||||
channels.Feishu.AppSecret = *c.Channels.Feishu.AppSecret
|
||||
}
|
||||
if c.Channels.Feishu.EncryptKey != nil {
|
||||
channels.Feishu.EncryptKey = *c.Channels.Feishu.EncryptKey
|
||||
}
|
||||
if c.Channels.Feishu.VerificationToken != nil {
|
||||
channels.Feishu.VerificationToken = *c.Channels.Feishu.VerificationToken
|
||||
}
|
||||
}
|
||||
|
||||
if c.Channels.QQ != nil && supportedChannels["qq"] {
|
||||
channels.QQ = QQConfig{
|
||||
Enabled: true,
|
||||
|
|
@ -1021,13 +972,6 @@ func (c ChannelsConfig) ToStandardChannels() config.ChannelsConfig {
|
|||
Token: c.Telegram.Token,
|
||||
Proxy: c.Telegram.Proxy,
|
||||
},
|
||||
Feishu: config.FeishuConfig{
|
||||
Enabled: c.Feishu.Enabled,
|
||||
AppID: c.Feishu.AppID,
|
||||
AppSecret: c.Feishu.AppSecret,
|
||||
EncryptKey: c.Feishu.EncryptKey,
|
||||
VerificationToken: c.Feishu.VerificationToken,
|
||||
},
|
||||
Discord: config.DiscordConfig{
|
||||
Enabled: c.Discord.Enabled,
|
||||
Token: c.Discord.Token,
|
||||
|
|
|
|||
|
|
@ -198,12 +198,6 @@ func TestConvertToPicoClaw(t *testing.T) {
|
|||
"enabled": true,
|
||||
"bridgeUrl": "http://localhost:3000"
|
||||
},
|
||||
"feishu": {
|
||||
"enabled": true,
|
||||
"appId": "app-id",
|
||||
"appSecret": "app-secret",
|
||||
"allowFrom": ["user3"]
|
||||
},
|
||||
"signal": {
|
||||
"enabled": true
|
||||
}
|
||||
|
|
@ -270,10 +264,6 @@ func TestConvertToPicoClaw(t *testing.T) {
|
|||
t.Errorf("expected whatsapp bridge URL 'http://localhost:3000', got '%s'", picoCfg.Channels.WhatsApp.BridgeURL)
|
||||
}
|
||||
|
||||
if picoCfg.Channels.Feishu.AppID != "app-id" {
|
||||
t.Errorf("expected feishu app ID 'app-id', got '%s'", picoCfg.Channels.Feishu.AppID)
|
||||
}
|
||||
|
||||
if len(picoCfg.ModelList) != 1 {
|
||||
t.Errorf("expected 1 model config (no models.json provided), got %d", len(picoCfg.ModelList))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import (
|
|||
func TestSendFileTool_MissingPath(t *testing.T) {
|
||||
store := media.NewFileMediaStore()
|
||||
tool := NewSendFileTool("/tmp", false, 0, store)
|
||||
tool.SetContext("feishu", "chat123")
|
||||
tool.SetContext("telegram", "chat123")
|
||||
|
||||
result := tool.Execute(context.Background(), map[string]any{})
|
||||
if !result.IsError {
|
||||
|
|
@ -35,7 +35,7 @@ func TestSendFileTool_NoContext(t *testing.T) {
|
|||
|
||||
func TestSendFileTool_NoMediaStore(t *testing.T) {
|
||||
tool := NewSendFileTool("/tmp", false, 0, nil)
|
||||
tool.SetContext("feishu", "chat123")
|
||||
tool.SetContext("telegram", "chat123")
|
||||
|
||||
result := tool.Execute(context.Background(), map[string]any{"path": "/tmp/test.txt"})
|
||||
if !result.IsError {
|
||||
|
|
@ -46,7 +46,7 @@ func TestSendFileTool_NoMediaStore(t *testing.T) {
|
|||
func TestSendFileTool_Directory(t *testing.T) {
|
||||
store := media.NewFileMediaStore()
|
||||
tool := NewSendFileTool("/tmp", false, 0, store)
|
||||
tool.SetContext("feishu", "chat123")
|
||||
tool.SetContext("telegram", "chat123")
|
||||
|
||||
result := tool.Execute(context.Background(), map[string]any{"path": "/tmp"})
|
||||
if !result.IsError {
|
||||
|
|
@ -64,7 +64,7 @@ func TestSendFileTool_FileTooLarge(t *testing.T) {
|
|||
|
||||
store := media.NewFileMediaStore()
|
||||
tool := NewSendFileTool(dir, false, 512, store) // 512 byte limit
|
||||
tool.SetContext("feishu", "chat123")
|
||||
tool.SetContext("telegram", "chat123")
|
||||
|
||||
result := tool.Execute(context.Background(), map[string]any{"path": testFile})
|
||||
if !result.IsError {
|
||||
|
|
@ -91,7 +91,7 @@ func TestSendFileTool_Success(t *testing.T) {
|
|||
|
||||
store := media.NewFileMediaStore()
|
||||
tool := NewSendFileTool(dir, false, 0, store)
|
||||
tool.SetContext("feishu", "chat123")
|
||||
tool.SetContext("telegram", "chat123")
|
||||
|
||||
result := tool.Execute(context.Background(), map[string]any{"path": testFile})
|
||||
if result.IsError {
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ var channelCatalog = []channelCatalogItem{
|
|||
{Name: "telegram", ConfigKey: "telegram"},
|
||||
{Name: "discord", ConfigKey: "discord"},
|
||||
{Name: "slack", ConfigKey: "slack"},
|
||||
{Name: "feishu", ConfigKey: "feishu"},
|
||||
{Name: "dingtalk", ConfigKey: "dingtalk"},
|
||||
{Name: "line", ConfigKey: "line"},
|
||||
{Name: "qq", ConfigKey: "qq"},
|
||||
|
|
|
|||
1
web/backend/dist/.gitkeep
vendored
1
web/backend/dist/.gitkeep
vendored
|
|
@ -1 +0,0 @@
|
|||
# Keep the embedded web backend dist directory in version control.
|
||||
|
|
@ -13,7 +13,6 @@ import {
|
|||
} from "@/api/channels"
|
||||
import { getChannelDisplayName } from "@/components/channels/channel-display-name"
|
||||
import { DiscordForm } from "@/components/channels/channel-forms/discord-form"
|
||||
import { FeishuForm } from "@/components/channels/channel-forms/feishu-form"
|
||||
import { GenericForm } from "@/components/channels/channel-forms/generic-form"
|
||||
import { SlackForm } from "@/components/channels/channel-forms/slack-form"
|
||||
import { TelegramForm } from "@/components/channels/channel-forms/telegram-form"
|
||||
|
|
@ -125,10 +124,6 @@ function isConfigured(
|
|||
return asString(config.token) !== ""
|
||||
case "slack":
|
||||
return asString(config.bot_token) !== ""
|
||||
case "feishu":
|
||||
return (
|
||||
asString(config.app_id) !== "" && asString(config.app_secret) !== ""
|
||||
)
|
||||
case "dingtalk":
|
||||
return (
|
||||
asString(config.client_id) !== "" &&
|
||||
|
|
@ -179,8 +174,6 @@ function getRequiredFieldKeys(channelName: string): string[] {
|
|||
return ["token"]
|
||||
case "slack":
|
||||
return ["bot_token"]
|
||||
case "feishu":
|
||||
return ["app_id", "app_secret"]
|
||||
case "dingtalk":
|
||||
return ["client_id", "client_secret"]
|
||||
case "line":
|
||||
|
|
@ -437,15 +430,6 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
|||
fieldErrors={fieldErrors}
|
||||
/>
|
||||
)
|
||||
case "feishu":
|
||||
return (
|
||||
<FeishuForm
|
||||
config={editConfig}
|
||||
onChange={handleChange}
|
||||
isEdit={isEdit}
|
||||
fieldErrors={fieldErrors}
|
||||
/>
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<GenericForm
|
||||
|
|
|
|||
|
|
@ -1,121 +0,0 @@
|
|||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import type { ChannelConfig } from "@/api/channels"
|
||||
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
|
||||
import { Field, KeyInput } from "@/components/shared-form"
|
||||
import { Input } from "@/components/ui/input"
|
||||
|
||||
interface FeishuFormProps {
|
||||
config: ChannelConfig
|
||||
onChange: (key: string, value: unknown) => void
|
||||
isEdit: boolean
|
||||
fieldErrors?: Record<string, string>
|
||||
}
|
||||
|
||||
function asString(value: unknown): string {
|
||||
return typeof value === "string" ? value : ""
|
||||
}
|
||||
|
||||
function asStringArray(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value.filter((item): item is string => typeof item === "string")
|
||||
}
|
||||
|
||||
export function FeishuForm({
|
||||
config,
|
||||
onChange,
|
||||
isEdit,
|
||||
fieldErrors = {},
|
||||
}: FeishuFormProps) {
|
||||
const { t } = useTranslation()
|
||||
const appSecretExtraHint =
|
||||
isEdit && asString(config.app_secret)
|
||||
? ` ${t("channels.field.secretHintSet")}`
|
||||
: ""
|
||||
const verificationExtraHint =
|
||||
isEdit && asString(config.verification_token)
|
||||
? ` ${t("channels.field.secretHintSet")}`
|
||||
: ""
|
||||
const encryptExtraHint =
|
||||
isEdit && asString(config.encrypt_key)
|
||||
? ` ${t("channels.field.secretHintSet")}`
|
||||
: ""
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<Field
|
||||
label={t("channels.field.appId")}
|
||||
required
|
||||
hint={t("channels.form.desc.appId")}
|
||||
error={fieldErrors.app_id}
|
||||
>
|
||||
<Input
|
||||
value={asString(config.app_id)}
|
||||
onChange={(e) => onChange("app_id", e.target.value)}
|
||||
placeholder="cli_xxxx"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("channels.field.appSecret")}
|
||||
required
|
||||
hint={`${t("channels.form.desc.appSecret")}${appSecretExtraHint}`}
|
||||
error={fieldErrors.app_secret}
|
||||
>
|
||||
<KeyInput
|
||||
value={asString(config._app_secret)}
|
||||
onChange={(v) => onChange("_app_secret", v)}
|
||||
placeholder={maskedSecretPlaceholder(
|
||||
config.app_secret,
|
||||
t("channels.field.secretPlaceholder"),
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("channels.field.verificationToken")}
|
||||
hint={`${t("channels.form.desc.verificationToken")}${verificationExtraHint}`}
|
||||
>
|
||||
<KeyInput
|
||||
value={asString(config._verification_token)}
|
||||
onChange={(v) => onChange("_verification_token", v)}
|
||||
placeholder={maskedSecretPlaceholder(
|
||||
config.verification_token,
|
||||
t("channels.field.secretPlaceholder"),
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label={t("channels.field.encryptKey")}
|
||||
hint={`${t("channels.form.desc.encryptKey")}${encryptExtraHint}`}
|
||||
>
|
||||
<KeyInput
|
||||
value={asString(config._encrypt_key)}
|
||||
onChange={(v) => onChange("_encrypt_key", v)}
|
||||
placeholder={maskedSecretPlaceholder(
|
||||
config.encrypt_key,
|
||||
t("channels.field.secretPlaceholder"),
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label={t("channels.field.allowFrom")}
|
||||
hint={t("channels.form.desc.allowFrom")}
|
||||
>
|
||||
<Input
|
||||
value={asStringArray(config.allow_from).join(", ")}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
"allow_from",
|
||||
e.target.value
|
||||
.split(",")
|
||||
.map((s: string) => s.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
}
|
||||
placeholder={t("channels.field.allowFromPlaceholder")}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -30,7 +30,6 @@ import { gatewayAtom } from "@/store/gateway"
|
|||
const DEFAULT_VISIBLE_CHANNELS = 4
|
||||
const CHANNEL_IMPORTANCE_ORDER = [
|
||||
"discord",
|
||||
"feishu",
|
||||
"telegram",
|
||||
"slack",
|
||||
"line",
|
||||
|
|
@ -51,19 +50,6 @@ const CHANNEL_IMPORTANCE_INDEX = new Map(
|
|||
CHANNEL_IMPORTANCE_ORDER.map((name, index) => [name, index]),
|
||||
)
|
||||
|
||||
function IconLark({ className }: { className?: string }) {
|
||||
return React.createElement("span", {
|
||||
className,
|
||||
"aria-hidden": "true",
|
||||
style: {
|
||||
display: "inline-block",
|
||||
backgroundColor: "currentColor",
|
||||
mask: "url(/lark.svg) center / contain no-repeat",
|
||||
WebkitMask: "url(/lark.svg) center / contain no-repeat",
|
||||
} as React.CSSProperties,
|
||||
})
|
||||
}
|
||||
|
||||
const CHANNEL_ICON_MAP: Record<
|
||||
string,
|
||||
React.ComponentType<{ className?: string }>
|
||||
|
|
@ -71,7 +57,6 @@ const CHANNEL_ICON_MAP: Record<
|
|||
telegram: IconBrandTelegram,
|
||||
discord: IconBrandDiscord,
|
||||
slack: IconBrandSlack,
|
||||
feishu: IconLark,
|
||||
dingtalk: IconBrandDingtalk,
|
||||
line: IconBrandLine,
|
||||
qq: IconBrandQq,
|
||||
|
|
|
|||
|
|
@ -223,7 +223,6 @@
|
|||
"telegram": "Telegram",
|
||||
"discord": "Discord",
|
||||
"slack": "Slack",
|
||||
"feishu": "Feishu",
|
||||
"dingtalk": "DingTalk",
|
||||
"line": "LINE",
|
||||
"qq": "QQ",
|
||||
|
|
|
|||
|
|
@ -223,7 +223,6 @@
|
|||
"telegram": "Telegram",
|
||||
"discord": "Discord",
|
||||
"slack": "Slack",
|
||||
"feishu": "飞书",
|
||||
"dingtalk": "钉钉",
|
||||
"line": "LINE",
|
||||
"qq": "QQ",
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ Ultra-lightweight personal AI assistant written in Go, inspired by nanobot.
|
|||
- Web search and content fetching
|
||||
- File system operations (read, write, edit)
|
||||
- Shell command execution
|
||||
- Multi-channel messaging (Telegram, WhatsApp, Feishu)
|
||||
- Multi-channel messaging (Telegram, WhatsApp, Discord)
|
||||
- Skill-based extensibility
|
||||
- Memory and context management
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue