<?xml version='1.0' encoding='UTF-8'?>
<?xml-stylesheet href="/rss/stylesheet/" type="text/xsl"?>
<rss xmlns:content='http://purl.org/rss/1.0/modules/content/' xmlns:taxo='http://purl.org/rss/1.0/modules/taxonomy/' xmlns:rdf='http://www.w3.org/1999/02/22-rdf-syntax-ns#' xmlns:itunes='http://www.itunes.com/dtds/podcast-1.0.dtd' xmlns:googleplay="http://www.google.com/schemas/play-podcasts/1.0" xmlns:dc='http://purl.org/dc/elements/1.1/' xmlns:atom='http://www.w3.org/2005/Atom' xmlns:podbridge='http://www.podbridge.com/podbridge-ad.dtd' version='2.0'>
<channel>
  <title>Jaryl Chng&apos;s Knowledge Base</title>
  <language>en-us</language>
  <generator>microfeed.org</generator>
  <itunes:type>episodic</itunes:type>
  <itunes:explicit>false</itunes:explicit>
  <atom:link rel="self" href="https://kb-jarylchng-com.pages.dev/rss/" type="application/rss+xml"/>
  <link>https://kb.jarylchng.com</link>
  <description>
    <![CDATA[<p>Welcome to the index page of my knowledge base, if you haven't done so, do visit my website at <a href="https://jarylchng.com" rel="noopener noreferrer" target="_blank">https://jarylchng.com</a>.</p><p>I will mainly use this site to document stuff, most of which will likely be in the public domain.</p>]]>
  </description>
  <itunes:author>Jaryl Chng</itunes:author>
  <itunes:image href="https://kb-static.jarylchng.com/kb-jarylchng-com/production/images/channel-c68f1f55f856ab833b4365991609dbec.png"/>
  <image>
    <title>Jaryl Chng&apos;s Knowledge Base</title>
    <url>https://kb-static.jarylchng.com/kb-jarylchng-com/production/images/channel-c68f1f55f856ab833b4365991609dbec.png</url>
    <link>https://kb.jarylchng.com</link>
  </image>
  <copyright>©2024</copyright>
  <itunes:category text="Technology"/>
  <item>
    <title>N8N - Carousell.sg - Automatic Reply Bot</title>
    <guid>k95O_GJ6Sdf</guid>
    <pubDate>Thu, 17 Jun 2021 10:02:00 GMT</pubDate>
    <itunes:explicit>false</itunes:explicit>
    <description>
      <![CDATA[<p><img src="https://kb-static.jarylchng.com/kb-jarylchng-com/production/media/rich-editor/items/k95O_GJ6Sdf/image-11ef9b231e071e02933af945d3bd0829.png"></p><h1>This has been superceded by <a href="https://gitlab.com/jarylc/carousell-gobot" rel="noopener noreferrer" target="_blank">Carousell GoBot</a> project.</h1><h2>Context</h2><p>I was looking for ideas to try out <a href="http://n8n.io" rel="noopener noreferrer" target="_blank">n8n.io</a>'s capabilities and picked this as the first big idea to try out.</p><p>Basically, I would like to <a href="http://n8n.io" rel="noopener noreferrer" target="_blank">n8n.io</a> to handle chat messages and offers from <a href="https://carousell.sg" rel="noopener noreferrer" target="_blank">Carousell</a>, a Singaporean marketplace platform, similar to Facebook Marketplace.</p><p>Right now I would only like it to:</p><ul><li>Reply a standard response template on new messages and offers</li><li>Forward to my personal Telegram bot to notify me via one additional channel</li><li>Detect any low-balling (offering a price much lower than the listed price)</li></ul><p>The problem was that Carousell does not have any public documentation on their APIs and I have to resort to inspecting the network traffic manually to achieve my goals.</p><p>Docker image: <a href="https://hub.docker.com/r/jarylc/n8n" rel="noopener noreferrer" target="_blank">jarylc/n8n</a></p><h2>Process</h2><h3>Automatic Triggers</h3><p>There are 3 ways to this workflow is automatically triggered:</p><p>-&gt; <strong>Webhook</strong> - Using Tasker on my Android device, it will send a GET request to this webhook everytime a notification from Carousell app arrives.</p><p>-&gt; <strong>IMAP</strong> - Reads a dedicated inbox for e-mails from Carousell and trigger if so, acts as fallback for the above.</p><p>-&gt; <strong>Cron</strong> - Every 30 minutes as fallback in case all of the above doesn't work. Note that there is a better to do this if standalone, you could just connect to the chat WebSocket and wait for messages.</p><h3>Retrieve Chat Data and Preliminary Checks</h3><p>When meddling around <a href="https://www.carousell.sg/inbox/received/" rel="noopener noreferrer" target="_blank">https://www.carousell.sg/inbox/received/</a> and inspecting the network trace, I found out that Carousell has an undocumented API to retreive that I could tap on.</p><p>This API call allows me to retrieve the list of messages and offers that I have recieved as a seller which I put into good use as the first step after the trigger.</p><p>It seems that only the Cookie header is required to authenticate myself.</p><p>The preliminary checks consists of checking if the chat list is empty, or the very last message contains my message signature.</p><p>Now this comes the hard part for this workflow, upon inspecting, Carousell uses SendBird for their chat platform which uses WebSockets instead of API calls.</p><p>I had to customize my n8n.io installation to include ws Node dependency and add it to NODE_FUNCTION_ALLOW_EXTERNAL environment variable to be used in the vm2 environment. This was made easy with my own Docker image on Docker hub using the environment variable ADDITIONAL_MODULES, jarylc/n8n.</p><p>After which, I had to mimic the WebSocket calls when chatting and came up with this final JavaScript node.</p><h3>Custom function codes</h3><p><strong>Check &amp; Split Checks</strong></p><pre class="ql-syntax" spellcheck="false">const fs = require("fs")

const seen = JSON.parse(fs.readFileSync('/data/carousell.json', 'utf8'))

const split = []
let save = {}
for (const item of $node["HTTP Request"].json["data"]["offers"]) {
  if (item['state'] === 'A')
    continue

  let old_price = seen[item['id']] || 0.0
  let latest_price = parseFloat(item['latest_price'].replace(',', ''))

  let message_price_search = item['latest_price_message'].match(/^(\d{1,5}\.?\d{0,2})$|(\d+\.?\d{0,2}((?&lt;=(\$|offer|quote|can|please|pls|quick|fast|sell).*)|(?=.*(\$|offer|quote|can|please|pls|quick|fast|bucks|ok|\?))))/gi)
  if (message_price_search != null) {
    let message_price = parseFloat(message_price_search[message_price_search.length - 1])
    if (message_price &gt; old_price &amp;&amp; message_price &gt; latest_price) {
      latest_price = message_price
      item['latest_price_formatted'] = (''+latest_price.toFixed(2)).replace(/\B(?=(\d{3})+(?!\d))/g, ",")
    }
  }

  save[item['id']] = latest_price
  if (item['id'] in seen &amp;&amp; old_price &gt;= latest_price)
    continue

  item['price_changed'] = old_price !== 0.0
  item['low_balled'] = latest_price !== 0 &amp;&amp; latest_price &lt;= parseFloat(item['product']['price']) * 0.85

  split.push({json: item})
}

if (split.length &gt; 0) {
  const fs = require("fs")
  fs.writeFileSync('/data/carousell.json', JSON.stringify(save))
}

return split
</pre><p><strong>Send Reply</strong></p><pre class="ql-syntax" spellcheck="false">const WebSocket = require('ws')

return await new Promise((resolve, reject) =&gt; {
  let sent = false
  const sendbird_subdomain = items[0].json['channel_url'].slice(0, items[0].json['channel_url'].indexOf('-carousell')).toLowerCase()
  const client = new WebSocket('wss://ws-' + sendbird_subdomain + '.sendbird.com/?p=JS&amp;pv=Mozilla%2F5.0%20(X11%3B%20Linux%20x86_64%3B%20rv%3A90.0)%20Gecko%2F20100101%20Firefox%2F90.0&amp;sv=3.0.149&amp;ai=F3CB6187-CB42-4CD1-95FC-1C46F8856006&amp;user_id=344194&amp;access_token=' + $node['Get Chat Token'].json['data']['token'] + '&amp;active=1&amp;SB-User-Agent=JS%2Fc3.0.149%2F%2F&amp;Request-Sent-Timestamp=' + Date.now() + '&amp;include_extra_data=premium_feature_list%2Cfile_upload_size_limit%2Capplication_attributes%2Cemoji_hash', {
    perMessageDeflate: true
  })
  client.on('message', (data) =&gt; {
    if (data.startsWith('LOGI') &amp;&amp; !sent) {
      sent = true
      for (const i in items) {
        try {
          const item = items[i].json
          let text = 'Hello @' + item['user']['username'] + '!\n\n' +
            'Thank you for your '
          if (item['latest_price_formatted'] !== '0') {
            text += (item['price_changed'] ? 'new ' : '') + 'offer of ' + item['currency_symbol'] + item['latest_price_formatted'] + ' on'
          } else {
            text += 'interest in'
          }
          text += ' my item: ' + item['product']['title'] + '.'
          if (!item['is_product_sold'] &amp;&amp; item['product']['status'] !== 'R') {
            if (item['low_balled'])
              text += '\n\nWARNING: Offer price is more than 15% below listing price, it is too low and may not get a future reply unless you increase it!'

            if (!item['price_changed']) {
              text += '\n\nFAQ:\n' +
                '» Where do I normally deal?\n' +
                'Choa Chu Kang or Bukit Panjang area if I am not in office, Bencoolen area if I am.\n' +
                '» What payment methods do I accept?\n' +
                'In order of preference: Google Pay, PayLah, PayNow, Cash, CarouPay, Bank Transfer\n' +
                '» What happens if I did not reply?\n' +
                'Very likely you offered too low-ball of a price' + (item['low_balled'] ? ' (which you probably did)' : '') + '. If not, feel free to message me again.'
            }
          } else {
            text += '\n\nHowever, this listing has already been ' + (item['is_product_sold'] ? 'sold' : 'reserved') + ' and not available anymore.'
          }
          text += '\n\n- @jarylc'

          const msg = {
            channel_url: '' + item['channel_url'],
            message: text,
            data: JSON.stringify({
              offer_id: '' + item['id'],
              source: 'web'
            }),
            mention_type: 'users',
            mentioned_user_ids: [],
            custom_type: 'MESSAGE',
            req_id: Date.now()
          }

          client.send('MESG' + JSON.stringify(msg) + '\n')
        } catch
          (err) {
          reject(err)
        }
      }
    } else if (data.startsWith('READ')) {
      client.terminate()
      resolve(true)
    }
  })
  client.on('error', (err) =&gt; {
    reject(err)
  })
  client.on('open', () =&gt; {
    setTimeout(() =&gt; {
      client.terminate()
      resolve(true)
    }, 10000)
  })
}).then(_ =&gt; {
  return []
}).catch(err =&gt; {
  throw err
})
</pre><h3>Telegram Notify Flow</h3><p>The other branch just forwards this to my personal Telegram bot so that I can be notified via another channel. Right now it will also tell me if the offer was a low-ball or not.</p>]]>
    </description>
    <link>https://kb.jarylchng.com/i/n8n-carousellsg-automatic-reply-bot-k95O_GJ6Sdf/</link>
    <itunes:episodeType>full</itunes:episodeType>
  </item>
</channel>
</rss>