package isphere import ( "path/filepath" "regexp" "sort" "strings" ) type File struct { FileID string MessageID string ConversationID string FileName string SizeBytes *int64 MimeType string CreatedAt string DownloadRef string SavedPath string SHA256 string Source string RawRef string } type ListFilesQuery struct { ConversationID string MessageID string FileID string NameContains string Limit int } type ListFilesResult struct { Files []File } var fileNamePattern = regexp.MustCompile(`(?i)[^\s<>:"|?*]+\.(?:docx?|xlsx?|pptx?|pdf|zip|rar|png|jpe?g|txt|csv)\b`) func ListFilesFromMessages(messages []Message, query ListFilesQuery) ListFilesResult { files := make([]File, 0) for _, message := range messages { if !messageLooksFileBacked(message) { continue } fileName := extractMessageFileName(message.Text) if fileName == "" { continue } file := File{ FileID: fileID(message.ID, fileName), MessageID: message.ID, ConversationID: message.ConversationID, FileName: fileName, MimeType: mimeTypeForFileName(fileName), CreatedAt: message.Timestamp, Source: "local_readonly", RawRef: "packetlog_file_transfer", } if fileMatchesQuery(file, query) { files = append(files, file) } } sort.Slice(files, func(i, j int) bool { if files[i].FileID == files[j].FileID { return files[i].MessageID < files[j].MessageID } return files[i].FileID < files[j].FileID }) limit := query.Limit if limit <= 0 || limit > len(files) { limit = len(files) } return ListFilesResult{Files: files[:limit]} } func messageLooksFileBacked(message Message) bool { return strings.Contains(strings.ToUpper(message.Subject), "FILE_TRANSFER") || extractMessageFileName(message.Text) != "" } func extractMessageFileName(text string) string { match := fileNamePattern.FindString(strings.TrimSpace(text)) return strings.Trim(match, " .,;,。;") } func fileID(messageID string, fileName string) string { if messageID == "" { return fileName } return messageID + ":" + fileName } func fileMatchesQuery(file File, query ListFilesQuery) bool { if query.ConversationID != "" && file.ConversationID != query.ConversationID { return false } if query.MessageID != "" && file.MessageID != query.MessageID { return false } if query.FileID != "" && file.FileID != query.FileID { return false } nameQuery := strings.ToLower(strings.TrimSpace(query.NameContains)) if nameQuery != "" && !strings.Contains(strings.ToLower(file.FileName), nameQuery) { return false } return true } func mimeTypeForFileName(name string) string { switch strings.ToLower(filepath.Ext(name)) { case ".doc": return "application/msword" case ".docx": return "application/vnd.openxmlformats-officedocument.wordprocessingml.document" case ".xls": return "application/vnd.ms-excel" case ".xlsx": return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" case ".ppt": return "application/vnd.ms-powerpoint" case ".pptx": return "application/vnd.openxmlformats-officedocument.presentationml.presentation" case ".pdf": return "application/pdf" case ".zip": return "application/zip" case ".rar": return "application/vnd.rar" case ".png": return "image/png" case ".jpg", ".jpeg": return "image/jpeg" case ".txt": return "text/plain" case ".csv": return "text/csv" default: return "" } }