{
  "name": "Archive Discord attachments to Google Drive with a Google Sheets log",
  "nodes": [
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "triggerAtHour": 3
            }
          ]
        }
      },
      "id": "3642bd2d-416e-481e-9233-01721dec1f8b",
      "name": "Run Daily Archive Sweep",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.3,
      "position": [
        0,
        304
      ]
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "guild-id",
              "name": "guildId",
              "value": "<__PLACEHOLDER_VALUE__Discord server (guild) ID, e.g. 1122334455667788990__>",
              "type": "string"
            },
            {
              "id": "channel-ids",
              "name": "channelIds",
              "value": "<__PLACEHOLDER_VALUE__Comma separated Discord channel IDs to sweep, e.g. 9988776655443322110,5566778899001122334__>",
              "type": "string"
            },
            {
              "id": "drive-parent-folder",
              "name": "driveParentFolderId",
              "value": "<__PLACEHOLDER_VALUE__Google Drive folder ID that will hold the dated archive folders, e.g. 1AbCdEfGhIjKlMnOpQrStUvWxYz__>",
              "type": "string"
            },
            {
              "id": "log-sheet-url",
              "name": "logSheetUrl",
              "value": "<__PLACEHOLDER_VALUE__Google Sheet URL of the archive log, e.g. https://docs.google.com/spreadsheets/d/1AbCdEfGhIjKlMnOpQrStUvWxYz/edit__>",
              "type": "string"
            },
            {
              "id": "log-tab-name",
              "name": "logTabName",
              "value": "Archive Log",
              "type": "string"
            },
            {
              "id": "max-file-mb",
              "name": "maxFileMb",
              "value": 25,
              "type": "number"
            },
            {
              "id": "first-run-lookback-days",
              "name": "firstRunLookbackDays",
              "value": 1,
              "type": "number"
            }
          ]
        },
        "options": {}
      },
      "id": "b4612043-e04f-45c3-a942-ba45aab5dfe6",
      "name": "Set Archive Settings",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        224,
        304
      ]
    },
    {
      "parameters": {
        "documentId": {
          "__rl": true,
          "mode": "url",
          "value": "={{ $(\"Set Archive Settings\").first().json.logSheetUrl }}"
        },
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "={{ $(\"Set Archive Settings\").first().json.logTabName }}"
        },
        "options": {}
      },
      "id": "527c05c3-6b7a-4d39-8a98-add6e858bf21",
      "name": "Read Archive Log",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.7,
      "position": [
        448,
        304
      ],
      "alwaysOutputData": true,
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "const settings = $('Set Archive Settings').first().json;\n\nconst rows = $input.all().map((entry) => entry.json || {});\n\nconst stamps = rows\n  .map((row) => row['Posted At'])\n  .filter((value) => typeof value === 'string' && value.trim() !== '')\n  .map((value) => new Date(value).getTime())\n  .filter((ms) => Number.isFinite(ms));\n\nconst lookbackDays = Number(settings.firstRunLookbackDays) > 0 ? Number(settings.firstRunLookbackDays) : 1;\nconst fallbackMs = Date.now() - lookbackDays * 24 * 60 * 60 * 1000;\nconst watermarkMs = stamps.length > 0 ? Math.max.apply(null, stamps) : fallbackMs;\n\nconst guildId = String(settings.guildId || '').trim();\nif (guildId === '') {\n  throw new Error('Set Archive Settings has no guild ID. Add your Discord server ID before running.');\n}\n\nconst channelIds = String(settings.channelIds || '')\n  .split(',')\n  .map((value) => value.trim())\n  .filter((value) => value !== '');\n\nif (channelIds.length === 0) {\n  throw new Error('Set Archive Settings has no channel IDs. Add at least one comma separated Discord channel ID before running.');\n}\n\nconst runDate = $now.toFormat('yyyy-MM-dd');\n\nreturn channelIds.map((channelId) => ({\n  json: {\n    guildId,\n    channelId,\n    runDate,\n    watermarkMs,\n    watermarkIso: new Date(watermarkMs).toISOString(),\n    loggedRowCount: stamps.length,\n  },\n}));"
      },
      "id": "9c64ac0f-cce7-4180-8d50-017a9da1d243",
      "name": "Build Channel List",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        672,
        304
      ]
    },
    {
      "parameters": {
        "resource": "message",
        "operation": "getAll",
        "guildId": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $json.guildId }}"
        },
        "channelId": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $json.channelId }}"
        },
        "options": {
          "simplify": false
        }
      },
      "id": "21cc3a80-b992-47c3-bb5d-d5a1f5816f94",
      "name": "Get Recent Messages",
      "type": "n8n-nodes-base.discord",
      "typeVersion": 2,
      "position": [
        944,
        304
      ],
      "credentials": {
        "discordBotApi": {
          "name": "<your credential>"
        }
      },
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "jsCode": "const plan = $('Build Channel List').first().json;\nconst watermarkMs = Number(plan.watermarkMs);\nconst guildId = String(plan.guildId);\nconst runDate = String(plan.runDate);\n\nconst seen = new Set();\nconst rows = [];\n\nfor (const entry of $input.all()) {\n  const message = entry.json || {};\n  if (!message.id || !message.timestamp) continue;\n\n  const postedAtMs = new Date(message.timestamp).getTime();\n  if (!Number.isFinite(postedAtMs) || postedAtMs <= watermarkMs) continue;\n\n  const attachments = Array.isArray(message.attachments) ? message.attachments : [];\n\n  for (const attachment of attachments) {\n    if (!attachment || !attachment.url) continue;\n\n    const attachmentId = String(attachment.id || attachment.url);\n    if (seen.has(attachmentId)) continue;\n    seen.add(attachmentId);\n\n    const channelId = String(message.channel_id || '');\n    const sizeBytes = Number(attachment.size) || 0;\n\n    rows.push({\n      json: {\n        runDate,\n        messageId: String(message.id),\n        channelId,\n        postedAt: message.timestamp,\n        postedAtMs,\n        postedBy: (message.author && message.author.username) || 'unknown',\n        postedById: (message.author && message.author.id) || '',\n        attachmentId,\n        fileName: String(attachment.filename || 'discord-attachment'),\n        fileSizeBytes: sizeBytes,\n        fileSizeKb: Math.round((sizeBytes / 1024) * 10) / 10,\n        contentType: String(attachment.content_type || ''),\n        downloadUrl: String(attachment.url),\n        messageLink: 'https://discord.com/channels/' + guildId + '/' + channelId + '/' + String(message.id),\n      },\n    });\n  }\n}\n\nrows.sort((a, b) => a.json.postedAtMs - b.json.postedAtMs);\n\nreturn rows;"
      },
      "id": "c33c539e-8b71-4fcd-b9bf-c8617ccfff5e",
      "name": "Extract New Attachments",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1184,
        304
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "under-size-cap",
              "leftValue": "={{ $json.fileSizeBytes }}",
              "rightValue": "={{ $(\"Set Archive Settings\").first().json.maxFileMb * 1024 * 1024 }}",
              "operator": {
                "type": "number",
                "operation": "lte"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "f0019ecd-dc30-4a6f-a2ed-e299cb055150",
      "name": "Skip Oversized Attachments",
      "type": "n8n-nodes-base.filter",
      "typeVersion": 2.3,
      "position": [
        1456,
        304
      ]
    },
    {
      "parameters": {
        "resource": "folder",
        "name": "={{ $(\"Build Channel List\").first().json.runDate }}",
        "driveId": {
          "__rl": true,
          "mode": "list",
          "value": "My Drive",
          "cachedResultName": "My Drive"
        },
        "folderId": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $(\"Set Archive Settings\").first().json.driveParentFolderId }}"
        },
        "options": {}
      },
      "id": "a9831476-331b-4783-a3ac-bd4602111666",
      "name": "Create Dated Drive Folder",
      "type": "n8n-nodes-base.googleDrive",
      "typeVersion": 3,
      "position": [
        1680,
        304
      ],
      "executeOnce": true,
      "credentials": {
        "googleDriveOAuth2Api": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "const folder = $('Create Dated Drive Folder').first().json;\nconst folderId = String(folder.id || '');\nconst folderName = String(folder.name || '');\n\nif (folderId === '') {\n  throw new Error('Create Dated Drive Folder returned no folder ID. Check the Drive parent folder ID in Set Archive Settings.');\n}\n\nreturn $('Skip Oversized Attachments').all().map((entry) => {\n  const file = entry.json;\n\n  const safeName = String(file.fileName || 'discord-attachment')\n    .split('/')\n    .join('_')\n    .replace(/[<>:\"|?*]/g, '_');\n\n  return {\n    json: {\n      ...file,\n      driveFolderId: folderId,\n      driveFolderName: folderName,\n      driveFileName: String(file.messageId) + '-' + safeName,\n    },\n  };\n});"
      },
      "id": "0893b32c-8f82-4bf3-9079-a488f85b86a3",
      "name": "Plan Uploads Into Folder",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1904,
        304
      ]
    },
    {
      "parameters": {
        "url": "={{ $json.downloadUrl }}",
        "options": {
          "batching": {
            "batch": {
              "batchSize": 5
            }
          },
          "response": {
            "response": {
              "responseFormat": "file"
            }
          },
          "timeout": 60000
        }
      },
      "id": "9e3a0430-5ab6-4f66-979d-1f5b718d9922",
      "name": "Download Attachment",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.4,
      "position": [
        2208,
        304
      ]
    },
    {
      "parameters": {
        "name": "={{ $(\"Plan Uploads Into Folder\").item.json.driveFileName }}",
        "driveId": {
          "__rl": true,
          "mode": "list",
          "value": "My Drive",
          "cachedResultName": "My Drive"
        },
        "folderId": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $(\"Create Dated Drive Folder\").first().json.id }}"
        },
        "options": {}
      },
      "id": "ca8c7546-0588-46ba-bbe1-2dc0a9868017",
      "name": "Upload To Drive Folder",
      "type": "n8n-nodes-base.googleDrive",
      "typeVersion": 3,
      "position": [
        2432,
        304
      ],
      "credentials": {
        "googleDriveOAuth2Api": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "operation": "append",
        "documentId": {
          "__rl": true,
          "mode": "url",
          "value": "={{ $(\"Set Archive Settings\").first().json.logSheetUrl }}"
        },
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "={{ $(\"Set Archive Settings\").first().json.logTabName }}"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "Posted At": "={{ $(\"Plan Uploads Into Folder\").item.json.postedAt }}",
            "Channel ID": "={{ $(\"Plan Uploads Into Folder\").item.json.channelId }}",
            "Posted By": "={{ $(\"Plan Uploads Into Folder\").item.json.postedBy }}",
            "File Name": "={{ $(\"Plan Uploads Into Folder\").item.json.fileName }}",
            "File Size KB": "={{ $(\"Plan Uploads Into Folder\").item.json.fileSizeKb }}",
            "Content Type": "={{ $(\"Plan Uploads Into Folder\").item.json.contentType }}",
            "Message Link": "={{ $(\"Plan Uploads Into Folder\").item.json.messageLink }}",
            "Drive File ID": "={{ $json.id }}",
            "Drive Link": "={{ $json.webViewLink || \"https://drive.google.com/file/d/\" + $json.id + \"/view\" }}",
            "Archived At": "={{ $now.toISO() }}"
          },
          "schema": [
            {
              "id": "Posted At",
              "displayName": "Posted At",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Channel ID",
              "displayName": "Channel ID",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Posted By",
              "displayName": "Posted By",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "File Name",
              "displayName": "File Name",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "File Size KB",
              "displayName": "File Size KB",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Content Type",
              "displayName": "Content Type",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Message Link",
              "displayName": "Message Link",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Drive File ID",
              "displayName": "Drive File ID",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Drive Link",
              "displayName": "Drive Link",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Archived At",
              "displayName": "Archived At",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            }
          ]
        },
        "options": {}
      },
      "id": "bb007edf-deb5-4172-bf4a-39bcad6dc039",
      "name": "Append To Archive Log",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.7,
      "position": [
        2656,
        304
      ],
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "content": "## Archive Discord attachments to Google Drive with a Google Sheets log\n\nDiscord signs attachment CDN links with `ex`, `is` and `hm` query parameters that expire, so a file posted and never saved is on a clock and the link in your scrollback will eventually 404. This sweep asks Discord for a fresh signed URL and downloads the file inside the same run, which is the one detail that makes the archive work at all.\n\n### How it works\n1. A daily schedule starts the sweep, and `Set Archive Settings` supplies the server, the channel list, the Drive parent folder and the log sheet from one node.\n2. `Read Archive Log` pulls the existing log and `Build Channel List` reduces it to a single watermark, the newest `Posted At` already archived, then emits one item per named channel.\n3. `Get Recent Messages` reads up to 100 messages per channel, which is the Discord API maximum, and `Extract New Attachments` keeps only messages newer than the watermark that carry files.\n4. `Skip Oversized Attachments` drops anything over your size cap before it is ever downloaded.\n5. `Create Dated Drive Folder` makes one folder named for the run date, and only when there is something to save.\n6. Each file is fetched from its signed URL as binary, written into the dated folder, and logged with its poster, channel, size, message jump link and Drive link.\n\n### Setup steps\n- [ ] Create a Discord application, add a bot to it, and invite the bot to your server with the View Channels and Read Message History permissions.\n- [ ] Turn on the MESSAGE CONTENT privileged intent on the Bot tab of the Discord developer portal. Attachments ride on the message payload, so the read comes back useless without it.\n- [ ] Paste the bot token into the Discord Bot credential on `Get Recent Messages`.\n- [ ] Connect your Google Drive and Google Sheets credentials.\n- [ ] Create the log sheet and put these headers in row 1: Posted At, Channel ID, Posted By, File Name, File Size KB, Content Type, Message Link, Drive File ID, Drive Link, Archived At.\n- [ ] Fill in `Set Archive Settings`: guild ID, comma separated channel IDs, Drive parent folder ID, log sheet URL, log tab name and the size cap.\n- [ ] Run it once by hand against one quiet channel, check the dated folder and the log rows, then activate.\n\n### Customization\n`maxFileMb` caps what gets downloaded, so raise it only as far as your Drive quota and run time allow. `firstRunLookbackDays` decides how far back the very first sweep reaches while the log is still empty. Channel IDs can be added at any time, but the watermark is one timestamp for the whole sweep rather than one per channel, so a channel added later starts from the same point as the rest instead of backfilling.",
        "height": 1132,
        "width": 692
      },
      "id": "59f4dae1-9121-4338-89e0-6532866dfc1e",
      "name": "Overview",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -848,
        -64
      ]
    },
    {
      "parameters": {
        "content": "## Configure and find the watermark\n\nEvery server ID, channel ID, folder and sheet lives in one Set node. The watermark is the newest `Posted At` already in the log, so the sheet is the memory. An empty log falls back to the lookback window.",
        "height": 400,
        "width": 896,
        "color": 7
      },
      "id": "90cca53f-18bb-405e-8d64-c2c29d0bf457",
      "name": "Section Configure And Watermark",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -80,
        112
      ]
    },
    {
      "parameters": {
        "content": "## Read the named channels\n\nOne item per channel fans out into a bounded read of 100 messages each. Simplify is off because the simplified payload strips the attachment array this workflow needs.",
        "height": 400,
        "width": 464,
        "color": 7
      },
      "id": "9b9bc42f-960b-4c9c-ac42-2fd95f10a323",
      "name": "Section Read The Channels",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        880,
        112
      ]
    },
    {
      "parameters": {
        "content": "## Cap size and make the folder\n\nAnything over `maxFileMb` is dropped before it is ever downloaded, so one huge upload cannot stall the sweep. The folder is created after the filter, so a quiet day leaves no empty folder behind.",
        "height": 400,
        "width": 672,
        "color": 7
      },
      "id": "0b50f99a-1958-4f5a-9a20-2f604dcf355d",
      "name": "Section Cap And Folder",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1408,
        112
      ]
    },
    {
      "parameters": {
        "content": "## Download, upload and log\n\nThe signed URL is fetched as binary and pushed straight into the dated folder, then one log row per file records where it came from and where it went.",
        "height": 400,
        "width": 720,
        "color": 7
      },
      "id": "cacac4c3-17bf-47e2-b24c-5f6cf151548b",
      "name": "Section Download Upload Log",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        2144,
        112
      ]
    },
    {
      "parameters": {
        "content": "## Read this before you rely on it\n\n**The Discord bot setup is where installs die.** Reading messages needs a bot token, and the bot has to be in the server with View Channels and Read Message History. Reading the attachment payload also needs the MESSAGE CONTENT privileged intent, toggled on the Bot tab of the Discord developer portal. For a bot in fewer than 100 servers that is a switch rather than an approval queue, but nothing here works until it is on.\n\n**The watermark filter is client side.** The Discord node message getAll operation has no before, after or around parameter. It takes a channel and a limit, nothing else, so this workflow pulls the latest 100 messages per channel and compares timestamps itself. If a channel takes more than 100 messages between two runs, the older ones fall off the end and are silently never archived. Run this daily, and on busy channels either run it more often or split the channel list across two schedules.\n\n**Signed URLs expire.** The download has to happen in the same run as the read. Do not park attachment URLs in a sheet and fetch them tomorrow, they will 404.",
        "height": 452,
        "width": 808,
        "color": 3
      },
      "id": "616d94ca-0c2a-4fc4-ad5b-319bf215ff6f",
      "name": "Warning Before You Rely On It",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        16,
        576
      ]
    }
  ],
  "connections": {
    "Run Daily Archive Sweep": {
      "main": [
        [
          {
            "node": "Set Archive Settings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Set Archive Settings": {
      "main": [
        [
          {
            "node": "Read Archive Log",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read Archive Log": {
      "main": [
        [
          {
            "node": "Build Channel List",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Channel List": {
      "main": [
        [
          {
            "node": "Get Recent Messages",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get Recent Messages": {
      "main": [
        [
          {
            "node": "Extract New Attachments",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract New Attachments": {
      "main": [
        [
          {
            "node": "Skip Oversized Attachments",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Skip Oversized Attachments": {
      "main": [
        [
          {
            "node": "Create Dated Drive Folder",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Create Dated Drive Folder": {
      "main": [
        [
          {
            "node": "Plan Uploads Into Folder",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Plan Uploads Into Folder": {
      "main": [
        [
          {
            "node": "Download Attachment",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Download Attachment": {
      "main": [
        [
          {
            "node": "Upload To Drive Folder",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Upload To Drive Folder": {
      "main": [
        [
          {
            "node": "Append To Archive Log",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1"
  },
  "tags": []
}