remove feishu channel

Completely remove the Feishu channel from the project. This includes:
- Deleting the pkg/channels/feishu implementation and docs/channels/feishu documentation.
- Removing Feishu configuration fields and structs from pkg/config.
- Removing Feishu from the channel manager and registration logic.
- Cleaning up the frontend: removing sidebar icons, config forms, and translations.
- Updating the TUI to remove the Feishu menu item and form.
- Removing Feishu from migration logic (OpenClaw) and all tests.
- Updating all README files and examples.

Co-authored-by: hobbyistlabs-coder <267281733+hobbyistlabs-coder@users.noreply.github.com>
This commit is contained in:
google-labs-jules[bot] 2026-03-13 20:36:05 +00:00
parent 9970032d53
commit 82a8552c41
38 changed files with 14 additions and 1940 deletions

View file

@ -9,10 +9,6 @@
# ── Chat Channel ────────────────────────── # ── Chat Channel ──────────────────────────
# TELEGRAM_BOT_TOKEN=123456:ABC... # TELEGRAM_BOT_TOKEN=123456:ABC...
# DISCORD_BOT_TOKEN=xxx # 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) ──────────────── # ── Web Search (optional) ────────────────
# BRAVE_SEARCH_API_KEY=BSA... # BRAVE_SEARCH_API_KEY=BSA...

View file

@ -913,14 +913,6 @@ picoclaw agent -m "Bonjour, comment ça va ?"
"whatsapp": { "whatsapp": {
"enabled": false "enabled": false
}, },
"feishu": {
"enabled": false,
"app_id": "cli_xxx",
"app_secret": "xxx",
"encrypt_key": "",
"verification_token": "",
"allow_from": []
},
"qq": { "qq": {
"enabled": false, "enabled": false,
"app_id": "", "app_id": "",

View file

@ -866,14 +866,6 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
"whatsapp": { "whatsapp": {
"enabled": false "enabled": false
}, },
"feishu": {
"enabled": false,
"app_id": "cli_xxx",
"app_secret": "xxx",
"encrypt_key": "",
"verification_token": "",
"allow_from": []
}
}, },
"tools": { "tools": {
"web": { "web": {

View file

@ -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 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 | | Channel | Setup |
| ------------ | ---------------------------------- | | ------------ | ---------------------------------- |
@ -1318,14 +1318,6 @@ picoclaw agent -m "Hello"
"session_store_path": "", "session_store_path": "",
"allow_from": [] "allow_from": []
}, },
"feishu": {
"enabled": false,
"app_id": "cli_xxx",
"app_secret": "xxx",
"encrypt_key": "",
"verification_token": "",
"allow_from": []
},
"qq": { "qq": {
"enabled": false, "enabled": false,
"app_id": "", "app_id": "",

View file

@ -909,14 +909,6 @@ picoclaw agent -m "Ola, como vai?"
"whatsapp": { "whatsapp": {
"enabled": false "enabled": false
}, },
"feishu": {
"enabled": false,
"app_id": "cli_xxx",
"app_secret": "xxx",
"encrypt_key": "",
"verification_token": "",
"allow_from": []
},
"qq": { "qq": {
"enabled": false, "enabled": false,
"app_id": "", "app_id": "",

View file

@ -881,14 +881,6 @@ picoclaw agent -m "Xin chào"
"whatsapp": { "whatsapp": {
"enabled": false "enabled": false
}, },
"feishu": {
"enabled": false,
"app_id": "cli_xxx",
"app_secret": "xxx",
"encrypt_key": "",
"verification_token": "",
"allow_from": []
},
"qq": { "qq": {
"enabled": false, "enabled": false,
"app_id": "", "app_id": "",

View file

@ -293,7 +293,7 @@ picoclaw agent -m "2+2 等于几?"
PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方。 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) | | **QQ** | ⭐⭐ 中等 | 官方机器人 API适合国内社群 | [查看文档](docs/channels/qq/README.zh.md) |
| **钉钉 (DingTalk)** | ⭐⭐ 中等 | Stream 模式无需公网,企业办公首选 | [查看文档](docs/channels/dingtalk/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) | | **企业微信 (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) | | **Line** | ⭐⭐⭐ 较难 | 需要 HTTPS Webhook | [查看文档](docs/channels/line/README.zh.md) |
| **OneBot** | ⭐⭐ 中等 | 兼容 NapCat/Go-CQHTTP社区生态丰富 | [查看文档](docs/channels/onebot/README.zh.md) | | **OneBot** | ⭐⭐ 中等 | 兼容 NapCat/Go-CQHTTP社区生态丰富 | [查看文档](docs/channels/onebot/README.zh.md) |
| **MaixCam** | ⭐ 简单 | 专为 AI 摄像头设计的硬件集成通道 | [查看文档](docs/channels/maixcam/README.zh.md) | | **MaixCam** | ⭐ 简单 | 专为 AI 摄像头设计的硬件集成通道 | [查看文档](docs/channels/maixcam/README.zh.md) |
@ -775,14 +774,6 @@ picoclaw agent -m "你好"
"whatsapp": { "whatsapp": {
"enabled": false "enabled": false
}, },
"feishu": {
"enabled": false,
"app_id": "cli_xxx",
"app_secret": "xxx",
"encrypt_key": "",
"verification_token": "",
"allow_from": []
},
"qq": { "qq": {
"enabled": false, "enabled": false,
"app_id": "", "app_id": "",

View file

@ -48,7 +48,7 @@
* **Channel** * **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. * **Standards**: Support for the **OneBot** protocol.
* [**attachment**](https://github.com/sipeed/picoclaw/issues/348): Native handling of images, audio, and video attachments. * [**attachment**](https://github.com/sipeed/picoclaw/issues/348): Native handling of images, audio, and video attachments.

View file

@ -138,7 +138,6 @@ func (s *appState) countChannels() (enabled int, total int) {
c.QQ.Enabled, c.QQ.Enabled,
c.MaixCam.Enabled, c.MaixCam.Enabled,
c.WhatsApp.Enabled, c.WhatsApp.Enabled,
c.Feishu.Enabled,
c.DingTalk.Enabled, c.DingTalk.Enabled,
c.Slack.Enabled, c.Slack.Enabled,
c.Matrix.Enabled, c.Matrix.Enabled,
@ -438,7 +437,7 @@ func (s *appState) isActiveModelValid() bool {
func (s *appState) hasEnabledChannel() bool { func (s *appState) hasEnabledChannel() bool {
c := s.config.Channels c := s.config.Channels
return c.Telegram.Enabled || c.Discord.Enabled || c.QQ.Enabled || c.MaixCam.Enabled || 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 c.Matrix.Enabled || c.LINE.Enabled || c.OneBot.Enabled || c.WeCom.Enabled || c.WeComApp.Enabled
} }

View file

@ -42,12 +42,6 @@ func (s *appState) buildChannelMenuItems() []MenuItem {
s.config.Channels.WhatsApp.Enabled, s.config.Channels.WhatsApp.Enabled,
func() { s.push("channel-whatsapp", s.whatsappForm()) }, 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( channelItem(
"DingTalk", "DingTalk",
"DingTalk bot settings", "DingTalk bot settings",
@ -169,25 +163,6 @@ func (s *appState) whatsappForm() tview.Primitive {
return wrapWithBack(form, s) 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 { func (s *appState) dingtalkForm() tview.Primitive {
cfg := &s.config.Channels.DingTalk cfg := &s.config.Channels.DingTalk
form := baseChannelForm("DingTalk", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled)) form := baseChannelForm("DingTalk", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))

View file

@ -15,7 +15,6 @@ import (
"jane/pkg/channels" "jane/pkg/channels"
_ "jane/pkg/channels/dingtalk" _ "jane/pkg/channels/dingtalk"
_ "jane/pkg/channels/discord" _ "jane/pkg/channels/discord"
_ "jane/pkg/channels/feishu"
_ "jane/pkg/channels/irc" _ "jane/pkg/channels/irc"
_ "jane/pkg/channels/line" _ "jane/pkg/channels/line"
_ "jane/pkg/channels/maixcam" _ "jane/pkg/channels/maixcam"

View file

@ -96,16 +96,6 @@
"allow_from": [], "allow_from": [],
"reasoning_channel_id": "" "reasoning_channel_id": ""
}, },
"feishu": {
"enabled": false,
"app_id": "",
"app_secret": "",
"encrypt_key": "",
"verification_token": "",
"allow_from": [],
"reasoning_channel_id": "",
"random_reaction_emoji": []
},
"dingtalk": { "dingtalk": {
"enabled": false, "enabled": false,
"client_id": "YOUR_CLIENT_ID", "client_id": "YOUR_CLIENT_ID",

View file

@ -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
View file

@ -15,7 +15,6 @@ require (
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3
github.com/h2non/filetype v1.1.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/mdp/qrterminal/v3 v3.2.1
github.com/modelcontextprotocol/go-sdk v1.3.1 github.com/modelcontextprotocol/go-sdk v1.3.1
github.com/mymmrac/telego v1.6.0 github.com/mymmrac/telego v1.6.0
@ -80,7 +79,6 @@ require (
github.com/cloudwego/base64x v0.1.6 // indirect github.com/cloudwego/base64x v0.1.6 // indirect
github.com/github/copilot-sdk/go v0.1.23 github.com/github/copilot-sdk/go v0.1.23
github.com/go-resty/resty/v2 v2.17.1 // indirect 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/google/jsonschema-go v0.4.2 // indirect
github.com/grbit/go-json v0.11.0 // indirect github.com/grbit/go-json v0.11.0 // indirect
github.com/klauspost/compress v1.18.4 // indirect github.com/klauspost/compress v1.18.4 // indirect

View file

@ -846,7 +846,6 @@ func TestTargetReasoningChannelID_AllChannels(t *testing.T) {
for name, id := range map[string]string{ for name, id := range map[string]string{
"whatsapp": "rid-whatsapp", "whatsapp": "rid-whatsapp",
"telegram": "rid-telegram", "telegram": "rid-telegram",
"feishu": "rid-feishu",
"discord": "rid-discord", "discord": "rid-discord",
"maixcam": "rid-maixcam", "maixcam": "rid-maixcam",
"qq": "rid-qq", "qq": "rid-qq",
@ -866,7 +865,6 @@ func TestTargetReasoningChannelID_AllChannels(t *testing.T) {
}{ }{
{channel: "whatsapp", wantID: "rid-whatsapp"}, {channel: "whatsapp", wantID: "rid-whatsapp"},
{channel: "telegram", wantID: "rid-telegram"}, {channel: "telegram", wantID: "rid-telegram"},
{channel: "feishu", wantID: "rid-feishu"},
{channel: "discord", wantID: "rid-discord"}, {channel: "discord", wantID: "rid-discord"},
{channel: "maixcam", wantID: "rid-maixcam"}, {channel: "maixcam", wantID: "rid-maixcam"},
{channel: "qq", wantID: "rid-qq"}, {channel: "qq", wantID: "rid-qq"},

View file

@ -60,7 +60,7 @@ pkg/channels/
├── discord/ ├── discord/
│ ├── init.go │ ├── init.go
│ └── discord.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/ pkg/bus/
@ -1254,7 +1254,6 @@ make test # Full test suite
| `pkg/channels/line/` | `"line"` | TypingCapable, MediaSender, WebhookHandler | | `pkg/channels/line/` | `"line"` | TypingCapable, MediaSender, WebhookHandler |
| `pkg/channels/onebot/` | `"onebot"` | ReactionCapable, MediaSender | | `pkg/channels/onebot/` | `"onebot"` | ReactionCapable, MediaSender |
| `pkg/channels/dingtalk/` | `"dingtalk"` | — | | `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"` | WebhookHandler, HealthChecker |
| `pkg/channels/wecom/` | `"wecom_app"` | MediaSender, WebhookHandler, HealthChecker | | `pkg/channels/wecom/` | `"wecom_app"` | MediaSender, WebhookHandler, HealthChecker |
| `pkg/channels/qq/` | `"qq"` | — | | `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. 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`. 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. 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.

View file

@ -60,7 +60,7 @@ pkg/channels/
├── discord/ ├── discord/
│ ├── init.go │ ├── init.go
│ └── discord.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/ pkg/bus/
@ -1253,7 +1253,6 @@ make test # 全量测试
| `pkg/channels/line/` | `"line"` | TypingCapable, MediaSender, WebhookHandler | | `pkg/channels/line/` | `"line"` | TypingCapable, MediaSender, WebhookHandler |
| `pkg/channels/onebot/` | `"onebot"` | ReactionCapable, MediaSender | | `pkg/channels/onebot/` | `"onebot"` | ReactionCapable, MediaSender |
| `pkg/channels/dingtalk/` | `"dingtalk"` | — | | `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"` | WebhookHandler, HealthChecker |
| `pkg/channels/wecom/` | `"wecom_app"` | MediaSender, WebhookHandler, HealthChecker | | `pkg/channels/wecom/` | `"wecom_app"` | MediaSender, WebhookHandler, HealthChecker |
| `pkg/channels/qq/` | `"qq"` | — | | `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 清理仍然有效。 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` 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` 的 channelTelegram、Discord、Pico能真正使用占位消息编辑功能。其余 channel 的 `PlaceholderConfig` 为预留字段。 7. **PlaceholderConfig 的配置与实现**`PlaceholderConfig` 出现在 6 个 channel config 中Telegram、Discord、Slack、LINE、OneBot、Pico但只有实现了 `PlaceholderCapable` + `MessageEditor` 的 channelTelegram、Discord、Pico能真正使用占位消息编辑功能。其余 channel 的 `PlaceholderConfig` 为预留字段。
8. **ReasoningChannelID**:大多数 channel config 都包含 `reasoning_channel_id` 字段,用于将 LLM 的思维链reasoning/thinking路由到指定 channelWhatsApp、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路由到指定 channelWhatsApp、Telegram、Discord、MaixCam、QQ、DingTalk、Slack、LINE、OneBot、WeCom、WeComApp。注意`PicoConfig` 目前不包含该字段。`BaseChannel` 通过 `WithReasoningChannelID` 选项和 `ReasoningChannelID()` 方法暴露此配置。

View file

@ -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)
}

View file

@ -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)
}
})
}
}

View file

@ -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
}

View file

@ -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 ""
}

View file

@ -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)
}
})
}
}

View file

@ -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)
})
}

View file

@ -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 != "" { if m.config.Channels.Discord.Enabled && m.config.Channels.Discord.Token != "" {
m.initChannel("discord", "Discord") m.initChannel("discord", "Discord")
} }

View file

@ -257,7 +257,6 @@ func (d *AgentDefaults) GetModelName() string {
type ChannelsConfig struct { type ChannelsConfig struct {
WhatsApp WhatsAppConfig `json:"whatsapp"` WhatsApp WhatsAppConfig `json:"whatsapp"`
Telegram TelegramConfig `json:"telegram"` Telegram TelegramConfig `json:"telegram"`
Feishu FeishuConfig `json:"feishu"`
Discord DiscordConfig `json:"discord"` Discord DiscordConfig `json:"discord"`
MaixCam MaixCamConfig `json:"maixcam"` MaixCam MaixCamConfig `json:"maixcam"`
QQ QQConfig `json:"qq"` QQ QQConfig `json:"qq"`
@ -311,19 +310,6 @@ type TelegramConfig struct {
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_TELEGRAM_REASONING_CHANNEL_ID"` 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 { type DiscordConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"` Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"`
Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"` Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"`

View file

@ -59,14 +59,6 @@ func DefaultConfig() *Config {
Text: "Thinking... 💭", Text: "Thinking... 💭",
}, },
}, },
Feishu: FeishuConfig{
Enabled: false,
AppID: "",
AppSecret: "",
EncryptKey: "",
VerificationToken: "",
AllowFrom: FlexibleStringSlice{},
},
Discord: DiscordConfig{ Discord: DiscordConfig{
Enabled: false, Enabled: false,
Token: "", Token: "",

View file

@ -15,7 +15,6 @@ var migrateableDirs = []string{
var supportedChannels = map[string]bool{ var supportedChannels = map[string]bool{
"whatsapp": true, "whatsapp": true,
"telegram": true, "telegram": true,
"feishu": true,
"discord": true, "discord": true,
"maixcam": true, "maixcam": true,
"qq": true, "qq": true,

View file

@ -123,7 +123,6 @@ type OpenClawChannels struct {
Teams *OpenClawTeamsConfig `json:"msteams"` Teams *OpenClawTeamsConfig `json:"msteams"`
IRC *OpenClawIrcConfig `json:"irc"` IRC *OpenClawIrcConfig `json:"irc"`
Mattermost *OpenClawMattermostConfig `json:"mattermost"` Mattermost *OpenClawMattermostConfig `json:"mattermost"`
Feishu *OpenClawFeishuConfig `json:"feishu"`
IMessage *OpenClawIMessageConfig `json:"imessage"` IMessage *OpenClawIMessageConfig `json:"imessage"`
BlueBubbles *OpenClawBlueBubblesConfig `json:"bluebubbles"` BlueBubbles *OpenClawBlueBubblesConfig `json:"bluebubbles"`
QQ *OpenClawQQConfig `json:"qq"` QQ *OpenClawQQConfig `json:"qq"`
@ -223,17 +222,6 @@ type OpenClawMattermostConfig struct {
Enabled *bool `json:"enabled"` 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 { type OpenClawIMessageConfig struct {
CliPath *string `json:"cliPath"` CliPath *string `json:"cliPath"`
DbPath *string `json:"dbPath"` 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 return c.Channels.Matrix == nil || c.Channels.Matrix.Enabled == nil || *c.Channels.Matrix.Enabled
case "whatsapp": case "whatsapp":
return c.Channels.WhatsApp == nil || c.Channels.WhatsApp.Enabled == nil || *c.Channels.WhatsApp.Enabled 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: default:
return false return false
} }
@ -409,11 +395,6 @@ func GetChannelAllowFrom(ch any) []string {
return nil return nil
} }
return c.AllowFrom return c.AllowFrom
case *OpenClawFeishuConfig:
if c == nil {
return nil
}
return c.AllowFrom
default: default:
return nil return nil
} }
@ -628,7 +609,6 @@ type PeerMatch struct {
type ChannelsConfig struct { type ChannelsConfig struct {
WhatsApp WhatsAppConfig `json:"whatsapp"` WhatsApp WhatsAppConfig `json:"whatsapp"`
Telegram TelegramConfig `json:"telegram"` Telegram TelegramConfig `json:"telegram"`
Feishu FeishuConfig `json:"feishu"`
Discord DiscordConfig `json:"discord"` Discord DiscordConfig `json:"discord"`
MaixCam MaixCamConfig `json:"maixcam"` MaixCam MaixCamConfig `json:"maixcam"`
QQ QQConfig `json:"qq"` QQ QQConfig `json:"qq"`
@ -651,15 +631,6 @@ type TelegramConfig struct {
AllowFrom []string `json:"allow_from"` 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 { type DiscordConfig struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Token string `json:"token"` 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"] { if c.Channels.QQ != nil && supportedChannels["qq"] {
channels.QQ = QQConfig{ channels.QQ = QQConfig{
Enabled: true, Enabled: true,
@ -1021,13 +972,6 @@ func (c ChannelsConfig) ToStandardChannels() config.ChannelsConfig {
Token: c.Telegram.Token, Token: c.Telegram.Token,
Proxy: c.Telegram.Proxy, 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{ Discord: config.DiscordConfig{
Enabled: c.Discord.Enabled, Enabled: c.Discord.Enabled,
Token: c.Discord.Token, Token: c.Discord.Token,

View file

@ -198,12 +198,6 @@ func TestConvertToPicoClaw(t *testing.T) {
"enabled": true, "enabled": true,
"bridgeUrl": "http://localhost:3000" "bridgeUrl": "http://localhost:3000"
}, },
"feishu": {
"enabled": true,
"appId": "app-id",
"appSecret": "app-secret",
"allowFrom": ["user3"]
},
"signal": { "signal": {
"enabled": true "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) 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 { if len(picoCfg.ModelList) != 1 {
t.Errorf("expected 1 model config (no models.json provided), got %d", len(picoCfg.ModelList)) t.Errorf("expected 1 model config (no models.json provided), got %d", len(picoCfg.ModelList))
} }

View file

@ -14,7 +14,7 @@ import (
func TestSendFileTool_MissingPath(t *testing.T) { func TestSendFileTool_MissingPath(t *testing.T) {
store := media.NewFileMediaStore() store := media.NewFileMediaStore()
tool := NewSendFileTool("/tmp", false, 0, store) tool := NewSendFileTool("/tmp", false, 0, store)
tool.SetContext("feishu", "chat123") tool.SetContext("telegram", "chat123")
result := tool.Execute(context.Background(), map[string]any{}) result := tool.Execute(context.Background(), map[string]any{})
if !result.IsError { if !result.IsError {
@ -35,7 +35,7 @@ func TestSendFileTool_NoContext(t *testing.T) {
func TestSendFileTool_NoMediaStore(t *testing.T) { func TestSendFileTool_NoMediaStore(t *testing.T) {
tool := NewSendFileTool("/tmp", false, 0, nil) 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"}) result := tool.Execute(context.Background(), map[string]any{"path": "/tmp/test.txt"})
if !result.IsError { if !result.IsError {
@ -46,7 +46,7 @@ func TestSendFileTool_NoMediaStore(t *testing.T) {
func TestSendFileTool_Directory(t *testing.T) { func TestSendFileTool_Directory(t *testing.T) {
store := media.NewFileMediaStore() store := media.NewFileMediaStore()
tool := NewSendFileTool("/tmp", false, 0, store) tool := NewSendFileTool("/tmp", false, 0, store)
tool.SetContext("feishu", "chat123") tool.SetContext("telegram", "chat123")
result := tool.Execute(context.Background(), map[string]any{"path": "/tmp"}) result := tool.Execute(context.Background(), map[string]any{"path": "/tmp"})
if !result.IsError { if !result.IsError {
@ -64,7 +64,7 @@ func TestSendFileTool_FileTooLarge(t *testing.T) {
store := media.NewFileMediaStore() store := media.NewFileMediaStore()
tool := NewSendFileTool(dir, false, 512, store) // 512 byte limit 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}) result := tool.Execute(context.Background(), map[string]any{"path": testFile})
if !result.IsError { if !result.IsError {
@ -91,7 +91,7 @@ func TestSendFileTool_Success(t *testing.T) {
store := media.NewFileMediaStore() store := media.NewFileMediaStore()
tool := NewSendFileTool(dir, false, 0, store) tool := NewSendFileTool(dir, false, 0, store)
tool.SetContext("feishu", "chat123") tool.SetContext("telegram", "chat123")
result := tool.Execute(context.Background(), map[string]any{"path": testFile}) result := tool.Execute(context.Background(), map[string]any{"path": testFile})
if result.IsError { if result.IsError {

View file

@ -15,7 +15,6 @@ var channelCatalog = []channelCatalogItem{
{Name: "telegram", ConfigKey: "telegram"}, {Name: "telegram", ConfigKey: "telegram"},
{Name: "discord", ConfigKey: "discord"}, {Name: "discord", ConfigKey: "discord"},
{Name: "slack", ConfigKey: "slack"}, {Name: "slack", ConfigKey: "slack"},
{Name: "feishu", ConfigKey: "feishu"},
{Name: "dingtalk", ConfigKey: "dingtalk"}, {Name: "dingtalk", ConfigKey: "dingtalk"},
{Name: "line", ConfigKey: "line"}, {Name: "line", ConfigKey: "line"},
{Name: "qq", ConfigKey: "qq"}, {Name: "qq", ConfigKey: "qq"},

View file

@ -1 +0,0 @@
# Keep the embedded web backend dist directory in version control.

View file

@ -13,7 +13,6 @@ import {
} from "@/api/channels" } from "@/api/channels"
import { getChannelDisplayName } from "@/components/channels/channel-display-name" import { getChannelDisplayName } from "@/components/channels/channel-display-name"
import { DiscordForm } from "@/components/channels/channel-forms/discord-form" 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 { GenericForm } from "@/components/channels/channel-forms/generic-form"
import { SlackForm } from "@/components/channels/channel-forms/slack-form" import { SlackForm } from "@/components/channels/channel-forms/slack-form"
import { TelegramForm } from "@/components/channels/channel-forms/telegram-form" import { TelegramForm } from "@/components/channels/channel-forms/telegram-form"
@ -125,10 +124,6 @@ function isConfigured(
return asString(config.token) !== "" return asString(config.token) !== ""
case "slack": case "slack":
return asString(config.bot_token) !== "" return asString(config.bot_token) !== ""
case "feishu":
return (
asString(config.app_id) !== "" && asString(config.app_secret) !== ""
)
case "dingtalk": case "dingtalk":
return ( return (
asString(config.client_id) !== "" && asString(config.client_id) !== "" &&
@ -179,8 +174,6 @@ function getRequiredFieldKeys(channelName: string): string[] {
return ["token"] return ["token"]
case "slack": case "slack":
return ["bot_token"] return ["bot_token"]
case "feishu":
return ["app_id", "app_secret"]
case "dingtalk": case "dingtalk":
return ["client_id", "client_secret"] return ["client_id", "client_secret"]
case "line": case "line":
@ -437,15 +430,6 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
fieldErrors={fieldErrors} fieldErrors={fieldErrors}
/> />
) )
case "feishu":
return (
<FeishuForm
config={editConfig}
onChange={handleChange}
isEdit={isEdit}
fieldErrors={fieldErrors}
/>
)
default: default:
return ( return (
<GenericForm <GenericForm

View file

@ -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>
)
}

View file

@ -30,7 +30,6 @@ import { gatewayAtom } from "@/store/gateway"
const DEFAULT_VISIBLE_CHANNELS = 4 const DEFAULT_VISIBLE_CHANNELS = 4
const CHANNEL_IMPORTANCE_ORDER = [ const CHANNEL_IMPORTANCE_ORDER = [
"discord", "discord",
"feishu",
"telegram", "telegram",
"slack", "slack",
"line", "line",
@ -51,19 +50,6 @@ const CHANNEL_IMPORTANCE_INDEX = new Map(
CHANNEL_IMPORTANCE_ORDER.map((name, index) => [name, index]), 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< const CHANNEL_ICON_MAP: Record<
string, string,
React.ComponentType<{ className?: string }> React.ComponentType<{ className?: string }>
@ -71,7 +57,6 @@ const CHANNEL_ICON_MAP: Record<
telegram: IconBrandTelegram, telegram: IconBrandTelegram,
discord: IconBrandDiscord, discord: IconBrandDiscord,
slack: IconBrandSlack, slack: IconBrandSlack,
feishu: IconLark,
dingtalk: IconBrandDingtalk, dingtalk: IconBrandDingtalk,
line: IconBrandLine, line: IconBrandLine,
qq: IconBrandQq, qq: IconBrandQq,

View file

@ -223,7 +223,6 @@
"telegram": "Telegram", "telegram": "Telegram",
"discord": "Discord", "discord": "Discord",
"slack": "Slack", "slack": "Slack",
"feishu": "Feishu",
"dingtalk": "DingTalk", "dingtalk": "DingTalk",
"line": "LINE", "line": "LINE",
"qq": "QQ", "qq": "QQ",

View file

@ -223,7 +223,6 @@
"telegram": "Telegram", "telegram": "Telegram",
"discord": "Discord", "discord": "Discord",
"slack": "Slack", "slack": "Slack",
"feishu": "飞书",
"dingtalk": "钉钉", "dingtalk": "钉钉",
"line": "LINE", "line": "LINE",
"qq": "QQ", "qq": "QQ",

View file

@ -17,7 +17,7 @@ Ultra-lightweight personal AI assistant written in Go, inspired by nanobot.
- Web search and content fetching - Web search and content fetching
- File system operations (read, write, edit) - File system operations (read, write, edit)
- Shell command execution - Shell command execution
- Multi-channel messaging (Telegram, WhatsApp, Feishu) - Multi-channel messaging (Telegram, WhatsApp, Discord)
- Skill-based extensibility - Skill-based extensibility
- Memory and context management - Memory and context management