mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-06-18 00:47:44 +00:00
2c5ae04c01
- IsSharedWorkspace() reads team.settings.workspace_scope
- Shared: workspace at teams/{teamID}/ (all chats share)
- Isolated (default): workspace at teams/{teamID}/{chatID}/
- Remove _default fallback; isolated mode requires chat_id
- Update loop.go, task creation, task listing, message dispatch,
workspace API (list/read/delete), task board snapshot
- Update UI descriptions to reflect per-conversation scoping
302 lines
9.9 KiB
Go
302 lines
9.9 KiB
Go
package methods
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/gateway"
|
|
"github.com/nextlevelbuilder/goclaw/internal/i18n"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
"github.com/nextlevelbuilder/goclaw/internal/tools"
|
|
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
|
)
|
|
|
|
// RegisterWorkspace adds workspace RPC handlers to the method router.
|
|
func (m *TeamsMethods) RegisterWorkspace(router *gateway.MethodRouter) {
|
|
router.Register(protocol.MethodTeamsWorkspaceList, m.handleWorkspaceList)
|
|
router.Register(protocol.MethodTeamsWorkspaceRead, m.handleWorkspaceRead)
|
|
router.Register(protocol.MethodTeamsWorkspaceDelete, m.handleWorkspaceDelete)
|
|
}
|
|
|
|
// teamWorkspaceDir returns the base directory for a team's workspace files.
|
|
// Pattern: {dataDir}/teams/{teamID}/
|
|
// If chatID is provided, scopes to {dataDir}/teams/{teamID}/{chatID}/
|
|
func teamWorkspaceDir(dataDir string, teamID uuid.UUID, chatID string) string {
|
|
if chatID != "" {
|
|
return filepath.Join(dataDir, "teams", teamID.String(), chatID)
|
|
}
|
|
return filepath.Join(dataDir, "teams", teamID.String())
|
|
}
|
|
|
|
// workspaceFileEntry is the response shape for workspace file listing.
|
|
type workspaceFileEntry struct {
|
|
Name string `json:"name"`
|
|
Path string `json:"path"`
|
|
Size int64 `json:"size"`
|
|
ChatID string `json:"chat_id"`
|
|
IsDir bool `json:"is_dir,omitempty"`
|
|
UpdatedAt string `json:"updated_at,omitempty"`
|
|
}
|
|
|
|
// --- Workspace List ---
|
|
|
|
type workspaceListParams struct {
|
|
TeamID string `json:"team_id"`
|
|
ChatID string `json:"chat_id"`
|
|
}
|
|
|
|
func (m *TeamsMethods) handleWorkspaceList(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
|
locale := store.LocaleFromContext(ctx)
|
|
|
|
var params workspaceListParams
|
|
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
|
|
return
|
|
}
|
|
if params.TeamID == "" {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "team_id")))
|
|
return
|
|
}
|
|
|
|
teamID, err := uuid.Parse(params.TeamID)
|
|
if err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid team_id"))
|
|
return
|
|
}
|
|
|
|
// Check if team uses shared workspace (no chatID scoping).
|
|
shared := false
|
|
if team, err := m.teamStore.GetTeam(ctx, teamID); err == nil {
|
|
shared = tools.IsSharedWorkspace(team.Settings)
|
|
}
|
|
|
|
baseDir := teamWorkspaceDir(m.dataDir, teamID, "")
|
|
var files []workspaceFileEntry
|
|
|
|
if shared || params.ChatID != "" {
|
|
// Shared mode: list team root directly. Scoped mode: list specific chatID.
|
|
scopeDir := baseDir
|
|
scopeChatID := ""
|
|
if !shared && params.ChatID != "" {
|
|
scopeDir = teamWorkspaceDir(m.dataDir, teamID, params.ChatID)
|
|
scopeChatID = params.ChatID
|
|
}
|
|
files = walkDir(scopeDir, "", scopeChatID)
|
|
} else {
|
|
// Isolated + unscoped: list all chatID subdirectories with chatID as top-level folder.
|
|
entries, err := os.ReadDir(baseDir)
|
|
if err != nil {
|
|
// Directory doesn't exist = empty workspace.
|
|
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
|
|
"files": []workspaceFileEntry{},
|
|
"count": 0,
|
|
}))
|
|
return
|
|
}
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() {
|
|
continue
|
|
}
|
|
chatID := entry.Name()
|
|
scopeDir := filepath.Join(baseDir, chatID)
|
|
// Add the chatID directory itself as a top-level entry.
|
|
files = append(files, workspaceFileEntry{
|
|
Name: chatID,
|
|
Path: scopeDir,
|
|
ChatID: chatID,
|
|
IsDir: true,
|
|
})
|
|
// Prefix children with chatID so the tree nests under it.
|
|
files = append(files, walkDir(scopeDir, chatID, chatID)...)
|
|
}
|
|
}
|
|
|
|
if files == nil {
|
|
files = []workspaceFileEntry{}
|
|
}
|
|
|
|
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
|
|
"files": files,
|
|
"count": len(files),
|
|
}))
|
|
}
|
|
|
|
// walkDir recursively lists files and directories, returning workspaceFileEntry slice
|
|
// with relative paths. prefix is the relative path prefix for nested entries.
|
|
func walkDir(baseDir, prefix, chatID string) []workspaceFileEntry {
|
|
entries, err := os.ReadDir(baseDir)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var files []workspaceFileEntry
|
|
for _, entry := range entries {
|
|
relPath := entry.Name()
|
|
if prefix != "" {
|
|
relPath = prefix + "/" + entry.Name()
|
|
}
|
|
if entry.IsDir() {
|
|
files = append(files, workspaceFileEntry{
|
|
Name: relPath,
|
|
Path: filepath.Join(baseDir, entry.Name()),
|
|
ChatID: chatID,
|
|
IsDir: true,
|
|
})
|
|
// Recurse into subdirectory.
|
|
files = append(files, walkDir(filepath.Join(baseDir, entry.Name()), relPath, chatID)...)
|
|
continue
|
|
}
|
|
info, err := entry.Info()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
files = append(files, workspaceFileEntry{
|
|
Name: relPath,
|
|
Path: filepath.Join(baseDir, entry.Name()),
|
|
Size: info.Size(),
|
|
ChatID: chatID,
|
|
UpdatedAt: info.ModTime().UTC().Format("2006-01-02T15:04:05Z"),
|
|
})
|
|
}
|
|
return files
|
|
}
|
|
|
|
// --- Workspace Read ---
|
|
|
|
type workspaceReadParams struct {
|
|
TeamID string `json:"team_id"`
|
|
ChatID string `json:"chat_id"`
|
|
FileName string `json:"file_name"`
|
|
}
|
|
|
|
func (m *TeamsMethods) handleWorkspaceRead(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
|
locale := store.LocaleFromContext(ctx)
|
|
|
|
var params workspaceReadParams
|
|
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
|
|
return
|
|
}
|
|
if params.TeamID == "" || params.FileName == "" {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "team_id, file_name")))
|
|
return
|
|
}
|
|
// Security: reject path traversal (allow "/" for nested paths, reject "\" and "..").
|
|
if strings.Contains(params.FileName, "..") || strings.Contains(params.FileName, "\\") {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid file_name"))
|
|
return
|
|
}
|
|
|
|
teamID, err := uuid.Parse(params.TeamID)
|
|
if err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid team_id"))
|
|
return
|
|
}
|
|
|
|
// Shared workspace: read from team root. Isolated: require chatID.
|
|
chatID := params.ChatID
|
|
if team, err := m.teamStore.GetTeam(ctx, teamID); err == nil && tools.IsSharedWorkspace(team.Settings) {
|
|
chatID = ""
|
|
} else if chatID == "" {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "chat_id")))
|
|
return
|
|
}
|
|
|
|
scopeDir := teamWorkspaceDir(m.dataDir, teamID, chatID)
|
|
diskPath := filepath.Clean(filepath.Join(scopeDir, params.FileName))
|
|
// Ensure resolved path stays within the workspace scope directory.
|
|
if !strings.HasPrefix(diskPath, filepath.Clean(scopeDir)+string(os.PathSeparator)) && diskPath != filepath.Clean(scopeDir) {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid file_name"))
|
|
return
|
|
}
|
|
data, err := os.ReadFile(diskPath)
|
|
if err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, fmt.Sprintf("file not found: %s", params.FileName)))
|
|
return
|
|
}
|
|
|
|
content := string(data)
|
|
if len(content) > 500000 {
|
|
content = content[:500000] + "\n\n[...truncated]"
|
|
}
|
|
|
|
info, _ := os.Stat(diskPath)
|
|
file := workspaceFileEntry{
|
|
Name: params.FileName,
|
|
Path: diskPath,
|
|
Size: int64(len(data)),
|
|
ChatID: chatID,
|
|
}
|
|
if info != nil {
|
|
file.UpdatedAt = info.ModTime().UTC().Format("2006-01-02T15:04:05Z")
|
|
}
|
|
|
|
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
|
|
"file": file,
|
|
"content": content,
|
|
}))
|
|
}
|
|
|
|
// --- Workspace Delete ---
|
|
|
|
type workspaceDeleteParams struct {
|
|
TeamID string `json:"team_id"`
|
|
ChatID string `json:"chat_id"`
|
|
FileName string `json:"file_name"`
|
|
}
|
|
|
|
func (m *TeamsMethods) handleWorkspaceDelete(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
|
locale := store.LocaleFromContext(ctx)
|
|
|
|
var params workspaceDeleteParams
|
|
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
|
|
return
|
|
}
|
|
if params.TeamID == "" || params.FileName == "" {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "team_id, file_name")))
|
|
return
|
|
}
|
|
// Security: reject path traversal (allow "/" for nested paths, reject "\" and "..").
|
|
if strings.Contains(params.FileName, "..") || strings.Contains(params.FileName, "\\") {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid file_name"))
|
|
return
|
|
}
|
|
|
|
teamID, err := uuid.Parse(params.TeamID)
|
|
if err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid team_id"))
|
|
return
|
|
}
|
|
|
|
// Shared workspace: delete from team root. Isolated: require chatID.
|
|
chatID := params.ChatID
|
|
if team, err := m.teamStore.GetTeam(ctx, teamID); err == nil && tools.IsSharedWorkspace(team.Settings) {
|
|
chatID = ""
|
|
} else if chatID == "" {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "chat_id")))
|
|
return
|
|
}
|
|
|
|
scopeDir := teamWorkspaceDir(m.dataDir, teamID, chatID)
|
|
diskPath := filepath.Clean(filepath.Join(scopeDir, params.FileName))
|
|
// Ensure resolved path stays within the workspace scope directory.
|
|
if !strings.HasPrefix(diskPath, filepath.Clean(scopeDir)+string(os.PathSeparator)) && diskPath != filepath.Clean(scopeDir) {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid file_name"))
|
|
return
|
|
}
|
|
if err := os.Remove(diskPath); err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, fmt.Sprintf("file not found: %s", params.FileName)))
|
|
return
|
|
}
|
|
|
|
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
|
|
"deleted": params.FileName,
|
|
}))
|
|
}
|