feat(feishu): 新增下载目录配置功能

- 在配置文件中添加 download_dir 字段用于自定义文件下载路径
- 支持相对路径(相对于工作空间)、绝对路径和家目录(~)简写
- 实现路径解析逻辑,包括环境变量展开和路径验证
- 当配置路径不可用时自动回退到系统临时目录
- 添加完整的路径解析测试用例

📝 docs(feishu): 更新文档说明下载目录配置

- 在README中新增download_dir字段说明
- 详细描述各种路径配置方式和使用示例
- 说明默认行为和安全回退机制
- 添加环境变量配置示例
This commit is contained in:
Back Yu 2026-03-25 14:28:40 +08:00
parent 9d6a445bb1
commit c9197c9370
No known key found for this signature in database
GPG key ID: F1E8FE817128573F
6 changed files with 371 additions and 11 deletions

View file

@ -30,6 +30,7 @@ Feishu (international name: Lark) is an enterprise collaboration platform by Byt
| verification_token | string | No | Token used for Webhook event verification |
| allow_from | array | No | Allowlist of user IDs; empty means all users are allowed |
| random_reaction_emoji | array | No | List of random reaction emojis; empty uses the default "Pin" |
| download_dir | string | No | Custom directory for downloaded files (relative/absolute paths) |
## Setup
@ -50,3 +51,63 @@ Feishu (international name: Lark) is an enterprise collaboration platform by Byt
## Platform Limitations
> ⚠️ **Feishu channel does not support 32-bit devices.** The Feishu SDK only provides 64-bit builds. Devices running armv6, armv7, mipsle, or other 32-bit architectures cannot use the Feishu channel. For messaging on 32-bit devices, use Telegram, Discord, or OneBot instead.
## Download Directory Configuration
The `download_dir` field specifies where files received from Feishu (images, documents, etc.) are stored.
### Path Resolution
1. **Relative path** (recommended): Resolved relative to the PicoClaw workspace directory
- Default workspace: `~/.picoclaw/workspace` (or `$PICOCLAW_HOME/workspace`)
- Example: If workspace is `~/.picoclaw/workspace`, `download_dir: "downloads"` resolves to `~/.picoclaw/workspace/downloads`
```json
{
"channels": {
"feishu": {
"download_dir": "downloads"
}
}
}
```
2. **Absolute path**: Use an absolute path directly
```json
{
"channels": {
"feishu": {
"download_dir": "/Users/username/Downloads/feishu"
}
}
}
```
3. **Home directory shorthand**: Use `~` for your home directory
```json
{
"channels": {
"feishu": {
"download_dir": "~/Downloads/feishu"
}
}
}
```
4. **Default (not configured)**: Files are downloaded to the system temp directory
- macOS: `/var/folders/.../picoclaw_media`
- Linux: `/tmp/picoclaw_media`
- Windows: `C:\Users\xxx\AppData\Local\Temp\picoclaw_media`
### Safety & Fallback
- The directory will be created automatically if it doesn't exist
- Write permissions are verified before use
- If the configured directory cannot be accessed (no permission, path conflicts, etc.), the system automatically falls back to the system temp directory to ensure files are received successfully
### Environment Variable
You can also configure via environment variable:
```bash
export PICOCLAW_CHANNELS_FEISHU_DOWNLOAD_DIR="downloads"
```

View file

@ -19,7 +19,7 @@ type FeishuChannel struct {
var errUnsupported = errors.New("feishu channel is not supported on 32-bit architectures")
// NewFeishuChannel returns an error on 32-bit architectures where the Feishu SDK is not supported
func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChannel, error) {
func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus, globalCfg *config.Config) (*FeishuChannel, error) {
return nil, errors.New(
"feishu channel is not supported on 32-bit architectures (armv7l, 386, etc.). Please use a 64-bit system or disable feishu in your config",
)

View file

@ -38,6 +38,7 @@ const errCodeTenantTokenInvalid = 99991663
type FeishuChannel struct {
*channels.BaseChannel
config config.FeishuConfig
globalCfg *config.Config // Access to workspace configuration
client *lark.Client
wsClient *larkws.Client
tokenCache *tokenCache // custom cache that supports invalidation
@ -48,7 +49,7 @@ type FeishuChannel struct {
cancel context.CancelFunc
}
func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChannel, error) {
func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus, globalCfg *config.Config) (*FeishuChannel, error) {
base := channels.NewBaseChannel("feishu", cfg, bus, cfg.AllowFrom,
channels.WithGroupTrigger(cfg.GroupTrigger),
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
@ -62,6 +63,7 @@ func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChan
ch := &FeishuChannel{
BaseChannel: base,
config: cfg,
globalCfg: globalCfg,
tokenCache: tc,
client: lark.NewClient(cfg.AppID, cfg.AppSecret(), opts...),
}
@ -642,8 +644,86 @@ func (c *FeishuChannel) downloadInboundMedia(
return refs
}
// resolveDownloadDir resolves the download directory based on configuration.
// Returns the resolved directory path, or empty string if TempDir should be used.
func (c *FeishuChannel) resolveDownloadDir() string {
downloadDir := c.config.DownloadDir
if downloadDir == "" {
// Default to system TempDir
return ""
}
// Expand ~ if present
if strings.HasPrefix(downloadDir, "~") {
homeDir, err := os.UserHomeDir()
if err != nil {
logger.WarnCF("feishu", "Failed to expand home directory, falling back to TempDir", map[string]any{
"error": err.Error(),
})
return ""
}
downloadDir = filepath.Join(homeDir, downloadDir[1:])
}
// If path is relative, resolve it relative to workspace
if !filepath.IsAbs(downloadDir) {
downloadDir = filepath.Join(c.globalCfg.WorkspacePath(), downloadDir)
}
// Convert to absolute path (clean any .. or . components)
absPath, err := filepath.Abs(downloadDir)
if err != nil {
logger.WarnCF("feishu", "Failed to resolve download directory, falling back to TempDir", map[string]any{
"path": downloadDir,
"error": err.Error(),
})
return ""
}
// Validate the resolved path is accessible
if err := c.validateAndCreateDir(absPath); err != nil {
logger.WarnCF("feishu", "Configured download_dir not accessible, falling back to TempDir", map[string]any{
"path": absPath,
"error": err.Error(),
})
return ""
}
return absPath
}
// validateAndCreateDir validates that a directory path is accessible and creates it if needed.
func (c *FeishuChannel) validateAndCreateDir(dir string) error {
// Check if path exists and is a directory
info, err := os.Stat(dir)
if err == nil {
if !info.IsDir() {
return fmt.Errorf("path exists but is not a directory")
}
// Test write permission by creating a temporary file
testFile := filepath.Join(dir, ".picoclaw_write_test")
f, err := os.Create(testFile)
if err != nil {
return fmt.Errorf("directory not writable: %w", err)
}
f.Close()
os.Remove(testFile)
return nil
}
// Directory doesn't exist, try to create it
if os.IsNotExist(err) {
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("failed to create directory: %w", err)
}
return nil
}
return fmt.Errorf("failed to access directory: %w", err)
}
// downloadResource downloads a message resource (image/file) from Feishu,
// writes it to the project media directory, and stores the reference in MediaStore.
// writes it to the configured media directory, and stores the reference in MediaStore.
// fallbackExt (e.g. ".jpg") is appended when the resolved filename has no extension.
func (c *FeishuChannel) downloadResource(
ctx context.Context,
@ -692,14 +772,16 @@ func (c *FeishuChannel) downloadResource(
filename += fallbackExt
}
// Write to the shared picoclaw_media directory using a unique name to avoid collisions.
mediaDir := media.TempDir()
if mkdirErr := os.MkdirAll(mediaDir, 0o700); mkdirErr != nil {
logger.ErrorCF("feishu", "Failed to create media directory", map[string]any{
"error": mkdirErr.Error(),
})
return ""
// Determine download directory
mediaDir := c.resolveDownloadDir()
if mediaDir == "" {
// Fallback to TempDir
mediaDir = media.TempDir()
}
logger.DebugCF("feishu", "Downloading resource to directory", map[string]any{
"directory": mediaDir,
})
ext := filepath.Ext(filename)
localPath := filepath.Join(mediaDir, utils.SanitizeFilename(messageID+"-"+fileKey+ext))

View file

@ -3,8 +3,10 @@
package feishu
import (
"os"
"testing"
"github.com/sipeed/picoclaw/pkg/config"
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
)
@ -279,3 +281,217 @@ func TestExtractFeishuSenderID(t *testing.T) {
})
}
}
func TestValidateAndCreateDir(t *testing.T) {
tests := []struct {
name string
dir string
setup func() string // 创建测试目录并返回路径,返回空字符串表示不创建
wantErr bool
cleanup func(string) // 清理函数
}{
{
name: "directory exists and is writable",
setup: func() string {
dir := t.TempDir()
return dir
},
wantErr: false,
},
{
name: "directory does not exist - creates successfully",
setup: func() string {
baseDir := t.TempDir()
newDir := baseDir + "/newdir/subdir"
return newDir
},
wantErr: false,
},
{
name: "path exists but is a file not directory",
setup: func() string {
dir := t.TempDir()
filePath := dir + "/notadir"
// 创建一个文件
if err := os.WriteFile(filePath, []byte("test"), 0o644); err != nil {
t.Fatalf("failed to create test file: %v", err)
}
return filePath
},
wantErr: true,
},
{
name: "directory exists but not writable",
setup: func() string {
dir := t.TempDir()
// 在 Unix 系统上,通过 chmod 移除写权限来测试
// 注意:这在 Windows 上可能不工作
return dir
},
wantErr: false, // 实际上 TempDir 通常是可写的,这个测试场景较难模拟
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dir := tt.setup()
if dir == "" {
t.Skip("test setup returned empty directory")
}
// 创建一个测试用的 FeishuChannel 实例
channel := &FeishuChannel{
globalCfg: &config.Config{},
}
err := channel.validateAndCreateDir(dir)
if (err != nil) != tt.wantErr {
t.Errorf("validateAndCreateDir() error = %v, wantErr %v", err, tt.wantErr)
}
// 如果期望成功,验证目录确实存在且是目录
if !tt.wantErr && err == nil {
info, statErr := os.Stat(dir)
if statErr != nil {
t.Errorf("directory %s does not exist after validation: %v", dir, statErr)
}
if info != nil && !info.IsDir() {
t.Errorf("path %s exists but is not a directory", dir)
}
}
})
}
}
func TestResolveDownloadDir(t *testing.T) {
tests := []struct {
name string
downloadDir string
workspace string
setup func() string // 创建测试目录并返回路径
wantEmpty bool // 是否期望返回空字符串
cleanup func(string) // 清理函数
}{
{
name: "no download dir configured returns empty",
downloadDir: "",
workspace: "/workspace",
wantEmpty: true,
},
{
name: "existing directory returns as-is",
downloadDir: "",
workspace: "/workspace",
setup: func() string {
dir := t.TempDir()
return dir
},
wantEmpty: false,
},
{
name: "non-existing directory is created",
downloadDir: "",
workspace: "/workspace",
setup: func() string {
baseDir := t.TempDir()
newDir := baseDir + "/newdir/subdir"
return newDir
},
wantEmpty: false,
},
{
name: "path exists but is a file returns empty",
downloadDir: "",
workspace: "/workspace",
setup: func() string {
dir := t.TempDir()
filePath := dir + "/notadir"
if err := os.WriteFile(filePath, []byte("test"), 0o644); err != nil {
t.Fatalf("failed to create test file: %v", err)
}
return filePath
},
wantEmpty: true,
},
{
name: "relative path resolves relative to workspace",
downloadDir: "downloads",
workspace: "",
setup: func() string { return "" }, // Dummy setup
wantEmpty: false,
},
{
name: "absolute path remains absolute",
downloadDir: "",
workspace: "",
setup: func() string {
// Return absolute path
return t.TempDir()
},
wantEmpty: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var downloadDir string
var workspace string
if tt.downloadDir != "" {
downloadDir = tt.downloadDir
}
// For relative path test, create a temp workspace
if tt.name == "relative path resolves relative to workspace" {
workspace = t.TempDir()
} else if tt.setup != nil {
downloadDir = tt.setup()
} else {
workspace = tt.workspace
}
// 创建测试用的 FeishuChannel 实例
channel := &FeishuChannel{
config: config.FeishuConfig{
DownloadDir: downloadDir,
},
globalCfg: &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: workspace,
},
},
},
}
// 调用被测试的函数
got := channel.resolveDownloadDir()
// 验证结果
if tt.wantEmpty {
if got != "" {
t.Errorf("resolveDownloadDir() = %q, want empty string", got)
}
} else {
if got == "" {
t.Errorf("resolveDownloadDir() returned empty string, want non-empty")
}
// 验证目录存在且可访问
if got != "" {
info, err := os.Stat(got)
if err != nil {
t.Errorf("resolveDownloadDir() returned %q but stat failed: %v", got, err)
} else if !info.IsDir() {
t.Errorf("resolveDownloadDir() returned %q but it's not a directory", got)
}
}
}
// 清理
if tt.cleanup != nil {
tt.cleanup(got)
}
})
}
}

View file

@ -8,6 +8,6 @@ import (
func init() {
channels.RegisterFactory("feishu", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
return NewFeishuChannel(cfg.Channels.Feishu, b)
return NewFeishuChannel(cfg.Channels.Feishu, b, cfg)
})
}

View file

@ -452,6 +452,7 @@ type FeishuConfig struct {
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_FEISHU_REASONING_CHANNEL_ID"`
RandomReactionEmoji FlexibleStringSlice `json:"random_reaction_emoji" env:"PICOCLAW_CHANNELS_FEISHU_RANDOM_REACTION_EMOJI"`
IsLark bool `json:"is_lark" env:"PICOCLAW_CHANNELS_FEISHU_IS_LARK"`
DownloadDir string `json:"download_dir,omitempty" env:"PICOCLAW_CHANNELS_FEISHU_DOWNLOAD_DIR"`
secDirty bool
}