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:
ywj 2026-03-13 14:45:40 +08:00
parent 8654ec90d9
commit c797d600d2
31 changed files with 3932 additions and 128 deletions

View file

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

View file

@ -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": [

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

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

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

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

View 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"`
}

View file

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

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

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

View file

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

View file

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

View 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")
}
}

View file

@ -0,0 +1,3 @@
//go:build amd64 || arm64 || riscv64 || mips64 || ppc64
package feishu

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View 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
View 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")
}
}