diff --git a/docs/channels/feishu/README.md b/docs/channels/feishu/README.md index 2aeaa31cb..0fd38be19 100644 --- a/docs/channels/feishu/README.md +++ b/docs/channels/feishu/README.md @@ -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" +``` diff --git a/pkg/channels/feishu/feishu_32.go b/pkg/channels/feishu/feishu_32.go index f5e3aa224..cf664effa 100644 --- a/pkg/channels/feishu/feishu_32.go +++ b/pkg/channels/feishu/feishu_32.go @@ -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", ) diff --git a/pkg/channels/feishu/feishu_64.go b/pkg/channels/feishu/feishu_64.go index 76df988ad..cbbcf8e4f 100644 --- a/pkg/channels/feishu/feishu_64.go +++ b/pkg/channels/feishu/feishu_64.go @@ -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)) diff --git a/pkg/channels/feishu/feishu_64_test.go b/pkg/channels/feishu/feishu_64_test.go index 9010abf69..8b66e95e1 100644 --- a/pkg/channels/feishu/feishu_64_test.go +++ b/pkg/channels/feishu/feishu_64_test.go @@ -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) + } + }) + } +} diff --git a/pkg/channels/feishu/init.go b/pkg/channels/feishu/init.go index 7e5a62dae..f5805befb 100644 --- a/pkg/channels/feishu/init.go +++ b/pkg/channels/feishu/init.go @@ -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) }) } diff --git a/pkg/config/config.go b/pkg/config/config.go index 367952301..a831c58ce 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -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 }