fix(providers): 增强 OpenAI 兼容接口报错信息,完善 SiliconFlow 兼容性并修复 Windows 下 Codex CLI 测试
- 优化 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 相关测试
This commit is contained in:
parent
8654ec90d9
commit
c797d600d2
31 changed files with 3932 additions and 128 deletions
20
README.md
20
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.
|
||||
</details>
|
||||
|
||||
## 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.) |
|
||||
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 的回归测试。
|
||||
|
|
|
|||
4
go.mod
4
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
|
||||
)
|
||||
|
|
|
|||
4
go.sum
4
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=
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
415
pkg/channels/feishu/api_helpers.go
Normal file
415
pkg/channels/feishu/api_helpers.go
Normal file
|
|
@ -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 ""
|
||||
}
|
||||
170
pkg/channels/feishu/api_helpers_test.go
Normal file
170
pkg/channels/feishu/api_helpers_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
691
pkg/channels/feishu/api_methods.go
Normal file
691
pkg/channels/feishu/api_methods.go
Normal file
|
|
@ -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) }
|
||||
|
||||
137
pkg/channels/feishu/api_types.go
Normal file
137
pkg/channels/feishu/api_types.go
Normal file
|
|
@ -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"`
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
110
pkg/channels/feishu/drive_helpers.go
Normal file
110
pkg/channels/feishu/drive_helpers.go
Normal file
|
|
@ -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
|
||||
}
|
||||
87
pkg/channels/feishu/drive_helpers_test.go
Normal file
87
pkg/channels/feishu/drive_helpers_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 ""
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
43
pkg/channels/feishu/integration_test.go
Normal file
43
pkg/channels/feishu/integration_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
3
pkg/channels/feishu/runtime_64_shared.go
Normal file
3
pkg/channels/feishu/runtime_64_shared.go
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
//go:build amd64 || arm64 || riscv64 || mips64 || ppc64
|
||||
|
||||
package feishu
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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: <!DOCTYPE html><html><body>") {
|
||||
if !strings.Contains(err.Error(), "Body: <!DOCTYPE html><html><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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
643
pkg/tools/feishu.go
Normal file
643
pkg/tools/feishu.go
Normal file
|
|
@ -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 ""
|
||||
}
|
||||
315
pkg/tools/feishu_adapter.go
Normal file
315
pkg/tools/feishu_adapter.go
Normal file
|
|
@ -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} }
|
||||
23
pkg/tools/feishu_registry.go
Normal file
23
pkg/tools/feishu_registry.go
Normal file
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
13
pkg/tools/feishu_registry_test.go
Normal file
13
pkg/tools/feishu_registry_test.go
Normal file
|
|
@ -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{})
|
||||
}
|
||||
385
pkg/tools/feishu_test.go
Normal file
385
pkg/tools/feishu_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue