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:
Max 2026-04-14 15:01:05 +08:00
parent c976a3e398
commit 1deef3cabf
3 changed files with 196 additions and 4 deletions

View file

@ -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
View 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)
}

View file

@ -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)