diff --git a/.env.example b/.env.example index e0a07236e..e899d2adc 100644 --- a/.env.example +++ b/.env.example @@ -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... diff --git a/README.fr.md b/README.fr.md index 82e587f75..f25dc0157 100644 --- a/README.fr.md +++ b/README.fr.md @@ -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": "", diff --git a/README.ja.md b/README.ja.md index ad4a86505..a0e9291eb 100644 --- a/README.ja.md +++ b/README.ja.md @@ -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": { diff --git a/README.md b/README.md index b816b7f52..b2fb79ad3 100644 --- a/README.md +++ b/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": "", diff --git a/README.pt-br.md b/README.pt-br.md index 58c69f4d9..f638e4aee 100644 --- a/README.pt-br.md +++ b/README.pt-br.md @@ -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": "", diff --git a/README.vi.md b/README.vi.md index ac8efb900..2b255439a 100644 --- a/README.vi.md +++ b/README.vi.md @@ -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": "", diff --git a/README.zh.md b/README.zh.md index f720cf202..c347c04a2 100644 --- a/README.zh.md +++ b/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": "", diff --git a/ROADMAP.md b/ROADMAP.md index 8c5c0e252..842f91ec8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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. diff --git a/cmd/picoclaw-launcher-tui/internal/ui/app.go b/cmd/picoclaw-launcher-tui/internal/ui/app.go index a59d00303..b015fc480 100644 --- a/cmd/picoclaw-launcher-tui/internal/ui/app.go +++ b/cmd/picoclaw-launcher-tui/internal/ui/app.go @@ -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 } diff --git a/cmd/picoclaw-launcher-tui/internal/ui/channel.go b/cmd/picoclaw-launcher-tui/internal/ui/channel.go index e2afc5c94..a8f116eb5 100644 --- a/cmd/picoclaw-launcher-tui/internal/ui/channel.go +++ b/cmd/picoclaw-launcher-tui/internal/ui/channel.go @@ -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)) diff --git a/cmd/picoclaw/internal/gateway/helpers.go b/cmd/picoclaw/internal/gateway/helpers.go index ff09e6f05..168306181 100644 --- a/cmd/picoclaw/internal/gateway/helpers.go +++ b/cmd/picoclaw/internal/gateway/helpers.go @@ -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" diff --git a/config/config.example.json b/config/config.example.json index b259df6f6..aef8dc063 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -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", diff --git a/docs/channels/feishu/README.zh.md b/docs/channels/feishu/README.zh.md deleted file mode 100644 index 3fafffb7d..000000000 --- a/docs/channels/feishu/README.zh.md +++ /dev/null @@ -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)) diff --git a/go.mod b/go.mod index 2d9efa48b..d056cc817 100644 --- a/go.mod +++ b/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 diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index d6b6197d5..1a8a13bf7 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -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"}, diff --git a/pkg/channels/README.md b/pkg/channels/README.md index b7c56660b..95602b5fb 100644 --- a/pkg/channels/README.md +++ b/pkg/channels/README.md @@ -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. \ No newline at end of file +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. \ No newline at end of file diff --git a/pkg/channels/README.zh.md b/pkg/channels/README.zh.md index 2c5e7356e..08330cc6a 100644 --- a/pkg/channels/README.zh.md +++ b/pkg/channels/README.zh.md @@ -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()` 方法暴露此配置。 \ No newline at end of file +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()` 方法暴露此配置。 \ No newline at end of file diff --git a/pkg/channels/feishu/common.go b/pkg/channels/feishu/common.go deleted file mode 100644 index fbe085b73..000000000 --- a/pkg/channels/feishu/common.go +++ /dev/null @@ -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) -} diff --git a/pkg/channels/feishu/common_test.go b/pkg/channels/feishu/common_test.go deleted file mode 100644 index fefc9f7c1..000000000 --- a/pkg/channels/feishu/common_test.go +++ /dev/null @@ -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" & '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) - } - }) - } -} diff --git a/pkg/channels/feishu/feishu_32.go b/pkg/channels/feishu/feishu_32.go deleted file mode 100644 index 35f170e57..000000000 --- a/pkg/channels/feishu/feishu_32.go +++ /dev/null @@ -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 -} diff --git a/pkg/channels/feishu/feishu_64.go b/pkg/channels/feishu/feishu_64.go deleted file mode 100644 index 9a8a637db..000000000 --- a/pkg/channels/feishu/feishu_64.go +++ /dev/null @@ -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 "" -} diff --git a/pkg/channels/feishu/feishu_64_test.go b/pkg/channels/feishu/feishu_64_test.go deleted file mode 100644 index dc3eab2e7..000000000 --- a/pkg/channels/feishu/feishu_64_test.go +++ /dev/null @@ -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) - } - }) - } -} diff --git a/pkg/channels/feishu/init.go b/pkg/channels/feishu/init.go deleted file mode 100644 index 2bbdd5b95..000000000 --- a/pkg/channels/feishu/init.go +++ /dev/null @@ -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) - }) -} diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index c163cbce3..0f3c23ae4 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -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") } diff --git a/pkg/config/config.go b/pkg/config/config.go index 9e3552cda..0bafb23eb 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -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"` diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index a4b734769..855d13073 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -66,14 +66,6 @@ func DefaultConfig() *Config { Text: "Thinking... 💭", }, }, - Feishu: FeishuConfig{ - Enabled: false, - AppID: "", - AppSecret: "", - EncryptKey: "", - VerificationToken: "", - AllowFrom: FlexibleStringSlice{}, - }, Discord: DiscordConfig{ Enabled: false, Token: "", diff --git a/pkg/migrate/sources/openclaw/common.go b/pkg/migrate/sources/openclaw/common.go index 337c950d0..8fbd0e533 100644 --- a/pkg/migrate/sources/openclaw/common.go +++ b/pkg/migrate/sources/openclaw/common.go @@ -15,7 +15,6 @@ var migrateableDirs = []string{ var supportedChannels = map[string]bool{ "whatsapp": true, "telegram": true, - "feishu": true, "discord": true, "maixcam": true, "qq": true, diff --git a/pkg/migrate/sources/openclaw/openclaw_config.go b/pkg/migrate/sources/openclaw/openclaw_config.go index 3a5032eda..1fe985813 100644 --- a/pkg/migrate/sources/openclaw/openclaw_config.go +++ b/pkg/migrate/sources/openclaw/openclaw_config.go @@ -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, diff --git a/pkg/migrate/sources/openclaw/openclaw_config_test.go b/pkg/migrate/sources/openclaw/openclaw_config_test.go index 802693825..8a64f0430 100644 --- a/pkg/migrate/sources/openclaw/openclaw_config_test.go +++ b/pkg/migrate/sources/openclaw/openclaw_config_test.go @@ -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)) } diff --git a/pkg/tools/send_file_test.go b/pkg/tools/send_file_test.go index 9a2152a5d..f33ea5fe6 100644 --- a/pkg/tools/send_file_test.go +++ b/pkg/tools/send_file_test.go @@ -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 { diff --git a/web/backend/api/channels.go b/web/backend/api/channels.go index 507882823..84b0c8662 100644 --- a/web/backend/api/channels.go +++ b/web/backend/api/channels.go @@ -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"}, diff --git a/web/backend/dist/.gitkeep b/web/backend/dist/.gitkeep deleted file mode 100644 index 4b533f03a..000000000 --- a/web/backend/dist/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -# Keep the embedded web backend dist directory in version control. diff --git a/web/frontend/src/components/channels/channel-config-page.tsx b/web/frontend/src/components/channels/channel-config-page.tsx index b19d11e6a..d7925c340 100644 --- a/web/frontend/src/components/channels/channel-config-page.tsx +++ b/web/frontend/src/components/channels/channel-config-page.tsx @@ -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 ( - - ) default: return ( void - isEdit: boolean - fieldErrors?: Record -} - -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 ( -
- - onChange("app_id", e.target.value)} - placeholder="cli_xxxx" - /> - - - - onChange("_app_secret", v)} - placeholder={maskedSecretPlaceholder( - config.app_secret, - t("channels.field.secretPlaceholder"), - )} - /> - - - - onChange("_verification_token", v)} - placeholder={maskedSecretPlaceholder( - config.verification_token, - t("channels.field.secretPlaceholder"), - )} - /> - - - onChange("_encrypt_key", v)} - placeholder={maskedSecretPlaceholder( - config.encrypt_key, - t("channels.field.secretPlaceholder"), - )} - /> - - - - onChange( - "allow_from", - e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - ) - } - placeholder={t("channels.field.allowFromPlaceholder")} - /> - -
- ) -} diff --git a/web/frontend/src/hooks/use-sidebar-channels.ts b/web/frontend/src/hooks/use-sidebar-channels.ts index 5579a955b..2d85653d2 100644 --- a/web/frontend/src/hooks/use-sidebar-channels.ts +++ b/web/frontend/src/hooks/use-sidebar-channels.ts @@ -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, diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index 453c5905f..7266358d0 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -223,7 +223,6 @@ "telegram": "Telegram", "discord": "Discord", "slack": "Slack", - "feishu": "Feishu", "dingtalk": "DingTalk", "line": "LINE", "qq": "QQ", diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json index b6bdedbfa..1a0c46f9a 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -223,7 +223,6 @@ "telegram": "Telegram", "discord": "Discord", "slack": "Slack", - "feishu": "飞书", "dingtalk": "钉钉", "line": "LINE", "qq": "QQ", diff --git a/workspace/IDENTITY.md b/workspace/IDENTITY.md index 20e3e49fa..11b10e7ea 100644 --- a/workspace/IDENTITY.md +++ b/workspace/IDENTITY.md @@ -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