Zendesk > Jira: Attachments.created missing?

Originally asked by Hannu Turunen on 15 March 2021 (original question)


Hi.

I’m trying to merge last/latest comment & all the attachments on that comment from ZD to JIRA. For that I think I need the attachment created timestamp so I can compare that to comment timestamp.

Or is there a better way to catch all the attachments of a single comment?

Looking at JIRA remote replica, I can’t find attachment created timestamp.

According to https://docs.idalko.com/exalate/display/ED/Attachments should there be a timestamp?

Here’s the JIRA remote replica attachments & comments from ZD:

{
“version”: {
“major”: 1,
“minor”: 15,
“patch”: 0
},

“hubIssue”: {
“components”: ,
attachments”: [
{
“id”: “369197845598”,
“mimetype”: “image/png”,
“filename”: “inline-453156139.png”,
“filesize”: 927,
“internal”: false,
“zip”: false
},
{
“id”: “369242875237”,
“mimetype”: “image/png”,
“filename”: “image.png”,
“filesize”: 4009,
“internal”: true,
“zip”: false
},
{
“id”: “369242886377”,
“mimetype”: “text/plain”,
“filename”: “test.txt”,
“filesize”: 58080,
“internal”: true,
“zip”: false
},
{
“id”: “369197873058”,
“mimetype”: “image/png”,
“filename”: “image.png”,
“filesize”: 1762,
“internal”: true,
“zip”: false
}
],

“description”: “test”,
“watchers”: ,
“fixVersions”: ,
“key”: “860”,
“summary”: “testing 4.5”,
comments”: [
{
“id”: “782263108038”,
“author”: {
“key”: “398349362114”,
“active”: false,
“email”: “xxx”,
“displayName”: “xxx”,
“username”: “xxx”
},
“body”: “xxx”,
“created”: 1614775537000,
“internal”: true,
“restrictSync”: false
},
{
“id”: “782263552298”,
“author”: {
“key”: “398349362114”,
“active”: false,
“email”: “xxx”,
“displayName”: “xxx”,
“username”: “xxx”
},
“body”: “xxx2”,
“created”: 1614775574000,
“internal”: true,
“restrictSync”: false
}
],


Comments:

Francis Martens (Exalate) commented on 16 March 2021

It is a bit unclear what exactly you would like to achieve.
Send over all the attachments contained in a comment?

Hannu Turunen commented on 16 March 2021

Hi.

Yes, just the latest comment and all the attachments contained on it.

ZD agents might do internal commenting/notes before escalating to JIRA and we don’t want those comments & attachments to JIRA. Just the last comment & all the attachements on that comment.

I can successfully take the last comment and last attachment but if the last comment contains 2+ attachments, I can’t tell which attachment is linked to last comment.

Francis Martens (Exalate) commented on 26 March 2021

An approach would be to scan the last comment for attachments and add these attachments to the replica?

Answer by Francis Martens (Exalate) on 22 May 2021

Hannu Turunen

Found some time to work on this one.
Following snippet will add to the replica only the attachments which are mentioned in a comment

Outgoing Sync | Zendesk side

// commentFiles is used to collect all the filenames mentioned in comments
//

def commentFiles = []

issue.comments.each { 
    comment ->

    // the regexp is used to fetch the name of a file mentioned in a zendesk ticket comment
    // THis is something like 
    // ![](https://d3v-peter.zendesk.com/attachments/token/EfCbLqeb9gmdox1IS4PFL20Yh/?name=AzureDevOps-Jira.png)
    def matcher = comment.body =~ /!\[\]\(\S+?name=(\S+)\)/
    matcher.each {
        commentFiles.add(it[1])
    }
}

// only send the attachments which have been mentioned in a comment
replica.attachments = issue.attachments.findAll { commentFiles.contains(it.filename) }




The snippet can also be found here.
It should be self explanatory


Hi!

I also have the same problem, I cannot identify which attachments were added together with the latest comment on Zendesk.

In my case the attachments are NOT inline attachments, but added during the comment creation, and they appear inside the comment box under the comment text in the Zendesk UI.

So the comment body does NOT contain anything about the attached file (no ![](https://.zendesk.com/attachments/token/EfCbLqeb9gmdox1IS4PFL20Yh/?name=.png or anything like this*)*, body is only the simple text added by the commenter.

So I would either need a created property of attachments (which is not there at all), or some other property to have the reference between comments and attachments.

There is an attachmentsTokens property of the comments (besides id, author, body, created, internal and restrictSync) , however it is always empty (no value). What is the purpose of attachmentsTokens?

Hi @rehorvath & Hannu great questions! You are both hitting the same underlying limitation.

Why the obvious approaches don’t work:

  • There is no created timestamp on attachments in the Exalate replica. Hannu, your instinct to compare timestamps is the right mental model, but that data simply is not available in the replica payload.
  • The attachmentsTokens field on comments: rehorvath, that field is a legacy ZD internal property. It is always empty in practice and cannot be relied on.

The correct approach: build a correlation map in the ZD outgoing script

Zendesk’s REST API returns per-comment attachment lists when you fetch a ticket’s comments. You can use this in the ZD outgoing script to build a caCorrelation map that links each comment ID to its attachment IDs. That map travels in the replica to Jira, where the incoming script uses it to put each attachment with the right comment.

This also handles inline images (the ![]() links in comment bodies); the incoming script replaces them with Jira wiki markup !filename! so they render as actual images instead of broken links.


ZD Outgoing Script

replica.key          = issue.key
replica.assignee     = issue.assignee
replica.reporter     = issue.reporter
replica.summary      = issue.summary
replica.type         = issue.type
replica.status       = issue.status
replica.comments     = issue.comments
replica.attachments  = issue.attachments
replica.description  = issue.description

// Fetch comments via ZD REST API to get per-comment attachment associations
def fetchedComments = httpClient.get("/api/v2/tickets/${issue.key}/comments.json?include=users&include_inline_images=true&sort=created_at")?.comments

// Build correlation map: ZD comment ID -> list of attachment IDs
if (fetchedComments) {
    def commentToAttachmentsMap = [:]
    fetchedComments.each { comment ->
        def commentId = comment.id.toLong()
        def attachmentIds = comment.attachments?.collect { it.id.toLong() } ?: []
        commentToAttachmentsMap[commentId] = attachmentIds
    }
    replica.caCorrelation = commentToAttachmentsMap
}

Jira Incoming Script

class AttachmentProcessor {

    def replica
    def issue
    def commentHelper
    def caCorrelation
    def storeFunc

    AttachmentProcessor(def replica, def issue, def commentHelper, def caCorrelation, def storeFunc) {
        this.replica       = replica
        this.issue         = issue
        this.commentHelper = commentHelper
        this.caCorrelation = caCorrelation
        this.storeFunc     = storeFunc
    }

    private def correlatedAttachments(def attachmentIds) {
        def attachmentIdsLong = attachmentIds.collect { it.toLong() }
        return replica.attachments.findAll { attachment ->
            attachmentIdsLong.contains(attachment.remoteId.toLong())
        }
    }

    private def commentAlreadyExists(String body) {
        return issue.comments?.any { it.body == body }
    }

    private def createCommentsMap(def comments) {
        return comments.collectEntries { comment ->
            [comment.remoteId?.toString(), comment]
        }
    }

    // Detects duplicate filenames across all attachments and assigns (1), (2) suffixes
    private def buildRenameMap() {
        def filenameCounts = [:]
        def renameMap = [:]
        replica.attachments.each { attachment ->
            def original = attachment.filename ?: ""
            def count = filenameCounts[original] ?: 0
            def dotIndex = original.lastIndexOf('.')
            def renamed = count == 0 ? original
                : (dotIndex >= 0
                    ? "${original[0..<dotIndex]}(${count})${original[dotIndex..-1]}"
                    : "${original}(${count})")
            renameMap[attachment.remoteId?.toString()] = [original: original, renamed: renamed]
            filenameCounts[original] = count + 1
        }
        return renameMap
    }

    private String replaceInlineImages(String body, def correlated, Map renameMap) {
        if (!body) return body
        def result = body
        correlated.each { attachment ->
            def info = renameMap[attachment.remoteId?.toString()]
            if (info) {
                result = result.replaceFirst(
                    /\!\[\]\([^\)]*[?&]name=${java.util.regex.Pattern.quote(info.original)}[^\)]*\)/,
                    "!${info.renamed}!"
                )
            }
        }
        return result
    }

    private def processDescription(String description, def attachmentIds, Map renameMap) {
        if (!description) return
        def correlated = correlatedAttachments(attachmentIds)
        issue.description = replaceInlineImages(description, correlated, renameMap)
    }

    private def processComment(def comment, def attachmentIds, Map renameMap) {
        if (!comment.body) return
        def correlated = correlatedAttachments(attachmentIds)
        def body = replaceInlineImages(comment.body, correlated, renameMap)
        if (!commentAlreadyExists(body)) {
            issue.comments = commentHelper.addComment(body, false, issue.comments)
        }
    }

    public def processCommentsAndAttachments() {
        def commentsMap = createCommentsMap(replica.comments)
        def renameMap = buildRenameMap()

        // Pass 1: add all attachments, applying rename for duplicates
        this.caCorrelation.eachWithIndex { correlationId, attachmentIds, index ->
            correlatedAttachments(attachmentIds).each { attachment ->
                def info = renameMap[attachment.remoteId?.toString()]
                if (info && info.original != info.renamed) {
                    attachment.filename = info.renamed
                }
                issue.attachments = (issue.attachments ?: []) + [attachment]
            }
        }

        // Pass 2: set description and add comments with inline images replaced
        this.caCorrelation.eachWithIndex { correlationId, attachmentIds, index ->
            def comment = commentsMap[correlationId?.toString()]
            if (index == 0 && replica.description) {
                processDescription(replica.description, attachmentIds, renameMap)
            } else if (comment) {
                processComment(comment, attachmentIds, renameMap)
            }
        }

        // Single store: persists both attachments and comments
        storeFunc(issue)
    }
}

if (firstSync) {
    issue.projectKey = "DEMO"
}

issue.type    = nodeHelper.getIssueType(replica.type?.name, issue.projectKey) ?: nodeHelper.getIssueType("Task", issue.projectKey)
issue.summary = replica.summary
issue.labels  = replica.labels

if (replica.caCorrelation) {
    def processor = new AttachmentProcessor(replica, issue, commentHelper, replica.caCorrelation, { i -> store(i) })
    processor.processCommentsAndAttachments()
} else {
    issue.comments = commentHelper.mergeComments(issue, replica, { it })
}

How it works, step by step:

  1. The ZD outgoing script calls /api/v2/tickets/{id}/comments.json, which returns each comment with its own attachments array. This is the only reliable way to know which attachment belongs to which comment.
  2. A caCorrelation map is built: { commentId: [attachmentId, attachmentId, ...] } and sent in the replica.
  3. On the Jira side, the AttachmentProcessor runs two passes:
    • Pass 1: Adds all attachments to the Jira issue. If two attachments share the same filename (e.g., both named image.png), the second is renamed to image(1).png automatically.
    • Pass 2: Sets the description and adds each comment. Inline image references (![]() markdown from ZD) are replaced with Jira wiki markup (!filename!) so they render as embedded images.
  4. A single store() at the end persists everything together.

Fallback: If caCorrelation is null (tickets with no attachments), the script falls back to the standard mergeComments so regular comment sync is not affected.

Note: This solution is for Zendesk to Jira unidirectional sync only.
Sync time will be longer than a standard sync. The ZD outgoing script makes an additional REST API call to fetch the comment list and map each attachment to its correct comment. Every attachment then requires a download from Zendesk and an upload to Jira. The more attachments on a ticket, the longer the sync will take. This is expected behavior.

Hope this helps! Let us know if you run into anything.