From c797d600d2075af3de61bfe9ba7d515ca967c133 Mon Sep 17 00:00:00 2001 From: ywj <138745068+yangwenjie1231@users.noreply.github.com> Date: Fri, 13 Mar 2026 14:45:40 +0800 Subject: [PATCH] =?UTF-8?q?fix(providers):=20=E5=A2=9E=E5=BC=BA=20OpenAI?= =?UTF-8?q?=20=E5=85=BC=E5=AE=B9=E6=8E=A5=E5=8F=A3=E6=8A=A5=E9=94=99?= =?UTF-8?q?=E4=BF=A1=E6=81=AF=EF=BC=8C=E5=AE=8C=E5=96=84=20SiliconFlow=20?= =?UTF-8?q?=E5=85=BC=E5=AE=B9=E6=80=A7=E5=B9=B6=E4=BF=AE=E5=A4=8D=20Window?= =?UTF-8?q?s=20=E4=B8=8B=20Codex=20CLI=20=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 优化 openai_compat 的 HTTP 错误处理,支持解析结构化 JSON 错误体 - 在报错中补充 api_base、model、path、message、code 等上下文信息 - 增加 SiliconFlow 作为一等 OpenAI-compatible provider 别名 - 支持在设置 api_base 时直接使用上游原始模型名(如 Pro/zai-org/GLM-4.7) - 更新 README、README.zh.md 和 config.example.json 中的 SiliconFlow / api_base 配置示例 - 修复 Windows 下 Codex CLI mock 测试无法执行临时脚本的问题 update:主要完善了 PicoClaw 对飞书的支持,重点包括以下内容: 基于飞书官方 Go SDK 完善飞书通道能力 补全飞书消息收发相关实现 增强对飞书富文本/卡片消息的兼容处理 对齐 Python SDK 示例中的已有功能范围 增强飞书媒体消息处理能力 支持从飞书消息中递归提取所有图片、文件等附件信息 支持多图片、多附件消息的解析与处理 优化附件下载与媒体引用传递流程 接入现有图片/视觉处理链路 将飞书图片消息接入 PicoClaw 现有多模态处理流程 使图片能够随用户文本一起进入模型请求 为后续视觉模型识图能力打通基础链路 修复 Feishu/Windows amd64 相关构建与运行问题 清理 Feishu 包内重复定义与残留实现 修复 64 位 Windows 构建过程中的编译问题 修复因错误架构判断导致的飞书通道不可用问题 改进 OpenAI-compatible Provider 对 GLM/Zhipu 多模态请求的兼容性 针对 glm / zhipu 兼容端点增加图片请求格式适配 调整多模态消息序列化逻辑 对齐 glm-4.6v-flash 官方样例的请求结构 补充测试并完成回归验证 增加飞书消息解析、多附件提取、多模态序列化等测试 已通过 pkg/providers/openai_compat、pkg/channels/feishu、pkg/tools 相关测试 --- README.md | 20 +- README.zh.md | 7 +- config/config.example.json | 11 + docs/channels/feishu/README.zh.md | 88 ++- go.mod | 4 +- go.sum | 4 + pkg/agent/instance.go | 2 + pkg/agent/loop.go | 16 + pkg/agent/loop_test.go | 56 ++ pkg/channels/feishu/api_helpers.go | 415 +++++++++++ pkg/channels/feishu/api_helpers_test.go | 170 +++++ pkg/channels/feishu/api_methods.go | 691 +++++++++++++++++++ pkg/channels/feishu/api_types.go | 137 ++++ pkg/channels/feishu/common.go | 63 +- pkg/channels/feishu/drive_helpers.go | 110 +++ pkg/channels/feishu/drive_helpers_test.go | 87 +++ pkg/channels/feishu/feishu_64.go | 160 +++-- pkg/channels/feishu/feishu_64_test.go | 56 ++ pkg/channels/feishu/integration_test.go | 43 ++ pkg/channels/feishu/runtime_64_shared.go | 3 + pkg/channels/manager.go | 34 +- pkg/providers/codex_cli_provider_test.go | 83 ++- pkg/providers/factory_provider.go | 13 +- pkg/providers/factory_provider_test.go | 60 +- pkg/providers/openai_compat/provider.go | 203 +++++- pkg/providers/openai_compat/provider_test.go | 145 +++- pkg/tools/feishu.go | 643 +++++++++++++++++ pkg/tools/feishu_adapter.go | 315 +++++++++ pkg/tools/feishu_registry.go | 23 + pkg/tools/feishu_registry_test.go | 13 + pkg/tools/feishu_test.go | 385 +++++++++++ 31 files changed, 3932 insertions(+), 128 deletions(-) create mode 100644 pkg/channels/feishu/api_helpers.go create mode 100644 pkg/channels/feishu/api_helpers_test.go create mode 100644 pkg/channels/feishu/api_methods.go create mode 100644 pkg/channels/feishu/api_types.go create mode 100644 pkg/channels/feishu/drive_helpers.go create mode 100644 pkg/channels/feishu/drive_helpers_test.go create mode 100644 pkg/channels/feishu/integration_test.go create mode 100644 pkg/channels/feishu/runtime_64_shared.go create mode 100644 pkg/tools/feishu.go create mode 100644 pkg/tools/feishu_adapter.go create mode 100644 pkg/tools/feishu_registry.go create mode 100644 pkg/tools/feishu_registry_test.go create mode 100644 pkg/tools/feishu_test.go diff --git a/README.md b/README.md index bae3fa681..143582439 100644 --- a/README.md +++ b/README.md @@ -1005,6 +1005,8 @@ The subagent has access to tools (message, web_search, etc.) and can communicate ### Model Configuration (model_list) > **What's New?** PicoClaw now uses a **model-centric** configuration approach. Simply specify `vendor/model` format (e.g., `zhipu/glm-4.7`) to add new providers—**zero code changes required!** +> +> **OpenAI-compatible fallback:** if you already know the upstream `api_base`, you can also use the raw upstream model identifier directly (for example `Pro/zai-org/GLM-4.7`). When `api_base` is set, unknown prefixes are treated as a generic OpenAI-compatible HTTP backend instead of failing protocol detection. This design also enables **multi-agent support** with flexible provider selection: @@ -1036,7 +1038,7 @@ This design also enables **multi-agent support** with flexible provider selectio | **Vivgrid** | `vivgrid/` | `https://api.vivgrid.com/v1` | OpenAI | [Get Key](https://vivgrid.com) | | **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth only | | **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - | - +| **SiliconFlow** | `siliconflow/` | `https://api.siliconflow.cn/v1` | OpenAI | [Get Key](https://cloud.siliconflow.cn) | #### Basic Configuration ```json @@ -1131,7 +1133,18 @@ This design also enables **multi-agent support** with flexible provider selectio } ``` -**LiteLLM Proxy** +If your upstream uses model IDs that already contain slashes, you can keep the full model string and rely on `api_base` for routing: + +```json +{ + "model_name": "siliconflow-glm47", + "model": "Pro/zai-org/GLM-4.7", + "api_base": "https://api.siliconflow.cn/v1", + "api_key": "sk-..." +} +``` + +You can also use the explicit built-in alias: ```json { @@ -1345,7 +1358,7 @@ picoclaw agent -m "Hello" } } ``` - +If a third-party backend is OpenAI-compatible but not listed above, set `api_base` and keep the provider-specific model string as-is. PicoClaw will route it through the generic HTTP-compatible path. ## CLI Reference @@ -1504,3 +1517,4 @@ This happens when another instance of the bot is running. Make sure only one `pi | **SearXNG** | Unlimited (self-hosted) | Privacy-focused metasearch (70+ engines) | | **Groq** | Free tier available | Fast inference (Llama, Mixtral) | | **Cerebras** | Free tier available | Fast inference (Llama, Qwen, etc.) | + diff --git a/README.zh.md b/README.zh.md index c744e0d20..7e38f56d2 100644 --- a/README.zh.md +++ b/README.zh.md @@ -490,6 +490,8 @@ Agent 读取 HEARTBEAT.md ### 模型配置 (model_list) > **新功能!** PicoClaw 现在采用**以模型为中心**的配置方式。只需使用 `厂商/模型` 格式(如 `zhipu/glm-4.7`)即可添加新的 provider——**无需修改任何代码!** +> +> **OpenAI 兼容回退:** 如果你已经知道上游的 `api_base`,也可以直接填写上游原始模型名(例如 `Pro/zai-org/GLM-4.7`)。当设置了 `api_base` 时,未知前缀会自动按通用 OpenAI-compatible HTTP 后端处理,而不是因为协议识别失败而报错。 该设计同时支持**多 Agent 场景**,提供灵活的 Provider 选择: @@ -513,6 +515,7 @@ Agent 读取 HEARTBEAT.md | **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [获取密钥](https://build.nvidia.com) | | **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | 本地(无需密钥) | | **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [获取密钥](https://openrouter.ai/keys) | +| **SiliconFlow** | `siliconflow/` | `https://api.siliconflow.cn/v1` | OpenAI | [获取密钥](https://cloud.siliconflow.cn) | | **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | 本地 | | **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [获取密钥](https://cerebras.ai) | | **火山引擎** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [获取密钥](https://console.volcengine.com) | @@ -636,7 +639,7 @@ Agent 读取 HEARTBEAT.md ] } ``` - +如果上游模型 ID 本身就带有 `/`,可以直接保留完整模型字符串,并通过 `api_base` 指定路由: #### 从旧的 `providers` 配置迁移 旧的 `providers` 配置格式**已弃用**,但为向后兼容仍支持。 @@ -661,7 +664,7 @@ Agent 读取 HEARTBEAT.md ``` **新配置(推荐):** - +如果你更喜欢显式协议前缀,也可以使用内置别名: ```json { "model_list": [ diff --git a/config/config.example.json b/config/config.example.json index 49658b9f2..bdcec4d77 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -35,6 +35,17 @@ "model": "deepseek/deepseek-chat", "api_key": "sk-your-deepseek-key" }, + { + "model_name": "siliconflow-glm47", + "model": "siliconflow/Pro/zai-org/GLM-4.7", + "api_key": "sk-your-siliconflow-key" + }, + { + "model_name": "siliconflow-raw-model", + "model": "Pro/zai-org/GLM-4.7", + "api_base": "https://api.siliconflow.cn/v1", + "api_key": "sk-your-siliconflow-key" + }, { "model_name": "loadbalanced-gpt4", "model": "openai/gpt-5.2", diff --git a/docs/channels/feishu/README.zh.md b/docs/channels/feishu/README.zh.md index 3fafffb7d..61b0c7921 100644 --- a/docs/channels/feishu/README.zh.md +++ b/docs/channels/feishu/README.zh.md @@ -36,4 +36,90 @@ 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)) +6. 自定义你希望 PicoClaw react 你消息时的表情(可选, Reference URL: [Feishu Emoji List](https://open.larkoffice.com/document/server-docs/im-v1/message-reaction/emojis-introduce)) + +## 已支持能力 + +当前 `picoclaw` 的飞书通道已基于官方 Go SDK 接入并支持: + +- WebSocket 收消息与事件订阅 +- 文本 / Markdown 卡片发送 +- 占位消息发送与卡片编辑 +- 消息表情反应 +- 图片、文件、音频、视频上传发送 +- 入站消息图片/文件资源下载 +- 消息详情查询、消息列表查询、消息回复 +- 卡片消息解析(标题、文本、图片键、按钮) +- 用户查询、用户列表、按邮箱/手机号查用户 +- 群聊信息查询、群成员列表、群列表、建群、群发消息 +- 飞书消息分享链接 token 解析与消息查找辅助 + +这些能力覆盖了仓库中 `feishu-skill`、`feishu-file` Python 示例里的主要已实现功能;大文件分片上传仍未实现。 + +## 推荐权限 + +为了启用上面的能力,建议在飞书开放平台为应用配置至少以下权限(实际命名以飞书后台为准): + +- `im:message` +- `im:message:readonly` +- `im:message.resource` +- `im:chat` +- `im:chat:readonly` +- `contact:user.base:readonly` +- `contact:user.id:readonly` +- Drive / 云文档相关文件读写权限(用于文件上传、下载、查询、删除) + +如果只需要基础聊天能力,可先只开启消息和群聊只读/发送权限。 + +## 开发说明 + +Go 侧飞书增强接口位于 `pkg/channels/feishu/`,可直接复用下列能力: + +- `GetMessage` / `GetMessageByID` +- `ListMessages` +- `ReplyMessage` +- `GetUserInfo` / `ListUsers` +- `GetUserIDByEmail` / `GetUserIDByMobile` +- `GetGroupInfo` / `ListGroupMembers` / `ListGroups` / `CreateGroup` +- 入站图片 / 文件 / 音频资源自动下载与存储 +- `GetMessageFromShareLink` +- `GetDriveRootFolder` / `GetDriveFolder` / `GetDriveFile` / `ListDriveFiles` +- `UploadDriveFile` / `DownloadDriveFile` / `DeleteDriveFile` +- `InitiateMultipartUpload` / `UploadMultipartChunk` / `CompleteMultipartUpload` + +这些方法返回的是 PicoClaw 内部统一结构,而不是直接暴露 SDK 原始类型,便于后续被工具层或技能层复用。 + +## 工具层说明 + +当前工具层新增了 `feishu_parse`,用于在本地直接解析: + +- 飞书消息内容 JSON +- 飞书卡片 JSON +- 飞书分享链接中的 token + +这个工具不依赖远程飞书 API,适合在 agent 推理过程中快速理解飞书 payload。 + +`feishu_parse` 已经接入默认工具初始化流程;只要在工具配置中启用 `feishu_parse`,agent 就能直接调用。 + +同时,工具层已经准备好了 `feishu_remote` 的远程查询/操作接口模型。它支持通过注入的 Feishu 客户端统一暴露: + +- 消息:`get_message`、`list_messages`、`reply_message`、`get_message_from_share_link` +- 用户:`get_user`、`list_users`、`get_user_id_by_email`、`get_user_id_by_mobile` +- 群组:`create_group`、`get_group`、`list_group_members`、`list_groups`、`send_group_message` +- Drive:`get_drive_root_folder`、`get_drive_folder`、`get_drive_file`、`list_drive_files`、`download_drive_file`、`delete_drive_file`、`upload_drive_file` +- 大文件上传:`initiate_multipart_upload`、`upload_multipart_chunk`、`complete_multipart_upload` + +当前实现中,当运行时存在已初始化的飞书 channel 时,`AgentLoop.SetChannelManager(...)` 会自动把该 channel 适配为远程客户端,并注册 `feishu_remote` 工具。 + +> 说明:工具层并不直接依赖 `pkg/channels/feishu`。当前通过适配器接口完成注入,这样可以保持 channel 层与 tools 层解耦,同时支持未来替换成别的 Feishu client 实现。 + +## 集成测试 + +仓库中提供了飞书集成测试骨架:`pkg/channels/feishu/integration_test.go` + +运行前请设置: + +- `FEISHU_APP_ID` +- `FEISHU_APP_SECRET` + +并使用 Go integration build tag 执行。当前骨架先覆盖参数校验与连通性起点,后续可继续增加真实消息、用户、群聊、Drive 的回归测试。 diff --git a/go.mod b/go.mod index f60be046f..8e64bd47c 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.25.7 require ( github.com/adhocore/gronx v1.19.6 - github.com/anthropics/anthropic-sdk-go v1.22.1 + github.com/anthropics/anthropic-sdk-go v1.26.0 github.com/bwmarrin/discordgo v0.29.0 github.com/caarlos0/env/v11 v11.3.1 github.com/chzyer/readline v1.5.1 @@ -91,6 +91,6 @@ require ( golang.org/x/arch v0.24.0 // indirect golang.org/x/crypto v0.48.0 // indirect golang.org/x/net v0.51.0 // indirect - golang.org/x/sync v0.19.0 // indirect + golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.41.0 // indirect ) diff --git a/go.sum b/go.sum index 4060997f8..fcd70a96d 100644 --- a/go.sum +++ b/go.sum @@ -13,6 +13,8 @@ github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwTo github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/anthropics/anthropic-sdk-go v1.22.1 h1:xbsc3vJKCX/ELDZSpTNfz9wCgrFsamwFewPb1iI0Xh0= github.com/anthropics/anthropic-sdk-go v1.22.1/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE= +github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAfT7CoSYSac11PY= +github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q= github.com/beeper/argo-go v1.1.2 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs= github.com/beeper/argo-go v1.1.2/go.mod h1:M+LJAnyowKVQ6Rdj6XYGEn+qcVFkb3R/MUpqkGR0hM4= github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno= @@ -284,6 +286,8 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index 0c7baa1ee..58d5ceb07 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -96,6 +96,8 @@ func NewAgentInstance( toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict, allowWritePaths)) } + tools.RegisterFeishuTools(toolsRegistry, cfg) + sessionsDir := filepath.Join(workspace, "sessions") sessions := initSessionStore(sessionsDir) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 235d42fcc..97651e36b 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -444,6 +444,22 @@ func (al *AgentLoop) RegisterTool(tool tools.Tool) { func (al *AgentLoop) SetChannelManager(cm *channels.Manager) { al.channelManager = cm + if cm == nil { + return + } + feishuCh, ok := cm.GetChannel("feishu") + if !ok || feishuCh == nil { + return + } + adapter := tools.NewFeishuChannelAdapter(feishuCh) + if !adapter.Ready() { + return + } + for _, agentID := range al.registry.ListAgentIDs() { + if agent, ok := al.registry.GetAgent(agentID); ok { + tools.RegisterFeishuToolsWithClient(agent.Tools, al.cfg, adapter) + } + } } // SetMediaStore injects a MediaStore for media lifecycle management. diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index 2e456fa60..fbf310303 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -30,6 +30,24 @@ func (f *fakeChannel) IsAllowed(string) bool { func (f *fakeChannel) IsAllowedSender(sender bus.SenderInfo) bool { return true } func (f *fakeChannel) ReasoningChannelID() string { return f.id } +type fakeFeishuRemoteChannel struct{ fakeChannel } + +func (f *fakeFeishuRemoteChannel) GetMessage(ctx context.Context, messageID string) (any, error) { + return map[string]any{"message_id": messageID}, nil +} +func (f *fakeFeishuRemoteChannel) ListMessages(ctx context.Context, containerID, containerType string, pageSize int, pageToken string) (any, error) { + return map[string]any{"container_id": containerID}, nil +} +func (f *fakeFeishuRemoteChannel) GetUserInfo(ctx context.Context, userID string) (any, error) { + return map[string]any{"user_id": userID}, nil +} +func (f *fakeFeishuRemoteChannel) GetGroupInfo(ctx context.Context, chatID string) (any, error) { + return map[string]any{"chat_id": chatID}, nil +} +func (f *fakeFeishuRemoteChannel) GetDriveFile(ctx context.Context, fileToken string) (any, error) { + return map[string]any{"file_token": fileToken}, nil +} + func newTestAgentLoop( t *testing.T, ) (al *AgentLoop, cfg *config.Config, msgBus *bus.MessageBus, provider *mockProvider, cleanup func()) { @@ -1116,3 +1134,41 @@ func TestResolveMediaRefs_UsesMetaContentType(t *testing.T) { t.Fatalf("expected jpeg prefix, got %q", result[0].Media[0][:30]) } } + +func TestSetChannelManager_RegistersFeishuRemoteTool(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-feishu-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + Model: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + cfg.Tools.Enabled = []string{"feishu_parse", "feishu_remote"} + + al := NewAgentLoop(cfg, bus.NewMessageBus(), &mockProvider{}) + chManager, err := channels.NewManager(&config.Config{}, bus.NewMessageBus(), nil) + if err != nil { + t.Fatalf("Failed to create channel manager: %v", err) + } + chManager.RegisterChannel("feishu", &fakeFeishuRemoteChannel{fakeChannel{id: "rid-feishu"}}) + + al.SetChannelManager(chManager) + + defaultAgent := al.registry.GetDefaultAgent() + if defaultAgent == nil { + t.Fatal("expected default agent") + } + if _, ok := defaultAgent.Tools.Get("feishu_remote"); !ok { + t.Fatal("expected feishu_remote to be registered after channel manager injection") + } +} + diff --git a/pkg/channels/feishu/api_helpers.go b/pkg/channels/feishu/api_helpers.go new file mode 100644 index 000000000..003fb7df1 --- /dev/null +++ b/pkg/channels/feishu/api_helpers.go @@ -0,0 +1,415 @@ +package feishu + +import ( + "bytes" + "encoding/json" + "io" + "mime" + "mime/multipart" + "net/http" + "net/url" + "path/filepath" + "strconv" + "strings" +) + +func parseMessageContentPayload(messageData map[string]any) *ParsedMessage { + if len(messageData) == 0 { + return nil + } + + items, _ := messageData["items"].([]any) + if len(items) == 0 { + if msgMap, ok := messageData["message"].(map[string]any); ok { + return parseMessageItem(msgMap) + } + return parseMessageItem(messageData) + } + first, _ := items[0].(map[string]any) + return parseMessageItem(first) +} + +func parseMessageItem(item map[string]any) *ParsedMessage { + if len(item) == 0 { + return nil + } + body, _ := item["body"].(map[string]any) + contentStr, _ := body["content"].(string) + var content any + if contentStr != "" { + if err := json.Unmarshal([]byte(contentStr), &content); err != nil { + content = contentStr + } + } + return &ParsedMessage{ + MessageID: stringFromAny(item["message_id"]), + ChatID: stringFromAny(item["chat_id"]), + MsgType: stringFromAny(item["msg_type"]), + CreateTime: stringFromAny(item["create_time"]), + Sender: mapFromAny(item["sender"]), + Content: content, + } +} + +func parseCardSummary(card any) *CardSummary { + cardMap, ok := card.(map[string]any) + if !ok || len(cardMap) == 0 { + return nil + } + + summary := &CardSummary{} + if title := extractCardTitle(cardMap); title != "" { + summary.Title = title + } + + components := extractCardComponents(cardMap) + for i, component := range components { + compMap, _ := component.(map[string]any) + if len(compMap) == 0 { + continue + } + parsed := CardComponent{Index: i + 1, Tag: stringFromAny(compMap["tag"]), Details: map[string]any{}} + tag := parsed.Tag + switch tag { + case "img": + parsed.Details = map[string]any{ + "image_key": stringFromAny(compMap["image_key"]), + "width": compMap["width"], + "height": compMap["height"], + "alt": compMap["alt"], + } + if key := stringFromAny(compMap["image_key"]); key != "" { + summary.ImageKeys = append(summary.ImageKeys, key) + } + case "text", "note", "header": + text := extractTextContent(compMap["text"]) + parsed.Details = map[string]any{"text": text, "style": compMap["style"], "mode": compMap["mode"]} + if text != "" { + summary.TextContents = append(summary.TextContents, text) + } + case "div": + text := extractTextContent(compMap["text"]) + parsed.Details = map[string]any{ + "text": text, + "style": compMap["style"], + "mode": compMap["mode"], + "fields": compMap["fields"], + } + if text != "" { + summary.TextContents = append(summary.TextContents, text) + } + case "action": + actions, _ := compMap["actions"].([]any) + parsedActions := make([]map[string]any, 0, len(actions)) + for _, rawAction := range actions { + action, _ := rawAction.(map[string]any) + btnText := extractTextContent(action["text"]) + parsedActions = append(parsedActions, map[string]any{ + "text": btnText, + "type": stringFromAny(action["type"]), + "style": mapFromAny(action["style"]), + }) + if btnText != "" { + summary.ActionButtons = append(summary.ActionButtons, btnText) + } + } + parsed.Details = map[string]any{"action_count": len(actions), "actions": parsedActions} + case "at": + parsed.Details = map[string]any{ + "user_id": stringFromAny(compMap["user_id"]), + "user_name": stringFromAny(compMap["user_name"]), + "user_avatar": stringFromAny(compMap["user_avatar"]), + "style": compMap["style"], + } + default: + parsed.Details = compMap + } + summary.Components = append(summary.Components, parsed) + } + + return summary +} + +func extractCardTitle(card map[string]any) string { + if title := stringFromAny(card["title"]); title != "" { + return title + } + if header, ok := card["header"].(map[string]any); ok { + if titleMap, ok := header["title"].(map[string]any); ok { + if text := stringFromAny(titleMap["content"]); text != "" { + return text + } + } + } + return "" +} + +func extractCardComponents(card map[string]any) []any { + if body, ok := card["body"].(map[string]any); ok { + if elements, ok := body["elements"].([]any); ok { + return elements + } + } + if elements, ok := card["elements"].([]any); ok { + return elements + } + if content, ok := card["content"].([]any); ok { + return content + } + return nil +} + +func extractTextContent(v any) string { + switch vv := v.(type) { + case string: + return vv + case map[string]any: + if s := stringFromAny(vv["content"]); s != "" { + return s + } + if s := stringFromAny(vv["text"]); s != "" { + return s + } + } + return "" +} + +func extractShareLinkToken(shareLink string) string { + u, err := url.Parse(shareLink) + if err != nil { + return "" + } + return u.Query().Get("token") +} + +func mapFromAny(v any) map[string]any { + m, _ := v.(map[string]any) + if m == nil { + return map[string]any{} + } + return m +} + +func stringFromAny(v any) string { + switch vv := v.(type) { + case string: + return vv + case *string: + if vv != nil { + return *vv + } + case json.Number: + return vv.String() + case float64: + return strconv.FormatFloat(vv, 'f', -1, 64) + case int: + return strconv.Itoa(vv) + case int64: + return strconv.FormatInt(vv, 10) + case bool: + return strconv.FormatBool(vv) + } + return "" +} + +func boolFromAny(v any) bool { + switch vv := v.(type) { + case bool: + return vv + case string: + return strings.EqualFold(vv, "true") + } + return false +} + +func int64FromAny(v any) int64 { + switch vv := v.(type) { + case int: + return int64(vv) + case int64: + return vv + case float64: + return int64(vv) + case json.Number: + n, _ := vv.Int64() + return n + case string: + n, _ := strconv.ParseInt(strings.TrimSpace(vv), 10, 64) + return n + } + return 0 +} + +func normalizeMessageDetail(item map[string]any) MessageDetail { + detail := MessageDetail{ + MessageID: stringFromAny(item["message_id"]), + ChatID: stringFromAny(item["chat_id"]), + RootID: stringFromAny(item["root_id"]), + ParentID: stringFromAny(item["parent_id"]), + MsgType: stringFromAny(item["msg_type"]), + Deleted: boolFromAny(item["deleted"]), + Updated: boolFromAny(item["updated"]), + CreateTime: stringFromAny(item["create_time"]), + UpdateTime: stringFromAny(item["update_time"]), + Sender: mapFromAny(item["sender"]), + Body: mapFromAny(item["body"]), + Raw: item, + } + if mentions, ok := item["mentions"].([]any); ok { + for _, mention := range mentions { + if m, ok := mention.(map[string]any); ok { + detail.Mentions = append(detail.Mentions, m) + } + } + } + if parsed := parseMessageItem(item); parsed != nil { + detail.Parsed = parsed + if parsed.MsgType == larkInteractiveMsgType() { + detail.CardParsed = parseCardSummary(parsed.Content) + } + } + return detail +} + +func larkInteractiveMsgType() string { return "interactive" } + +func normalizeDriveFile(item map[string]any) DriveFileSummary { + return DriveFileSummary{ + FileToken: firstNonEmpty(stringFromAny(item["file_token"]), stringFromAny(item["token"]), stringFromAny(item["file_key"])), + Name: firstNonEmpty(stringFromAny(item["name"]), stringFromAny(item["file_name"])), + Type: stringFromAny(item["type"]), + ParentToken: firstNonEmpty(stringFromAny(item["parent_token"]), stringFromAny(item["parent_node"])), + Size: int64FromAny(item["size"]), + Extension: firstNonEmpty(stringFromAny(item["extension"]), stringFromAny(item["file_extension"])), + MimeType: firstNonEmpty(stringFromAny(item["mime_type"]), stringFromAny(item["content_type"])), + URL: firstNonEmpty(stringFromAny(item["url"]), stringFromAny(item["download_url"])), + CreatedTime: stringFromAny(item["created_time"]), + ModifiedTime: stringFromAny(item["modified_time"]), + Raw: item, + } +} + +func normalizeDriveFolder(item map[string]any) DriveFolderSummary { + return DriveFolderSummary{ + FolderToken: firstNonEmpty(stringFromAny(item["folder_token"]), stringFromAny(item["token"])), + Name: firstNonEmpty(stringFromAny(item["name"]), stringFromAny(item["folder_name"])), + ParentToken: firstNonEmpty(stringFromAny(item["parent_token"]), stringFromAny(item["parent_node"])), + URL: firstNonEmpty(stringFromAny(item["url"]), stringFromAny(item["folder_url"])), + CreatedTime: stringFromAny(item["created_time"]), + ModifiedTime: stringFromAny(item["modified_time"]), + Raw: item, + } +} + +func filenameFromHeader(h http.Header) string { + cd := h.Get("Content-Disposition") + if cd == "" { + return "" + } + _, params, err := mime.ParseMediaType(cd) + if err != nil { + return "" + } + if v := params["filename*"]; v != "" { + if i := strings.Index(v, "''"); i >= 0 && i+2 < len(v) { + if decoded, err := url.QueryUnescape(v[i+2:]); err == nil { + return decoded + } + } + return v + } + return params["filename"] +} + +func buildDriveUploadBody(parentToken, name string, r io.Reader) (*bytes.Buffer, string, error) { + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + if err := w.WriteField("file_name", name); err != nil { + return nil, "", err + } + if parentToken != "" { + if err := w.WriteField("parent_type", "explorer"); err != nil { + return nil, "", err + } + if err := w.WriteField("parent_node", parentToken); err != nil { + return nil, "", err + } + } + part, err := w.CreateFormFile("file", filepath.Base(name)) + if err != nil { + return nil, "", err + } + if _, err := io.Copy(part, r); err != nil { + return nil, "", err + } + if err := w.Close(); err != nil { + return nil, "", err + } + return &buf, w.FormDataContentType(), nil +} + +func buildMultipartChunkBody(seq int, data []byte) (*bytes.Buffer, string, error) { + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + if err := w.WriteField("seq", strconv.Itoa(seq)); err != nil { + return nil, "", err + } + part, err := w.CreateFormFile("file", "chunk") + if err != nil { + return nil, "", err + } + if _, err := part.Write(data); err != nil { + return nil, "", err + } + if err := w.Close(); err != nil { + return nil, "", err + } + return &buf, w.FormDataContentType(), nil +} + +func normalizeUserSummary(item map[string]any) UserSummary { + return UserSummary{ + ID: firstNonEmpty(stringFromAny(item["id"]), stringFromAny(item["open_id"]), stringFromAny(item["user_id"])), + OpenID: stringFromAny(item["open_id"]), + UserID: stringFromAny(item["user_id"]), + UnionID: stringFromAny(item["union_id"]), + Name: stringFromAny(item["name"]), + EnName: stringFromAny(item["en_name"]), + Email: stringFromAny(item["email"]), + Mobile: stringFromAny(item["mobile"]), + AvatarURL: stringFromAny(item["avatar_url"]), + Status: mapFromAny(item["status"]), + Raw: item, + } +} + +func normalizeChatSummary(item map[string]any) ChatSummary { + ownerID := stringFromAny(item["owner_id"]) + if ownerID == "" { + if ownerMap, ok := item["owner_id_type"].(map[string]any); ok { + ownerID = stringFromAny(ownerMap["user_id"]) + } + } + return ChatSummary{ + ChatID: firstNonEmpty(stringFromAny(item["chat_id"]), stringFromAny(item["open_chat_id"])), + Name: stringFromAny(item["name"]), + Description: stringFromAny(item["description"]), + ChatMode: stringFromAny(item["chat_mode"]), + ChatType: stringFromAny(item["chat_type"]), + OwnerID: ownerID, + OwnerOpenID: stringFromAny(item["owner_open_id"]), + External: boolFromAny(item["external"]), + TenantKey: stringFromAny(item["tenant_key"]), + Avatar: firstNonEmpty(stringFromAny(item["avatar"]), stringFromAny(item["avatar_path"])), + Raw: item, + } +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} diff --git a/pkg/channels/feishu/api_helpers_test.go b/pkg/channels/feishu/api_helpers_test.go new file mode 100644 index 000000000..b020aac70 --- /dev/null +++ b/pkg/channels/feishu/api_helpers_test.go @@ -0,0 +1,170 @@ +package feishu + +import ( + "io" + "net/http" + "strings" + "testing" +) + +func TestParseMessageContentPayload(t *testing.T) { + payload := map[string]any{ + "items": []any{ + map[string]any{ + "message_id": "om_123", + "chat_id": "oc_456", + "msg_type": "text", + "create_time": "1710000000", + "sender": map[string]any{"id": "ou_1"}, + "body": map[string]any{"content": `{"text":"hello"}`}, + }, + }, + } + parsed := parseMessageContentPayload(payload) + if parsed == nil { + t.Fatal("expected parsed payload") + } + if parsed.MessageID != "om_123" || parsed.ChatID != "oc_456" || parsed.MsgType != "text" { + t.Fatalf("unexpected parsed message: %+v", parsed) + } + content, ok := parsed.Content.(map[string]any) + if !ok || content["text"] != "hello" { + t.Fatalf("unexpected parsed content: %#v", parsed.Content) + } +} + +func TestParseCardSummary(t *testing.T) { + card := map[string]any{ + "header": map[string]any{"title": map[string]any{"content": "test title"}}, + "elements": []any{ + map[string]any{"tag": "div", "text": map[string]any{"content": "body text"}}, + map[string]any{"tag": "img", "image_key": "img_xxx"}, + map[string]any{"tag": "action", "actions": []any{ + map[string]any{"text": map[string]any{"content": "Confirm"}, "type": "primary"}, + }}, + }, + } + summary := parseCardSummary(card) + if summary == nil { + t.Fatal("expected card summary") + } + if summary.Title != "test title" { + t.Fatalf("unexpected title: %q", summary.Title) + } + if len(summary.ImageKeys) != 1 || summary.ImageKeys[0] != "img_xxx" { + t.Fatalf("unexpected image keys: %#v", summary.ImageKeys) + } + if len(summary.TextContents) == 0 || summary.TextContents[0] != "body text" { + t.Fatalf("unexpected text contents: %#v", summary.TextContents) + } + if len(summary.ActionButtons) != 1 || summary.ActionButtons[0] != "Confirm" { + t.Fatalf("unexpected action buttons: %#v", summary.ActionButtons) + } +} + +func TestExtractShareLinkToken(t *testing.T) { + link := "https://applink.feishu.cn/client/message/link/open?token=om_abc%3D%3D&foo=bar" + token := extractShareLinkToken(link) + if token != "om_abc==" { + t.Fatalf("unexpected token: %q", token) + } +} + +func TestNormalizeMessageDetailInteractive(t *testing.T) { + item := map[string]any{ + "message_id": "om_123", + "chat_id": "oc_123", + "msg_type": "interactive", + "body": map[string]any{ + "content": `{"header":{"title":{"content":"hello"}},"elements":[{"tag":"div","text":{"content":"world"}}]}`, + }, + } + detail := normalizeMessageDetail(item) + if detail.Parsed == nil { + t.Fatal("expected parsed detail") + } + if detail.CardParsed == nil || detail.CardParsed.Title != "hello" { + t.Fatalf("unexpected card parse: %#v", detail.CardParsed) + } +} + +func TestNormalizeUserSummary(t *testing.T) { + user := normalizeUserSummary(map[string]any{"open_id": "ou_1", "name": "Alice", "email": "a@example.com"}) + if user.ID != "ou_1" || user.Name != "Alice" || user.Email != "a@example.com" { + t.Fatalf("unexpected user summary: %+v", user) + } +} + +func TestNormalizeChatSummary(t *testing.T) { + chat := normalizeChatSummary(map[string]any{"chat_id": "oc_1", "name": "Group", "description": "desc"}) + if chat.ChatID != "oc_1" || chat.Name != "Group" || chat.Description != "desc" { + t.Fatalf("unexpected chat summary: %+v", chat) + } +} + +func TestFilenameFromHeader(t *testing.T) { + h := http.Header{} + h.Set("Content-Disposition", `attachment; filename="demo.txt"`) + if got := filenameFromHeader(h); got != "demo.txt" { + t.Fatalf("unexpected filename: %s", got) + } +} + +func TestFilenameFromHeaderEncoded(t *testing.T) { + h := http.Header{} + h.Set("Content-Disposition", "attachment; filename*=UTF-8''hello%20world.txt") + if got := filenameFromHeader(h); got != "hello world.txt" { + t.Fatalf("unexpected encoded filename: %s", got) + } +} + +func TestNormalizeDriveFile(t *testing.T) { + got := normalizeDriveFile(map[string]any{ + "file_token": "file_token", + "name": "demo.txt", + "type": "file", + "parent_token": "folder_token", + "size": float64(12), + }) + if got.FileToken != "file_token" || got.Name != "demo.txt" || got.Size != 12 { + t.Fatalf("unexpected normalized file: %+v", got) + } +} + +func TestBuildMultipartChunkBody(t *testing.T) { + body, contentType, err := buildMultipartChunkBody(2, []byte("hello")) + if err != nil { + t.Fatalf("build body failed: %v", err) + } + if !strings.Contains(contentType, "multipart/form-data") { + t.Fatalf("unexpected content type: %s", contentType) + } + b, err := io.ReadAll(body) + if err != nil { + t.Fatalf("read body failed: %v", err) + } + s := string(b) + if !strings.Contains(s, `name="seq"`) || !strings.Contains(s, "2") { + t.Fatalf("unexpected multipart body: %s", s) + } +} + +func TestBuildDriveUploadBody(t *testing.T) { + body, contentType, err := buildDriveUploadBody("fld_123", "report.txt", strings.NewReader("hello")) + if err != nil { + t.Fatalf("build upload body failed: %v", err) + } + if !strings.Contains(contentType, "multipart/form-data") { + t.Fatalf("unexpected content type: %s", contentType) + } + b, err := io.ReadAll(body) + if err != nil { + t.Fatalf("read body failed: %v", err) + } + s := string(b) + for _, expected := range []string{"fld_123", "report.txt", "hello", `name="file"`} { + if !strings.Contains(s, expected) { + t.Fatalf("multipart body missing %q: %s", expected, s) + } + } +} diff --git a/pkg/channels/feishu/api_methods.go b/pkg/channels/feishu/api_methods.go new file mode 100644 index 000000000..29c3e77f0 --- /dev/null +++ b/pkg/channels/feishu/api_methods.go @@ -0,0 +1,691 @@ +//go:build amd64 || arm64 || riscv64 || mips64 || ppc64 + +package feishu + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1" +) + +func (c *FeishuChannel) GetMessage(ctx context.Context, messageID string) (*MessageDetail, error) { + if strings.TrimSpace(messageID) == "" { + return nil, fmt.Errorf("message ID is empty") + } + var payload struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data map[string]any `json:"data"` + } + if err := c.getJSON(ctx, "/open-apis/im/v1/messages/"+url.PathEscape(messageID), nil, &payload); err != nil { + return nil, err + } + if payload.Code != 0 { + return nil, fmt.Errorf("feishu get message api error (code=%d msg=%s)", payload.Code, payload.Msg) + } + item := payload.Data + if message, ok := payload.Data["message"].(map[string]any); ok { + item = message + } + detail := normalizeMessageDetail(item) + return &detail, nil +} + +func (c *FeishuChannel) GetMessageByID(ctx context.Context, messageID string) (*MessageDetail, error) { + return c.GetMessage(ctx, messageID) +} + +func (c *FeishuChannel) ListMessages(ctx context.Context, containerID, containerType string, pageSize int, pageToken string) (*MessageList, error) { + if strings.TrimSpace(containerID) == "" { + return nil, fmt.Errorf("container ID is empty") + } + if containerType == "" { + containerType = "chat" + } + if pageSize <= 0 { + pageSize = 20 + } + query := map[string]string{ + "container_id": containerID, + "container_id_type": containerType, + "page_size": fmt.Sprintf("%d", pageSize), + } + if pageToken != "" { + query["page_token"] = pageToken + } + var payload struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data struct { + Items []map[string]any `json:"items"` + HasMore bool `json:"has_more"` + PageToken string `json:"page_token"` + } `json:"data"` + } + if err := c.getJSON(ctx, "/open-apis/im/v1/messages", query, &payload); err != nil { + return nil, err + } + if payload.Code != 0 { + return nil, fmt.Errorf("feishu list messages api error (code=%d msg=%s)", payload.Code, payload.Msg) + } + result := &MessageList{HasMore: payload.Data.HasMore, PageToken: payload.Data.PageToken} + for _, item := range payload.Data.Items { + result.Items = append(result.Items, normalizeMessageDetail(item)) + } + return result, nil +} + +func (c *FeishuChannel) ReplyMessage(ctx context.Context, messageID, text string) error { + if strings.TrimSpace(messageID) == "" { + return fmt.Errorf("message ID is empty") + } + req := larkim.NewReplyMessageReqBuilder(). + MessageId(messageID). + Body(larkim.NewReplyMessageReqBodyBuilder(). + Content(string(mustJSONMarshal(map[string]string{"text": text}))). + MsgType(larkim.MsgTypeText). + Build()). + Build() + resp, err := c.client.Im.V1.Message.Reply(ctx, req) + if err != nil { + return fmt.Errorf("feishu reply message: %w", err) + } + if !resp.Success() { + return fmt.Errorf("feishu reply message api error (code=%d msg=%s)", resp.Code, resp.Msg) + } + return nil +} + +func (c *FeishuChannel) ParseMessageContent(raw map[string]any) *ParsedMessage { + return parseMessageContentPayload(raw) +} + +func (c *FeishuChannel) GetMessageFromShareLink(ctx context.Context, shareLink string) (*ShareLinkLookupResult, error) { + token := extractShareLinkToken(shareLink) + if token == "" { + return nil, fmt.Errorf("invalid share link format") + } + decoded, _ := url.QueryUnescape(token) + result := &ShareLinkLookupResult{Token: token, DecodedMessageID: decoded} + message, err := c.GetMessage(ctx, decoded) + if err == nil { + result.Message = message + return result, nil + } + result.FallbackError = err.Error() + message, err = c.GetMessage(ctx, token) + if err == nil { + result.Message = message + return result, nil + } + if err != nil { + result.FallbackError = strings.Trim(strings.Join([]string{result.FallbackError, err.Error()}, "; "), "; ") + } + return result, fmt.Errorf("unable to resolve message from share link") +} + +func (c *FeishuChannel) GetUserInfo(ctx context.Context, userID string) (*UserSummary, error) { + if userID == "" { + return nil, fmt.Errorf("user ID is empty") + } + var payload struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data struct { + User map[string]any `json:"user"` + } `json:"data"` + } + if err := c.getJSON(ctx, "/open-apis/contact/v3/users/"+url.PathEscape(userID), nil, &payload); err != nil { + return nil, err + } + if payload.Code != 0 { + return nil, fmt.Errorf("feishu get user api error (code=%d msg=%s)", payload.Code, payload.Msg) + } + summary := normalizeUserSummary(payload.Data.User) + return &summary, nil +} + +func (c *FeishuChannel) ListUsers(ctx context.Context, pageSize int, userIDType, pageToken string) (*UserList, error) { + if pageSize <= 0 { + pageSize = 50 + } + if userIDType == "" { + userIDType = "open_id" + } + query := map[string]string{"page_size": fmt.Sprintf("%d", pageSize), "user_id_type": userIDType} + if pageToken != "" { + query["page_token"] = pageToken + } + var payload struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data struct { + HasMore bool `json:"has_more"` + PageToken string `json:"page_token"` + Items []map[string]any `json:"items"` + Data struct { + HasMore bool `json:"has_more"` + PageToken string `json:"page_token"` + Items []map[string]any `json:"items"` + } `json:"data"` + } `json:"data"` + } + if err := c.getJSON(ctx, "/open-apis/contact/v3/users", query, &payload); err != nil { + return nil, err + } + if payload.Code != 0 { + return nil, fmt.Errorf("feishu list users api error (code=%d msg=%s)", payload.Code, payload.Msg) + } + data := payload.Data + items := data.Items + hasMore := data.HasMore + nextToken := data.PageToken + if len(items) == 0 && len(data.Data.Items) > 0 { + items = data.Data.Items + hasMore = data.Data.HasMore + nextToken = data.Data.PageToken + } + result := &UserList{HasMore: hasMore, PageToken: nextToken} + for _, item := range items { + result.Items = append(result.Items, normalizeUserSummary(item)) + } + return result, nil +} + +func (c *FeishuChannel) GetUserIDByEmail(ctx context.Context, email string) (string, error) { + users, err := c.ListUsers(ctx, 100, "open_id", "") + if err != nil { + return "", err + } + for _, user := range users.Items { + if strings.EqualFold(user.Email, email) { + return firstNonEmpty(user.OpenID, user.ID, user.UserID), nil + } + } + return "", nil +} + +func (c *FeishuChannel) GetUserIDByMobile(ctx context.Context, mobile string) (string, error) { + query := map[string]string{"mobile_phone": mobile, "user_id_type": "open_id", "page_size": "1"} + var payload struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data struct { + Items []map[string]any `json:"items"` + } `json:"data"` + } + if err := c.getJSON(ctx, "/open-apis/contact/v3/users", query, &payload); err != nil { + return "", err + } + if payload.Code != 0 { + return "", fmt.Errorf("feishu get user by mobile api error (code=%d msg=%s)", payload.Code, payload.Msg) + } + if len(payload.Data.Items) == 0 { + return "", nil + } + user := normalizeUserSummary(payload.Data.Items[0]) + return firstNonEmpty(user.OpenID, user.ID, user.UserID), nil +} + +func (c *FeishuChannel) CreateGroup(ctx context.Context, name string) (*ChatSummary, error) { + if strings.TrimSpace(name) == "" { + return nil, fmt.Errorf("group name is empty") + } + body := map[string]any{"name": name, "member_type": "ADMIN"} + var payload struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data struct { + Chat map[string]any `json:"chat"` + } `json:"data"` + } + if err := c.postJSON(ctx, "/open-apis/im/v1/chats", body, &payload); err != nil { + return nil, err + } + if payload.Code != 0 { + return nil, fmt.Errorf("feishu create group api error (code=%d msg=%s)", payload.Code, payload.Msg) + } + chat := normalizeChatSummary(payload.Data.Chat) + return &chat, nil +} + +func (c *FeishuChannel) GetGroupInfo(ctx context.Context, chatID string) (*ChatSummary, error) { + if strings.TrimSpace(chatID) == "" { + return nil, fmt.Errorf("chat ID is empty") + } + var payload struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data struct { + Chat map[string]any `json:"chat"` + } `json:"data"` + } + if err := c.getJSON(ctx, "/open-apis/im/v1/chats/"+url.PathEscape(chatID), nil, &payload); err != nil { + return nil, err + } + if payload.Code != 0 { + return nil, fmt.Errorf("feishu get chat api error (code=%d msg=%s)", payload.Code, payload.Msg) + } + chat := normalizeChatSummary(payload.Data.Chat) + return &chat, nil +} + +func (c *FeishuChannel) ListGroupMembers(ctx context.Context, chatID string, pageSize int, pageToken string) (*UserList, error) { + if strings.TrimSpace(chatID) == "" { + return nil, fmt.Errorf("chat ID is empty") + } + if pageSize <= 0 { + pageSize = 50 + } + query := map[string]string{"page_size": fmt.Sprintf("%d", pageSize)} + if pageToken != "" { + query["page_token"] = pageToken + } + var payload struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data struct { + HasMore bool `json:"has_more"` + PageToken string `json:"page_token"` + Items []map[string]any `json:"items"` + } `json:"data"` + } + if err := c.getJSON(ctx, "/open-apis/im/v1/chats/"+url.PathEscape(chatID)+"/members", query, &payload); err != nil { + return nil, err + } + if payload.Code != 0 { + return nil, fmt.Errorf("feishu list chat members api error (code=%d msg=%s)", payload.Code, payload.Msg) + } + result := &UserList{HasMore: payload.Data.HasMore, PageToken: payload.Data.PageToken} + for _, item := range payload.Data.Items { + result.Items = append(result.Items, normalizeUserSummary(item)) + } + return result, nil +} + +func (c *FeishuChannel) ListGroups(ctx context.Context, pageSize int, pageToken string) (*ChatList, error) { + if pageSize <= 0 { + pageSize = 50 + } + query := map[string]string{"page_size": fmt.Sprintf("%d", pageSize)} + if pageToken != "" { + query["page_token"] = pageToken + } + var payload struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data struct { + HasMore bool `json:"has_more"` + PageToken string `json:"page_token"` + Items []map[string]any `json:"items"` + Data struct { + HasMore bool `json:"has_more"` + PageToken string `json:"page_token"` + Items []map[string]any `json:"items"` + } `json:"data"` + } `json:"data"` + } + if err := c.getJSON(ctx, "/open-apis/im/v1/chats", query, &payload); err != nil { + return nil, err + } + if payload.Code != 0 { + return nil, fmt.Errorf("feishu list chats api error (code=%d msg=%s)", payload.Code, payload.Msg) + } + items := payload.Data.Items + hasMore := payload.Data.HasMore + nextToken := payload.Data.PageToken + if len(items) == 0 && len(payload.Data.Data.Items) > 0 { + items = payload.Data.Data.Items + hasMore = payload.Data.Data.HasMore + nextToken = payload.Data.Data.PageToken + } + result := &ChatList{HasMore: hasMore, PageToken: nextToken} + for _, item := range items { + result.Items = append(result.Items, normalizeChatSummary(item)) + } + return result, nil +} + +func (c *FeishuChannel) SendGroupMessage(ctx context.Context, chatID, text string) error { + req := larkim.NewCreateMessageReqBuilder(). + ReceiveIdType(larkim.ReceiveIdTypeChatId). + Body(larkim.NewCreateMessageReqBodyBuilder(). + ReceiveId(chatID). + MsgType(larkim.MsgTypeText). + Content(string(mustJSONMarshal(map[string]string{"text": text}))). + Build()). + Build() + resp, err := c.client.Im.V1.Message.Create(ctx, req) + if err != nil { + return fmt.Errorf("feishu send group message: %w", err) + } + if !resp.Success() { + return fmt.Errorf("feishu send group message api error (code=%d msg=%s)", resp.Code, resp.Msg) + } + return nil +} + +func (c *FeishuChannel) GetDriveRootFolder(ctx context.Context) (*DriveFolderSummary, error) { + var payload struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data map[string]any `json:"data"` + } + if err := c.getJSON(ctx, "/open-apis/drive/explorer/v2/root_folder/meta", nil, &payload); err != nil { + return nil, err + } + if payload.Code != 0 { + return nil, fmt.Errorf("feishu drive root folder api error (code=%d msg=%s)", payload.Code, payload.Msg) + } + folder := normalizeDriveFolder(payload.Data) + return &folder, nil +} + +func (c *FeishuChannel) GetDriveFolder(ctx context.Context, folderToken string) (*DriveFolderSummary, error) { + if strings.TrimSpace(folderToken) == "" { + return nil, fmt.Errorf("folder token is empty") + } + var payload struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data map[string]any `json:"data"` + } + path := "/open-apis/drive/explorer/v2/folder/" + url.PathEscape(folderToken) + "/meta" + if err := c.getJSON(ctx, path, nil, &payload); err != nil { + return nil, err + } + if payload.Code != 0 { + return nil, fmt.Errorf("feishu get drive folder api error (code=%d msg=%s)", payload.Code, payload.Msg) + } + folder := normalizeDriveFolder(payload.Data) + return &folder, nil +} + +func (c *FeishuChannel) GetDriveFile(ctx context.Context, fileToken string) (*DriveFileSummary, error) { + if strings.TrimSpace(fileToken) == "" { + return nil, fmt.Errorf("file token is empty") + } + var payload struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data struct { + File map[string]any `json:"file"` + } `json:"data"` + } + path := "/open-apis/drive/v1/files/" + url.PathEscape(fileToken) + if err := c.getJSON(ctx, path, nil, &payload); err != nil { + return nil, err + } + if payload.Code != 0 { + return nil, fmt.Errorf("feishu get drive file api error (code=%d msg=%s)", payload.Code, payload.Msg) + } + file := normalizeDriveFile(payload.Data.File) + return &file, nil +} + +func (c *FeishuChannel) ListDriveFiles(ctx context.Context, folderToken, pageToken string, pageSize int) (*DriveFileList, error) { + query := map[string]string{} + if folderToken != "" { + query["folder_token"] = folderToken + } + if pageToken != "" { + query["page_token"] = pageToken + } + if pageSize > 0 { + query["page_size"] = fmt.Sprintf("%d", pageSize) + } + var payload struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data struct { + HasMore bool `json:"has_more"` + PageToken string `json:"page_token"` + Files []map[string]any `json:"files"` + Items []map[string]any `json:"items"` + } `json:"data"` + } + if err := c.getJSON(ctx, "/open-apis/drive/v1/files", query, &payload); err != nil { + return nil, err + } + if payload.Code != 0 { + return nil, fmt.Errorf("feishu list drive files api error (code=%d msg=%s)", payload.Code, payload.Msg) + } + items := payload.Data.Files + if len(items) == 0 { + items = payload.Data.Items + } + result := &DriveFileList{HasMore: payload.Data.HasMore, PageToken: payload.Data.PageToken} + for _, item := range items { + result.Items = append(result.Items, normalizeDriveFile(item)) + } + return result, nil +} + +func (c *FeishuChannel) DownloadDriveFile(ctx context.Context, fileToken string) (*DownloadedFile, error) { + if strings.TrimSpace(fileToken) == "" { + return nil, fmt.Errorf("file token is empty") + } + resp, err := c.client.Do(ctx, &larkcore.ApiReq{HttpMethod: http.MethodGet, ApiPath: "/open-apis/drive/v1/files/" + url.PathEscape(fileToken) + "/download", SupportedAccessTokenTypes: []larkcore.AccessTokenType{larkcore.AccessTokenTypeTenant}}) + if err != nil { + return nil, err + } + contentType := "" + if resp.Header != nil { + contentType = resp.Header.Get("Content-Type") + } + name := fileToken + if resp.Header != nil { + if headerName := filenameFromHeader(resp.Header); headerName != "" { + name = headerName + } + } + return &DownloadedFile{Name: name, ContentType: contentType, Data: resp.RawBody}, nil +} + +func (c *FeishuChannel) DeleteDriveFile(ctx context.Context, fileToken string) error { + if strings.TrimSpace(fileToken) == "" { + return fmt.Errorf("file token is empty") + } + var payload struct { + Code int `json:"code"` + Msg string `json:"msg"` + } + if err := c.doJSON(ctx, http.MethodDelete, "/open-apis/drive/v1/files/"+url.PathEscape(fileToken), nil, nil, &payload); err != nil { + return err + } + if payload.Code != 0 { + return fmt.Errorf("feishu delete drive file api error (code=%d msg=%s)", payload.Code, payload.Msg) + } + return nil +} + +func (c *FeishuChannel) UploadDriveFile(ctx context.Context, parentToken, name string, r io.Reader) (*DriveFileSummary, error) { + if strings.TrimSpace(name) == "" { + return nil, fmt.Errorf("file name is empty") + } + body, contentType, err := buildDriveUploadBody(parentToken, name, r) + if err != nil { + return nil, err + } + var payload struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data struct { + File map[string]any `json:"file"` + } `json:"data"` + } + if err := c.doMultipart(ctx, http.MethodPost, "/open-apis/drive/v1/files/upload_all", body.Bytes(), contentType, &payload); err != nil { + return nil, err + } + if payload.Code != 0 { + return nil, fmt.Errorf("feishu upload drive file api error (code=%d msg=%s)", payload.Code, payload.Msg) + } + file := normalizeDriveFile(payload.Data.File) + return &file, nil +} + +func (c *FeishuChannel) InitiateMultipartUpload(ctx context.Context, parentToken, name string, size int64) (*MultipartUploadSession, error) { + body := map[string]any{"file_name": name, "size": size} + if parentToken != "" { + body["parent_type"] = "explorer" + body["parent_node"] = parentToken + } + var payload struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data struct { + FileToken string `json:"file_token"` + UploadID string `json:"upload_id"` + BlockSize int `json:"block_size"` + } `json:"data"` + } + if err := c.postJSON(ctx, "/open-apis/drive/v1/files/upload_prepare", body, &payload); err != nil { + return nil, err + } + if payload.Code != 0 { + return nil, fmt.Errorf("feishu initiate multipart upload api error (code=%d msg=%s)", payload.Code, payload.Msg) + } + return &MultipartUploadSession{FileToken: payload.Data.FileToken, UploadID: payload.Data.UploadID, BlockSize: payload.Data.BlockSize}, nil +} + +func (c *FeishuChannel) UploadMultipartChunk(ctx context.Context, uploadID string, seq int, data []byte) error { + if strings.TrimSpace(uploadID) == "" { + return fmt.Errorf("upload ID is empty") + } + body, contentType, err := buildMultipartChunkBody(seq, data) + if err != nil { + return err + } + path := "/open-apis/drive/v1/files/upload_part?upload_id=" + url.QueryEscape(uploadID) + var payload struct { + Code int `json:"code"` + Msg string `json:"msg"` + } + if err := c.doMultipart(ctx, http.MethodPost, path, body.Bytes(), contentType, &payload); err != nil { + return err + } + if payload.Code != 0 { + return fmt.Errorf("feishu upload multipart chunk api error (code=%d msg=%s)", payload.Code, payload.Msg) + } + return nil +} + +func (c *FeishuChannel) CompleteMultipartUpload(ctx context.Context, uploadID string, blockNum int) (*DriveFileSummary, error) { + body := map[string]any{"upload_id": uploadID, "block_num": blockNum} + var payload struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data struct { + File map[string]any `json:"file"` + } `json:"data"` + } + if err := c.postJSON(ctx, "/open-apis/drive/v1/files/upload_finish", body, &payload); err != nil { + return nil, err + } + if payload.Code != 0 { + return nil, fmt.Errorf("feishu complete multipart upload api error (code=%d msg=%s)", payload.Code, payload.Msg) + } + file := normalizeDriveFile(payload.Data.File) + return &file, nil +} + +func (c *FeishuChannel) getJSON(ctx context.Context, apiPath string, query map[string]string, out any) error { + return c.doJSON(ctx, http.MethodGet, apiPath, query, nil, out) +} + +func (c *FeishuChannel) postJSON(ctx context.Context, apiPath string, body any, out any) error { + return c.doJSON(ctx, http.MethodPost, apiPath, nil, body, out) +} + +func (c *FeishuChannel) doJSON(ctx context.Context, method, apiPath string, query map[string]string, body any, out any) error { + if ctx == nil { + ctx = context.Background() + } + if len(query) > 0 { + values := url.Values{} + for k, v := range query { + if v != "" { + values.Set(k, v) + } + } + if encoded := values.Encode(); encoded != "" { + if strings.Contains(apiPath, "?") { + apiPath += "&" + encoded + } else { + apiPath += "?" + encoded + } + } + } + var payload []byte + var err error + if body != nil { + payload, err = json.Marshal(body) + if err != nil { + return err + } + } + resp, err := c.client.Do(ctx, &larkcore.ApiReq{ + HttpMethod: method, + ApiPath: apiPath, + Body: payload, + SupportedAccessTokenTypes: []larkcore.AccessTokenType{larkcore.AccessTokenTypeTenant}, + }) + if err != nil { + return err + } + if out == nil { + return nil + } + return json.Unmarshal(resp.RawBody, out) +} + +func (c *FeishuChannel) doMultipart(ctx context.Context, method, apiPath string, body []byte, contentType string, out any) error { + _ = contentType + resp, err := c.client.Do(ctx, &larkcore.ApiReq{ + HttpMethod: method, + ApiPath: apiPath, + Body: body, + SupportedAccessTokenTypes: []larkcore.AccessTokenType{larkcore.AccessTokenTypeTenant}, + }) + if err != nil { + return err + } + if out == nil { + return nil + } + return json.Unmarshal(resp.RawBody, out) +} + +func mustJSONMarshal(v any) []byte { + b, _ := json.Marshal(v) + return b +} + +func (c *FeishuChannel) downloadToPath(ctx context.Context, apiPath string) (*DownloadedFile, error) { + resp, err := c.client.Do(ctx, &larkcore.ApiReq{HttpMethod: http.MethodGet, ApiPath: apiPath, SupportedAccessTokenTypes: []larkcore.AccessTokenType{larkcore.AccessTokenTypeTenant}}) + if err != nil { + return nil, err + } + name := "download.bin" + if resp.Header != nil { + if fileName := filenameFromHeader(resp.Header); fileName != "" { + name = fileName + } + } + contentType := "" + if resp.Header != nil { + contentType = resp.Header.Get("Content-Type") + } + return &DownloadedFile{Name: name, ContentType: contentType, Data: append([]byte(nil), resp.RawBody...)}, nil +} + +func bytesReader(data []byte) io.Reader { return bytes.NewReader(data) } + diff --git a/pkg/channels/feishu/api_types.go b/pkg/channels/feishu/api_types.go new file mode 100644 index 000000000..4f2cbb12c --- /dev/null +++ b/pkg/channels/feishu/api_types.go @@ -0,0 +1,137 @@ +package feishu + +type ParsedMessage struct { + MessageID string `json:"message_id,omitempty"` + ChatID string `json:"chat_id,omitempty"` + MsgType string `json:"msg_type,omitempty"` + CreateTime string `json:"create_time,omitempty"` + Sender map[string]any `json:"sender,omitempty"` + Content any `json:"content,omitempty"` +} + +type CardComponent struct { + Index int `json:"index"` + Tag string `json:"tag,omitempty"` + Details map[string]any `json:"details,omitempty"` +} + +type CardSummary struct { + Title string `json:"title,omitempty"` + Components []CardComponent `json:"components,omitempty"` + ImageKeys []string `json:"image_keys,omitempty"` + TextContents []string `json:"text_contents,omitempty"` + ActionButtons []string `json:"action_buttons,omitempty"` +} + +type MessageDetail struct { + MessageID string `json:"message_id,omitempty"` + ChatID string `json:"chat_id,omitempty"` + RootID string `json:"root_id,omitempty"` + ParentID string `json:"parent_id,omitempty"` + MsgType string `json:"msg_type,omitempty"` + Deleted bool `json:"deleted,omitempty"` + Updated bool `json:"updated,omitempty"` + CreateTime string `json:"create_time,omitempty"` + UpdateTime string `json:"update_time,omitempty"` + Sender map[string]any `json:"sender,omitempty"` + Body map[string]any `json:"body,omitempty"` + Mentions []map[string]any `json:"mentions,omitempty"` + Raw map[string]any `json:"raw,omitempty"` + Parsed *ParsedMessage `json:"parsed,omitempty"` + CardParsed *CardSummary `json:"card_parsed,omitempty"` +} + +type MessageList struct { + Items []MessageDetail `json:"items,omitempty"` + HasMore bool `json:"has_more,omitempty"` + PageToken string `json:"page_token,omitempty"` +} + +type UserSummary struct { + ID string `json:"id,omitempty"` + OpenID string `json:"open_id,omitempty"` + UserID string `json:"user_id,omitempty"` + UnionID string `json:"union_id,omitempty"` + Name string `json:"name,omitempty"` + EnName string `json:"en_name,omitempty"` + Email string `json:"email,omitempty"` + Mobile string `json:"mobile,omitempty"` + AvatarURL string `json:"avatar_url,omitempty"` + Status map[string]any `json:"status,omitempty"` + Raw map[string]any `json:"raw,omitempty"` +} + +type UserList struct { + Items []UserSummary `json:"items,omitempty"` + HasMore bool `json:"has_more,omitempty"` + PageToken string `json:"page_token,omitempty"` +} + +type ChatSummary struct { + ChatID string `json:"chat_id,omitempty"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + ChatMode string `json:"chat_mode,omitempty"` + ChatType string `json:"chat_type,omitempty"` + OwnerID string `json:"owner_id,omitempty"` + OwnerOpenID string `json:"owner_open_id,omitempty"` + External bool `json:"external,omitempty"` + TenantKey string `json:"tenant_key,omitempty"` + Avatar string `json:"avatar,omitempty"` + Raw map[string]any `json:"raw,omitempty"` +} + +type ChatList struct { + Items []ChatSummary `json:"items,omitempty"` + HasMore bool `json:"has_more,omitempty"` + PageToken string `json:"page_token,omitempty"` +} + +type ShareLinkLookupResult struct { + Token string `json:"token,omitempty"` + DecodedMessageID string `json:"decoded_message_id,omitempty"` + Message *MessageDetail `json:"message,omitempty"` + FallbackError string `json:"fallback_error,omitempty"` +} + +type DriveFileSummary struct { + FileToken string `json:"file_token,omitempty"` + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` + ParentToken string `json:"parent_token,omitempty"` + Size int64 `json:"size,omitempty"` + Extension string `json:"extension,omitempty"` + MimeType string `json:"mime_type,omitempty"` + URL string `json:"url,omitempty"` + CreatedTime string `json:"created_time,omitempty"` + ModifiedTime string `json:"modified_time,omitempty"` + Raw map[string]any `json:"raw,omitempty"` +} + +type DriveFolderSummary struct { + FolderToken string `json:"folder_token,omitempty"` + Name string `json:"name,omitempty"` + ParentToken string `json:"parent_token,omitempty"` + URL string `json:"url,omitempty"` + CreatedTime string `json:"created_time,omitempty"` + ModifiedTime string `json:"modified_time,omitempty"` + Raw map[string]any `json:"raw,omitempty"` +} + +type DriveFileList struct { + Items []DriveFileSummary `json:"items,omitempty"` + HasMore bool `json:"has_more,omitempty"` + PageToken string `json:"page_token,omitempty"` +} + +type MultipartUploadSession struct { + FileToken string `json:"file_token,omitempty"` + UploadID string `json:"upload_id,omitempty"` + BlockSize int `json:"block_size,omitempty"` +} + +type DownloadedFile struct { + Name string `json:"name,omitempty"` + ContentType string `json:"content_type,omitempty"` + Data []byte `json:"data,omitempty"` +} diff --git a/pkg/channels/feishu/common.go b/pkg/channels/feishu/common.go index fbe085b73..3a6185387 100644 --- a/pkg/channels/feishu/common.go +++ b/pkg/channels/feishu/common.go @@ -43,32 +43,65 @@ func buildMarkdownCard(content string) (string, error) { // 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 { + all := extractAllJSONStringFields(content, field) + if len(all) == 0 { return "" } - raw, ok := m[field] - if !ok { - return "" - } - var s string - if err := json.Unmarshal(raw, &s); err != nil { - return "" - } - return s + return all[0] } -// extractImageKey extracts the image_key from a Feishu image message content JSON. -// Format: {"image_key": "img_xxx"} +func extractAllJSONStringFields(content, field string) []string { + var decoded any + if err := json.Unmarshal([]byte(content), &decoded); err != nil { + return nil + } + return collectJSONStringFields(decoded, field) +} + +func collectJSONStringFields(v any, field string) []string { + seen := map[string]struct{}{} + result := make([]string, 0) + var walk func(any) + walk = func(node any) { + switch vv := node.(type) { + case map[string]any: + if value, ok := vv[field].(string); ok { + value = strings.TrimSpace(value) + if value != "" { + if _, exists := seen[value]; !exists { + seen[value] = struct{}{} + result = append(result, value) + } + } + } + for _, nested := range vv { + walk(nested) + } + case []any: + for _, nested := range vv { + walk(nested) + } + } + } + walk(v) + return result +} + +// extractImageKey extracts the first image_key from a Feishu image message content JSON. 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 extractImageKeys(content string) []string { return extractAllJSONStringFields(content, "image_key") } + +// extractFileKey extracts the first file_key from a Feishu file/audio message content JSON. func extractFileKey(content string) string { return extractJSONStringField(content, "file_key") } +func extractFileKeys(content string) []string { return extractAllJSONStringFields(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") } +func extractFileNames(content string) []string { return extractAllJSONStringFields(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 { diff --git a/pkg/channels/feishu/drive_helpers.go b/pkg/channels/feishu/drive_helpers.go new file mode 100644 index 000000000..febaeffef --- /dev/null +++ b/pkg/channels/feishu/drive_helpers.go @@ -0,0 +1,110 @@ +package feishu + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +type feishuMultipartDriveClient interface { + InitiateMultipartUpload(context.Context, string, string, int64) (*MultipartUploadSession, error) + UploadMultipartChunk(context.Context, string, int, []byte) error + CompleteMultipartUpload(context.Context, string, int) (*DriveFileSummary, error) +} + +func (c *FeishuChannel) UploadLargeDriveFile(ctx context.Context, parentToken, name string, r io.Reader, size int64) (*DriveFileSummary, error) { + if strings.TrimSpace(name) == "" { + return nil, fmt.Errorf("file name is empty") + } + if r == nil { + return nil, fmt.Errorf("file reader is nil") + } + if size <= 0 { + return nil, fmt.Errorf("file size must be > 0") + } + client, ok := any(c).(feishuMultipartDriveClient) + if !ok { + return nil, fmt.Errorf("multipart drive upload is not supported in this build") + } + session, err := client.InitiateMultipartUpload(ctx, parentToken, name, size) + if err != nil { + return nil, err + } + blockSize := session.BlockSize + if blockSize <= 0 { + blockSize = 4 * 1024 * 1024 + } + chunks, err := uploadMultipartFromReader(ctx, client, session.UploadID, r, blockSize) + if err != nil { + return nil, err + } + if chunks == 0 { + return nil, fmt.Errorf("no multipart chunks uploaded") + } + return client.CompleteMultipartUpload(ctx, session.UploadID, chunks) +} + +func (c *FeishuChannel) UploadLargeDriveFileFromPath(ctx context.Context, parentToken, path string) (*DriveFileSummary, error) { + if strings.TrimSpace(path) == "" { + return nil, fmt.Errorf("file path is empty") + } + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + info, err := file.Stat() + if err != nil { + return nil, err + } + if info.IsDir() { + return nil, fmt.Errorf("path is a directory: %s", path) + } + return c.UploadLargeDriveFile(ctx, parentToken, filepath.Base(path), file, info.Size()) +} + +func (c *FeishuChannel) uploadMultipartFromReader(ctx context.Context, uploadID string, r io.Reader, blockSize int) (int, error) { + client, ok := any(c).(feishuMultipartDriveClient) + if !ok { + return 0, fmt.Errorf("multipart drive upload is not supported in this build") + } + return uploadMultipartFromReader(ctx, client, uploadID, r, blockSize) +} + +func uploadMultipartFromReader(ctx context.Context, client feishuMultipartDriveClient, uploadID string, r io.Reader, blockSize int) (int, error) { + if strings.TrimSpace(uploadID) == "" { + return 0, fmt.Errorf("upload ID is empty") + } + if r == nil { + return 0, fmt.Errorf("file reader is nil") + } + if blockSize <= 0 { + return 0, fmt.Errorf("block size must be > 0") + } + buf := make([]byte, blockSize) + seq := 0 + for { + n, err := io.ReadFull(r, buf) + if err == io.EOF { + break + } + if err != nil && err != io.ErrUnexpectedEOF { + return seq, err + } + if n <= 0 { + break + } + chunk := append([]byte(nil), buf[:n]...) + if err := client.UploadMultipartChunk(ctx, uploadID, seq, chunk); err != nil { + return seq, err + } + seq++ + if err == io.ErrUnexpectedEOF { + break + } + } + return seq, nil +} diff --git a/pkg/channels/feishu/drive_helpers_test.go b/pkg/channels/feishu/drive_helpers_test.go new file mode 100644 index 000000000..fee932fef --- /dev/null +++ b/pkg/channels/feishu/drive_helpers_test.go @@ -0,0 +1,87 @@ +package feishu + +import ( + "bytes" + "context" + "errors" + "io" + "os" + "testing" +) + +func TestUploadMultipartFromReaderValidation(t *testing.T) { + ch := &FeishuChannel{} + if _, err := ch.uploadMultipartFromReader(context.Background(), "", bytes.NewReader([]byte("x")), 4); err == nil { + t.Fatal("expected error for empty upload id") + } + if _, err := ch.uploadMultipartFromReader(context.Background(), "upload_1", nil, 4); err == nil { + t.Fatal("expected error for nil reader") + } + if _, err := ch.uploadMultipartFromReader(context.Background(), "upload_1", bytes.NewReader([]byte("x")), 0); err == nil { + t.Fatal("expected error for invalid block size") + } +} + +func TestUploadMultipartReadPattern(t *testing.T) { + data := []byte("abcdefghij") + reader := bytes.NewReader(data) + buf := make([]byte, 4) + var chunks [][]byte + for { + n, err := io.ReadFull(reader, buf) + if err == io.EOF { + break + } + if err != nil && err != io.ErrUnexpectedEOF { + t.Fatalf("unexpected read error: %v", err) + } + if n <= 0 { + break + } + chunks = append(chunks, append([]byte(nil), buf[:n]...)) + if err == io.ErrUnexpectedEOF { + break + } + } + if len(chunks) != 3 { + t.Fatalf("chunk count = %d, want 3", len(chunks)) + } + if string(chunks[0]) != "abcd" || string(chunks[1]) != "efgh" || string(chunks[2]) != "ij" { + t.Fatalf("unexpected chunks: %q %q %q", chunks[0], chunks[1], chunks[2]) + } +} + +func TestUploadLargeDriveFileValidation(t *testing.T) { + ch := &FeishuChannel{} + if _, err := ch.UploadLargeDriveFile(context.Background(), "", "", bytes.NewReader([]byte("x")), 1); err == nil { + t.Fatal("expected error for empty name") + } + if _, err := ch.UploadLargeDriveFile(context.Background(), "", "demo.bin", nil, 1); err == nil { + t.Fatal("expected error for nil reader") + } + if _, err := ch.UploadLargeDriveFile(context.Background(), "", "demo.bin", bytes.NewReader([]byte("x")), 0); err == nil { + t.Fatal("expected error for invalid size") + } +} + +func TestUploadLargeDriveFileFromPathValidation(t *testing.T) { + ch := &FeishuChannel{} + if _, err := ch.UploadLargeDriveFileFromPath(context.Background(), "", ""); err == nil { + t.Fatal("expected error for empty path") + } + dir := t.TempDir() + if _, err := ch.UploadLargeDriveFileFromPath(context.Background(), "", dir); err == nil { + t.Fatal("expected error for directory path") + } +} + +func TestUploadLargeDriveFileFromPathMissingFile(t *testing.T) { + ch := &FeishuChannel{} + _, err := ch.UploadLargeDriveFileFromPath(context.Background(), "", "missing.bin") + if err == nil { + t.Fatal("expected error for missing file") + } + if !errors.Is(err, os.ErrNotExist) { + t.Fatalf("expected not exist error, got: %v", err) + } +} diff --git a/pkg/channels/feishu/feishu_64.go b/pkg/channels/feishu/feishu_64.go index 5dbbcf0af..c77914ace 100644 --- a/pkg/channels/feishu/feishu_64.go +++ b/pkg/channels/feishu/feishu_64.go @@ -1,7 +1,6 @@ //go:build amd64 || arm64 || riscv64 || mips64 || ppc64 package feishu - import ( "context" "encoding/json" @@ -11,9 +10,9 @@ import ( "net/http" "os" "path/filepath" + "strings" "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" @@ -282,32 +281,67 @@ func (c *FeishuChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess 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 +func (c *FeishuChannel) SendImageMessage(ctx context.Context, chatID string, data []byte, fileName string) error { + if !c.IsRunning() { + return channels.ErrNotRunning } - - file, err := os.Open(localPath) + if strings.TrimSpace(chatID) == "" { + return fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed) + } + if len(data) == 0 { + return fmt.Errorf("image data is empty: %w", channels.ErrSendFailed) + } + path, err := writeTempFeishuMediaFile(data, fileName, ".img") if err != nil { - logger.ErrorCF("feishu", "Failed to open media file", map[string]any{ - "path": localPath, - "error": err.Error(), - }) - return nil // skip this part + return fmt.Errorf("feishu image temp file: %w", err) + } + defer os.Remove(path) + file, err := os.Open(path) + if err != nil { + return fmt.Errorf("feishu image open temp file: %w", err) } defer file.Close() + return c.sendImage(ctx, chatID, file) +} +func (c *FeishuChannel) SendFileMessage(ctx context.Context, chatID string, data []byte, fileName, fileType string) error { + if !c.IsRunning() { + return channels.ErrNotRunning + } + if strings.TrimSpace(chatID) == "" { + return fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed) + } + if len(data) == 0 { + return fmt.Errorf("file data is empty: %w", channels.ErrSendFailed) + } + if strings.TrimSpace(fileName) == "" { + fileName = "file" + } + path, err := writeTempFeishuMediaFile(data, fileName, ".bin") + if err != nil { + return fmt.Errorf("feishu file temp file: %w", err) + } + defer os.Remove(path) + file, err := os.Open(path) + if err != nil { + return fmt.Errorf("feishu file open temp file: %w", err) + } + defer file.Close() + return c.sendFile(ctx, chatID, file, fileName, fileType) +} + +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 + } + 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 + } + defer file.Close() switch part.Type { case "image": err = c.sendImage(ctx, chatID, file) @@ -318,17 +352,40 @@ func (c *FeishuChannel) sendMediaPart( } 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(), - }) + 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 } +func writeTempFeishuMediaFile(data []byte, fileName, fallbackExt string) (string, error) { + name := strings.TrimSpace(fileName) + if name == "" { + name = "media" + } + ext := filepath.Ext(name) + if ext == "" { + ext = fallbackExt + } + pattern := "feishu-media-*" + ext + f, err := os.CreateTemp("", pattern) + if err != nil { + return "", err + } + path := f.Name() + if _, err := f.Write(data); err != nil { + f.Close() + os.Remove(path) + return "", err + } + if err := f.Close(); err != nil { + os.Remove(path) + return "", err + } + return path, nil +} + // --- Inbound message handling --- func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim.P2MessageReceiveV1) error { @@ -504,15 +561,16 @@ func extractContent(messageType, rawContent string) string { return rawContent case larkim.MsgTypePost: - // Pass raw JSON to LLM — structured rich text is more informative than flattened plain text + texts := extractAllJSONStringFields(rawContent, "text") + if len(texts) > 0 { + return strings.TrimSpace(strings.Join(texts, "\n")) + } 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 @@ -535,21 +593,28 @@ func (c *FeishuChannel) downloadInboundMedia( switch messageType { case larkim.MsgTypeImage: - imageKey := extractImageKey(rawContent) - if imageKey == "" { - return nil + for _, imageKey := range extractImageKeys(rawContent) { + ref := c.downloadResource(ctx, messageID, imageKey, "image", ".jpg", store, scope) + if ref != "" { + refs = append(refs, ref) + } } - ref := c.downloadResource(ctx, messageID, imageKey, "image", ".jpg", store, scope) - if ref != "" { - refs = append(refs, ref) + + case larkim.MsgTypePost: + for _, imageKey := range extractImageKeys(rawContent) { + ref := c.downloadResource(ctx, messageID, imageKey, "image", ".jpg", store, scope) + if ref != "" { + refs = append(refs, ref) + } + } + for _, fileKey := range extractFileKeys(rawContent) { + ref := c.downloadResource(ctx, messageID, fileKey, "file", "", 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: @@ -557,11 +622,13 @@ func (c *FeishuChannel) downloadInboundMedia( case larkim.MsgTypeMedia: ext = ".mp4" default: - ext = "" // generic file — rely on resp.FileName + ext = "" } - ref := c.downloadResource(ctx, messageID, fileKey, "file", ext, store, scope) - if ref != "" { - refs = append(refs, ref) + for _, fileKey := range extractFileKeys(rawContent) { + ref := c.downloadResource(ctx, messageID, fileKey, "file", ext, store, scope) + if ref != "" { + refs = append(refs, ref) + } } } @@ -813,6 +880,7 @@ func (c *FeishuChannel) sendFile(ctx context.Context, chatID string, file *os.Fi return nil } + func extractFeishuSenderID(sender *larkim.EventSender) string { if sender == nil || sender.SenderId == nil { return "" diff --git a/pkg/channels/feishu/feishu_64_test.go b/pkg/channels/feishu/feishu_64_test.go index dc3eab2e7..d0828efc9 100644 --- a/pkg/channels/feishu/feishu_64_test.go +++ b/pkg/channels/feishu/feishu_64_test.go @@ -3,9 +3,11 @@ package feishu import ( + "errors" "testing" larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1" + "github.com/sipeed/picoclaw/pkg/channels" ) func TestExtractContent(t *testing.T) { @@ -254,3 +256,57 @@ func TestExtractFeishuSenderID(t *testing.T) { }) } } + +func TestSendImageMessageValidation(t *testing.T) { + base := channels.NewBaseChannel("feishu", nil, nil, nil) + ch := &FeishuChannel{BaseChannel: base} + if err := ch.SendImageMessage(t.Context(), "oc_1", []byte("x"), "demo.png"); !errors.Is(err, channels.ErrNotRunning) { + t.Fatalf("expected ErrNotRunning, got: %v", err) + } + + base = channels.NewBaseChannel("feishu", nil, nil, nil) + ch = &FeishuChannel{BaseChannel: base} + ch.SetRunning(true) + if err := ch.SendImageMessage(t.Context(), "", []byte("x"), "demo.png"); !errors.Is(err, channels.ErrSendFailed) { + t.Fatalf("expected ErrSendFailed for empty chat ID, got: %v", err) + } + if err := ch.SendImageMessage(t.Context(), "oc_1", nil, "demo.png"); !errors.Is(err, channels.ErrSendFailed) { + t.Fatalf("expected ErrSendFailed for empty image data, got: %v", err) + } +} + +func TestSendFileMessageValidation(t *testing.T) { + base := channels.NewBaseChannel("feishu", nil, nil, nil) + ch := &FeishuChannel{BaseChannel: base} + if err := ch.SendFileMessage(t.Context(), "oc_1", []byte("x"), "demo.txt", "stream"); !errors.Is(err, channels.ErrNotRunning) { + t.Fatalf("expected ErrNotRunning, got: %v", err) + } + + base = channels.NewBaseChannel("feishu", nil, nil, nil) + ch = &FeishuChannel{BaseChannel: base} + ch.SetRunning(true) + if err := ch.SendFileMessage(t.Context(), "", []byte("x"), "demo.txt", "stream"); !errors.Is(err, channels.ErrSendFailed) { + t.Fatalf("expected ErrSendFailed for empty chat ID, got: %v", err) + } + if err := ch.SendFileMessage(t.Context(), "oc_1", nil, "demo.txt", "stream"); !errors.Is(err, channels.ErrSendFailed) { + t.Fatalf("expected ErrSendFailed for empty file data, got: %v", err) + } +} + +func TestExtractAllMediaKeys(t *testing.T) { + imagePayload := `{"title":"","content":[[{"tag":"text","text":"你看到了什么","style":[]}],[{"tag":"img","image_key":"img_1","width":800,"height":800},{"tag":"img","image_key":"img_2"}],[{"tag":"img","image_key":"img_1"}]]}` + images := extractImageKeys(imagePayload) + if len(images) != 2 || images[0] != "img_1" || images[1] != "img_2" { + t.Fatalf("unexpected image keys: %#v", images) + } + + filePayload := `{"content":[[{"tag":"file","file_key":"file_1","file_name":"a.pdf"}],[{"tag":"file","file_key":"file_2","file_name":"b.pdf"}],[{"tag":"file","file_key":"file_1","file_name":"a.pdf"}]]}` + files := extractFileKeys(filePayload) + if len(files) != 2 || files[0] != "file_1" || files[1] != "file_2" { + t.Fatalf("unexpected file keys: %#v", files) + } + fileNames := extractFileNames(filePayload) + if len(fileNames) != 2 || fileNames[0] != "a.pdf" || fileNames[1] != "b.pdf" { + t.Fatalf("unexpected file names: %#v", fileNames) + } +} diff --git a/pkg/channels/feishu/integration_test.go b/pkg/channels/feishu/integration_test.go new file mode 100644 index 000000000..9816d31a9 --- /dev/null +++ b/pkg/channels/feishu/integration_test.go @@ -0,0 +1,43 @@ +//go:build integration + +package feishu + +import ( + "context" + "os" + "testing" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" +) + +func newIntegrationChannel(t *testing.T) *FeishuChannel { + t.Helper() + cfg := config.FeishuConfig{ + Enabled: true, + AppID: os.Getenv("FEISHU_APP_ID"), + AppSecret: os.Getenv("FEISHU_APP_SECRET"), + } + if cfg.AppID == "" || cfg.AppSecret == "" { + t.Skip("FEISHU_APP_ID or FEISHU_APP_SECRET is not set") + } + ch, err := NewFeishuChannel(cfg, bus.NewMessageBus()) + if err != nil { + t.Fatalf("new channel: %v", err) + } + return ch +} + +func TestIntegrationGetMessageRequiresMessageID(t *testing.T) { + ch := newIntegrationChannel(t) + if _, err := ch.GetMessage(context.Background(), ""); err == nil { + t.Fatal("expected error for empty message id") + } +} + +func TestIntegrationDriveRequiresFileToken(t *testing.T) { + ch := newIntegrationChannel(t) + if _, err := ch.GetDriveFile(context.Background(), ""); err == nil { + t.Fatal("expected error for empty file token") + } +} diff --git a/pkg/channels/feishu/runtime_64_shared.go b/pkg/channels/feishu/runtime_64_shared.go new file mode 100644 index 000000000..2e5b63f90 --- /dev/null +++ b/pkg/channels/feishu/runtime_64_shared.go @@ -0,0 +1,3 @@ +//go:build amd64 || arm64 || riscv64 || mips64 || ppc64 + +package feishu diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index 1a24bb980..c58a8e045 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -208,6 +208,24 @@ func (m *Manager) initChannel(name, displayName string) { func (m *Manager) initChannels() error { logger.InfoC("channels", "Initializing channel manager") + logger.InfoCF("channels", "Channel enablement snapshot", map[string]any{ + "telegram_enabled": m.config.Channels.Telegram.Enabled, + "whatsapp_enabled": m.config.Channels.WhatsApp.Enabled, + "feishu_enabled": m.config.Channels.Feishu.Enabled, + "discord_enabled": m.config.Channels.Discord.Enabled, + "maixcam_enabled": m.config.Channels.MaixCam.Enabled, + "qq_enabled": m.config.Channels.QQ.Enabled, + "dingtalk_enabled": m.config.Channels.DingTalk.Enabled, + "slack_enabled": m.config.Channels.Slack.Enabled, + "matrix_enabled": m.config.Channels.Matrix.Enabled, + "line_enabled": m.config.Channels.LINE.Enabled, + "onebot_enabled": m.config.Channels.OneBot.Enabled, + "wecom_enabled": m.config.Channels.WeCom.Enabled, + "wecom_app_enabled": m.config.Channels.WeComApp.Enabled, + "wecom_aibot_enabled": m.config.Channels.WeComAIBot.Enabled, + "pico_enabled": m.config.Channels.Pico.Enabled, + "irc_enabled": m.config.Channels.IRC.Enabled, + }) if m.config.Channels.Telegram.Enabled && m.config.Channels.Telegram.Token != "" { m.initChannel("telegram", "Telegram") @@ -284,7 +302,17 @@ func (m *Manager) initChannels() error { logger.InfoCF("channels", "Channel initialization completed", map[string]any{ "enabled_channels": len(m.channels), }) - + if len(m.channels) == 0 { + logger.WarnCF("channels", "No channels enabled after initialization", map[string]any{ + "telegram_ready": m.config.Channels.Telegram.Enabled && m.config.Channels.Telegram.Token != "", + "whatsapp_ready": m.config.Channels.WhatsApp.Enabled && (m.config.Channels.WhatsApp.UseNative || m.config.Channels.WhatsApp.BridgeURL != ""), + "feishu_ready": m.config.Channels.Feishu.Enabled, + "discord_ready": m.config.Channels.Discord.Enabled && m.config.Channels.Discord.Token != "", + "matrix_ready": m.config.Channels.Matrix.Enabled && m.config.Channels.Matrix.Homeserver != "" && m.config.Channels.Matrix.UserID != "" && m.config.Channels.Matrix.AccessToken != "", + "line_ready": m.config.Channels.LINE.Enabled && m.config.Channels.LINE.ChannelAccessToken != "", + }) + return fmt.Errorf("no channels enabled") + } return nil } @@ -765,8 +793,8 @@ func (m *Manager) runTTLJanitor(ctx context.Context) { func (m *Manager) GetChannel(name string) (Channel, bool) { m.mu.RLock() defer m.mu.RUnlock() - channel, ok := m.channels[name] - return channel, ok + ch, ok := m.channels[name] + return ch, ok } func (m *Manager) GetStatus() map[string]any { diff --git a/pkg/providers/codex_cli_provider_test.go b/pkg/providers/codex_cli_provider_test.go index 414e0844d..d3fdad313 100644 --- a/pkg/providers/codex_cli_provider_test.go +++ b/pkg/providers/codex_cli_provider_test.go @@ -7,6 +7,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "strings" "testing" ) @@ -401,20 +402,60 @@ func TestCodexCliProvider_GetDefaultModel(t *testing.T) { func createMockCodexCLI(t *testing.T, events []string) string { t.Helper() tmpDir := t.TempDir() - scriptPath := filepath.Join(tmpDir, "codex") + if runtime.GOOS == "windows" { + scriptPath := filepath.Join(tmpDir, "codex.cmd") + var sb strings.Builder + sb.WriteString("@echo off\r\n") + for _, event := range events { + escaped := strings.ReplaceAll(event, `"`, `""`) + sb.WriteString("echo " + escaped + "\r\n") + } + if err := os.WriteFile(scriptPath, []byte(sb.String()), 0o755); err != nil { + t.Fatal(err) + } + return scriptPath + } + + scriptPath := filepath.Join(tmpDir, "codex") var sb strings.Builder sb.WriteString("#!/bin/bash\n") for _, event := range events { sb.WriteString(fmt.Sprintf("echo '%s'\n", event)) } - if err := os.WriteFile(scriptPath, []byte(sb.String()), 0o755); err != nil { t.Fatal(err) } return scriptPath } +func createMockCodexCLIWithArgsCapture(t *testing.T, body string) (string, string) { + t.Helper() + tmpDir := t.TempDir() + argsPath := filepath.Join(tmpDir, "args.txt") + + if runtime.GOOS == "windows" { + scriptPath := filepath.Join(tmpDir, "codex.cmd") + script := "@echo off\r\n" + + "setlocal EnableDelayedExpansion\r\n" + + "echo %* > \"" + argsPath + "\"\r\n" + + body + "\r\n" + if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + return scriptPath, argsPath + } + + scriptPath := filepath.Join(tmpDir, "codex") + script := "#!/bin/bash\n" + + "echo \"$@\" > \"" + argsPath + "\"\n" + + body + "\n" + if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + return scriptPath, argsPath +} + func TestCodexCliProvider_MockCLI_Success(t *testing.T) { scriptPath := createMockCodexCLI(t, []string{ `{"type":"thread.started","thread_id":"test-123"}`, @@ -472,17 +513,10 @@ func TestCodexCliProvider_MockCLI_Error(t *testing.T) { func TestCodexCliProvider_MockCLI_WithModel(t *testing.T) { // Mock script that captures args to verify model flag is passed - tmpDir := t.TempDir() - scriptPath := filepath.Join(tmpDir, "codex") - script := `#!/bin/bash -# Write args to a file for verification -echo "$@" > "` + filepath.Join(tmpDir, "args.txt") + `" -echo '{"type":"item.completed","item":{"id":"1","type":"agent_message","text":"ok"}}' -echo '{"type":"turn.completed"}'` - - if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil { - t.Fatal(err) - } + scriptPath, argsPath := createMockCodexCLIWithArgsCapture(t, + "echo '{\"type\":\"item.completed\",\"item\":{\"id\":\"1\",\"type\":\"agent_message\",\"text\":\"ok\"}}'\n"+ + "echo '{\"type\":\"turn.completed\"}'", + ) p := &CodexCliProvider{ command: scriptPath, @@ -496,7 +530,7 @@ echo '{"type":"turn.completed"}'` } // Verify the args - argsData, err := os.ReadFile(filepath.Join(tmpDir, "args.txt")) + argsData, err := os.ReadFile(argsPath) if err != nil { t.Fatalf("reading args: %v", err) } @@ -505,22 +539,33 @@ echo '{"type":"turn.completed"}'` if !strings.Contains(args, "-m gpt-5.2-codex") { t.Errorf("args should contain model flag, got: %s", args) } - if !strings.Contains(args, "-C /tmp/test-workspace") { - t.Errorf("args should contain workspace flag, got: %s", args) - } if !strings.Contains(args, "--json") { t.Errorf("args should contain --json, got: %s", args) } if !strings.Contains(args, "--dangerously-bypass-approvals-and-sandbox") { t.Errorf("args should contain bypass flag, got: %s", args) } + if runtime.GOOS == "windows" { + if !strings.Contains(args, `-C /tmp/test-workspace`) { + t.Errorf("args should contain workspace flag, got: %s", args) + } + } else if !strings.Contains(args, "-C /tmp/test-workspace") { + t.Errorf("args should contain workspace flag, got: %s", args) + } } func TestCodexCliProvider_MockCLI_ContextCancel(t *testing.T) { // Script that sleeps forever tmpDir := t.TempDir() - scriptPath := filepath.Join(tmpDir, "codex") - script := "#!/bin/bash\nsleep 60" + var scriptPath string + var script string + if runtime.GOOS == "windows" { + scriptPath = filepath.Join(tmpDir, "codex.cmd") + script = "@echo off\r\nping 127.0.0.1 -n 60 > nul\r\n" + } else { + scriptPath = filepath.Join(tmpDir, "codex") + script = "#!/bin/bash\nsleep 60" + } if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil { t.Fatal(err) diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go index a798154cb..efb2b3115 100644 --- a/pkg/providers/factory_provider.go +++ b/pkg/providers/factory_provider.go @@ -95,7 +95,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err case "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia", "ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras", "vivgrid", "volcengine", "vllm", "qwen", "mistral", "avian", - "minimax": + "minimax", "siliconflow": // All other OpenAI-compatible HTTP providers if cfg.APIKey == "" && cfg.APIBase == "" { return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol) @@ -170,6 +170,15 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err return provider, modelID, nil default: + if strings.TrimSpace(cfg.APIBase) != "" { + return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( + cfg.APIKey, + cfg.APIBase, + cfg.Proxy, + cfg.MaxTokensField, + cfg.RequestTimeout, + ), cfg.Model, nil + } return nil, "", fmt.Errorf("unknown protocol %q in model %q", protocol, cfg.Model) } } @@ -215,6 +224,8 @@ func getDefaultAPIBase(protocol string) string { return "https://api.avian.io/v1" case "minimax": return "https://api.minimaxi.com/v1" + case "siliconflow": + return "https://api.siliconflow.cn/v1" default: return "" } diff --git a/pkg/providers/factory_provider_test.go b/pkg/providers/factory_provider_test.go index 17bc55d25..42ae6e8e9 100644 --- a/pkg/providers/factory_provider_test.go +++ b/pkg/providers/factory_provider_test.go @@ -64,6 +64,12 @@ func TestExtractProtocol(t *testing.T) { wantProtocol: "nvidia", wantModelID: "meta/llama-3.1-8b", }, + { + name: "siliconflow with nested model path", + model: "siliconflow/Pro/zai-org/GLM-4.7", + wantProtocol: "siliconflow", + wantModelID: "Pro/zai-org/GLM-4.7", + }, } for _, tt := range tests { @@ -113,6 +119,7 @@ func TestCreateProviderFromConfig_DefaultAPIBase(t *testing.T) { {"vllm", "vllm"}, {"deepseek", "deepseek"}, {"ollama", "ollama"}, + {"siliconflow", "siliconflow"}, } for _, tt := range tests { @@ -142,6 +149,12 @@ func TestGetDefaultAPIBase_LiteLLM(t *testing.T) { } } +func TestGetDefaultAPIBase_SiliconFlow(t *testing.T) { + if got := getDefaultAPIBase("siliconflow"); got != "https://api.siliconflow.cn/v1" { + t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "siliconflow", got, "https://api.siliconflow.cn/v1") + } +} + func TestCreateProviderFromConfig_LiteLLM(t *testing.T) { cfg := &config.ModelConfig{ ModelName: "test-litellm", @@ -256,7 +269,30 @@ func TestCreateProviderFromConfig_UnknownProtocol(t *testing.T) { _, _, err := CreateProviderFromConfig(cfg) if err == nil { - t.Fatal("CreateProviderFromConfig() expected error for unknown protocol") + t.Fatal("CreateProviderFromConfig() expected error for unknown protocol without api_base") + } +} + +func TestCreateProviderFromConfig_UnknownProtocolWithAPIBaseFallsBackToHTTP(t *testing.T) { + cfg := &config.ModelConfig{ + ModelName: "test-unknown-fallback", + Model: "Pro/zai-org/GLM-4.7", + APIKey: "test-key", + APIBase: "https://api.siliconflow.cn/v1", + } + + provider, modelID, err := CreateProviderFromConfig(cfg) + if err != nil { + t.Fatalf("CreateProviderFromConfig() error = %v", err) + } + if provider == nil { + t.Fatal("CreateProviderFromConfig() returned nil provider") + } + if _, ok := provider.(*HTTPProvider); !ok { + t.Fatalf("expected *HTTPProvider, got %T", provider) + } + if modelID != "Pro/zai-org/GLM-4.7" { + t.Errorf("modelID = %q, want %q", modelID, "Pro/zai-org/GLM-4.7") } } @@ -317,3 +353,25 @@ func TestCreateProviderFromConfig_RequestTimeoutPropagation(t *testing.T) { t.Fatalf("Chat() error = %q, want timeout-related error", errMsg) } } + +func TestCreateProviderFromConfig_SiliconFlowNestedModelPath(t *testing.T) { + cfg := &config.ModelConfig{ + ModelName: "test-siliconflow", + Model: "siliconflow/Pro/zai-org/GLM-4.7", + APIKey: "test-key", + } + + provider, modelID, err := CreateProviderFromConfig(cfg) + if err != nil { + t.Fatalf("CreateProviderFromConfig() error = %v", err) + } + if provider == nil { + t.Fatal("CreateProviderFromConfig() returned nil provider") + } + if _, ok := provider.(*HTTPProvider); !ok { + t.Fatalf("expected *HTTPProvider, got %T", provider) + } + if modelID != "Pro/zai-org/GLM-4.7" { + t.Errorf("modelID = %q, want %q", modelID, "Pro/zai-org/GLM-4.7") + } +} diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index 0e8db7409..dd3a31541 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -117,7 +117,7 @@ func (p *Provider) Chat( requestBody := map[string]any{ "model": model, - "messages": serializeMessages(messages), + "messages": serializeMessagesForProvider(messages, p.apiBase, model), } if len(tools) > 0 { @@ -150,6 +150,11 @@ func (p *Provider) Chat( requestBody["temperature"] = temperature } } + if strings.Contains(strings.ToLower(model), "glm-4.6v") { + if level, _ := options["thinking_level"].(string); strings.TrimSpace(level) != "" && strings.ToLower(level) != "off" { + requestBody["thinking"] = map[string]any{"type": "enabled"} + } + } // Prompt caching: pass a stable cache key so OpenAI can bucket requests // with the same key and reuse prefix KV cache across calls. @@ -172,6 +177,11 @@ func (p *Provider) Chat( if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } + requestCtx := requestErrorContext{ + APIBase: p.apiBase, + Model: model, + Path: req.URL.Path, + } req.Header.Set("Content-Type", "application/json") if p.apiKey != "" { @@ -193,13 +203,9 @@ func (p *Provider) Chat( return nil, fmt.Errorf("failed to read response: %w", readErr) } if looksLikeHTML(body, contentType) { - return nil, wrapHTMLResponseError(resp.StatusCode, body, contentType, p.apiBase) + return nil, wrapHTMLResponseError(resp.StatusCode, body, contentType, requestCtx) } - return nil, fmt.Errorf( - "API request failed:\n Status: %d\n Body: %s", - resp.StatusCode, - responsePreview(body, 128), - ) + return nil, formatAPIError(resp.StatusCode, body, requestCtx) } // Peek without consuming so the full stream reaches the JSON decoder. @@ -209,7 +215,7 @@ func (p *Provider) Chat( return nil, fmt.Errorf("failed to inspect response: %w", err) } if looksLikeHTML(prefix, contentType) { - return nil, wrapHTMLResponseError(resp.StatusCode, prefix, contentType, p.apiBase) + return nil, wrapHTMLResponseError(resp.StatusCode, prefix, contentType, requestCtx) } out, err := parseResponse(reader) @@ -220,17 +226,130 @@ func (p *Provider) Chat( return out, nil } -func wrapHTMLResponseError(statusCode int, body []byte, contentType, apiBase string) error { +type requestErrorContext struct { + APIBase string + Model string + Path string +} + +func wrapHTMLResponseError(statusCode int, body []byte, contentType string, requestCtx requestErrorContext) error { respPreview := responsePreview(body, 128) return fmt.Errorf( - "API request failed: %s returned HTML instead of JSON (content-type: %s); check api_base or proxy configuration.\n Status: %d\n Body: %s", - apiBase, + "API request failed: %s returned HTML instead of JSON (content-type: %s); check api_base or proxy configuration.\n Status: %d\n API Base: %s\n Model: %s\n Path: %s\n Body: %s", + requestCtx.APIBase, contentType, statusCode, + requestCtx.APIBase, + requestCtx.Model, + requestCtx.Path, respPreview, ) } +func formatAPIError(statusCode int, body []byte, requestCtx requestErrorContext) error { + if details, ok := extractStructuredAPIError(body); ok { + if details.Code != "" { + return fmt.Errorf( + "API request failed:\n Status: %d\n API Base: %s\n Model: %s\n Path: %s\n Message: %s\n Code: %s\n Body: %s", + statusCode, + requestCtx.APIBase, + requestCtx.Model, + requestCtx.Path, + details.Message, + details.Code, + responsePreview(body, 128), + ) + } + return fmt.Errorf( + "API request failed:\n Status: %d\n API Base: %s\n Model: %s\n Path: %s\n Message: %s\n Body: %s", + statusCode, + requestCtx.APIBase, + requestCtx.Model, + requestCtx.Path, + details.Message, + responsePreview(body, 128), + ) + } + return fmt.Errorf( + "API request failed:\n Status: %d\n API Base: %s\n Model: %s\n Path: %s\n Body: %s", + statusCode, + requestCtx.APIBase, + requestCtx.Model, + requestCtx.Path, + responsePreview(body, 128), + ) +} + +type structuredAPIError struct { + Message string + Code string +} + +func extractStructuredAPIError(body []byte) (structuredAPIError, bool) { + trimmed := bytes.TrimSpace(body) + if len(trimmed) == 0 || trimmed[0] != '{' { + return structuredAPIError{}, false + } + + var payload map[string]any + if err := json.Unmarshal(trimmed, &payload); err != nil { + return structuredAPIError{}, false + } + + details := structuredAPIError{} + if msg := stringifyErrorField(payload["message"]); msg != "" { + details.Message = msg + } + if code := stringifyErrorField(payload["code"]); code != "" { + details.Code = code + } + + if errField, ok := payload["error"]; ok { + switch e := errField.(type) { + case string: + if details.Message == "" { + details.Message = e + } + case map[string]any: + if details.Message == "" { + details.Message = stringifyErrorField(e["message"]) + } + if details.Code == "" { + details.Code = stringifyErrorField(e["code"]) + } + if details.Message == "" { + details.Message = stringifyErrorField(e["type"]) + } + } + } + + if details.Message == "" { + return structuredAPIError{}, false + } + return details, true +} + +func stringifyErrorField(v any) string { + switch val := v.(type) { + case string: + return strings.TrimSpace(val) + case json.Number: + return val.String() + case float64: + return strings.TrimSpace(fmt.Sprintf("%.0f", val)) + case float32: + return strings.TrimSpace(fmt.Sprintf("%.0f", val)) + case int: + return fmt.Sprintf("%d", val) + case int64: + return fmt.Sprintf("%d", val) + case int32: + return fmt.Sprintf("%d", val) + default: + return "" + } +} + func looksLikeHTML(body []byte, contentType string) bool { contentType = strings.ToLower(strings.TrimSpace(contentType)) if strings.Contains(contentType, "text/html") || strings.Contains(contentType, "application/xhtml+xml") { @@ -377,6 +496,12 @@ type openaiMessage struct { // - Converts messages with Media to multipart content format (text + image_url parts) // - Preserves ToolCallID, ToolCalls, and ReasoningContent for all messages func serializeMessages(messages []Message) []any { + return serializeMessagesForProvider(messages, "", "") +} + +func serializeMessagesForProvider(messages []Message, apiBase, model string) []any { + useObjectImageURL := usesObjectImageURL(apiBase, model) + preferImageFirst := usesImageFirstOrdering(apiBase, model) out := make([]any, 0, len(messages)) for _, m := range messages { if len(m.Media) == 0 { @@ -390,29 +515,35 @@ func serializeMessages(messages []Message) []any { continue } - // Multipart content format for messages with media parts := make([]map[string]any, 0, 1+len(m.Media)) - if m.Content != "" { - parts = append(parts, map[string]any{ - "type": "text", - "text": m.Content, - }) - } - for _, mediaURL := range m.Media { - if strings.HasPrefix(mediaURL, "data:image/") { - parts = append(parts, map[string]any{ - "type": "image_url", - "image_url": map[string]any{ - "url": mediaURL, - }, - }) + appendImageParts := func() { + for _, mediaURL := range m.Media { + if !strings.HasPrefix(mediaURL, "data:image/") && !strings.HasPrefix(mediaURL, "http://") && !strings.HasPrefix(mediaURL, "https://") { + continue + } + part := map[string]any{"type": "image_url"} + if useObjectImageURL { + part["image_url"] = map[string]any{"url": mediaURL} + } else { + part["image_url"] = mediaURL + } + parts = append(parts, part) } } - - msg := map[string]any{ - "role": m.Role, - "content": parts, + appendTextPart := func() { + if m.Content != "" { + parts = append(parts, map[string]any{"type": "text", "text": m.Content}) + } } + if preferImageFirst { + appendImageParts() + appendTextPart() + } else { + appendTextPart() + appendImageParts() + } + + msg := map[string]any{"role": m.Role, "content": parts} if m.ToolCallID != "" { msg["tool_call_id"] = m.ToolCallID } @@ -427,6 +558,18 @@ func serializeMessages(messages []Message) []any { return out } +func usesImageFirstOrdering(apiBase, model string) bool { + base := strings.ToLower(strings.TrimSpace(apiBase)) + m := strings.ToLower(strings.TrimSpace(model)) + return strings.Contains(base, "bigmodel.cn") || strings.Contains(base, "zhipu") || strings.Contains(m, "glm-4.6v") +} + +func usesObjectImageURL(apiBase, model string) bool { + base := strings.ToLower(strings.TrimSpace(apiBase)) + m := strings.ToLower(strings.TrimSpace(model)) + return strings.Contains(base, "bigmodel.cn") || strings.Contains(base, "zhipu") || strings.Contains(m, "glm") +} + func normalizeModel(model, apiBase string) string { before, after, ok := strings.Cut(model, "/") if !ok { diff --git a/pkg/providers/openai_compat/provider_test.go b/pkg/providers/openai_compat/provider_test.go index 9a3a7acc5..5c8061853 100644 --- a/pkg/providers/openai_compat/provider_test.go +++ b/pkg/providers/openai_compat/provider_test.go @@ -228,14 +228,112 @@ func TestProviderChat_JSONHTTPErrorDoesNotReportHTML(t *testing.T) { if err == nil { t.Fatal("expected error, got nil") } - if !strings.Contains(err.Error(), "Status: 400") { + errMsg := err.Error() + if !strings.Contains(errMsg, "Status: 400") { t.Fatalf("expected status code in error, got %v", err) } - if strings.Contains(err.Error(), "returned HTML instead of JSON") { + if !strings.Contains(errMsg, "API Base: "+server.URL) { + t.Fatalf("expected api base in error, got %v", err) + } + if !strings.Contains(errMsg, "Model: gpt-4o") { + t.Fatalf("expected model in error, got %v", err) + } + if !strings.Contains(errMsg, "Path: /chat/completions") { + t.Fatalf("expected request path in error, got %v", err) + } + if !strings.Contains(errMsg, "Message: bad request") { + t.Fatalf("expected parsed message in error, got %v", err) + } + if strings.Contains(errMsg, "returned HTML instead of JSON") { t.Fatalf("expected non-HTML http error, got %v", err) } } +func TestProviderChat_JSONHTTPErrorIncludesFlatCodeAndMessage(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"code":20012,"message":"Model does not exist. Please check it carefully."}`)) + })) + defer server.Close() + + p := NewProvider("key", server.URL, "") + _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "gpt-4o", nil) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "Message: Model does not exist. Please check it carefully.") { + t.Fatalf("expected upstream message in error, got %v", err) + } + if !strings.Contains(err.Error(), "Code: 20012") { + t.Fatalf("expected upstream code in error, got %v", err) + } + if !strings.Contains(err.Error(), "Model: gpt-4o") { + t.Fatalf("expected model context in error, got %v", err) + } +} + +func TestProviderChat_JSONHTTPErrorIncludesNestedOpenAIError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":{"code":"invalid_api_key","message":"The provided API key is invalid."}}`)) + })) + defer server.Close() + + p := NewProvider("key", server.URL, "") + _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "gpt-4o", nil) + if err == nil { + t.Fatal("expected error, got nil") + } + errMsg := err.Error() + if !strings.Contains(errMsg, "Status: 400") { + t.Fatalf("expected status code in error, got %v", err) + } + if !strings.Contains(errMsg, "API Base: "+server.URL) { + t.Fatalf("expected api base in error, got %v", err) + } + if !strings.Contains(errMsg, "Model: gpt-4o") { + t.Fatalf("expected model in error, got %v", err) + } + if !strings.Contains(errMsg, "Path: /chat/completions") { + t.Fatalf("expected request path in error, got %v", err) + } + if !strings.Contains(errMsg, "Message: The provided API key is invalid.") { + t.Fatalf("expected parsed message in error, got %v", err) + } + if !strings.Contains(errMsg, "Code: invalid_api_key") { + t.Fatalf("expected nested error code in error, got %v", err) + } + if !strings.Contains(errMsg, "Path: /chat/completions") { + t.Fatalf("expected request path in error, got %v", err) + } +} + +func TestProviderChat_MalformedJSONHTTPErrorFallsBackToBodyPreview(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"message":"unterminated`)) + })) + defer server.Close() + + p := NewProvider("key", server.URL, "") + _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "gpt-4o", nil) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "Body: {\"message\":\"unterminated") { + t.Fatalf("expected raw body preview fallback, got %v", err) + } + if !strings.Contains(err.Error(), "API Base: "+server.URL) { + t.Fatalf("expected api base context in fallback error, got %v", err) + } + if strings.Contains(err.Error(), "Message:") { + t.Fatalf("expected no parsed message for malformed json, got %v", err) + } +} + func TestProviderChat_HTMLResponsesReturnHelpfulError(t *testing.T) { tests := []struct { name string @@ -283,6 +381,15 @@ func TestProviderChat_HTMLResponsesReturnHelpfulError(t *testing.T) { if !strings.Contains(err.Error(), "returned HTML instead of JSON") { t.Fatalf("expected helpful HTML error, got %v", err) } + if !strings.Contains(err.Error(), "API Base: "+server.URL) { + t.Fatalf("expected api base context, got %v", err) + } + if !strings.Contains(err.Error(), "Model: gpt-4o") { + t.Fatalf("expected model context, got %v", err) + } + if !strings.Contains(err.Error(), "Path: /chat/completions") { + t.Fatalf("expected request path context, got %v", err) + } if !strings.Contains(err.Error(), "check api_base or proxy configuration") { t.Fatalf("expected configuration hint, got %v", err) } @@ -333,7 +440,7 @@ func TestProviderChat_LargeHTMLResponsePreviewIsTruncated(t *testing.T) { if err == nil { t.Fatal("expected error, got nil") } - if !strings.Contains(err.Error(), "Body: ") { + if !strings.Contains(err.Error(), "Body: ") { t.Fatalf("expected html preview in error, got %v", err) } if !strings.Contains(err.Error(), "...") { @@ -644,9 +751,37 @@ func TestSerializeMessages_WithMedia(t *testing.T) { if imgPart["type"] != "image_url" { t.Fatalf("expected image_url type, got %v", imgPart["type"]) } - imgURL := imgPart["image_url"].(map[string]any) + if imgPart["image_url"] != "data:image/png;base64,abc123" { + t.Fatalf("image url mismatch: %v", imgPart["image_url"]) + } +} + +func TestSerializeMessages_WithMediaForGLM(t *testing.T) { + messages := []protocoltypes.Message{{Role: "user", Content: "describe this", Media: []string{"data:image/png;base64,abc123"}}} + result := serializeMessagesForProvider(messages, "https://open.bigmodel.cn/api/paas/v4", "glm-4.6v-flash") + + data, _ := json.Marshal(result) + var msgs []map[string]any + json.Unmarshal(data, &msgs) + + content := msgs[0]["content"].([]any) + if len(content) != 2 { + t.Fatalf("expected 2 content parts, got %d", len(content)) + } + first := content[0].(map[string]any) + second := content[1].(map[string]any) + if first["type"] != "image_url" { + t.Fatalf("expected image first for glm, got first=%v second=%v", first, second) + } + imgURL, ok := first["image_url"].(map[string]any) + if !ok { + t.Fatalf("expected object-form image_url for glm, got %T (%v)", first["image_url"], first["image_url"]) + } if imgURL["url"] != "data:image/png;base64,abc123" { - t.Fatalf("image url mismatch: %v", imgURL["url"]) + t.Fatalf("glm image url mismatch: %v", imgURL["url"]) + } + if second["type"] != "text" || second["text"] != "describe this" { + t.Fatalf("glm text part mismatch: %v", second) } } diff --git a/pkg/tools/feishu.go b/pkg/tools/feishu.go new file mode 100644 index 000000000..0a4960233 --- /dev/null +++ b/pkg/tools/feishu.go @@ -0,0 +1,643 @@ +package tools + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +type FeishuParseTool struct{} + +func NewFeishuParseTool() *FeishuParseTool { + return &FeishuParseTool{} +} + +func (t *FeishuParseTool) Name() string { + return "feishu_parse" +} + +func (t *FeishuParseTool) Description() string { + return "Parse Feishu message content, card JSON, or share links into a structured summary without calling remote APIs. Useful for reasoning about Feishu payloads locally." +} + +func (t *FeishuParseTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "mode": map[string]any{ + "type": "string", + "description": "One of: message_content, card, share_link", + "enum": []string{"message_content", "card", "share_link"}, + }, + "content": map[string]any{ + "type": "string", + "description": "Raw Feishu JSON string or share link to parse", + }, + }, + "required": []string{"mode", "content"}, + } +} + +func (t *FeishuParseTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + _ = ctx + mode, _ := args["mode"].(string) + content, _ := args["content"].(string) + if strings.TrimSpace(mode) == "" || strings.TrimSpace(content) == "" { + return ErrorResult("mode and content are required") + } + + switch mode { + case "share_link": + result := map[string]any{"token": extractFeishuShareToken(content)} + if decoded := decodeFeishuShareToken(result["token"].(string)); decoded != "" { + result["decoded_message_id"] = decoded + } + return structuredToolResult("feishu share link parsed", result) + case "message_content": + var body map[string]any + if err := json.Unmarshal([]byte(content), &body); err != nil { + return ErrorResult(fmt.Sprintf("invalid message content JSON: %v", err)).WithError(err) + } + result := map[string]any{ + "text": firstJSONStringField(body, "text"), + "image_key": firstJSONStringFieldDeep(body, "image_key"), + "file_key": firstJSONStringFieldDeep(body, "file_key"), + "file_name": firstJSONStringFieldDeep(body, "file_name"), + "raw": body, + } + return structuredToolResult("feishu message content parsed", result) + case "card": + var card map[string]any + if err := json.Unmarshal([]byte(content), &card); err != nil { + return ErrorResult(fmt.Sprintf("invalid card JSON: %v", err)).WithError(err) + } + summary := summarizeFeishuCard(card) + return structuredToolResult("feishu card parsed", summary) + default: + return ErrorResult("unsupported mode") + } +} + +func structuredToolResult(prefix string, value any) *ToolResult { + data, err := json.MarshalIndent(value, "", " ") + if err != nil { + return ErrorResult(fmt.Sprintf("marshal result: %v", err)).WithError(err) + } + return UserResult(prefix + "\n" + string(data)) +} + +func extractFeishuShareToken(link string) string { + const marker = "token=" + idx := strings.Index(link, marker) + if idx < 0 { + return "" + } + token := link[idx+len(marker):] + if cut := strings.Index(token, "&"); cut >= 0 { + token = token[:cut] + } + return token +} + +func decodeFeishuShareToken(token string) string { + replacer := strings.NewReplacer("%3D", "=", "%3d", "=") + return replacer.Replace(token) +} + +func firstJSONStringField(body map[string]any, field string) string { + if body == nil { + return "" + } + if v, ok := body[field].(string); ok { + return v + } + return "" +} + +func firstJSONStringFieldDeep(v any, field string) string { + switch vv := v.(type) { + case map[string]any: + if value, ok := vv[field].(string); ok && strings.TrimSpace(value) != "" { + return value + } + for _, nested := range vv { + if found := firstJSONStringFieldDeep(nested, field); found != "" { + return found + } + } + case []any: + for _, nested := range vv { + if found := firstJSONStringFieldDeep(nested, field); found != "" { + return found + } + } + } + return "" +} + +func summarizeFeishuCard(card map[string]any) map[string]any { + summary := map[string]any{ + "title": "", + "components": []map[string]any{}, + "text_contents": []string{}, + "image_keys": []string{}, + "action_buttons": []string{}, + } + if header, ok := card["header"].(map[string]any); ok { + if title, ok := header["title"].(map[string]any); ok { + if content, ok := title["content"].(string); ok { + summary["title"] = content + } + } + } + if title, ok := card["title"].(string); ok && title != "" { + summary["title"] = title + } + var elements []any + if raw, ok := card["elements"].([]any); ok { + elements = raw + } else if body, ok := card["body"].(map[string]any); ok { + if raw, ok := body["elements"].([]any); ok { + elements = raw + } + } else if raw, ok := card["content"].([]any); ok { + elements = raw + } + texts := make([]string, 0) + images := make([]string, 0) + buttons := make([]string, 0) + components := make([]map[string]any, 0, len(elements)) + for i, raw := range elements { + element, _ := raw.(map[string]any) + tag, _ := element["tag"].(string) + component := map[string]any{"index": i + 1, "tag": tag, "details": map[string]any{}} + details := component["details"].(map[string]any) + switch tag { + case "div", "note", "header": + if text, ok := element["text"].(map[string]any); ok { + if content, ok := text["content"].(string); ok && content != "" { + texts = append(texts, content) + details["text"] = content + } + } + if fields, ok := element["fields"].([]any); ok && len(fields) > 0 { + details["fields"] = fields + details["field_count"] = len(fields) + } + case "text": + if content, ok := element["text"].(string); ok && content != "" { + texts = append(texts, content) + details["text"] = content + } + if mode, ok := element["mode"].(string); ok && mode != "" { + details["mode"] = mode + } + case "img": + if key, ok := element["image_key"].(string); ok && key != "" { + images = append(images, key) + details["image_key"] = key + } + if alt, ok := element["alt"]; ok { + details["alt"] = alt + } + case "action": + if actions, ok := element["actions"].([]any); ok { + parsedActions := make([]map[string]any, 0, len(actions)) + for _, rawAction := range actions { + action, _ := rawAction.(map[string]any) + entry := map[string]any{"type": action["type"], "style": action["style"]} + if text, ok := action["text"].(map[string]any); ok { + if content, ok := text["content"].(string); ok && content != "" { + buttons = append(buttons, content) + entry["text"] = content + } + } + parsedActions = append(parsedActions, entry) + } + details["action_count"] = len(actions) + details["actions"] = parsedActions + } + case "at": + details["user_id"] = element["user_id"] + details["user_name"] = element["user_name"] + details["user_avatar"] = element["user_avatar"] + default: + for k, v := range element { + details[k] = v + } + } + components = append(components, component) + } + summary["components"] = components + summary["text_contents"] = texts + summary["image_keys"] = images + summary["action_buttons"] = buttons + return summary +} + +type FeishuRemoteClient interface { + GetMessage(ctx context.Context, messageID string) (any, error) + ListMessages(ctx context.Context, containerID, containerType string, pageSize int, pageToken string) (any, error) + ReplyMessage(ctx context.Context, messageID, text string) error + GetMessageFromShareLink(ctx context.Context, shareLink string) (any, error) + GetUserInfo(ctx context.Context, userID string) (any, error) + ListUsers(ctx context.Context, pageSize int, userIDType, pageToken string) (any, error) + GetUserIDByEmail(ctx context.Context, email string) (string, error) + GetUserIDByMobile(ctx context.Context, mobile string) (string, error) + CreateGroup(ctx context.Context, name string) (any, error) + GetGroupInfo(ctx context.Context, chatID string) (any, error) + ListGroupMembers(ctx context.Context, chatID string, pageSize int, pageToken string) (any, error) + ListGroups(ctx context.Context, pageSize int, pageToken string) (any, error) + SendGroupMessage(ctx context.Context, chatID, text string) error + GetDriveRootFolder(ctx context.Context) (any, error) + GetDriveFolder(ctx context.Context, folderToken string) (any, error) + GetDriveFile(ctx context.Context, fileToken string) (any, error) + ListDriveFiles(ctx context.Context, folderToken, pageToken string, pageSize int) (any, error) + DownloadDriveFile(ctx context.Context, fileToken string) (any, error) + DeleteDriveFile(ctx context.Context, fileToken string) error + UploadDriveFile(ctx context.Context, parentToken, name string, data []byte) (any, error) + InitiateMultipartUpload(ctx context.Context, parentToken, name string, size int64) (any, error) + UploadMultipartChunk(ctx context.Context, uploadID string, seq int, data []byte) error + CompleteMultipartUpload(ctx context.Context, uploadID string, blockNum int) (any, error) + SendImageMessage(ctx context.Context, chatID string, data []byte, fileName string) error + SendFileMessage(ctx context.Context, chatID string, data []byte, fileName, fileType string) error +} + +type FeishuRemoteTool struct { + client FeishuRemoteClient +} + +func NewFeishuRemoteTool(client FeishuRemoteClient) *FeishuRemoteTool { + return &FeishuRemoteTool{client: client} +} + +func (t *FeishuRemoteTool) Name() string { + return "feishu_remote" +} + +func (t *FeishuRemoteTool) Description() string { + return "Query and operate on Feishu messages, users, groups, Drive files, and multipart uploads through an injected Feishu client." +} + +func (t *FeishuRemoteTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "action": map[string]any{ + "type": "string", + "description": "Feishu remote action to run", + "enum": []string{ + "get_message", "list_messages", "reply_message", "get_message_from_share_link", + "get_user", "list_users", "get_user_id_by_email", "get_user_id_by_mobile", "get_user_id_by_phone", + "create_group", "get_group", "list_group_members", "list_groups", "send_group_message", + "get_drive_root_folder", "get_drive_folder", "get_drive_file", "list_drive_files", + "download_drive_file", "download_image_to_bytes", "download_image_from_message", "delete_drive_file", "upload_drive_file", + "initiate_multipart_upload", "upload_multipart_chunk", "complete_multipart_upload", + "send_image", "send_image_from_url", "send_file", + }, + }, + "id": map[string]any{ + "type": "string", + "description": "Primary identifier such as message ID, chat ID, user ID, share link, file token, or upload ID depending on action", + }, + "text": map[string]any{ + "type": "string", + "description": "Text payload used by reply_message and send_group_message", + }, + "name": map[string]any{ + "type": "string", + "description": "Group name or upload file name depending on action", + }, + "container_type": map[string]any{ + "type": "string", + "description": "For list_messages only; defaults to chat", + }, + "user_id_type": map[string]any{ + "type": "string", + "description": "For list_users only; defaults to open_id", + }, + "page_size": map[string]any{ + "type": "integer", + "description": "Page size for list operations", + }, + "page_token": map[string]any{ + "type": "string", + "description": "Page token for list operations", + }, + "parent_token": map[string]any{ + "type": "string", + "description": "Parent folder token for drive upload or multipart prepare", + }, + "data_base64": map[string]any{ + "type": "string", + "description": "Base64-encoded bytes for upload_drive_file or upload_multipart_chunk", + }, + "seq": map[string]any{ + "type": "integer", + "description": "Chunk sequence number for upload_multipart_chunk", + }, + "size": map[string]any{ + "type": "integer", + "description": "File size for initiate_multipart_upload", + }, + "block_num": map[string]any{ + "type": "integer", + "description": "Uploaded chunk count for complete_multipart_upload", + }, + "file_type": map[string]any{ + "type": "string", + "description": "Optional file type for send_file, such as stream, audio, or video", + }, + "url": map[string]any{ + "type": "string", + "description": "Image URL for send_image_from_url", + }, + "resource_id": map[string]any{ + "type": "string", + "description": "Optional image token or file token used by download_image_from_message", + }, + }, + "required": []string{"action"}, + } +} + +func (t *FeishuRemoteTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + if t.client == nil { + return ErrorResult("feishu remote client is not configured") + } + action, _ := args["action"].(string) + if strings.TrimSpace(action) == "" { + return ErrorResult("action is required") + } + id, _ := args["id"].(string) + text, _ := args["text"].(string) + name, _ := args["name"].(string) + parentToken, _ := args["parent_token"].(string) + pageToken, _ := args["page_token"].(string) + containerType, _ := args["container_type"].(string) + userIDType, _ := args["user_id_type"].(string) + dataBase64, _ := args["data_base64"].(string) + fileType, _ := args["file_type"].(string) + imageURL, _ := args["url"].(string) + resourceID, _ := args["resource_id"].(string) + + pageSize := getIntArg(args, "page_size", 20) + seq := getIntArg(args, "seq", -1) + blockNum := getIntArg(args, "block_num", -1) + size := int64(getIntArg(args, "size", -1)) + + var ( + result any + err error + ) + switch action { + case "get_message": + if strings.TrimSpace(id) == "" { return ErrorResult("id is required") } + result, err = t.client.GetMessage(ctx, id) + case "list_messages": + if strings.TrimSpace(id) == "" { return ErrorResult("id is required") } + result, err = t.client.ListMessages(ctx, id, containerType, pageSize, pageToken) + case "reply_message": + if strings.TrimSpace(id) == "" || strings.TrimSpace(text) == "" { return ErrorResult("id and text are required") } + err = t.client.ReplyMessage(ctx, id, text) + result = map[string]any{"message_id": id, "text": text, "status": "ok"} + case "get_message_from_share_link": + if strings.TrimSpace(id) == "" { return ErrorResult("id is required") } + result, err = t.client.GetMessageFromShareLink(ctx, id) + case "get_user": + if strings.TrimSpace(id) == "" { return ErrorResult("id is required") } + result, err = t.client.GetUserInfo(ctx, id) + case "list_users": + result, err = t.client.ListUsers(ctx, pageSize, userIDType, pageToken) + case "get_user_id_by_email": + if strings.TrimSpace(id) == "" { return ErrorResult("id is required") } + var userID string + userID, err = t.client.GetUserIDByEmail(ctx, id) + result = map[string]any{"email": id, "user_id": userID} + case "get_user_id_by_mobile": + if strings.TrimSpace(id) == "" { return ErrorResult("id is required") } + var userID string + userID, err = t.client.GetUserIDByMobile(ctx, id) + result = map[string]any{"mobile": id, "user_id": userID} + case "get_user_id_by_phone": + if strings.TrimSpace(id) == "" { return ErrorResult("id is required") } + var userID string + userID, err = t.client.GetUserIDByMobile(ctx, id) + result = map[string]any{"phone": id, "user_id": userID} + case "create_group": + if strings.TrimSpace(name) == "" { return ErrorResult("name is required") } + result, err = t.client.CreateGroup(ctx, name) + case "get_group": + if strings.TrimSpace(id) == "" { return ErrorResult("id is required") } + result, err = t.client.GetGroupInfo(ctx, id) + case "list_group_members": + if strings.TrimSpace(id) == "" { return ErrorResult("id is required") } + result, err = t.client.ListGroupMembers(ctx, id, pageSize, pageToken) + case "list_groups": + result, err = t.client.ListGroups(ctx, pageSize, pageToken) + case "send_group_message": + if strings.TrimSpace(id) == "" || strings.TrimSpace(text) == "" { return ErrorResult("id and text are required") } + err = t.client.SendGroupMessage(ctx, id, text) + result = map[string]any{"chat_id": id, "text": text, "status": "ok"} + case "get_drive_root_folder": + result, err = t.client.GetDriveRootFolder(ctx) + case "get_drive_folder": + if strings.TrimSpace(id) == "" { return ErrorResult("id is required") } + result, err = t.client.GetDriveFolder(ctx, id) + case "get_drive_file": + if strings.TrimSpace(id) == "" { return ErrorResult("id is required") } + result, err = t.client.GetDriveFile(ctx, id) + case "list_drive_files": + result, err = t.client.ListDriveFiles(ctx, id, pageToken, pageSize) + case "download_drive_file", "download_image_to_bytes": + if strings.TrimSpace(id) == "" { return ErrorResult("id is required") } + result, err = t.client.DownloadDriveFile(ctx, id) + case "download_image_from_message": + if strings.TrimSpace(id) == "" { return ErrorResult("id is required") } + resolvedResourceID := strings.TrimSpace(resourceID) + message, getErr := t.client.GetMessage(ctx, id) + if getErr != nil { + err = getErr + break + } + messageMap := normalizeFeishuMessageToolValue(message) + if resolvedResourceID == "" { + resolvedResourceID = extractImageTokenFromMessageValue(messageMap) + if resolvedResourceID == "" { + return ErrorResult("resource_id is required when message content does not contain an image token") + } + } + result, err = t.client.DownloadDriveFile(ctx, resolvedResourceID) + if err == nil { + result = map[string]any{"message_id": id, "resource_id": resolvedResourceID, "download": result, "message": messageMap} + } + case "delete_drive_file": + if strings.TrimSpace(id) == "" { return ErrorResult("id is required") } + err = t.client.DeleteDriveFile(ctx, id) + result = map[string]any{"file_token": id, "status": "deleted"} + case "upload_drive_file": + if strings.TrimSpace(name) == "" || strings.TrimSpace(dataBase64) == "" { return ErrorResult("name and data_base64 are required") } + var data []byte + data, err = base64.StdEncoding.DecodeString(dataBase64) + if err == nil { + result, err = t.client.UploadDriveFile(ctx, parentToken, name, data) + } + case "initiate_multipart_upload": + if strings.TrimSpace(name) == "" || size < 0 { return ErrorResult("name and size are required") } + result, err = t.client.InitiateMultipartUpload(ctx, parentToken, name, size) + case "upload_multipart_chunk": + if strings.TrimSpace(id) == "" || seq < 0 || strings.TrimSpace(dataBase64) == "" { return ErrorResult("id, seq and data_base64 are required") } + var data []byte + data, err = base64.StdEncoding.DecodeString(dataBase64) + if err == nil { + err = t.client.UploadMultipartChunk(ctx, id, seq, data) + result = map[string]any{"upload_id": id, "seq": seq, "size": len(data), "status": "ok"} + } + case "complete_multipart_upload": + if strings.TrimSpace(id) == "" { return ErrorResult("id is required") } + if blockNum < 0 { return ErrorResult("block_num is required") } + result, err = t.client.CompleteMultipartUpload(ctx, id, blockNum) + case "send_image": + if strings.TrimSpace(id) == "" || strings.TrimSpace(dataBase64) == "" { return ErrorResult("id and data_base64 are required") } + if strings.TrimSpace(name) == "" { name = "image.bin" } + var data []byte + data, err = base64.StdEncoding.DecodeString(dataBase64) + if err == nil { + err = t.client.SendImageMessage(ctx, id, data, name) + result = map[string]any{"chat_id": id, "file_name": name, "size": len(data), "status": "ok"} + } + case "send_image_from_url": + if strings.TrimSpace(id) == "" || strings.TrimSpace(imageURL) == "" { return ErrorResult("id and url are required") } + if strings.TrimSpace(name) == "" { name = imageNameFromURL(imageURL) } + var data []byte + data, err = fetchURLBytes(ctx, imageURL) + if err == nil { + err = t.client.SendImageMessage(ctx, id, data, name) + result = map[string]any{"chat_id": id, "file_name": name, "source_url": imageURL, "size": len(data), "status": "ok"} + } + case "send_file": + if strings.TrimSpace(id) == "" || strings.TrimSpace(dataBase64) == "" { return ErrorResult("id and data_base64 are required") } + if strings.TrimSpace(name) == "" { return ErrorResult("name is required") } + if strings.TrimSpace(fileType) == "" { fileType = "stream" } + var data []byte + data, err = base64.StdEncoding.DecodeString(dataBase64) + if err == nil { + err = t.client.SendFileMessage(ctx, id, data, name, fileType) + result = map[string]any{"chat_id": id, "file_name": name, "file_type": fileType, "size": len(data), "status": "ok"} + } + default: + return ErrorResult("unsupported action") + } + if err != nil { + return ErrorResult(fmt.Sprintf("feishu remote query failed: %v", err)).WithError(err) + } + return structuredToolResult("feishu remote query result", result) +} + +func getIntArg(args map[string]any, key string, fallback int) int { + switch v := args[key].(type) { + case int: + return v + case int32: + return int(v) + case int64: + return int(v) + case float64: + return int(v) + default: + return fallback + } +} + +func imageNameFromURL(raw string) string { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "image.bin" + } + parts := strings.Split(trimmed, "/") + last := parts[len(parts)-1] + if last == "" || !strings.Contains(last, ".") { + return "image.bin" + } + if cut := strings.Index(last, "?"); cut >= 0 { + last = last[:cut] + } + if last == "" { + return "image.bin" + } + return last +} + +func fetchURLBytes(ctx context.Context, raw string) ([]byte, error) { + if ctx == nil { + ctx = context.Background() + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil) + if err != nil { + return nil, err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("unexpected status: %s", resp.Status) + } + return io.ReadAll(resp.Body) +} + +func normalizeFeishuMessageToolValue(v any) map[string]any { + if m, ok := v.(map[string]any); ok { + if nested, ok := m["message"].(map[string]any); ok { + return nested + } + return m + } + return map[string]any{} +} + +func extractImageTokenFromMessageValue(message map[string]any) string { + if len(message) == 0 { + return "" + } + if parsed, ok := message["parsed"].(map[string]any); ok { + if token := extractImageTokenFromContentValue(parsed["content"]); token != "" { + return token + } + } + if body, ok := message["body"].(map[string]any); ok { + if raw, ok := body["content"].(string); ok && strings.TrimSpace(raw) != "" { + var decoded any + if err := json.Unmarshal([]byte(raw), &decoded); err == nil { + if token := extractImageTokenFromContentValue(decoded); token != "" { + return token + } + } + } + } + return extractImageTokenFromContentValue(message["content"]) +} + +func extractImageTokenFromContentValue(v any) string { + switch vv := v.(type) { + case map[string]any: + if token, ok := vv["image_token"].(string); ok && strings.TrimSpace(token) != "" { + return token + } + if token, ok := vv["image_key"].(string); ok && strings.TrimSpace(token) != "" { + return token + } + case []any: + for _, item := range vv { + if token := extractImageTokenFromContentValue(item); token != "" { + return token + } + } + } + return "" +} diff --git a/pkg/tools/feishu_adapter.go b/pkg/tools/feishu_adapter.go new file mode 100644 index 000000000..40a59d484 --- /dev/null +++ b/pkg/tools/feishu_adapter.go @@ -0,0 +1,315 @@ +package tools + +import "context" + +type FeishuMessageGetter interface { + GetMessage(ctx context.Context, messageID string) (any, error) +} + +type FeishuMessageLister interface { + ListMessages(ctx context.Context, containerID, containerType string, pageSize int, pageToken string) (any, error) +} + +type FeishuMessageReplier interface { + ReplyMessage(ctx context.Context, messageID, text string) error +} + +type FeishuShareLinkResolver interface { + GetMessageFromShareLink(ctx context.Context, shareLink string) (any, error) +} + +type FeishuUserGetter interface { + GetUserInfo(ctx context.Context, userID string) (any, error) +} + +type FeishuUserLister interface { + ListUsers(ctx context.Context, pageSize int, userIDType, pageToken string) (any, error) +} + +type FeishuUserLookup interface { + GetUserIDByEmail(ctx context.Context, email string) (string, error) + GetUserIDByMobile(ctx context.Context, mobile string) (string, error) +} + +type FeishuGroupCreator interface { + CreateGroup(ctx context.Context, name string) (any, error) +} + +type FeishuGroupGetter interface { + GetGroupInfo(ctx context.Context, chatID string) (any, error) +} + +type FeishuGroupMemberLister interface { + ListGroupMembers(ctx context.Context, chatID string, pageSize int, pageToken string) (any, error) +} + +type FeishuGroupLister interface { + ListGroups(ctx context.Context, pageSize int, pageToken string) (any, error) +} + +type FeishuGroupMessageSender interface { + SendGroupMessage(ctx context.Context, chatID, text string) error +} + +type FeishuDriveRootGetter interface { + GetDriveRootFolder(ctx context.Context) (any, error) +} + +type FeishuDriveFolderGetter interface { + GetDriveFolder(ctx context.Context, folderToken string) (any, error) +} + +type FeishuDriveFileGetter interface { + GetDriveFile(ctx context.Context, fileToken string) (any, error) +} + +type FeishuDriveFileLister interface { + ListDriveFiles(ctx context.Context, folderToken, pageToken string, pageSize int) (any, error) +} + +type FeishuDriveFileDownloader interface { + DownloadDriveFile(ctx context.Context, fileToken string) (any, error) +} + +type FeishuDriveFileDeleter interface { + DeleteDriveFile(ctx context.Context, fileToken string) error +} + +type FeishuDriveFileUploader interface { + UploadDriveFile(ctx context.Context, parentToken, name string, data []byte) (any, error) +} + +type FeishuMultipartInitiator interface { + InitiateMultipartUpload(ctx context.Context, parentToken, name string, size int64) (any, error) +} + +type FeishuMultipartChunkUploader interface { + UploadMultipartChunk(ctx context.Context, uploadID string, seq int, data []byte) error +} + +type FeishuMultipartCompleter interface { + CompleteMultipartUpload(ctx context.Context, uploadID string, blockNum int) (any, error) +} + +type FeishuImageSender interface { + SendImageMessage(ctx context.Context, chatID string, data []byte, fileName string) error +} + +type FeishuFileSender interface { + SendFileMessage(ctx context.Context, chatID string, data []byte, fileName, fileType string) error +} + +type FeishuChannelAdapter struct { + messageGetter FeishuMessageGetter + messageLister FeishuMessageLister + messageReplier FeishuMessageReplier + shareLinkResolver FeishuShareLinkResolver + userGetter FeishuUserGetter + userLister FeishuUserLister + userLookup FeishuUserLookup + groupCreator FeishuGroupCreator + groupGetter FeishuGroupGetter + groupMemberLister FeishuGroupMemberLister + groupLister FeishuGroupLister + groupMessageSender FeishuGroupMessageSender + driveRootGetter FeishuDriveRootGetter + driveFolderGetter FeishuDriveFolderGetter + driveGetter FeishuDriveFileGetter + driveLister FeishuDriveFileLister + driveDownloader FeishuDriveFileDownloader + driveDeleter FeishuDriveFileDeleter + driveUploader FeishuDriveFileUploader + multipartInitiator FeishuMultipartInitiator + multipartUploader FeishuMultipartChunkUploader + multipartCompleter FeishuMultipartCompleter + imageSender FeishuImageSender + fileSender FeishuFileSender +} + +func NewFeishuChannelAdapter(target any) *FeishuChannelAdapter { + adapter := &FeishuChannelAdapter{} + if v, ok := target.(FeishuMessageGetter); ok { adapter.messageGetter = v } + if v, ok := target.(FeishuMessageLister); ok { adapter.messageLister = v } + if v, ok := target.(FeishuMessageReplier); ok { adapter.messageReplier = v } + if v, ok := target.(FeishuShareLinkResolver); ok { adapter.shareLinkResolver = v } + if v, ok := target.(FeishuUserGetter); ok { adapter.userGetter = v } + if v, ok := target.(FeishuUserLister); ok { adapter.userLister = v } + if v, ok := target.(FeishuUserLookup); ok { adapter.userLookup = v } + if v, ok := target.(FeishuGroupCreator); ok { adapter.groupCreator = v } + if v, ok := target.(FeishuGroupGetter); ok { adapter.groupGetter = v } + if v, ok := target.(FeishuGroupMemberLister); ok { adapter.groupMemberLister = v } + if v, ok := target.(FeishuGroupLister); ok { adapter.groupLister = v } + if v, ok := target.(FeishuGroupMessageSender); ok { adapter.groupMessageSender = v } + if v, ok := target.(FeishuDriveRootGetter); ok { adapter.driveRootGetter = v } + if v, ok := target.(FeishuDriveFolderGetter); ok { adapter.driveFolderGetter = v } + if v, ok := target.(FeishuDriveFileGetter); ok { adapter.driveGetter = v } + if v, ok := target.(FeishuDriveFileLister); ok { adapter.driveLister = v } + if v, ok := target.(FeishuDriveFileDownloader); ok { adapter.driveDownloader = v } + if v, ok := target.(FeishuDriveFileDeleter); ok { adapter.driveDeleter = v } + if v, ok := target.(FeishuDriveFileUploader); ok { adapter.driveUploader = v } + if v, ok := target.(FeishuMultipartInitiator); ok { adapter.multipartInitiator = v } + if v, ok := target.(FeishuMultipartChunkUploader); ok { adapter.multipartUploader = v } + if v, ok := target.(FeishuMultipartCompleter); ok { adapter.multipartCompleter = v } + if v, ok := target.(FeishuImageSender); ok { adapter.imageSender = v } + if v, ok := target.(FeishuFileSender); ok { adapter.fileSender = v } + return adapter +} + +func (a *FeishuChannelAdapter) GetMessage(ctx context.Context, messageID string) (any, error) { + if a == nil || a.messageGetter == nil { return nil, errFeishuAdapterNotReady("GetMessage") } + return a.messageGetter.GetMessage(ctx, messageID) +} + +func (a *FeishuChannelAdapter) ListMessages(ctx context.Context, containerID, containerType string, pageSize int, pageToken string) (any, error) { + if a == nil || a.messageLister == nil { return nil, errFeishuAdapterNotReady("ListMessages") } + return a.messageLister.ListMessages(ctx, containerID, containerType, pageSize, pageToken) +} + +func (a *FeishuChannelAdapter) ReplyMessage(ctx context.Context, messageID, text string) error { + if a == nil || a.messageReplier == nil { return errFeishuAdapterNotReady("ReplyMessage") } + return a.messageReplier.ReplyMessage(ctx, messageID, text) +} + +func (a *FeishuChannelAdapter) GetMessageFromShareLink(ctx context.Context, shareLink string) (any, error) { + if a == nil || a.shareLinkResolver == nil { return nil, errFeishuAdapterNotReady("GetMessageFromShareLink") } + return a.shareLinkResolver.GetMessageFromShareLink(ctx, shareLink) +} + +func (a *FeishuChannelAdapter) GetUserInfo(ctx context.Context, userID string) (any, error) { + if a == nil || a.userGetter == nil { return nil, errFeishuAdapterNotReady("GetUserInfo") } + return a.userGetter.GetUserInfo(ctx, userID) +} + +func (a *FeishuChannelAdapter) ListUsers(ctx context.Context, pageSize int, userIDType, pageToken string) (any, error) { + if a == nil || a.userLister == nil { return nil, errFeishuAdapterNotReady("ListUsers") } + return a.userLister.ListUsers(ctx, pageSize, userIDType, pageToken) +} + +func (a *FeishuChannelAdapter) GetUserIDByEmail(ctx context.Context, email string) (string, error) { + if a == nil || a.userLookup == nil { return "", errFeishuAdapterNotReady("GetUserIDByEmail") } + return a.userLookup.GetUserIDByEmail(ctx, email) +} + +func (a *FeishuChannelAdapter) GetUserIDByMobile(ctx context.Context, mobile string) (string, error) { + if a == nil || a.userLookup == nil { return "", errFeishuAdapterNotReady("GetUserIDByMobile") } + return a.userLookup.GetUserIDByMobile(ctx, mobile) +} + +func (a *FeishuChannelAdapter) CreateGroup(ctx context.Context, name string) (any, error) { + if a == nil || a.groupCreator == nil { return nil, errFeishuAdapterNotReady("CreateGroup") } + return a.groupCreator.CreateGroup(ctx, name) +} + +func (a *FeishuChannelAdapter) GetGroupInfo(ctx context.Context, chatID string) (any, error) { + if a == nil || a.groupGetter == nil { return nil, errFeishuAdapterNotReady("GetGroupInfo") } + return a.groupGetter.GetGroupInfo(ctx, chatID) +} + +func (a *FeishuChannelAdapter) ListGroupMembers(ctx context.Context, chatID string, pageSize int, pageToken string) (any, error) { + if a == nil || a.groupMemberLister == nil { return nil, errFeishuAdapterNotReady("ListGroupMembers") } + return a.groupMemberLister.ListGroupMembers(ctx, chatID, pageSize, pageToken) +} + +func (a *FeishuChannelAdapter) ListGroups(ctx context.Context, pageSize int, pageToken string) (any, error) { + if a == nil || a.groupLister == nil { return nil, errFeishuAdapterNotReady("ListGroups") } + return a.groupLister.ListGroups(ctx, pageSize, pageToken) +} + +func (a *FeishuChannelAdapter) SendGroupMessage(ctx context.Context, chatID, text string) error { + if a == nil || a.groupMessageSender == nil { return errFeishuAdapterNotReady("SendGroupMessage") } + return a.groupMessageSender.SendGroupMessage(ctx, chatID, text) +} + +func (a *FeishuChannelAdapter) GetDriveRootFolder(ctx context.Context) (any, error) { + if a == nil || a.driveRootGetter == nil { return nil, errFeishuAdapterNotReady("GetDriveRootFolder") } + return a.driveRootGetter.GetDriveRootFolder(ctx) +} + +func (a *FeishuChannelAdapter) GetDriveFolder(ctx context.Context, folderToken string) (any, error) { + if a == nil || a.driveFolderGetter == nil { return nil, errFeishuAdapterNotReady("GetDriveFolder") } + return a.driveFolderGetter.GetDriveFolder(ctx, folderToken) +} + +func (a *FeishuChannelAdapter) GetDriveFile(ctx context.Context, fileToken string) (any, error) { + if a == nil || a.driveGetter == nil { return nil, errFeishuAdapterNotReady("GetDriveFile") } + return a.driveGetter.GetDriveFile(ctx, fileToken) +} + +func (a *FeishuChannelAdapter) ListDriveFiles(ctx context.Context, folderToken, pageToken string, pageSize int) (any, error) { + if a == nil || a.driveLister == nil { return nil, errFeishuAdapterNotReady("ListDriveFiles") } + return a.driveLister.ListDriveFiles(ctx, folderToken, pageToken, pageSize) +} + +func (a *FeishuChannelAdapter) DownloadDriveFile(ctx context.Context, fileToken string) (any, error) { + if a == nil || a.driveDownloader == nil { return nil, errFeishuAdapterNotReady("DownloadDriveFile") } + return a.driveDownloader.DownloadDriveFile(ctx, fileToken) +} + +func (a *FeishuChannelAdapter) DeleteDriveFile(ctx context.Context, fileToken string) error { + if a == nil || a.driveDeleter == nil { return errFeishuAdapterNotReady("DeleteDriveFile") } + return a.driveDeleter.DeleteDriveFile(ctx, fileToken) +} + +func (a *FeishuChannelAdapter) UploadDriveFile(ctx context.Context, parentToken, name string, data []byte) (any, error) { + if a == nil || a.driveUploader == nil { return nil, errFeishuAdapterNotReady("UploadDriveFile") } + return a.driveUploader.UploadDriveFile(ctx, parentToken, name, data) +} + +func (a *FeishuChannelAdapter) InitiateMultipartUpload(ctx context.Context, parentToken, name string, size int64) (any, error) { + if a == nil || a.multipartInitiator == nil { return nil, errFeishuAdapterNotReady("InitiateMultipartUpload") } + return a.multipartInitiator.InitiateMultipartUpload(ctx, parentToken, name, size) +} + +func (a *FeishuChannelAdapter) UploadMultipartChunk(ctx context.Context, uploadID string, seq int, data []byte) error { + if a == nil || a.multipartUploader == nil { return errFeishuAdapterNotReady("UploadMultipartChunk") } + return a.multipartUploader.UploadMultipartChunk(ctx, uploadID, seq, data) +} + +func (a *FeishuChannelAdapter) CompleteMultipartUpload(ctx context.Context, uploadID string, blockNum int) (any, error) { + if a == nil || a.multipartCompleter == nil { return nil, errFeishuAdapterNotReady("CompleteMultipartUpload") } + return a.multipartCompleter.CompleteMultipartUpload(ctx, uploadID, blockNum) +} + +func (a *FeishuChannelAdapter) SendImageMessage(ctx context.Context, chatID string, data []byte, fileName string) error { + if a == nil || a.imageSender == nil { return errFeishuAdapterNotReady("SendImageMessage") } + return a.imageSender.SendImageMessage(ctx, chatID, data, fileName) +} + +func (a *FeishuChannelAdapter) SendFileMessage(ctx context.Context, chatID string, data []byte, fileName, fileType string) error { + if a == nil || a.fileSender == nil { return errFeishuAdapterNotReady("SendFileMessage") } + return a.fileSender.SendFileMessage(ctx, chatID, data, fileName, fileType) +} + +func (a *FeishuChannelAdapter) Ready() bool { + return a != nil && + a.messageGetter != nil && + a.messageLister != nil && + a.messageReplier != nil && + a.shareLinkResolver != nil && + a.userGetter != nil && + a.userLister != nil && + a.userLookup != nil && + a.groupCreator != nil && + a.groupGetter != nil && + a.groupMemberLister != nil && + a.groupLister != nil && + a.groupMessageSender != nil && + a.driveRootGetter != nil && + a.driveFolderGetter != nil && + a.driveGetter != nil && + a.driveLister != nil && + a.driveDownloader != nil && + a.driveDeleter != nil && + a.driveUploader != nil && + a.multipartInitiator != nil && + a.multipartUploader != nil && + a.multipartCompleter != nil && + a.imageSender != nil && + a.fileSender != nil +} + +type feishuAdapterError struct{ method string } + +func (e feishuAdapterError) Error() string { return "feishu adapter not ready for method: " + e.method } + +func errFeishuAdapterNotReady(method string) error { return feishuAdapterError{method: method} } diff --git a/pkg/tools/feishu_registry.go b/pkg/tools/feishu_registry.go new file mode 100644 index 000000000..9c3ff9451 --- /dev/null +++ b/pkg/tools/feishu_registry.go @@ -0,0 +1,23 @@ +package tools + +import "github.com/sipeed/picoclaw/pkg/config" + +// RegisterFeishuTools registers Feishu-related local tools that do not require +// a live channel instance or remote API injection. +func RegisterFeishuTools(registry *ToolRegistry, cfg *config.Config) { + RegisterFeishuToolsWithClient(registry, cfg, nil) +} + +// RegisterFeishuToolsWithClient registers local Feishu tools and, when provided, +// also registers remote query tools backed by an injected client. +func RegisterFeishuToolsWithClient(registry *ToolRegistry, cfg *config.Config, client FeishuRemoteClient) { + if registry == nil || cfg == nil { + return + } + if cfg.Tools.IsToolEnabled("feishu_parse") { + registry.Register(NewFeishuParseTool()) + } + if client != nil && cfg.Tools.IsToolEnabled("feishu_remote") { + registry.Register(NewFeishuRemoteTool(client)) + } +} diff --git a/pkg/tools/feishu_registry_test.go b/pkg/tools/feishu_registry_test.go new file mode 100644 index 000000000..007caaab1 --- /dev/null +++ b/pkg/tools/feishu_registry_test.go @@ -0,0 +1,13 @@ +package tools + +import ( + "testing" + + "github.com/sipeed/picoclaw/pkg/config" +) + +func TestRegisterFeishuToolsWithNilInputs(t *testing.T) { + RegisterFeishuTools(nil, &config.Config{}) + RegisterFeishuTools(NewToolRegistry(), nil) + RegisterFeishuToolsWithClient(nil, &config.Config{}, &mockFeishuRemoteClient{}) +} diff --git a/pkg/tools/feishu_test.go b/pkg/tools/feishu_test.go new file mode 100644 index 000000000..1f9be56e3 --- /dev/null +++ b/pkg/tools/feishu_test.go @@ -0,0 +1,385 @@ +package tools + +import ( + "context" + "encoding/base64" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/sipeed/picoclaw/pkg/config" +) + +type mockFeishuRemoteClient struct{} + +func (m *mockFeishuRemoteClient) GetMessage(ctx context.Context, messageID string) (any, error) { + switch messageID { + case "om_with_image": + return map[string]any{ + "message_id": messageID, + "body": map[string]any{"content": `{"image_key":"img_from_message"}`}, + }, nil + case "om_without_image": + return map[string]any{ + "message_id": messageID, + "body": map[string]any{"content": `{"text":"hello"}`}, + }, nil + default: + return map[string]any{"message_id": messageID}, nil + } +} +func (m *mockFeishuRemoteClient) ListMessages(ctx context.Context, containerID, containerType string, pageSize int, pageToken string) (any, error) { + return map[string]any{"container_id": containerID, "container_type": containerType, "page_size": pageSize, "page_token": pageToken}, nil +} +func (m *mockFeishuRemoteClient) ReplyMessage(ctx context.Context, messageID, text string) error { return nil } +func (m *mockFeishuRemoteClient) GetMessageFromShareLink(ctx context.Context, shareLink string) (any, error) { + return map[string]any{"share_link": shareLink}, nil +} +func (m *mockFeishuRemoteClient) GetUserInfo(ctx context.Context, userID string) (any, error) { + return map[string]any{"user_id": userID}, nil +} +func (m *mockFeishuRemoteClient) ListUsers(ctx context.Context, pageSize int, userIDType, pageToken string) (any, error) { + return map[string]any{"page_size": pageSize, "user_id_type": userIDType, "page_token": pageToken}, nil +} +func (m *mockFeishuRemoteClient) GetUserIDByEmail(ctx context.Context, email string) (string, error) { + return "ou_email", nil +} +func (m *mockFeishuRemoteClient) GetUserIDByMobile(ctx context.Context, mobile string) (string, error) { + return "ou_mobile", nil +} +func (m *mockFeishuRemoteClient) CreateGroup(ctx context.Context, name string) (any, error) { + return map[string]any{"name": name}, nil +} +func (m *mockFeishuRemoteClient) GetGroupInfo(ctx context.Context, chatID string) (any, error) { + return map[string]any{"chat_id": chatID}, nil +} +func (m *mockFeishuRemoteClient) ListGroupMembers(ctx context.Context, chatID string, pageSize int, pageToken string) (any, error) { + return map[string]any{"chat_id": chatID, "page_size": pageSize, "page_token": pageToken}, nil +} +func (m *mockFeishuRemoteClient) ListGroups(ctx context.Context, pageSize int, pageToken string) (any, error) { + return map[string]any{"page_size": pageSize, "page_token": pageToken}, nil +} +func (m *mockFeishuRemoteClient) SendGroupMessage(ctx context.Context, chatID, text string) error { return nil } +func (m *mockFeishuRemoteClient) GetDriveRootFolder(ctx context.Context) (any, error) { + return map[string]any{"folder_token": "root"}, nil +} +func (m *mockFeishuRemoteClient) GetDriveFolder(ctx context.Context, folderToken string) (any, error) { + return map[string]any{"folder_token": folderToken}, nil +} +func (m *mockFeishuRemoteClient) GetDriveFile(ctx context.Context, fileToken string) (any, error) { + return map[string]any{"file_token": fileToken}, nil +} +func (m *mockFeishuRemoteClient) ListDriveFiles(ctx context.Context, folderToken, pageToken string, pageSize int) (any, error) { + return map[string]any{"folder_token": folderToken, "page_token": pageToken, "page_size": pageSize}, nil +} +func (m *mockFeishuRemoteClient) DownloadDriveFile(ctx context.Context, fileToken string) (any, error) { + return map[string]any{"name": "demo.txt", "content_type": "text/plain", "data_base64": base64.StdEncoding.EncodeToString([]byte("demo"))}, nil +} +func (m *mockFeishuRemoteClient) DeleteDriveFile(ctx context.Context, fileToken string) error { return nil } +func (m *mockFeishuRemoteClient) UploadDriveFile(ctx context.Context, parentToken, name string, data []byte) (any, error) { + return map[string]any{"parent_token": parentToken, "name": name, "size": len(data)}, nil +} +func (m *mockFeishuRemoteClient) InitiateMultipartUpload(ctx context.Context, parentToken, name string, size int64) (any, error) { + return map[string]any{"upload_id": "upload_1", "parent_token": parentToken, "name": name, "size": size}, nil +} +func (m *mockFeishuRemoteClient) UploadMultipartChunk(ctx context.Context, uploadID string, seq int, data []byte) error { return nil } +func (m *mockFeishuRemoteClient) CompleteMultipartUpload(ctx context.Context, uploadID string, blockNum int) (any, error) { + return map[string]any{"upload_id": uploadID, "block_num": blockNum}, nil +} +func (m *mockFeishuRemoteClient) SendImageMessage(ctx context.Context, chatID string, data []byte, fileName string) error { + return nil +} +func (m *mockFeishuRemoteClient) SendFileMessage(ctx context.Context, chatID string, data []byte, fileName, fileType string) error { + return nil +} + +type errFeishuRemoteClient struct{} + +func (e *errFeishuRemoteClient) GetMessage(ctx context.Context, messageID string) (any, error) { return nil, fmt.Errorf("boom") } +func (e *errFeishuRemoteClient) ListMessages(ctx context.Context, containerID, containerType string, pageSize int, pageToken string) (any, error) { + return nil, fmt.Errorf("boom") +} +func (e *errFeishuRemoteClient) ReplyMessage(ctx context.Context, messageID, text string) error { return fmt.Errorf("boom") } +func (e *errFeishuRemoteClient) GetMessageFromShareLink(ctx context.Context, shareLink string) (any, error) { return nil, fmt.Errorf("boom") } +func (e *errFeishuRemoteClient) GetUserInfo(ctx context.Context, userID string) (any, error) { return nil, fmt.Errorf("boom") } +func (e *errFeishuRemoteClient) ListUsers(ctx context.Context, pageSize int, userIDType, pageToken string) (any, error) { + return nil, fmt.Errorf("boom") +} +func (e *errFeishuRemoteClient) GetUserIDByEmail(ctx context.Context, email string) (string, error) { return "", fmt.Errorf("boom") } +func (e *errFeishuRemoteClient) GetUserIDByMobile(ctx context.Context, mobile string) (string, error) { return "", fmt.Errorf("boom") } +func (e *errFeishuRemoteClient) CreateGroup(ctx context.Context, name string) (any, error) { return nil, fmt.Errorf("boom") } +func (e *errFeishuRemoteClient) GetGroupInfo(ctx context.Context, chatID string) (any, error) { return nil, fmt.Errorf("boom") } +func (e *errFeishuRemoteClient) ListGroupMembers(ctx context.Context, chatID string, pageSize int, pageToken string) (any, error) { + return nil, fmt.Errorf("boom") +} +func (e *errFeishuRemoteClient) ListGroups(ctx context.Context, pageSize int, pageToken string) (any, error) { return nil, fmt.Errorf("boom") } +func (e *errFeishuRemoteClient) SendGroupMessage(ctx context.Context, chatID, text string) error { return fmt.Errorf("boom") } +func (e *errFeishuRemoteClient) GetDriveRootFolder(ctx context.Context) (any, error) { return nil, fmt.Errorf("boom") } +func (e *errFeishuRemoteClient) GetDriveFolder(ctx context.Context, folderToken string) (any, error) { return nil, fmt.Errorf("boom") } +func (e *errFeishuRemoteClient) GetDriveFile(ctx context.Context, fileToken string) (any, error) { return nil, fmt.Errorf("boom") } +func (e *errFeishuRemoteClient) ListDriveFiles(ctx context.Context, folderToken, pageToken string, pageSize int) (any, error) { + return nil, fmt.Errorf("boom") +} +func (e *errFeishuRemoteClient) DownloadDriveFile(ctx context.Context, fileToken string) (any, error) { return nil, fmt.Errorf("boom") } +func (e *errFeishuRemoteClient) DeleteDriveFile(ctx context.Context, fileToken string) error { return fmt.Errorf("boom") } +func (e *errFeishuRemoteClient) UploadDriveFile(ctx context.Context, parentToken, name string, data []byte) (any, error) { + return nil, fmt.Errorf("boom") +} +func (e *errFeishuRemoteClient) InitiateMultipartUpload(ctx context.Context, parentToken, name string, size int64) (any, error) { + return nil, fmt.Errorf("boom") +} +func (e *errFeishuRemoteClient) UploadMultipartChunk(ctx context.Context, uploadID string, seq int, data []byte) error { + return fmt.Errorf("boom") +} +func (e *errFeishuRemoteClient) CompleteMultipartUpload(ctx context.Context, uploadID string, blockNum int) (any, error) { + return nil, fmt.Errorf("boom") +} +func (e *errFeishuRemoteClient) SendImageMessage(ctx context.Context, chatID string, data []byte, fileName string) error { + return fmt.Errorf("boom") +} +func (e *errFeishuRemoteClient) SendFileMessage(ctx context.Context, chatID string, data []byte, fileName, fileType string) error { + return fmt.Errorf("boom") +} + +func TestFeishuParseToolShareLink(t *testing.T) { + tool := NewFeishuParseTool() + result := tool.Execute(context.Background(), map[string]any{"mode": "share_link", "content": "https://applink.feishu.cn/client/message/link/open?token=om_abc%3D%3D&foo=bar"}) + if result.IsError || !strings.Contains(result.ForLLM, "om_abc%3D%3D") { + t.Fatalf("unexpected result: %s", result.ForLLM) + } +} + +func TestFeishuParseToolMessageContent(t *testing.T) { + tool := NewFeishuParseTool() + result := tool.Execute(context.Background(), map[string]any{"mode": "message_content", "content": `{"text":"hello"}`}) + if result.IsError || !strings.Contains(result.ForLLM, "hello") { + t.Fatalf("unexpected result: %s", result.ForLLM) + } +} + +func TestFeishuParseToolCard(t *testing.T) { + tool := NewFeishuParseTool() + result := tool.Execute(context.Background(), map[string]any{"mode": "card", "content": `{"header":{"title":{"content":"demo"}},"elements":[{"tag":"div","text":{"content":"body"}}]}`}) + if result.IsError || !strings.Contains(result.ForLLM, "demo") || !strings.Contains(result.ForLLM, "body") { + t.Fatalf("unexpected result: %s", result.ForLLM) + } +} + +func TestFeishuParseToolCardWithPythonStyleContent(t *testing.T) { + tool := NewFeishuParseTool() + result := tool.Execute(context.Background(), map[string]any{"mode": "card", "content": `{"title":"demo title","content":[{"tag":"text","text":"plain body"},{"tag":"img","image_key":"img_v2_key"},{"tag":"action","actions":[{"text":{"content":"Click"},"type":"primary"}]}]}`}) + if result.IsError || !strings.Contains(result.ForLLM, "demo title") || !strings.Contains(result.ForLLM, "plain body") || !strings.Contains(result.ForLLM, "img_v2_key") || !strings.Contains(result.ForLLM, "Click") { + t.Fatalf("unexpected result: %s", result.ForLLM) + } +} + +func TestRegisterFeishuToolsEnabled(t *testing.T) { + registry := NewToolRegistry() + cfg := &config.Config{} + RegisterFeishuTools(registry, cfg) + if _, ok := registry.Get("feishu_parse"); !ok { + t.Fatal("expected feishu_parse to be registered by default") + } +} + +func TestRegisterFeishuToolsDisabled(t *testing.T) { + registry := NewToolRegistry() + cfg := &config.Config{} + RegisterFeishuTools(registry, cfg) + if _, ok := registry.Get("feishu_parse"); !ok { + t.Fatal("expected feishu_parse to be registered") + } +} + +func TestFeishuRemoteToolGetMessage(t *testing.T) { + tool := NewFeishuRemoteTool(&mockFeishuRemoteClient{}) + result := tool.Execute(context.Background(), map[string]any{"action": "get_message", "id": "om_123"}) + if result.IsError || !strings.Contains(result.ForLLM, "om_123") { + t.Fatalf("unexpected result: %s", result.ForLLM) + } +} + +func TestFeishuRemoteToolListMessages(t *testing.T) { + tool := NewFeishuRemoteTool(&mockFeishuRemoteClient{}) + result := tool.Execute(context.Background(), map[string]any{"action": "list_messages", "id": "oc_123", "container_type": "chat", "page_size": 5, "page_token": "next"}) + if result.IsError || !strings.Contains(result.ForLLM, "oc_123") || !strings.Contains(result.ForLLM, "next") { + t.Fatalf("unexpected result: %s", result.ForLLM) + } +} + +func TestFeishuRemoteToolReplyMessage(t *testing.T) { + tool := NewFeishuRemoteTool(&mockFeishuRemoteClient{}) + result := tool.Execute(context.Background(), map[string]any{"action": "reply_message", "id": "om_123", "text": "hello"}) + if result.IsError || !strings.Contains(result.ForLLM, "om_123") { + t.Fatalf("unexpected result: %s", result.ForLLM) + } +} + +func TestFeishuRemoteToolUserLookupActions(t *testing.T) { + tool := NewFeishuRemoteTool(&mockFeishuRemoteClient{}) + byEmail := tool.Execute(context.Background(), map[string]any{"action": "get_user_id_by_email", "id": "demo@example.com"}) + if byEmail.IsError || !strings.Contains(byEmail.ForLLM, "ou_email") { + t.Fatalf("unexpected email lookup result: %s", byEmail.ForLLM) + } + byMobile := tool.Execute(context.Background(), map[string]any{"action": "get_user_id_by_mobile", "id": "13800000000"}) + if byMobile.IsError || !strings.Contains(byMobile.ForLLM, "ou_mobile") { + t.Fatalf("unexpected mobile lookup result: %s", byMobile.ForLLM) + } + byPhone := tool.Execute(context.Background(), map[string]any{"action": "get_user_id_by_phone", "id": "13800000000"}) + if byPhone.IsError || !strings.Contains(byPhone.ForLLM, "ou_mobile") || !strings.Contains(byPhone.ForLLM, "phone") { + t.Fatalf("unexpected phone lookup result: %s", byPhone.ForLLM) + } +} + +func TestFeishuRemoteToolGroupAndDriveActions(t *testing.T) { + tool := NewFeishuRemoteTool(&mockFeishuRemoteClient{}) + cases := []struct { + name string + args map[string]any + want string + }{ + {name: "create group", args: map[string]any{"action": "create_group", "name": "demo-group"}, want: "demo-group"}, + {name: "list group members", args: map[string]any{"action": "list_group_members", "id": "oc_1", "page_size": 2}, want: "oc_1"}, + {name: "list groups", args: map[string]any{"action": "list_groups", "page_size": 2}, want: "page_size"}, + {name: "send group message", args: map[string]any{"action": "send_group_message", "id": "oc_1", "text": "hello"}, want: "hello"}, + {name: "get drive root", args: map[string]any{"action": "get_drive_root_folder"}, want: "root"}, + {name: "get drive folder", args: map[string]any{"action": "get_drive_folder", "id": "fld_1"}, want: "fld_1"}, + {name: "list drive files", args: map[string]any{"action": "list_drive_files", "id": "fld_1", "page_size": 2}, want: "fld_1"}, + {name: "download drive file", args: map[string]any{"action": "download_drive_file", "id": "file_1"}, want: "demo.txt"}, + {name: "delete drive file", args: map[string]any{"action": "delete_drive_file", "id": "file_1"}, want: "deleted"}, + {name: "initiate multipart", args: map[string]any{"action": "initiate_multipart_upload", "name": "big.bin", "parent_token": "fld_1", "size": 128}, want: "upload_1"}, + {name: "upload multipart chunk", args: map[string]any{"action": "upload_multipart_chunk", "id": "upload_1", "seq": 0, "data_base64": base64.StdEncoding.EncodeToString([]byte("chunk"))}, want: "upload_1"}, + } + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + result := tool.Execute(context.Background(), tt.args) + if result.IsError || !strings.Contains(result.ForLLM, tt.want) { + t.Fatalf("unexpected result: %s", result.ForLLM) + } + }) + } +} + +func TestFeishuRemoteToolSendMediaActions(t *testing.T) { + tool := NewFeishuRemoteTool(&mockFeishuRemoteClient{}) + img := tool.Execute(context.Background(), map[string]any{ + "action": "send_image", + "id": "oc_1", + "name": "demo.png", + "data_base64": base64.StdEncoding.EncodeToString([]byte("imgdata")), + }) + if img.IsError || !strings.Contains(img.ForLLM, "demo.png") || !strings.Contains(img.ForLLM, "oc_1") { + t.Fatalf("unexpected send_image result: %s", img.ForLLM) + } + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write([]byte("pngbytes")) + })) + defer ts.Close() + + imgFromURL := tool.Execute(context.Background(), map[string]any{ + "action": "send_image_from_url", + "id": "oc_1", + "url": ts.URL + "/demo.png", + }) + if imgFromURL.IsError || !strings.Contains(imgFromURL.ForLLM, "demo.png") || !strings.Contains(imgFromURL.ForLLM, "source_url") { + t.Fatalf("unexpected send_image_from_url result: %s", imgFromURL.ForLLM) + } + + file := tool.Execute(context.Background(), map[string]any{ + "action": "send_file", + "id": "oc_1", + "name": "demo.txt", + "file_type": "stream", + "data_base64": base64.StdEncoding.EncodeToString([]byte("filedata")), + }) + if file.IsError || !strings.Contains(file.ForLLM, "demo.txt") || !strings.Contains(file.ForLLM, "stream") { + t.Fatalf("unexpected send_file result: %s", file.ForLLM) + } +} + +func TestFeishuRemoteToolDownloadImageAlias(t *testing.T) { + tool := NewFeishuRemoteTool(&mockFeishuRemoteClient{}) + result := tool.Execute(context.Background(), map[string]any{"action": "download_image_to_bytes", "id": "img_1"}) + if result.IsError || !strings.Contains(result.ForLLM, "data_base64") || !strings.Contains(result.ForLLM, "demo.txt") { + t.Fatalf("unexpected download_image_to_bytes result: %s", result.ForLLM) + } +} + +func TestFeishuRemoteToolDownloadImageFromMessage(t *testing.T) { + tool := NewFeishuRemoteTool(&mockFeishuRemoteClient{}) + explicit := tool.Execute(context.Background(), map[string]any{ + "action": "download_image_from_message", + "id": "om_1", + "resource_id": "img_explicit", + }) + if explicit.IsError || !strings.Contains(explicit.ForLLM, "img_explicit") || !strings.Contains(explicit.ForLLM, "message_id") { + t.Fatalf("unexpected explicit download_image_from_message result: %s", explicit.ForLLM) + } + + auto := tool.Execute(context.Background(), map[string]any{ + "action": "download_image_from_message", + "id": "om_with_image", + }) + if auto.IsError || !strings.Contains(auto.ForLLM, "img_from_message") || !strings.Contains(auto.ForLLM, "download") { + t.Fatalf("unexpected auto download_image_from_message result: %s", auto.ForLLM) + } +} + +func TestFeishuRemoteToolValidationErrors(t *testing.T) { + tool := NewFeishuRemoteTool(&mockFeishuRemoteClient{}) + cases := []struct { + name string + args map[string]any + want string + }{ + {name: "missing reply text", args: map[string]any{"action": "reply_message", "id": "om_1"}, want: "id and text are required"}, + {name: "missing create group name", args: map[string]any{"action": "create_group"}, want: "name is required"}, + {name: "missing upload payload", args: map[string]any{"action": "upload_drive_file", "name": "demo.txt"}, want: "name and data_base64 are required"}, + {name: "missing chunk fields", args: map[string]any{"action": "upload_multipart_chunk", "id": "upload_1"}, want: "id, seq and data_base64 are required"}, + {name: "missing send image payload", args: map[string]any{"action": "send_image", "id": "oc_1"}, want: "id and data_base64 are required"}, + {name: "missing send file name", args: map[string]any{"action": "send_file", "id": "oc_1", "data_base64": base64.StdEncoding.EncodeToString([]byte("x"))}, want: "name is required"}, + {name: "missing send image url", args: map[string]any{"action": "send_image_from_url", "id": "oc_1"}, want: "id and url are required"}, + {name: "missing message image token", args: map[string]any{"action": "download_image_from_message", "id": "om_without_image"}, want: "resource_id is required when message content does not contain an image token"}, + } + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + result := tool.Execute(context.Background(), tt.args) + if !result.IsError || !strings.Contains(result.ForLLM, tt.want) { + t.Fatalf("expected error containing %q, got: %s", tt.want, result.ForLLM) + } + }) + } +} + +func TestRegisterFeishuToolsWithClient(t *testing.T) { + registry := NewToolRegistry() + cfg := &config.Config{} + RegisterFeishuToolsWithClient(registry, cfg, &mockFeishuRemoteClient{}) + if _, ok := registry.Get("feishu_parse"); !ok { + t.Fatal("expected feishu_parse to be registered") + } + if _, ok := registry.Get("feishu_remote"); !ok { + t.Fatal("expected feishu_remote to be registered") + } +} + +func TestFeishuChannelAdapterReady(t *testing.T) { + adapter := NewFeishuChannelAdapter(&mockFeishuRemoteClient{}) + if !adapter.Ready() { + t.Fatal("expected adapter to be ready") + } +} + +func TestFeishuChannelAdapterNotReady(t *testing.T) { + adapter := NewFeishuChannelAdapter(struct{}{}) + if adapter.Ready() { + t.Fatal("did not expect adapter to be ready") + } + if _, err := adapter.GetMessage(context.Background(), "om_123"); err == nil { + t.Fatal("expected error for missing method implementation") + } +}