feat(feishu): 添加文件下载索引功能

- 实现文件下载索引系统,记录下载文件的元数据
- 新增文件名冲突智能处理逻辑
- 添加文件哈希计算和校验功能
- 实现按年/月自动分类存储下载文件
- 增加索引文件原子写入和并发控制

 test(feishu): 添加文件下载相关测试用例

- 测试文件名冲突处理逻辑
- 测试文件哈希计算功能
- 测试文件路径生成逻辑
- 测试索引文件加载和保存
- 测试索引更新和查询功能

🌐 i18n(feishu): 添加下载文件只读设置国际化支持

- 添加下载文件只读设置字段
- 更新中英文翻译文本
This commit is contained in:
Back Yu 2026-03-25 16:35:34 +08:00
parent 17ab506b81
commit 0944de2c0b
No known key found for this signature in database
GPG key ID: F1E8FE817128573F
7 changed files with 774 additions and 25 deletions

View file

@ -4,6 +4,8 @@ package feishu
import ( import (
"context" "context"
"crypto/sha256"
"encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
@ -14,6 +16,7 @@ import (
"strings" "strings"
"sync" "sync"
"sync/atomic" "sync/atomic"
"time"
lark "github.com/larksuite/oapi-sdk-go/v3" lark "github.com/larksuite/oapi-sdk-go/v3"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core" larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
@ -35,6 +38,26 @@ import (
// on this error, so we do it ourselves. // on this error, so we do it ourselves.
const errCodeTenantTokenInvalid = 99991663 const errCodeTenantTokenInvalid = 99991663
const indexFileName = ".feishu_index.json"
const maxFilenameConflictRetries = 10000 // Maximum iterations to find unique filename
const readOnlyFileMode = 0o444 // Read-only file permission (user=r, group=r, other=r)
// DirectoryIndex represents a per-directory index file.
type DirectoryIndex struct {
Version int `json:"version"`
Files []FileMeta `json:"files"`
}
// FileMeta represents a single downloaded file in the directory index.
type FileMeta struct {
Filename string `json:"filename"` // Local filename
Hash string `json:"hash"` // SHA-256 hash
Size int64 `json:"size"` // File size in bytes
FileKey string `json:"file_key"` // Feishu file key (for re-downloading)
MessageID string `json:"message_id"` // Message ID
DownloadedAt time.Time `json:"downloaded_at"` // Download time
}
type FeishuChannel struct { type FeishuChannel struct {
*channels.BaseChannel *channels.BaseChannel
config config.FeishuConfig config config.FeishuConfig
@ -45,8 +68,9 @@ type FeishuChannel struct {
botOpenID atomic.Value // stores string; populated lazily for @mention detection botOpenID atomic.Value // stores string; populated lazily for @mention detection
mu sync.Mutex mu sync.Mutex
cancel context.CancelFunc cancel context.CancelFunc
indexMu sync.Map // map[string]*sync.Mutex - one lock per directory for index updates
} }
func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus, globalCfg *config.Config) (*FeishuChannel, error) { func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus, globalCfg *config.Config) (*FeishuChannel, error) {
@ -722,6 +746,180 @@ func (c *FeishuChannel) validateAndCreateDir(dir string) error {
return fmt.Errorf("failed to access directory: %w", err) return fmt.Errorf("failed to access directory: %w", err)
} }
// loadDirectoryIndex loads the directory index file.
// Returns an empty index if the file doesn't exist or cannot be read.
func (c *FeishuChannel) loadDirectoryIndex(dirPath string) (*DirectoryIndex, error) {
indexPath := filepath.Join(dirPath, indexFileName)
data, err := os.ReadFile(indexPath)
if err != nil {
if os.IsNotExist(err) {
// Index doesn't exist, return empty index
return &DirectoryIndex{Version: 1, Files: []FileMeta{}}, nil
}
return nil, err
}
var index DirectoryIndex
if err := json.Unmarshal(data, &index); err != nil {
return nil, err
}
return &index, nil
}
// saveDirectoryIndex saves the directory index file.
func (c *FeishuChannel) saveDirectoryIndex(dirPath string, index *DirectoryIndex) error {
indexPath := filepath.Join(dirPath, indexFileName)
data, err := json.MarshalIndent(index, "", " ")
if err != nil {
return err
}
// Atomic write: write to temp file first, then rename
tmpPath := indexPath + ".tmp"
if err := os.WriteFile(tmpPath, data, 0o644); err != nil {
return err
}
return os.Rename(tmpPath, indexPath)
}
// updateIndexWithFile updates the index, adding or updating file metadata.
// getIndexLock returns a mutex for the given directory path.
func (c *FeishuChannel) getIndexLock(dirPath string) *sync.Mutex {
lock, _ := c.indexMu.LoadOrStore(dirPath, &sync.Mutex{})
return lock.(*sync.Mutex)
}
func (c *FeishuChannel) updateIndexWithFile(dirPath string, meta FileMeta) error {
// Acquire lock for this directory to prevent concurrent index corruption
lock := c.getIndexLock(dirPath)
lock.Lock()
defer lock.Unlock()
index, err := c.loadDirectoryIndex(dirPath)
if err != nil {
return err
}
// Find if this file already exists in the index
found := false
for i, file := range index.Files {
if file.Filename == meta.Filename {
index.Files[i] = meta // Update
found = true
break
}
}
if !found {
index.Files = append(index.Files, meta) // Add
}
return c.saveDirectoryIndex(dirPath, index)
}
// findFileByKeyInIndex searches for a file by file_key in the index.
// Returns the FileMeta if found, nil otherwise.
func (c *FeishuChannel) findFileByKeyInIndex(dirPath, fileKey string) (*FileMeta, error) {
index, err := c.loadDirectoryIndex(dirPath)
if err != nil {
return nil, err
}
for _, file := range index.Files {
if file.FileKey == fileKey {
return &file, nil
}
}
return nil, nil // Not found
}
// generateFilePath generates a file path: {year}/{month}/{filename}.
func (c *FeishuChannel) generateFilePath(downloadDir, filename string) string {
now := time.Now()
yearMonth := now.Format("2006/01") // 2026/03
monthPath := filepath.Join(downloadDir, yearMonth)
return filepath.Join(monthPath, utils.SanitizeFilename(filename))
}
// generateUniqueFilename intelligently handles filename conflicts.
// Returns: unique file path, whether existing file is reused, error.
func (c *FeishuChannel) generateUniqueFilename(basePath, tmpPath string) (string, bool, error) {
// Check if file exists
if _, err := os.Stat(basePath); os.IsNotExist(err) {
return basePath, false, nil // File doesn't exist, use it directly
}
// File exists, check if it's the same file (hash + size)
existingHash, err := c.calculateFileHash(basePath)
if err == nil {
newHash, _ := c.calculateFileHash(tmpPath)
existingSize, _ := getFileSize(basePath)
newSize, _ := getFileSize(tmpPath)
// hash and size match, reuse existing file
if existingHash == newHash && existingSize == newSize {
return basePath, true, nil
}
}
// Not the same file, add serial number suffix
ext := filepath.Ext(basePath)
nameWithoutExt := strings.TrimSuffix(basePath, ext)
for i := 1; i <= maxFilenameConflictRetries; i++ {
// Use -1, -2 format
newPath := fmt.Sprintf("%s-%d%s", nameWithoutExt, i, ext)
if _, err := os.Stat(newPath); os.IsNotExist(err) {
return newPath, false, nil
}
// Check if new path has same content as temp file
if existingHash, err := c.calculateFileHash(newPath); err == nil {
newHash, _ := c.calculateFileHash(tmpPath)
if existingHash == newHash {
existingSize, _ := getFileSize(newPath)
newSize, _ := getFileSize(tmpPath)
if existingSize == newSize {
return newPath, true, nil
}
}
}
}
// Should never happen, but return error to be safe
return "", false, fmt.Errorf("too many filename conflicts (>%d), could not generate unique filename", maxFilenameConflictRetries)
}
// getFileSize gets the size of a file.
func getFileSize(filePath string) (int64, error) {
info, err := os.Stat(filePath)
if err != nil {
return 0, err
}
return info.Size(), nil
}
// calculateFileHash calculates SHA-256 hash of a file and returns it as hex string.
func (c *FeishuChannel) calculateFileHash(filePath string) (string, error) {
f, err := os.Open(filePath)
if err != nil {
return "", err
}
defer f.Close()
hasher := sha256.New()
if _, err := io.Copy(hasher, f); err != nil {
return "", err
}
return hex.EncodeToString(hasher.Sum(nil)), nil
}
// downloadResource downloads a message resource (image/file) from Feishu, // downloadResource downloads a message resource (image/file) from Feishu,
// writes it to the configured 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. // fallbackExt (e.g. ".jpg") is appended when the resolved filename has no extension.
@ -772,38 +970,124 @@ func (c *FeishuChannel) downloadResource(
filename += fallbackExt filename += fallbackExt
} }
// Determine download directory // 1. Determine download directory
mediaDir := c.resolveDownloadDir() downloadDir := c.resolveDownloadDir()
if mediaDir == "" { if downloadDir == "" {
// Fallback to TempDir // Fallback to TempDir
mediaDir = media.TempDir() downloadDir = media.TempDir()
} }
logger.DebugCF("feishu", "Downloading resource to directory", map[string]any{ // 2. Download to temporary file
"directory": mediaDir, tmpFile, err := os.CreateTemp("", "feishu_download_*")
})
ext := filepath.Ext(filename)
localPath := filepath.Join(mediaDir, utils.SanitizeFilename(messageID+"-"+fileKey+ext))
out, err := os.Create(localPath)
if err != nil { if err != nil {
logger.ErrorCF("feishu", "Failed to create local file for resource", map[string]any{ logger.ErrorCF("feishu", "Failed to create temporary file", map[string]any{
"error": err.Error(),
})
return ""
}
tmpPath := tmpFile.Name()
// Write downloaded content to temp file
size, err := io.Copy(tmpFile, resp.File)
tmpFile.Close()
if err != nil {
os.Remove(tmpPath)
logger.ErrorCF("feishu", "Failed to write resource to temp file", map[string]any{
"error": err.Error(), "error": err.Error(),
}) })
return "" return ""
} }
if _, copyErr := io.Copy(out, resp.File); copyErr != nil { // 3. Generate target path: {year}/{month}/{filename}
out.Close() basePath := c.generateFilePath(downloadDir, filename)
os.Remove(localPath)
logger.ErrorCF("feishu", "Failed to write resource to file", map[string]any{ // 4. Intelligently handle filename conflicts
"error": copyErr.Error(), targetPath, reused, err := c.generateUniqueFilename(basePath, tmpPath)
if err != nil {
os.Remove(tmpPath)
logger.ErrorCF("feishu", "Failed to generate unique filename", map[string]any{
"error": err.Error(),
}) })
return "" return ""
} }
out.Close()
ref, err := store.Store(localPath, media.MediaMeta{ if reused {
// Reuse existing file, delete temp file
os.Remove(tmpPath)
logger.InfoCF("feishu", "Reusing existing file (deduplication)", map[string]any{
"existing_path": targetPath,
})
// Store the reference in MediaStore
ref, err := store.Store(targetPath, media.MediaMeta{
Filename: filename,
Source: "feishu",
CleanupPolicy: media.CleanupPolicyDeleteOnCleanup,
}, scope)
if err != nil {
logger.ErrorCF("feishu", "Failed to store downloaded resource", map[string]any{
"file_key": fileKey,
"error": err.Error(),
})
return ""
}
return ref
}
// 5. Create directory and move file
monthDir := filepath.Dir(targetPath)
if err := os.MkdirAll(monthDir, 0o755); err != nil {
os.Remove(tmpPath)
logger.ErrorCF("feishu", "Failed to create month directory", map[string]any{
"error": err.Error(),
})
return ""
}
if err := os.Rename(tmpPath, targetPath); err != nil {
os.Remove(tmpPath)
logger.ErrorCF("feishu", "Failed to move temp file to final location", map[string]any{
"error": err.Error(),
})
return ""
}
// 6. Set file permissions (according to config)
if !c.config.DownloadReadonlyDisable {
// Set to read-only (prevent accidental deletion or modification)
if err := os.Chmod(targetPath, readOnlyFileMode); err != nil {
logger.WarnCF("feishu", "Failed to set file read-only", map[string]any{
"path": targetPath,
"error": err.Error(),
})
}
}
// 7. Update index file
hash, _ := c.calculateFileHash(targetPath)
meta := FileMeta{
Filename: filepath.Base(targetPath),
Hash: hash,
Size: size,
FileKey: fileKey,
MessageID: messageID,
DownloadedAt: time.Now(),
}
if err := c.updateIndexWithFile(monthDir, meta); err != nil {
// Index update failed, log warning but don't affect download
logger.WarnCF("feishu", "Failed to update index file", map[string]any{
"error": err.Error(),
})
}
logger.InfoCF("feishu", "Saved new file to download directory", map[string]any{
"filename": filename,
"local_path": targetPath,
"hash": hash,
})
ref, err := store.Store(targetPath, media.MediaMeta{
Filename: filename, Filename: filename,
Source: "feishu", Source: "feishu",
CleanupPolicy: media.CleanupPolicyDeleteOnCleanup, CleanupPolicy: media.CleanupPolicyDeleteOnCleanup,
@ -813,7 +1097,6 @@ func (c *FeishuChannel) downloadResource(
"file_key": fileKey, "file_key": fileKey,
"error": err.Error(), "error": err.Error(),
}) })
os.Remove(localPath)
return "" return ""
} }

View file

@ -3,8 +3,13 @@
package feishu package feishu
import ( import (
"encoding/json"
"fmt"
"os" "os"
"path/filepath"
"strings"
"testing" "testing"
"time"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1" larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
@ -495,3 +500,438 @@ func TestResolveDownloadDir(t *testing.T) {
}) })
} }
} }
func TestGenerateUniqueFilename(t *testing.T) {
tests := []struct {
name string
baseName string
tmpContent string
setupFiles func(string) // 创建已存在的文件
wantSuffix string // 期望的文件名后缀(空表示原名)
wantReused bool // 期望是否重用
}{
{
name: "returns base path when file doesn't exist",
baseName: "test.jpg",
tmpContent: "new content",
setupFiles: func(string) {},
wantSuffix: "",
wantReused: false,
},
{
name: "reuses file when hash and size match",
baseName: "test.jpg",
tmpContent: "same content",
setupFiles: func(dir string) {
_ = os.WriteFile(filepath.Join(dir, "test.jpg"), []byte("same content"), 0o644)
},
wantSuffix: "",
wantReused: true,
},
{
name: "adds suffix when file exists with different content",
baseName: "test.jpg",
tmpContent: "different content",
setupFiles: func(dir string) {
_ = os.WriteFile(filepath.Join(dir, "test.jpg"), []byte("old content"), 0o644)
},
wantSuffix: "-1.jpg",
wantReused: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dir := t.TempDir()
basePath := filepath.Join(dir, tt.baseName)
tt.setupFiles(dir)
// Create temporary file with test content
tmpFile, err := os.CreateTemp("", "test_*")
if err != nil {
t.Fatalf("failed to create temp file: %v", err)
}
if _, err := tmpFile.WriteString(tt.tmpContent); err != nil {
t.Fatalf("failed to write to temp file: %v", err)
}
tmpFile.Close()
channel := &FeishuChannel{}
gotPath, gotReused, err := channel.generateUniqueFilename(basePath, tmpFile.Name())
if err != nil {
t.Errorf("generateUniqueFilename() error = %v", err)
return
}
// Check suffix
gotBase := filepath.Base(gotPath)
wantBase := tt.baseName
if tt.wantSuffix != "" {
ext := filepath.Ext(tt.baseName)
nameWithoutExt := strings.TrimSuffix(tt.baseName, ext)
wantBase = nameWithoutExt + tt.wantSuffix
}
if gotBase != wantBase {
t.Errorf("generateUniqueFilename() filename = %q, want %q", gotBase, wantBase)
}
if gotReused != tt.wantReused {
t.Errorf("generateUniqueFilename() reused = %v, want %v", gotReused, tt.wantReused)
}
// Clean up
os.Remove(tmpFile.Name())
})
}
}
func TestCalculateFileHash(t *testing.T) {
tests := []struct {
name string
content string
wantHash string
wantError bool
}{
{
name: "calculates SHA-256 hash correctly",
content: "hello world",
wantHash: "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9", // SHA-256 of "hello world"
wantError: false,
},
{
name: "handles empty content",
content: "",
wantHash: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", // SHA-256 of empty string
wantError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
testFile := filepath.Join(t.TempDir(), "test.txt")
if err := os.WriteFile(testFile, []byte(tt.content), 0o644); err != nil {
t.Fatalf("failed to create test file: %v", err)
}
channel := &FeishuChannel{}
got, err := channel.calculateFileHash(testFile)
if (err != nil) != tt.wantError {
t.Errorf("calculateFileHash() error = %v, wantError %v", err, tt.wantError)
return
}
if got != tt.wantHash {
t.Errorf("calculateFileHash() = %q, want %q", got, tt.wantHash)
}
})
}
}
func TestGenerateFilePath(t *testing.T) {
tests := []struct {
name string
downloadDir string
filename string
wantPrefix string // 期望路径前缀(年/月部分会变化)
}{
{
name: "generates correct path structure",
downloadDir: "/downloads",
filename: "report.pdf",
wantPrefix: "/downloads/",
},
{
name: "sanitizes filename",
downloadDir: "/downloads",
filename: "report:name.pdf",
wantPrefix: "/downloads/",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
channel := &FeishuChannel{}
got := channel.generateFilePath(tt.downloadDir, tt.filename)
if !filepath.IsAbs(got) {
t.Errorf("generateFilePath() returned relative path, want absolute")
}
// Check path format: {download_dir}/{year}/{month}/{sanitized_filename}
if !strings.HasPrefix(got, tt.wantPrefix) {
t.Errorf("generateFilePath() = %q, want prefix %q", got, tt.wantPrefix)
}
// Check that filename is sanitized
base := filepath.Base(got)
if strings.Contains(base, ":") || strings.Contains(base, "*") {
t.Errorf("generateFilePath() filename not sanitized: %q", base)
}
})
}
}
func TestLoadDirectoryIndex(t *testing.T) {
tests := []struct {
name string
setupIndex func(string) error
wantFileCount int
wantVersion int
}{
{
name: "returns empty index when file doesn't exist",
setupIndex: func(string) error { return nil },
wantFileCount: 0,
wantVersion: 1,
},
{
name: "loads valid index file",
setupIndex: func(dir string) error {
index := DirectoryIndex{
Version: 1,
Files: []FileMeta{
{
Filename: "test.pdf",
Hash: "abc123",
Size: 1024,
FileKey: "key123",
MessageID: "msg123",
DownloadedAt: time.Now(),
},
},
}
data, err := json.Marshal(index)
if err != nil {
return err
}
return os.WriteFile(filepath.Join(dir, indexFileName), data, 0o644)
},
wantFileCount: 1,
wantVersion: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dir := t.TempDir()
_ = tt.setupIndex(dir)
channel := &FeishuChannel{}
index, err := channel.loadDirectoryIndex(dir)
if err != nil {
t.Errorf("loadDirectoryIndex() error = %v", err)
return
}
if len(index.Files) != tt.wantFileCount {
t.Errorf("loadDirectoryIndex() file count = %d, want %d", len(index.Files), tt.wantFileCount)
}
if index.Version != tt.wantVersion {
t.Errorf("loadDirectoryIndex() version = %d, want %d", index.Version, tt.wantVersion)
}
})
}
}
func TestSaveDirectoryIndex(t *testing.T) {
dir := t.TempDir()
index := &DirectoryIndex{
Version: 1,
Files: []FileMeta{
{
Filename: "test.pdf",
Hash: "abc123",
Size: 1024,
FileKey: "key123",
MessageID: "msg123",
DownloadedAt: time.Now(),
},
},
}
channel := &FeishuChannel{}
err := channel.saveDirectoryIndex(dir, index)
if err != nil {
t.Errorf("saveDirectoryIndex() error = %v", err)
return
}
// Verify file was created
indexPath := filepath.Join(dir, indexFileName)
if _, err := os.Stat(indexPath); os.IsNotExist(err) {
t.Errorf("saveDirectoryIndex() file not created")
return
}
// Verify content
data, err := os.ReadFile(indexPath)
if err != nil {
t.Errorf("failed to read index file: %v", err)
return
}
var loaded DirectoryIndex
if err := json.Unmarshal(data, &loaded); err != nil {
t.Errorf("failed to unmarshal index: %v", err)
return
}
if len(loaded.Files) != 1 {
t.Errorf("saveDirectoryIndex() saved wrong number of files: got %d, want 1", len(loaded.Files))
}
}
func TestUpdateIndexWithFile(t *testing.T) {
tests := []struct {
name string
setup func(string) (*DirectoryIndex, error)
meta FileMeta
verify func(*DirectoryIndex) error
}{
{
name: "adds new file to empty index",
setup: func(dir string) (*DirectoryIndex, error) {
return &DirectoryIndex{Version: 1, Files: []FileMeta{}}, nil
},
meta: FileMeta{
Filename: "new.pdf",
Hash: "abc123",
Size: 1024,
FileKey: "key123",
MessageID: "msg123",
DownloadedAt: time.Now(),
},
verify: func(index *DirectoryIndex) error {
if len(index.Files) != 1 {
return fmt.Errorf("expected 1 file, got %d", len(index.Files))
}
if index.Files[0].Filename != "new.pdf" {
return fmt.Errorf("expected filename new.pdf, got %s", index.Files[0].Filename)
}
return nil
},
},
{
name: "updates existing file in index",
setup: func(dir string) (*DirectoryIndex, error) {
index := &DirectoryIndex{
Version: 1,
Files: []FileMeta{
{
Filename: "existing.pdf",
Hash: "old123",
Size: 512,
FileKey: "key123",
MessageID: "msg123",
DownloadedAt: time.Now(),
},
},
}
return index, nil
},
meta: FileMeta{
Filename: "existing.pdf",
Hash: "new456",
Size: 2048,
FileKey: "key456",
MessageID: "msg456",
DownloadedAt: time.Now(),
},
verify: func(index *DirectoryIndex) error {
if len(index.Files) != 1 {
return fmt.Errorf("expected 1 file, got %d", len(index.Files))
}
if index.Files[0].Hash != "new456" {
return fmt.Errorf("expected hash new456, got %s", index.Files[0].Hash)
}
return nil
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dir := t.TempDir()
_, _ = tt.setup(dir)
channel := &FeishuChannel{}
err := channel.updateIndexWithFile(dir, tt.meta)
if err != nil {
t.Errorf("updateIndexWithFile() error = %v", err)
return
}
// Verify
index, err := channel.loadDirectoryIndex(dir)
if err != nil {
t.Errorf("failed to load index for verification: %v", err)
return
}
if err := tt.verify(index); err != nil {
t.Errorf("verification failed: %v", err)
}
})
}
}
func TestFindFileByKeyInIndex(t *testing.T) {
dir := t.TempDir()
// Setup: Create index with test data
index := &DirectoryIndex{
Version: 1,
Files: []FileMeta{
{
Filename: "file1.pdf",
Hash: "abc123",
Size: 1024,
FileKey: "key1",
MessageID: "msg1",
DownloadedAt: time.Now(),
},
{
Filename: "file2.pdf",
Hash: "def456",
Size: 2048,
FileKey: "key2",
MessageID: "msg2",
DownloadedAt: time.Now(),
},
},
}
channel := &FeishuChannel{}
_ = channel.saveDirectoryIndex(dir, index)
// Test: Find existing file
meta, err := channel.findFileByKeyInIndex(dir, "key1")
if err != nil {
t.Errorf("findFileByKeyInIndex() error = %v", err)
return
}
if meta == nil {
t.Errorf("findFileByKeyInIndex() = nil, want file metadata")
return
}
if meta.Filename != "file1.pdf" {
t.Errorf("findFileByKeyInIndex() filename = %q, want file1.pdf", meta.Filename)
}
// Test: Find non-existing file
meta, _ = channel.findFileByKeyInIndex(dir, "key999")
if meta != nil {
t.Errorf("findFileByKeyInIndex() = %v, want nil", meta)
}
}

View file

@ -452,7 +452,8 @@ type FeishuConfig struct {
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_FEISHU_REASONING_CHANNEL_ID"` 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"` RandomReactionEmoji FlexibleStringSlice `json:"random_reaction_emoji" env:"PICOCLAW_CHANNELS_FEISHU_RANDOM_REACTION_EMOJI"`
IsLark bool `json:"is_lark" env:"PICOCLAW_CHANNELS_FEISHU_IS_LARK"` IsLark bool `json:"is_lark" env:"PICOCLAW_CHANNELS_FEISHU_IS_LARK"`
DownloadDir string `json:"download_dir,omitempty" env:"PICOCLAW_CHANNELS_FEISHU_DOWNLOAD_DIR"` DownloadDir string `json:"download_dir,omitempty" env:"PICOCLAW_CHANNELS_FEISHU_DOWNLOAD_DIR"`
DownloadReadonlyDisable bool `json:"download_readonly_disable,omitempty" env:"PICOCLAW_CHANNELS_FEISHU_DOWNLOAD_READONLY_DISABLE"`
secDirty bool secDirty bool
} }

View file

@ -56,8 +56,23 @@ func SanitizeFilename(filename string) string {
// Remove any directory traversal attempts // Remove any directory traversal attempts
base = strings.ReplaceAll(base, "..", "") base = strings.ReplaceAll(base, "..", "")
base = strings.ReplaceAll(base, "/", "_")
base = strings.ReplaceAll(base, "\\", "_") // Replace filesystem invalid characters with underscore
// These characters are not allowed in filenames on various OS:
// Windows: < > : " / \ | ? *
// Unix/macOS: / (and : can cause issues in Finder)
invalidChars := []string{"/", "\\", ":", "*", "?", "\"", "<", ">", "|"}
for _, char := range invalidChars {
base = strings.ReplaceAll(base, char, "_")
}
// Trim spaces and dots from start/end
base = strings.Trim(base, " .")
// Ensure filename is not empty after sanitization
if base == "" {
base = "unnamed_file"
}
return base return base
} }

View file

@ -136,6 +136,12 @@ export function FeishuForm({
placeholder={t("channels.field.downloadDirPlaceholder")} placeholder={t("channels.field.downloadDirPlaceholder")}
/> />
</Field> </Field>
<SwitchCardField
label={t("channels.field.downloadReadonlyDisable")}
hint={t("channels.form.desc.downloadReadonlyDisable")}
checked={asBool(config.download_readonly_disable)}
onCheckedChange={(checked) => onChange("download_readonly_disable", checked)}
/>
</div> </div>
) )
} }

View file

@ -302,6 +302,7 @@
"allowOriginsPlaceholder": "e.g. https://example.com, http://localhost:5173", "allowOriginsPlaceholder": "e.g. https://example.com, http://localhost:5173",
"downloadDir": "Download Directory", "downloadDir": "Download Directory",
"downloadDirPlaceholder": "downloads or /absolute/path", "downloadDirPlaceholder": "downloads or /absolute/path",
"downloadReadonlyDisable": "Disable Download Readonly",
"secretPlaceholder": "Enter secret", "secretPlaceholder": "Enter secret",
"secretHintSet": "A value is already set. Leave blank to keep it unchanged." "secretHintSet": "A value is already set. Leave blank to keep it unchanged."
}, },
@ -334,6 +335,7 @@
"isLark": "Use Lark international domain (open.larksuite.com) instead of Feishu domain (open.feishu.cn).", "isLark": "Use Lark international domain (open.larksuite.com) instead of Feishu domain (open.feishu.cn).",
"allowFrom": "Allowed user or group IDs, separated by commas.", "allowFrom": "Allowed user or group IDs, separated by commas.",
"downloadDir": "Custom directory for downloaded files. Relative paths are resolved from the workspace directory. Leave empty to use system temp directory.", "downloadDir": "Custom directory for downloaded files. Relative paths are resolved from the workspace directory. Leave empty to use system temp directory.",
"downloadReadonlyDisable": "Disable read-only protection for downloaded files (default: read-only enabled). When enabled, files can be edited directly.",
"allowOrigins": "Allowed origin domains, separated by commas.", "allowOrigins": "Allowed origin domains, separated by commas.",
"wsUrl": "WebSocket service URL.", "wsUrl": "WebSocket service URL.",
"reconnectInterval": "Reconnect interval after disconnection (seconds).", "reconnectInterval": "Reconnect interval after disconnection (seconds).",

View file

@ -302,6 +302,7 @@
"allowOriginsPlaceholder": "例如 https://example.com, http://localhost:5173", "allowOriginsPlaceholder": "例如 https://example.com, http://localhost:5173",
"downloadDir": "下载目录", "downloadDir": "下载目录",
"downloadDirPlaceholder": "downloads 或 /absolute/path", "downloadDirPlaceholder": "downloads 或 /absolute/path",
"downloadReadonlyDisable": "禁用下载文件只读",
"secretPlaceholder": "输入密钥", "secretPlaceholder": "输入密钥",
"secretHintSet": "已设置密钥,留空表示不修改。" "secretHintSet": "已设置密钥,留空表示不修改。"
}, },
@ -334,6 +335,7 @@
"isLark": "使用 Lark 国际版域名open.larksuite.com替代飞书域名open.feishu.cn。", "isLark": "使用 Lark 国际版域名open.larksuite.com替代飞书域名open.feishu.cn。",
"allowFrom": "允许访问的用户或群组 ID多个值用逗号分隔。", "allowFrom": "允许访问的用户或群组 ID多个值用逗号分隔。",
"downloadDir": "自定义下载文件目录。相对路径将基于工作目录解析,留空则使用系统临时目录。", "downloadDir": "自定义下载文件目录。相对路径将基于工作目录解析,留空则使用系统临时目录。",
"downloadReadonlyDisable": "禁用下载文件的只读保护(默认:启用只读)。开启后,下载的文件可以直接编辑。",
"allowOrigins": "允许访问的来源域名,多个值用逗号分隔。", "allowOrigins": "允许访问的来源域名,多个值用逗号分隔。",
"wsUrl": "WebSocket 服务地址。", "wsUrl": "WebSocket 服务地址。",
"reconnectInterval": "断线后的重连间隔(秒)。", "reconnectInterval": "断线后的重连间隔(秒)。",