├── .env.example ├── .gitignore ├── README.md ├── package.json ├── server.js └── src └── duplicateChecker.js /.env.example: -------------------------------------------------------------------------------- 1 | INTERCOM_TOKEN=abcdefghijklmnopqrstuvwxyz1234567ABCDEFGHIJKLMNOPQRSTUVWXYZ= 2 | INTERCOM_APP_ID=abc1defg 3 | INTERCOM_ADMIN_ID=1234567 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Intercom duplicate checker 2 | 3 | **NOTE: Intercom has finally added a built-in duplicate checker. This tool still works, but it's only helpful if you want it to be more noticeable than the small one in the sidebar.** 4 | 5 | Let operators know if a user opens more than one conversation in 30 minutes, so they can avoid working on the same issue. 6 | 7 | ## Setup 8 | 9 | 1. Go to the Intercom Developer Hub (`https://app.intercom.com/a/apps/%YOUR_ID%/developer-hub`) and create an app. After you're redirected to the Authentication tab, copy the Access token. Don’t close the window—you’ll need it again shortly. 10 | 2. Copy `.env.example` to `.env` and update the variables: 11 | - `INTERCOM_TOKEN` is the Access token from the previous step; 12 | - `INTERCOM_APP_ID` is the one you see in almost every URL (`https://app.intercom.com/a/inbox/%YOUR_ID_HERE%/inbox/...`); 13 | - `INTERCOM_ADMIN_ID` is your admin ID, which you can find in the URL when you open your profile in the "Teammates" tab (`https://app.intercom.com/a/apps/x123456/admins/%YOUR_ADMIN_ID_HERE%`). 14 | 3. Set up the Node.js app. If you're unsure how to do that, you might consider using some paid service like glitch.com. 15 | 4. Go to Webhooks tab on your app page from step 1. Fill in the URL and add a `conversation.user.created` topic subscription. 16 | 5. That's it. 17 | 18 | ## Use it however you want 19 | 20 | This project is distributed "as is", without any warranties or guarantees of any kind. It is provided freely, and anyone is permitted to use, modify, and distribute it for both personal and commercial purposes without restriction. 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "intercom-duplicate-checker", 3 | "version": "0.1.0", 4 | "description": "A simple Node.js app to check for duplicate conversations in Intercom", 5 | "main": "server.js", 6 | "scripts": { 7 | "start": "node server.js" 8 | }, 9 | "dependencies": { 10 | "fastify": "^3.24.0", 11 | "fastify-formbody": "^5.2.0", 12 | "request": "^2.88.2" 13 | }, 14 | "engines": { 15 | "node": "14.x" 16 | }, 17 | "license": "MIT" 18 | } 19 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | const duplicateChecker = require("./src/duplicateChecker.js"); 2 | 3 | const fastify = require("fastify")({ 4 | logger: false, 5 | }); 6 | fastify.register(require("fastify-formbody")); 7 | 8 | fastify.get("/", () => "It works"); 9 | 10 | fastify.head("/conversation", (request, reply) => { 11 | console.log("HEAD", request.body.data); 12 | reply.code(200).send("OK"); 13 | }); 14 | fastify.post("/conversation", duplicateChecker.checkDuplicatesHandler); 15 | 16 | fastify.listen(process.env.PORT, "0.0.0.0", (err, address) => { 17 | if (err) { 18 | fastify.log.error(err); 19 | process.exit(1); 20 | } 21 | console.log(`The app is listening on ${address}`); 22 | }); 23 | -------------------------------------------------------------------------------- /src/duplicateChecker.js: -------------------------------------------------------------------------------- 1 | const request = require("request"); 2 | 3 | const SECONDS_IN_A_DAY = 24 * 60 * 60; 4 | const SECONDS_IN_AN_HOUR = 60 * 60; 5 | const SECONDS_IN_A_MINUTE = 60; 6 | const MILLISECONDS_IN_AN_HOUR = 60 * 60 * 1000; 7 | 8 | const intercomHeaders = { 9 | Authorization: "Bearer " + process.env.INTERCOM_TOKEN, 10 | Accept: "application/json", 11 | }; 12 | 13 | const checkDuplicatesHandler = { 14 | handler: async (req, reply) => { 15 | try { 16 | const conversationData = req.body.data.item; 17 | const conversationId = conversationData.id; 18 | const userId = conversationData.source?.author?.id; 19 | 20 | if (!conversationId || !userId) { 21 | reply.code(400).send("No conversation sent"); 22 | return; 23 | } 24 | 25 | const formatTime = (ts) => { 26 | const now = new Date(); 27 | const diff = Math.floor(now.getTime() / 1000) - ts; 28 | 29 | if (diff >= SECONDS_IN_A_DAY) { 30 | return `${Math.floor(diff / SECONDS_IN_A_DAY)} day(s) ago`; 31 | } else if (diff >= SECONDS_IN_AN_HOUR) { 32 | return `${Math.floor(diff / SECONDS_IN_AN_HOUR)} hour(s) ago`; 33 | } else if (diff >= SECONDS_IN_A_MINUTE) { 34 | return `${Math.floor(diff / SECONDS_IN_A_MINUTE)} min. ago`; 35 | } 36 | 37 | return `${diff} sec. ago`; 38 | }; 39 | 40 | const sendNote = (id, noteText) => { 41 | const opts = { 42 | url: `https://api.intercom.io/conversations/${id}/reply`, 43 | headers: intercomHeaders, 44 | json: true, 45 | body: { 46 | admin_id: process.env.INTERCOM_ADMIN_ID, 47 | body: noteText, 48 | type: "admin", 49 | message_type: "note", 50 | }, 51 | }; 52 | request.post(opts, (error, response) => { 53 | if (error && response.statusCode !== 200) { 54 | console.error(error || response); 55 | } 56 | }); 57 | }; 58 | 59 | const thirtyMinutesAgo = new Date(); 60 | thirtyMinutesAgo.setTime( 61 | thirtyMinutesAgo.getTime() - MILLISECONDS_IN_AN_HOUR / 2 62 | ); 63 | 64 | request.post( 65 | { 66 | url: "https://api.intercom.io/conversations/search", 67 | headers: intercomHeaders, 68 | json: true, 69 | body: { 70 | query: { 71 | operator: "AND", 72 | value: [ 73 | { 74 | field: "source.author.id", 75 | operator: "=", 76 | value: userId, 77 | }, 78 | { 79 | field: "state", 80 | operator: "!=", 81 | value: "closed", 82 | }, 83 | { 84 | field: "id", 85 | operator: "!=", 86 | value: conversationId, 87 | }, 88 | { 89 | field: "created_at", 90 | operator: ">", 91 | value: Math.floor(thirtyMinutesAgo.getTime() / 1000), 92 | }, 93 | ], 94 | }, 95 | }, 96 | }, 97 | (error, response, body) => { 98 | if (!error && response.statusCode == 200) { 99 | if (body.total_count > 0) { 100 | let noteText = 101 | "
Conversations with this users created less than half an hour ago:
"; 102 | body.conversations.forEach((c) => { 103 | noteText += `