{
  "name": "Business Card Scanner \u2014 Telegram to Sheets + vCard \u2013 powered by easybits",
  "nodes": [
    {
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// Build a single .vcf file for ONE contact per run.\n// n8n runs this once per input item automatically.\n\nconst c = $input.item.json;\n\nfunction esc(v) {\n  if (v === null || v === undefined) return '';\n  return String(v).replace(/\\\\/g, '\\\\\\\\').replace(/,/g, '\\\\,').replace(/;/g, '\\\\;').replace(/\\n/g, '\\\\n');\n}\n\nfunction splitName(full) {\n  if (!full) return { first: '', last: '' };\n  const parts = String(full).trim().split(/\\s+/);\n  if (parts.length === 1) return { first: parts[0], last: '' };\n  return { first: parts[0], last: parts.slice(1).join(' ') };\n}\n\nconst { first, last } = splitName(c.name);\nconst lines = ['BEGIN:VCARD', 'VERSION:3.0'];\nlines.push(`N:${esc(last)};${esc(first)};;;`);\nlines.push(`FN:${esc(c.name || '')}`);\nif (c.company) lines.push(`ORG:${esc(c.company)}`);\nif (c.title) lines.push(`TITLE:${esc(c.title)}`);\nif (c.email) lines.push(`EMAIL;TYPE=WORK:${esc(c.email)}`);\n\nconst phones = Array.isArray(c.phone) ? c.phone : (c.phone ? [c.phone] : []);\nfor (const p of phones) {\n  if (p) lines.push(`TEL;TYPE=WORK,VOICE:${esc(p)}`);\n}\n\nif (c.website) lines.push(`URL:${esc(c.website)}`);\nif (c.address) lines.push(`ADR;TYPE=WORK:;;${esc(c.address)};;;;`);\nif (c.notes) lines.push(`NOTE:${esc(c.notes)}`);\nlines.push('END:VCARD');\n\nconst vcfContent = lines.join('\\r\\n');\nconst safeName = (c.name || 'contact').replace(/[^a-z0-9]/gi, '-').toLowerCase();\nconst filename = `contact-${safeName}.vcf`;\n\nreturn {\n  json: {\n    name: c.name,\n    company: c.company,\n    title: c.title,\n    filename,\n    chat_id: $('New Photo from Telegram').first().json.message.chat.id\n  },\n  binary: {\n    data: {\n      data: Buffer.from(vcfContent, 'utf8').toString('base64'),\n      mimeType: 'text/vcard',\n      fileName: filename,\n      fileExtension: 'vcf'\n    }\n  }\n};"
      },
      "id": "5127c3a5-cbe2-4ebd-8057-37c47e8acab3",
      "name": "Build vCard File",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2960,
        320
      ]
    },
    {
      "parameters": {
        "documentId": {
          "__rl": true,
          "value": "",
          "mode": "list"
        },
        "sheetName": {
          "__rl": true,
          "value": "",
          "mode": "list"
        },
        "options": {}
      },
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.7,
      "position": [
        1280,
        784
      ],
      "id": "04206aa8-7e90-4a6d-814e-a1e7fd875a52",
      "name": "Read Existing Contacts"
    },
    {
      "parameters": {
        "updates": [
          "message"
        ],
        "additionalFields": {
          "download": true
        }
      },
      "id": "1986ab73-0c67-4b51-a3bc-09eb411f9ee5",
      "name": "New Photo from Telegram",
      "type": "n8n-nodes-base.telegramTrigger",
      "typeVersion": 1.1,
      "position": [
        944,
        304
      ]
    },
    {
      "parameters": {},
      "type": "@easybits/n8n-nodes-extractor.easybitsExtractor",
      "typeVersion": 2,
      "position": [
        1280,
        304
      ],
      "id": "f4d88b24-9b77-4e6e-9eab-17fd3f36a5b2",
      "name": "easybits: Extract Contacts"
    },
    {
      "parameters": {
        "fieldToSplitOut": "data.contacts",
        "options": {}
      },
      "id": "aa310080-ee16-434d-9c2c-2b6dbf97c211",
      "name": "Split Into Individual Contacts",
      "type": "n8n-nodes-base.splitOut",
      "typeVersion": 1,
      "position": [
        1616,
        304
      ]
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "92362cda-78e4-458c-9553-7b7f5cbc840a",
              "name": "match_key",
              "value": "={{ ($json.email || ($json.name + '|' + $json.company)).toLowerCase().trim() }}",
              "type": "string"
            }
          ]
        },
        "includeOtherFields": true,
        "options": {}
      },
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        1952,
        304
      ],
      "id": "736ac0e1-f3d4-493c-9e52-b71ad78581e2",
      "name": "Add Match Key (New Contacts)"
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "92362cda-78e4-458c-9553-7b7f5cbc840a",
              "name": "match_key",
              "value": "={{ ($json.email || ($json.name + '|' + $json.company)).toLowerCase().trim() }}",
              "type": "string"
            }
          ]
        },
        "includeOtherFields": true,
        "options": {}
      },
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        1952,
        784
      ],
      "id": "de2eb763-f782-4465-861e-77f560d53415",
      "name": "Add Match Key (Existing)"
    },
    {
      "parameters": {
        "mode": "combine",
        "advanced": true,
        "mergeByFields": {
          "values": [
            {
              "field1": "match_key",
              "field2": "match_key"
            }
          ]
        },
        "joinMode": "keepNonMatches",
        "outputDataFrom": "input1",
        "options": {}
      },
      "type": "n8n-nodes-base.merge",
      "typeVersion": 3.2,
      "position": [
        2288,
        320
      ],
      "id": "d2d8ce34-ab74-4628-a7fb-5377c66de89f",
      "name": "Filter Out Duplicates"
    },
    {
      "parameters": {
        "operation": "append",
        "documentId": {
          "__rl": true,
          "value": "",
          "mode": "list"
        },
        "sheetName": {
          "__rl": true,
          "value": "",
          "mode": "list"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "timestamp": "={{ $now.toISO() }}",
            "name": "={{ $json.name }}",
            "title": "={{ $json.title }}",
            "company": "={{ $json.company }}",
            "email": "={{ $json.email }}",
            "phone": "={{ Array.isArray($json.phone) ? $json.phone.join(', ') : $json.phone }}",
            "website": "={{ $json.website }}",
            "address": "={{ $json.address }}",
            "notes": "={{ $json.notes }}"
          },
          "matchingColumns": [],
          "schema": [
            {
              "id": "timestamp",
              "displayName": "timestamp",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "name",
              "displayName": "name",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "title",
              "displayName": "title",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "company",
              "displayName": "company",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "email",
              "displayName": "email",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "phone",
              "displayName": "phone",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "website",
              "displayName": "website",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "address",
              "displayName": "address",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "notes",
              "displayName": "notes",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            }
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {
          "cellFormat": "RAW"
        }
      },
      "id": "2042aca5-3cd2-47e7-a3d1-06d314241e48",
      "name": "Save to Google Sheets",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        2624,
        320
      ]
    },
    {
      "parameters": {
        "operation": "sendDocument",
        "chatId": "={{ $json.chat_id }}",
        "binaryData": true,
        "additionalFields": {
          "caption": "=\ud83d\udcc7 {{ $json.name }}{{ $json.title ? ' \u2014 ' + $json.title : '' }}{{ $json.company ? ' @ ' + $json.company : '' }}\n\nTap to add to your contacts."
        }
      },
      "id": "15ff5cb4-908b-450f-a286-a8ae2b0d2a3e",
      "name": "Send vCard to Telegram",
      "type": "n8n-nodes-base.telegram",
      "typeVersion": 1.2,
      "position": [
        3296,
        320
      ]
    },
    {
      "parameters": {
        "content": "## \ud83d\udcf1 New Photo from Telegram\n\nFires when someone sends a photo to your bot \u2013 whether it's a single card or a desk shot of 30 cards.\n\n**Setup:** Connect your Telegram credential and set Updates to `message`. The `download: true` flag tells n8n to pull the photo binary automatically.",
        "height": 528,
        "width": 320,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        832,
        -48
      ],
      "typeVersion": 1,
      "id": "59b8c698-59af-49d8-a556-7bf32bfa5106",
      "name": "Sticky Note"
    },
    {
      "parameters": {
        "content": "## \ud83d\udd0d Extract Contacts (easybits)\n\nThe extractor reads the photo and returns an **array of contacts** \u2013 one object per card visible in the image. The prompt asks for `name`, `title`, `company`, `email`, `phone`, `website`, `address`, and `notes`, with explicit null handling for missing fields.\n\n**Why an array?** The same workflow handles 1 card or 30 cards in a single photo \u2013 easybits detects each card as a distinct rectangular object and returns one entry per card.",
        "height": 528,
        "width": 320,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1168,
        -48
      ],
      "typeVersion": 1,
      "id": "213424fb-c2e3-44b1-941f-a9a7e7bb66c2",
      "name": "Sticky Note1"
    },
    {
      "parameters": {
        "content": "## \ud83d\udccb Split Into Individual Contacts\n\nThe extractor output looks like `{ contacts: [ {...}, {...}, {...} ] }`. This node splits the array so each contact becomes its own n8n item \u2014 needed for per-row sheet writes and per-contact vCards downstream.\n\n**Field to split:** `data.contacts`",
        "height": 528,
        "width": 320,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1504,
        -48
      ],
      "typeVersion": 1,
      "id": "c19c10d6-72ca-45ce-b558-b5cd61e04b90",
      "name": "Sticky Note2"
    },
    {
      "parameters": {
        "content": "## \ud83d\udd11 Add Match Key (New) & Add Match Key (Existing)\n\nCreates a `match_key` field on each contact for deduplication.\n\n**Logic:** uses `email` if present, otherwise falls back to `name|company`. Lowercased and trimmed for consistent matching across cases like `Sarah@Acme.com` vs `sarah@acme.com`.\n\n**Both sides** (incoming contacts + existing rows) need the same key built the exact same way \u2013 that's why this node exists twice.",
        "height": 992,
        "width": 320,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1840,
        -48
      ],
      "typeVersion": 1,
      "id": "404915be-8ada-4844-87b0-1b597c6a951b",
      "name": "Sticky Note3"
    },
    {
      "parameters": {
        "content": "## \ud83d\udcca Read Existing Contacts\n\nPulls every row from the Sheet so we can compare what's new vs. what's already saved. Runs in parallel with the extraction branch \u2013 both start from the Telegram Trigger and meet at the Merge node.\n\n**Note:** This reads the full sheet on every run. Fine up to a few thousand rows; consider a database for larger contact stores.",
        "height": 448,
        "width": 320,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1168,
        496
      ],
      "typeVersion": 1,
      "id": "ece3d079-008f-4530-b1c1-8f2017e048a4",
      "name": "Sticky Note4"
    },
    {
      "parameters": {
        "content": "## \ud83d\udeab Filter Out Duplicates\n\nCompares incoming contacts against existing rows by `match_key` and outputs **only the new ones**.\n\n**Mode:** Combine by Matching Fields  \n**Output:** Keep Non-Matches from Input 1\n\nIf a contact's match_key already exists in the sheet, it gets dropped here \u2013 no duplicate row, no duplicate vCard.",
        "height": 528,
        "width": 320,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2176,
        -48
      ],
      "typeVersion": 1,
      "id": "ac3a2ad4-59c4-4aaf-a463-3fc427cfe7b6",
      "name": "Sticky Note5"
    },
    {
      "parameters": {
        "content": "## \ud83d\udcdd Save to Google Sheets\n\nOne row per new contact. Phone arrays get joined into a comma-separated string. Cell format is set to `RAW` so phone numbers starting with `+` aren't misinterpreted as formulas.\n\n**Sheet headers required:**  \n`timestamp` \u00b7 `name` \u00b7 `title` \u00b7 `company` \u00b7 `email` \u00b7 `phone` \u00b7 `website` \u00b7 `address` \u00b7 `notes`",
        "height": 528,
        "width": 320,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2512,
        -48
      ],
      "typeVersion": 1,
      "id": "e77a192c-d30d-4efb-8630-42279ac9ee3a",
      "name": "Sticky Note6"
    },
    {
      "parameters": {
        "content": "## \ud83d\udcc7 Build vCard File\n\nBuilds a vCard 3.0 `.vcf` file for each contact \u2013 runs once per item.\n\nvCard 3.0 is broadly compatible with iOS Contacts, Android, macOS Contacts, and Outlook. Special characters in addresses (commas, semicolons) are escaped properly, and missing fields are skipped cleanly rather than producing empty lines.\n\n**Mode:** Run Once for Each Item",
        "height": 528,
        "width": 320,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2848,
        -48
      ],
      "typeVersion": 1,
      "id": "e395ffad-e451-4bbf-8985-05c8a5664ac6",
      "name": "Sticky Note7"
    },
    {
      "parameters": {
        "content": "## \ud83d\udce4 Send vCard to Telegram\n\nSends each `.vcf` file as a Telegram document with a labeled caption (`\ud83d\udcc7 Sarah Jones \u2013 Head of Sales @ Acme Corp`).\n\n**On iPhone:** tap the file \u2192 \"Add Contact\" \u2192 done.  \n**On Android:** opens directly in Contacts app.\n\nThe chat ID is passed through from the Code node (`$json.chat_id`) rather than referenced upstream \u2013 this avoids item-linking issues that can break when binary data is involved.",
        "height": 528,
        "width": 320,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        3184,
        -48
      ],
      "typeVersion": 1,
      "id": "e701a91a-7cf2-454d-a3b2-aef0bc9334dd",
      "name": "Sticky Note8"
    },
    {
      "parameters": {
        "content": "## \ud83d\udcc7 Business Card Scanner\n(powered by easybits)\n\n**Snap a photo of a business card \u2013 or a whole stack laid out on a desk \u2013 and get back a Google Sheets row for every contact plus a `.vcf` file you can tap to add directly to your iPhone.**\n\nBuilt for the moment you come back from a conference with 30 cards in your pocket. Lay them out, take one photo, and your contacts are in your phone before you finish your coffee.\n\n### How it works\n1. **Telegram bot** receives a photo of one or more business cards\n2. **easybits Extractor** reads every card in the image and returns structured contact data (name, title, company, email, phone, website, address, notes)\n3. **Deduplication check** compares each new contact against existing rows in the sheet \u2013 already-saved contacts are filtered out automatically\n4. **Google Sheets** gets a new row for every new contact\n5. **Individual vCard files** are sent back via Telegram, one per new contact \u2013 tap any of them to add directly to your phone\n\n### What you need\n- An n8n instance (Cloud or self-hosted)\n- A Telegram bot (free, takes 2 minutes to create via @BotFather)\n- A Google Sheet with these column headers: `timestamp` \u00b7 `name` \u00b7 `title` \u00b7 `company` \u00b7 `email` \u00b7 `phone` \u00b7 `website` \u00b7 `address` \u00b7 `notes`\n- An easybits account at [easybits.tech](https://easybits.tech) \u2013 your Pipeline ID and API Key from the pipeline details page\n\n### Setting up the easybits Extractor in n8n\nThe easybits Extractor is a **verified community node**, so it's available on n8n Cloud out of the box \u2013 just search for \"easybits Extractor\" in the node panel and start using it. No installation needed.\n\nOn a **self-hosted instance**, go to **Settings \u2192 Community Nodes \u2192 Install** and enter: `@easybits/n8n-nodes-extractor`\n\nOnce added to your canvas, you only need two values from your easybits pipeline details page:\n\n- **Pipeline ID**\n- **API Key**\n\nLeave **Input Type** on `Binary Files` (the default) \u2013 it reads the photo binary directly from the Telegram node, no encoding step needed.\n\n### Dedup logic\nContacts are matched by **email** when available, otherwise by **name + company**. If a contact already exists in the sheet, it won't be re-added or sent back as a vCard \u2013 only genuinely new contacts come through.",
        "height": 1088,
        "width": 560
      },
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        256,
        -96
      ],
      "typeVersion": 1,
      "id": "8ac39eea-e566-4c4a-8a39-f9730fc96243",
      "name": "Sticky Note9"
    }
  ],
  "connections": {
    "Build vCard File": {
      "main": [
        [
          {
            "node": "Send vCard to Telegram",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read Existing Contacts": {
      "main": [
        [
          {
            "node": "Add Match Key (Existing)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "New Photo from Telegram": {
      "main": [
        [
          {
            "node": "easybits: Extract Contacts",
            "type": "main",
            "index": 0
          },
          {
            "node": "Read Existing Contacts",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "easybits: Extract Contacts": {
      "main": [
        [
          {
            "node": "Split Into Individual Contacts",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Split Into Individual Contacts": {
      "main": [
        [
          {
            "node": "Add Match Key (New Contacts)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Add Match Key (New Contacts)": {
      "main": [
        [
          {
            "node": "Filter Out Duplicates",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Add Match Key (Existing)": {
      "main": [
        [
          {
            "node": "Filter Out Duplicates",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Filter Out Duplicates": {
      "main": [
        [
          {
            "node": "Save to Google Sheets",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Save to Google Sheets": {
      "main": [
        [
          {
            "node": "Build vCard File",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1",
    "availableInMCP": false
  },
  "tags": []
}