feat(robot): enhance DeliveryAttachment structure with size and content type fields
- Added Size field to represent the file size in bytes. - Introduced ContentType field to specify the MIME type of the attachment. - Updated API endpoint to support bundling multiple files into a ZIP archive.
This commit is contained in:
parent
c976a3e398
commit
1deef3cabf
3 changed files with 196 additions and 4 deletions
|
|
@ -404,10 +404,12 @@ type DeliveryContent struct {
|
|||
|
||||
// DeliveryAttachment - Task output attachment with metadata
|
||||
type DeliveryAttachment struct {
|
||||
Title string `json:"title"` // Human-readable title
|
||||
Description string `json:"description,omitempty"` // What this artifact is
|
||||
TaskID string `json:"task_id,omitempty"` // Which task produced this
|
||||
File string `json:"file"` // Wrapper: __<uploader>://<fileID>
|
||||
Title string `json:"title"` // Human-readable title
|
||||
Description string `json:"description,omitempty"` // What this artifact is
|
||||
TaskID string `json:"task_id,omitempty"` // Which task produced this
|
||||
File string `json:"file"` // Wrapper: __<uploader>://<fileID>, workspace://, or URL
|
||||
Size int64 `json:"size,omitempty"` // File size in bytes
|
||||
ContentType string `json:"content_type,omitempty"` // MIME type
|
||||
}
|
||||
|
||||
// DeliveryRequest - pushed to Delivery Center (no channels - center decides based on preferences)
|
||||
|
|
|
|||
187
openapi/file/bundle.go
Normal file
187
openapi/file/bundle.go
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
package file
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/yaoapp/yao/attachment"
|
||||
"github.com/yaoapp/yao/openapi/oauth/authorized"
|
||||
"github.com/yaoapp/yao/openapi/oauth/types"
|
||||
"github.com/yaoapp/yao/openapi/response"
|
||||
ws "github.com/yaoapp/yao/workspace"
|
||||
)
|
||||
|
||||
const maxBundleFiles = 50
|
||||
|
||||
type bundleRequest struct {
|
||||
Files []bundleFileItem `json:"files" binding:"required,min=1"`
|
||||
ArchiveName string `json:"archive_name"`
|
||||
}
|
||||
|
||||
type bundleFileItem struct {
|
||||
File string `json:"file" binding:"required"`
|
||||
Filename string `json:"filename" binding:"required"`
|
||||
}
|
||||
|
||||
// bundle streams a ZIP archive containing the requested files.
|
||||
//
|
||||
// Supported URI schemes in each item's `file` field:
|
||||
// - {uploaderID}://{fileID} — reads from attachment manager
|
||||
// - workspace://{wsId}/{path} — reads from workspace file system
|
||||
// - http(s)://... — fetches external URL
|
||||
func bundle(c *gin.Context) {
|
||||
var req bundleRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.RespondWithError(c, response.StatusBadRequest, &response.ErrorResponse{
|
||||
Code: response.ErrInvalidRequest.Code,
|
||||
ErrorDescription: "Invalid request body: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.Files) > maxBundleFiles {
|
||||
response.RespondWithError(c, response.StatusBadRequest, &response.ErrorResponse{
|
||||
Code: response.ErrInvalidRequest.Code,
|
||||
ErrorDescription: fmt.Sprintf("Too many files (%d), max %d", len(req.Files), maxBundleFiles),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
archiveName := req.ArchiveName
|
||||
if archiveName == "" {
|
||||
archiveName = "attachments.zip"
|
||||
}
|
||||
|
||||
authInfo := authorized.GetInfo(c)
|
||||
|
||||
c.Header("Content-Type", "application/zip")
|
||||
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"; filename*=UTF-8''%s`,
|
||||
archiveName, url.PathEscape(archiveName)))
|
||||
c.Status(http.StatusOK)
|
||||
|
||||
zw := zip.NewWriter(c.Writer)
|
||||
defer zw.Close()
|
||||
|
||||
seen := map[string]int{}
|
||||
|
||||
for _, item := range req.Files {
|
||||
name := dedup(item.Filename, seen)
|
||||
data, err := resolveFile(c, authInfo, item.File)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
w, err := zw.Create(name)
|
||||
if err != nil {
|
||||
if rc, ok := data.(io.ReadCloser); ok {
|
||||
rc.Close()
|
||||
}
|
||||
continue
|
||||
}
|
||||
io.Copy(w, data)
|
||||
if rc, ok := data.(io.ReadCloser); ok {
|
||||
rc.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func resolveFile(c *gin.Context, authInfo *types.AuthorizedInfo, fileURI string) (io.Reader, error) {
|
||||
if strings.HasPrefix(fileURI, "workspace://") {
|
||||
return resolveWorkspaceFile(c, fileURI)
|
||||
}
|
||||
if strings.HasPrefix(fileURI, "http://") || strings.HasPrefix(fileURI, "https://") {
|
||||
return resolveHTTPFile(fileURI)
|
||||
}
|
||||
return resolveWrapperFile(c, authInfo, fileURI)
|
||||
}
|
||||
|
||||
func resolveWrapperFile(c *gin.Context, authInfo *types.AuthorizedInfo, fileURI string) (io.Reader, error) {
|
||||
parts := strings.SplitN(fileURI, "://", 2)
|
||||
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||||
return nil, fmt.Errorf("invalid wrapper URI: %s", fileURI)
|
||||
}
|
||||
|
||||
uploaderID := parts[0]
|
||||
fileID := parts[1]
|
||||
|
||||
manager, ok := attachment.Managers[uploaderID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("uploader not found: %s", uploaderID)
|
||||
}
|
||||
|
||||
fileInfo, err := manager.Info(c.Request.Context(), fileID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("file not found: %w", err)
|
||||
}
|
||||
|
||||
if authInfo != nil {
|
||||
allowed, err := checkFilePermission(authInfo, fileInfo, true)
|
||||
if err != nil || !allowed {
|
||||
return nil, fmt.Errorf("permission denied for file %s", fileID)
|
||||
}
|
||||
}
|
||||
|
||||
data, err := manager.Read(c.Request.Context(), fileID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read file failed: %w", err)
|
||||
}
|
||||
|
||||
return bytes.NewReader(data), nil
|
||||
}
|
||||
|
||||
func resolveWorkspaceFile(c *gin.Context, fileURI string) (io.Reader, error) {
|
||||
rest := strings.TrimPrefix(fileURI, "workspace://")
|
||||
idx := strings.Index(rest, "/")
|
||||
if idx < 0 {
|
||||
return nil, fmt.Errorf("invalid workspace URI: %s", fileURI)
|
||||
}
|
||||
wsID := rest[:idx]
|
||||
filePath := rest[idx+1:]
|
||||
|
||||
m := ws.M()
|
||||
if m == nil {
|
||||
return nil, fmt.Errorf("workspace service not available")
|
||||
}
|
||||
|
||||
data, err := m.ReadFile(context.Background(), wsID, filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("workspace file read failed: %w", err)
|
||||
}
|
||||
|
||||
return bytes.NewReader(data), nil
|
||||
}
|
||||
|
||||
func resolveHTTPFile(fileURL string) (io.Reader, error) {
|
||||
resp, err := http.Get(fileURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch failed: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("fetch returned %d", resp.StatusCode)
|
||||
}
|
||||
return resp.Body, nil
|
||||
}
|
||||
|
||||
func dedup(name string, seen map[string]int) string {
|
||||
lower := strings.ToLower(name)
|
||||
n, exists := seen[lower]
|
||||
if !exists {
|
||||
seen[lower] = 1
|
||||
return name
|
||||
}
|
||||
seen[lower] = n + 1
|
||||
ext := ""
|
||||
base := name
|
||||
if dot := strings.LastIndex(name, "."); dot > 0 {
|
||||
ext = name[dot:]
|
||||
base = name[:dot]
|
||||
}
|
||||
return fmt.Sprintf("%s (%d)%s", base, n, ext)
|
||||
}
|
||||
|
|
@ -21,6 +21,9 @@ func Attach(group *gin.RouterGroup, oauth types.OAuth) {
|
|||
// Protect all endpoints with OAuth
|
||||
group.Use(oauth.Guard)
|
||||
|
||||
// Bundle multiple files into a ZIP archive (must be before /:uploaderID)
|
||||
group.POST("/bundle", bundle)
|
||||
|
||||
// Upload a file (supports chunked upload)
|
||||
group.POST("/:uploaderID", upload)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue