[
  {
    "path": ".github/workflows/server-ci.yml",
    "content": "name: Server CI\non: push\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n    strategy:\n      matrix:\n        node-version: [18.x]\n    steps:\n      - name: checkout\n        uses: actions/checkout@v2\n      - name: Use node ${{ matrix.node-version }}\n        uses: actions/setup-node@v1\n        with:\n          node-version: ${{ matrix.node-version }}\n      - name: cache npm modules\n        uses: actions/cache@v1\n        with:\n          path: ~/.npm\n          key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}\n          restore-keys: |\n            ${{ runner.os }}-node-\n      - name: npm ci\n        working-directory: unfollow-ninja-server\n        run: npm ci\n      - name: lint\n        working-directory: unfollow-ninja-server\n        run: npm run lint\n      - name: build\n        working-directory: unfollow-ninja-server\n        run: npm run build\n      - name: test\n        working-directory: unfollow-ninja-server\n        run: npm run specs\n\n  docker:\n    runs-on: ubuntu-latest\n    steps:\n      - name: checkout\n        uses: actions/checkout@v2\n      - name: create an empty env file\n        working-directory: unfollow-ninja-server\n        run: touch .env\n      - name: build\n        working-directory: unfollow-ninja-server/tests\n        run: docker-compose build\n      - name: run tests\n        working-directory: unfollow-ninja-server/tests\n        run: docker-compose up --exit-code-from tests\n"
  },
  {
    "path": ".gitignore",
    "content": "# Based on https://github.com/github/gitignore/blob/master/Node.gitignore\n#\n# Logs\n*.log\n*.log.gz\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n\n# Runtime data\npids\n*.pid\n*.seed\n*.pid.lock\n\n# Directory for instrumented libs generated by jscoverage/JSCover\nlib-cov\n\n# Coverage directory used by tools like istanbul\ncoverage\n\n# nyc test coverage\n.nyc_output\n\n# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)\n.grunt\n\n# Bower dependency directory (https://bower.io/)\nbower_components\n\n# node-waf configuration\n.lock-wscript\n\n# Compiled binary addons (https://nodejs.org/api/addons.html)\nbuild/Release\n\n# Dependency directories\nnode_modules/\njspm_packages/\n\n# Optional npm cache directory\n.npm\n\n# Optional eslint cache\n.eslintcache\n\n# Optional REPL history\n.node_repl_history\n\n# Output of 'npm pack'\n*.tgz\n\n# Yarn Integrity file\n.yarn-integrity\n\n# dotenv environment variables file\n.env\n\n# vuepress build output\n.vuepress/dist\n\n# Compiled typescript\ndist/\n\n# intellij idea\n.idea/\n\n# test reports\ntest-results/\n\n# osX\n.DS_Store"
  },
  {
    "path": "license.md",
    "content": "Copyright 2019 Paul-Louis Hery https://twitter.com/plhery\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License."
  },
  {
    "path": "readme.md",
    "content": "# Unfollow Ninja [![Server CI status](https://github.com/PLhery/unfollowNinja/workflows/Server%20CI/badge.svg)](https://github.com/PLhery/unfollowNinja/actions?query=workflow%3A%22Server+CI%22)\n\nGet notified when your Twitter account loses a follower\n\nUsed by ~500 000 Twitter users (11/2021)\n\n🇬🇧 https://unfollow-monkey.com\n🇫🇷 https://unfollow.ninja  \n\n![Screenshot](https://raw.githubusercontent.com/PLhery/unfollowNinja/master/unfollow-monkey-ui/public/preview.png)\n\n---\n\n**[Deprecated] This app was built mostly on top of Twitter API V1.1, which is now deprecated.**\n\n\n\nFeel free to reuse the UI on a personal server, but please don't reuse the name/logo on a public instance.\n\nIndeed, this software is under apache v2 license which means:\n\n- You need to aknowledge the use of this software and its license somewhere\n- A modified version can't have the same name\n\n## Build the UI\n\n- clone the repo `git clone git@github.com:PLhery/unfollowNinja.git`\n- `cd unfollowNinja/unfollow-monkey-ui`\n- change `api.unfollow-monkey.com` to you own server endpoints in ui/src/components/MiniApp.js\n- `npm install && npm start` to serve the UI in dev mode\n- `npm run build` to build the UI static files in the `build` folder.\n\nThen you can host it on github pages, amazon s3, netlify, your own nginx server...\n\n\n## Launch the server with docker-compose\n\n- clone the repo `git clone git@github.com:PLhery/unfollowNinja.git`\n- `cd unfollowNinja/unfollow-ninja-server`\n- create a .env file (see [.env file](#.env-file))\n- `docker-compose up  --build`\n\nThe server will be accessible on http://localhost:4000/  \n\nBy default, the db files and logs are stored in /data subfolders. Feel free to edit these, and the port/address in `docker-compose.yml`\n\n## Launch the server manually\n\n- clone the repo `git clone git@github.com:PLhery/unfollowNinja.git`\n- `cd unfollowNinja/unfollow-ninja-server`\n- install the dependencies and build the project `npm ci && npm build`\n- create a .env file (see [.env file](#.env-file))\n- install redis and optionally set a custom REDIS_URI + REDIS_BULL_URI in .env\n- launch the workers `node ./dist/api/workers`\n- launch the api server `node ./dist/api`\n- or launch both in the as a daemon with pm2 `pm2 start pm2.yml`\n\n## .env file\n\nTo get started, you'll need to create one or two twitter app https://developer.twitter.com/en/apps:  \n- One for the first step, which only need a read access, and a callback url = https://your-ui-url/1\n- One for the second step, which needs a DM sending access, and a callback url = https://your-ui-url/2\n- Both need to have sign in with twitter enabled\n\nThen you can create a .env in unfollow-ninja-server to set some parameters:\n\n```\n# your first step app API key and secret\nCONSUMER_KEY=xxx \nCONSUMER_SECRET=xxx\n# your second step app API key\nDM_CONSUMER_KEY=xxx\nDM_CONSUMER_SECRET=xxx # your second step app API secret key\n\n# Front-end URL (without any ending /)\nWEB_URL=https://unfollow.ninja\n\n# a secret key to sign cookies (ex. generate yours on https://password.new)\nCOOKIE_SIGNING_KEY=Kg8hfQoGj9GHjdKjsYqPtk6ShJqaoP\n\n# optionally:\n\n# -- The twitter account quoted in the notifications (ex. welcome to @[TWITTER_ACCOUNT]!)\nTWITTER_ACCOUNT=unfollowninja\n\n# -- The timezone use for follow time. ex: Europe/Paris\nTIMEZONE=UTC \n\n# -- Default language for the notifications (for new users).\nDEFAULT_LANGUAGE=en\n\n# -- Number of workers, defaults to number of CPUs\nCLUSTER_SIZE=2\n# -- Number of unique tasks managed at the same time, defaults to 15\nWORKER_RATE_LIMIT=15\n\n# -- When this twitter user is logged in, it has access to the /admin/user/[username] debug enpoint\nADMIN_USERID=\n\n# -- Sentry (API) DSN, if you want to report workers (or API) errors on sentry\nSENTRY_DSN=\nSENTRY_DSN_API=\n\n# -- If you set these variables, the server will send some metrics to these statsd / datadog servers\nSTATSD_HOST=\nDD_AGENT_HOST=\n# -- The metrics will start with this prefix (ex metric: [prefix].check-duration.worker.[cluster-nb])\nMETRICS_PREFIX=uninja\n```\n\nYou can also set these parameters as environment variables.\n\n## Contribute\n\nOpen an issue with your suggestions or assign yourself to an existing issue\n\n### Translate the app to your language\n\nYou can help to translate the app on https://hosted.weblate.org/projects/unfollow-monkey/notifications/\n\nWeblate will automatically open a PR to update or add the language in this repository.\n\n## Motivation behind improving the legacy version\n\nLegacy version: https://github.com/PLhery/unfollowNinja/tree/legacy\n\nThe legacy version couldn't scale and manage thousands of users, while still checking every 2 minutes the followers.\nNow, the program checks 100 000 users's followers in about 3 minutes with 12 cpus.\n\n- Based on a job queue for:\n    - Monitoring: I can see how many jobs/sec are happening, their errors, and rate limit them.\n    - Scalability: Everything was happening in one thread. Now the work is shared between 8 workers/vCPUs.\n    - Atomicity: When the server restart, wait for every atomic job to finish instead of interrupting them. (causing messages not sent or sent twice)\n    - Reliability: If a job fails, prepare better retry strategies.\n- Use Typescript to make code more bug-proof and clearer for contributors\n- Split the webserver from the worker: When the worker was busy, the webserver was slow because everything was happening in the same thread.\n- Use redis to store the list of followers as we need to read/write them really often/quickly\n- I18n\n- Use twitter's SnowFlake IDs to get the exact follow time\n- New UI\n\n# Licensing\n\n[License](./license.md) (Apache V2)\n\nUnfollowNinja uses multiple open-source projects:\n\n#### Twemoji\n\nCopyright 2020 Twitter, Inc and other contributors  \nCode licensed under the MIT License: http://opensource.org/licenses/MIT  \nGraphics licensed under CC-BY 4.0: https://creativecommons.org/licenses/by/4.0/"
  },
  {
    "path": "unfollow-monkey-ui/.gitignore",
    "content": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\n/node_modules\n/.pnp\n.pnp.js\n\n# testing\n/coverage\n\n# production\n/build\n\n# misc\n.DS_Store\n.env.local\n.env.development.local\n.env.test.local\n.env.production.local\n\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n"
  },
  {
    "path": "unfollow-monkey-ui/package.json",
    "content": "{\n  \"name\": \"unfollow-ninja-ui\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"dependencies\": {\n    \"@sentry/browser\": \"^7.3.0\",\n    \"grommet\": \"^2.17.5\",\n    \"grommet-icons\": \"^4.6.2\",\n    \"react\": \"^18.2.0\",\n    \"react-dom\": \"^18.2.0\",\n    \"react-dom-confetti\": \"^0.2.0\",\n    \"react-github-corner\": \"^2.5.0\",\n    \"react-scripts\": \"^5.0.0\",\n    \"react-snap\": \"^1.23.0\",\n    \"sass\": \"^1.49.8\",\n    \"styled-components\": \"^5.3.1\",\n    \"web-vitals\": \"^2.1.0\"\n  },\n  \"scripts\": {\n    \"start\": \"react-scripts start\",\n    \"build\": \"react-scripts build\",\n    \"postbuild\": \"react-snap\",\n    \"test\": \"react-scripts test\",\n    \"eject\": \"react-scripts eject\"\n  },\n  \"eslintConfig\": {\n    \"extends\": \"react-app\"\n  },\n  \"browserslist\": {\n    \"production\": [\n      \">1%\",\n      \"not dead\",\n      \"not op_mini all\"\n    ],\n    \"development\": [\n      \"last 1 chrome version\",\n      \"last 1 firefox version\",\n      \"last 1 safari version\"\n    ]\n  }\n}\n"
  },
  {
    "path": "unfollow-monkey-ui/public/_redirects",
    "content": "/1 /index.html 200\n/2 /index.html 200"
  },
  {
    "path": "unfollow-monkey-ui/public/favicon/site.webmanifest",
    "content": "{\n    \"name\": \"Unfollow Monkey\",\n    \"short_name\": \"Unfollow Monkey\",\n    \"icons\": [\n        {\n            \"src\": \"/android-chrome-192x192.png\",\n            \"sizes\": \"192x192\",\n            \"type\": \"image/png\"\n        },\n        {\n            \"src\": \"/android-chrome-512x512.png\",\n            \"sizes\": \"512x512\",\n            \"type\": \"image/png\"\n        }\n    ],\n    \"theme_color\": \"#b742a0\",\n    \"background_color\": \"#ffffff\",\n    \"display\": \"standalone\"\n}\n"
  },
  {
    "path": "unfollow-monkey-ui/public/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"utf-8\" />\n    <link rel=\"shortcut icon\" href=\"%PUBLIC_URL%/favicon.ico\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n\n    <!-- Icons -->\n    <link rel=\"apple-touch-icon\" sizes=\"180x180\" href=\"%PUBLIC_URL%/favicon/apple-touch-icon.png\">\n    <link rel=\"icon\" type=\"image/png\" sizes=\"32x32\" href=\"%PUBLIC_URL%/favicon/favicon-32x32.png\">\n    <link rel=\"icon\" type=\"image/png\" sizes=\"16x16\" href=\"%PUBLIC_URL%/favicon/favicon-16x16.png\">\n    <link rel=\"manifest\" href=\"%PUBLIC_URL%/favicon/site.webmanifest\">\n    <meta name=\"theme-color\" content=\"#b742a0\">\n\n    <!-- Primary Meta Tags -->\n    <title>Unfollow Monkey</title>\n    <meta name=\"title\" content=\"Unfollow Monkey\">\n    <meta name=\"description\" content=\"Get notified when your Twitter account loses a follower\">\n\n    <!-- Open Graph / Facebook -->\n    <meta property=\"og:type\" content=\"website\">\n    <meta property=\"og:url\" content=\"https://unfollow-monkey.com/\">\n    <meta property=\"og:title\" content=\"Unfollow Monkey\">\n    <meta property=\"og:description\" content=\"Get notified when your Twitter account loses a follower\">\n    <meta property=\"og:image\" content=\"https://unfollow-monkey.com/preview.png\">\n\n    <!-- Twitter -->\n    <meta property=\"twitter:card\" content=\"summary_large_image\">\n    <meta property=\"twitter:url\" content=\"https://unfollow-monkey.com/\">\n    <meta property=\"twitter:title\" content=\"Unfollow Monkey\">\n    <meta property=\"twitter:description\" content=\"Get notified when your Twitter account loses a follower\">\n    <meta property=\"twitter:image\" content=\"https://unfollow-monkey.com/preview.png\">\n\n    <link href=\"https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;600&family=Quicksand:wght@400;600&display=swap\" rel=\"stylesheet\">\n  </head>\n  <body>\n    <noscript>You need to enable JavaScript to run this app.</noscript>\n    <div id=\"root\"></div>\n  </body>\n</html>\n"
  },
  {
    "path": "unfollow-monkey-ui/public/manifest.json",
    "content": "{\n  \"short_name\": \"Unfollow Monkey\",\n  \"name\": \"Unfollow Monkey\",\n  \"icons\": [\n    {\n      \"src\": \"favicon.ico\",\n      \"sizes\": \"64x64 32x32 24x24 16x16\",\n      \"type\": \"image/x-icon\"\n    }\n  ],\n  \"start_url\": \".\",\n  \"display\": \"standalone\",\n  \"theme_color\": \"#b742a0\",\n  \"background_color\": \"#ffffff\"\n}\n"
  },
  {
    "path": "unfollow-monkey-ui/public/privacy-policy.txt",
    "content": "PRIVACY POLICY - UNFOLLOW MONKEY - https://unfollow-monkey.com\n--------------------------------------------------------------\nEffective 12/12/2021\n\nAt Unfollow Monkey, we take privacy and security seriously. This Privacy Policy outlines how Unfollow Monkey (collectively, Unfollow Monkey,” “we,” “our,” or “us”) process the information we collect about you through our website and other online services (collectively, the “Services”) and when you otherwise interact with us, such as through our customer service channels.\n\nIf you are a California resident, please also see the \"CCPA PRIVACY RIGHTS\" section below.\n\n1. INFORMATION WE COLLECT AND HOW WE COLLECT IT\n\nInformation You Provide\n-----------------------\nWe collect information you provide when you use our Services or otherwise engage or communicate with us as described below.\n\nIdentity Data, such as your name, when you buy a subscription\nContact Data, such as your email address, and mailing address, when you buy a subscription\nProfile Data, such as your Twitter profile and authentication token\nAdditional Data You Provide, such as via survey responses, contests/sweepstakes, customer support, or other means.\n\nInformation We Collect Automatically\n-----------------------\nAs is true of many digital platforms, we also collect certain information about you automatically when you use our Services, as described below.\n\nUsage Information. We collect information about your activity on our Services, which includes device identifiers (like IP address or mobile device identifiers), pages or features you use, time and date of access, and other similar usage information.\n\nTransactional Information.\nWhen you receive, submit, or complete a transaction via the Services, we collect information about the transaction, such as transaction amount, type and nature of the transaction, and time and date of the transaction.\n\nInformation Collected Through Tracking Technologies.\nWe and our service providers also use technologies, including cookies and web beacons, to automatically collect certain types of usage and device information when you use our Services or interact with our emails. The information collected through these technologies includes your IP address, browser type, Internet service provider, platform type, device type, operating system, date and time stamp, a unique device or account ID, usage information and other similar information. For information about how to disable cookies, please see the Your Controls section below.\n\nInformation We Collect from Other Sources\n-----------------------\nWe also obtain information about you from other sources as described below.\n\nConnected Services. When you connect your account to Twitter, Twitter may send us information such as your profile information from that service. This information varies and is controlled by that service or as authorized by you via your privacy settings at that service.\n\n2. HOW WE USE YOUR INFORMATION.\nWe use the information we collect for purposes described below or as otherwise described to you at the point of collection:\n\nMaintain and provide the Services, including to process account applications, authenticate your identity, repair our Services, and handle billing and account management;\nSend you transactional or relationship information, including confirmations, invoices, technical notices, customer support responses, software updates, security alerts, support and administrative messages, and information about your transactions;\nSend you notifications about who stopped following you on Twitter\nMonitor and improve our Services, including analyzing usage, research and development;\nHelp protect the safety and security of our Services, business, and users, such as to investigate and help prevent fraud or other unlawful activity;\nProtect or exercise our legal rights or defend against legal claims, including to enforce and carry out contracts and agreements; and\nComply with applicable laws and legal obligations, such as compliance obligations associated with being a regulated broker-dealer.\n\n3. DISCLOSURES OF INFORMATION.\nWe are committed to maintaining your trust, and we want you to understand when and with whom we share information about you. We share information about you in the instances described below.\n\nAuthorized third-party vendors and service providers. We share information about you with third-party vendors and service providers who perform services for us, such as mailing services, tax and accounting services, web hosting, customer support, and analytics services. \n\nLegal purposes. We disclose information about you if we believe that disclosure is in accordance with, or required by, any applicable law or legal process or to protect and defend the rights, interests, safety, and security of Unfollow Monkey, our users, or the public.\n\nWith your consent. We share information about you for any other purposes disclosed to you with your consent.\nWe share information with others in an aggregated or otherwise de-identified form that does not reasonably identify you.\n\n4. THIRD-PARTY TRACKING\nWe use third-party analytics services to better understand your online activity and serve you targeted advertisements. For example, we use Google Analytics and you can review the “How Google uses information from sites or apps that use our services” linked here: http://www.google.com/policies/privacy/partners/ for information about how Google processes the information it collects. These companies collect information about your use of our Services and other websites and online services over time through cookies, device identifiers, or other tracking technologies. The information collected includes your IP address, web browser, mobile network information, pages viewed, time spent, links clicked, and conversion information. We and our third-party partners use this information to, among other things, analyze and track data, and determine the popularity of content.\n\n5. DO NOT TRACK.\nSome web browsers transmit “do-not-track” signals to websites. We currently don’t take action in response to these signals.\n\n6. YOUR CONTROLS.\nAccount profile. You may update certain account profile information by logging into your account.\n\nHow to control your communications preferences.\nYou can stop receiving promotional emails from us by clicking the “unsubscribe” link in those emails. We may still send you service-related or other non-promotional communications, such as account notifications, receipts, security notices and other transactional or relationship messages.\n\nCookie controls. Many web browsers are set to accept cookies and similar tracking technologies by default. If you prefer, you can set your browser to delete or reject these technologies. If you choose to delete or reject these technologies, this could affect certain features of our Services. Furthermore, if you use a different device, change browsers, or delete the opt-out cookies that contain your preferences, you may need to perform the opt-out task again.\n\n7. CCPA PRIVACY RIGHTS (Do Not Sell My Personal Information)\nUnder the CCPA, among other rights, California consumers have the right to:\n\nRequest that a business that collects a consumer's personal data disclose the categories and specific pieces of personal data that a business has collected about consumers.\n\nRequest that a business delete any personal data about the consumer that a business has collected.\n\nRequest that a business that sells a consumer's personal data, not sell the consumer's personal data.\n\nIf you make a request, we have one month to respond to you. If you would like to exercise any of these rights, please contact us.\n\n8. GDPR DATA PROTECTION RIGHTS\nWe would like to make sure you are fully aware of all of your data protection rights. Every user is entitled to the following:\n\nThe right to access – You have the right to request copies of your personal data. We may charge you a small fee for this service.\n\nThe right to rectification – You have the right to request that we correct any information you believe is inaccurate. You also have the right to request that we complete the information you believe is incomplete.\n\nThe right to erasure – You have the right to request that we erase your personal data, under certain conditions.\n\nThe right to restrict processing – You have the right to request that we restrict the processing of your personal data, under certain conditions.\n\nThe right to object to processing – You have the right to object to our processing of your personal data, under certain conditions.\n\nThe right to data portability – You have the right to request that we transfer the data that we have collected to another organization, or directly to you, under certain conditions.\n\nIf you make a request, we have one month to respond to you. If you would like to exercise any of these rights, please contact us.\n\n8. CHILDREN.\nWe do not knowingly collect or solicit any information from anyone under the age of 13 on these Services. In the event we learn that we have inadvertently collected personal information from a child under age 13, we will take reasonable steps to delete that information. If you believe that we might have any information from a child under 13, please contact us at help (at) unfollow-monkey.com.\n\n9. TRANSFER OF INFORMATION.\nOur Services are currently designed for use only in the United States. If you are using our Services from another jurisdiction, your information may be processed in the United States or other countries that may not provide levels of data protection that are equivalent to those of your home jurisdiction.\n\n10. CHANGES TO THIS POLICY.\nThis Privacy Policy will evolve with time, and when we update it, we will revise the \"Effective Date\" above and post the new Policy and, in some cases, we provide additional notice (such as adding a statement to our website or sending you a notification). To stay informed of our privacy practices, we recommend you review the Policy on a regular basis as you continue to use our Services.\n\n11. HOW TO CONTACT US.\nIf you have any questions about this Privacy Policy, please contact us at help (at) unfollow-monkey.com"
  },
  {
    "path": "unfollow-monkey-ui/public/robots.txt",
    "content": "User-agent: *\nSitemap: https://unfollow-monkey.com/sitemap.xml"
  },
  {
    "path": "unfollow-monkey-ui/public/sitemap.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n    <url>\n        <loc>https://unfollow-monkey.com/</loc>\n    </url>\n</urlset>"
  },
  {
    "path": "unfollow-monkey-ui/public/tos.txt",
    "content": "TERMS OF SERVICE - UNFOLLOW MONKEY - https://unfollow-monkey.com\n--------------------------------------------------------------\nLast updated 12/12/2021\n\nFRENCH LEGAL TERMS\n\nL’édition et la direction de la publication du site https://unfollow-monkey.com/ est assurée par Paul-Louis HERY, domicilié au 30 rue de Chazelles, 75017 Paris, France.\nNumero de telephone: 0680489983\nAdresse email: help (at) unfollow-monkey.com\n\nL'hébergeur du site https://unfollow-monkey.com/ est la société PulseHeberg, dont le siège social est situé au 55 AVENUE DES CHAMPS PIERREUX, 92000 NANTERRE, avec pour adresse email contact (at) pulseheberg.com.\n\nAGREEMENT TO TERMS\n\nThese Terms of Service constitute a legally binding agreement made between you, whether personally or on behalf of an entity (“you”) and Unfollow Monkey (“we,” “us” or “our”), concerning your access to and use of the unfollow-monkey.com website as well as any other media form, media channel, mobile website or mobile application related, linked, or otherwise connected thereto (collectively, the “Site”).\n\nYou agree that by accessing the Site, you have read, understood, and agree to be bound by all of these Terms of Service. If you do not agree with all of these Terms of Service, then you are expressly prohibited from using the Site and you must discontinue use immediately.\n\nSupplemental Terms of Service or documents that may be posted on the Site from time to time are hereby expressly incorporated herein by reference. We reserve the right, in our sole discretion, to make changes or modifications to these Terms of Service at any time and for any reason.\n\nWe will alert you about any changes by updating the “Last updated” date of these Terms of Service, and you waive any right to receive specific notice of each such change.\n\nIt is your responsibility to periodically review these Terms of Service to stay informed of updates. You will be subject to, and will be deemed to have been made aware of and to have accepted, the changes in any revised Terms of Service by your continued use of the Site after the date such revised Terms of Service are posted.\n\nThe information provided on the Site is not intended for distribution to or use by any person or entity in any jurisdiction or country where such distribution or use would be contrary to law or regulation or which would subject us to any registration requirement within such jurisdiction or country.\n\nAccordingly, those persons who choose to access the Site from other locations do so on their own initiative and are solely responsible for compliance with local laws, if and to the extent local laws are applicable.\n\nThese Terms of Service were generated by Termly’s Terms and Conditions Generator.\n\nThe Site is intended for users who are at least 13 years of age. All users who are minors in the jurisdiction in which they reside (generally under the age of 18) must have the permission of, and be directly supervised by, their parent or guardian to use the Site. If you are a minor, you must have your parent or guardian read and agree to these Terms of Service prior to you using the Site.\n\nINTELLECTUAL PROPERTY RIGHTS\n\nUnless otherwise indicated, the Site is our proprietary property and all source code, databases, functionality, software, website designs, audio, video, text, photographs, and graphics on the Site (collectively, the “Content”) and the trademarks, service marks, and logos contained therein (the “Marks”) are owned or controlled by us or licensed to us, and are protected by copyright and trademark laws and various other intellectual property rights and unfair competition laws of the United States, foreign jurisdictions, and international conventions.\n\nThe Content and the Marks are provided on the Site “AS IS” for your information and personal use only. Except as expressly provided in these Terms of Service, no part of the Site and no Content or Marks may be copied, reproduced, aggregated, republished, uploaded, posted, publicly displayed, encoded, translated, transmitted, distributed, sold, licensed, or otherwise exploited for any commercial purpose whatsoever, without our express prior written permission.\n\nProvided that you are eligible to use the Site, you are granted a limited license to access and use the Site and to download or print a copy of any portion of the Content to which you have properly gained access solely for your personal, non-commercial use. We reserve all rights not expressly granted to you in and to the Site, the Content and the Marks.\n\nUSER REPRESENTATIONS\n\nBy using the Site, you represent and warrant that:\n\n(1) all registration information you submit will be true, accurate, current, and complete;\n\n(2) you will maintain the accuracy of such information and promptly update such registration information as necessary;\n\n(3) you have the legal capacity and you agree to comply with these Terms of Service;\n\n(4) you are not under the age of 13;\n\n(5) not a minor in the jurisdiction in which you reside, or if a minor, you have received parental permission to use the Site;\n\n(6) you will not access the Site through automated or non-human means, whether through a bot, script, or otherwise;\n\n(7) you will not use the Site for any illegal or unauthorized purpose;\n\n(8) your use of the Site will not violate any applicable law or regulation.\n\nIf you provide any information that is untrue, inaccurate, not current, or incomplete, we have the right to suspend or terminate your account and refuse any and all current or future use of the Site (or any portion thereof).\n\nUSER REGISTRATION\n\nYou may be required to register with the Site. You agree to keep your password confidential and will be responsible for all use of your account and password. We reserve the right to remove, reclaim, or change a username you select if we determine, in our sole discretion, that such username is inappropriate, obscene, or otherwise objectionable.\n\nPROHIBITED ACTIVITIES\n\nYou may not access or use the Site for any purpose other than that for which we make the Site available. The Site may not be used in connection with any commercial endeavors except those that are specifically endorsed or approved by us.\n\nAs a user of the Site, you agree not to:\n\nsystematically retrieve data or other content from the Site to create or compile, directly or indirectly, a collection, compilation, database, or directory without written permission from us.\nmake any unauthorized use of the Site, including collecting usernames and/or email addresses of users by electronic or other means for the purpose of sending unsolicited email, or creating user accounts by automated means or under false pretenses.\nuse a buying agent or purchasing agent to make purchases on the Site.\nuse the Site to advertise or offer to sell goods and services.\ncircumvent, disable, or otherwise interfere with security-related features of the Site, including features that prevent or restrict the use or copying of any Content or enforce limitations on the use of the Site and/or the Content contained therein.\nengage in unauthorized framing of or linking to the Site.\ntrick, defraud, or mislead us and other users, especially in any attempt to learn sensitive account information such as user passwords;\nmake improper use of our support services or submit false reports of abuse or misconduct.\nengage in any automated use of the system, such as using scripts to send comments or messages, or using any data mining, robots, or similar data gathering and extraction tools.\ninterfere with, disrupt, or create an undue burden on the Site or the networks or services connected to the Site.\nattempt to impersonate another user or person or use the username of another user.\nsell or otherwise transfer your profile.\nuse any information obtained from the Site in order to harass, abuse, or harm another person.\nuse the Site as part of any effort to compete with us or otherwise use the Site and/or the Content for any revenue-generating endeavor or commercial enterprise.\ndecipher, decompile, disassemble, or reverse engineer any of the software comprising or in any way making up a part of the Site.\nattempt to bypass any measures of the Site designed to prevent or restrict access to the Site, or any portion of the Site.\nharass, annoy, intimidate, or threaten any of our employees or agents engaged in providing any portion of the Site to you.\ndelete the copyright or other proprietary rights notice from any Content.\ncopy or adapt the Site’s software, including but not limited to Flash, PHP, HTML, JavaScript, or other code.\nupload or transmit (or attempt to upload or to transmit) viruses, Trojan horses, or other material, including excessive use of capital letters and spamming (continuous posting of repetitive text), that interferes with any party’s uninterrupted use and enjoyment of the Site or modifies, impairs, disrupts, alters, or interferes with the use, features, functions, operation, or maintenance of the Site.\nupload or transmit (or attempt to upload or to transmit) any material that acts as a passive or active information collection or transmission mechanism, including without limitation, clear graphics interchange formats (“gifs”), 1×1 pixels, web bugs, cookies, or other similar devices (sometimes referred to as “spyware” or “passive collection mechanisms” or “pcms”).\nexcept as may be the result of standard search engine or Internet browser usage, use, launch, develop, or distribute any automated system, including without limitation, any spider, robot, cheat utility, scraper, or offline reader that accesses the Site, or using or launching any unauthorized script or other software.\ndisparage, tarnish, or otherwise harm, in our opinion, us and/or the Site.\nuse the Site in a manner inconsistent with any applicable laws or regulations.\n\nUSER GENERATED CONTRIBUTIONS\n\nThe Site may invite you to chat, contribute to, or participate in blogs, message boards, online forums, and other functionality, and may provide you with the opportunity to create, submit, post, display, transmit, perform, publish, distribute, or broadcast content and materials to us or on the Site, including but not limited to text, writings, video, audio, photographs, graphics, comments, suggestions, or personal information or other material (collectively, “Contributions”).\n\nContributions may be viewable by other users of the Site and through third-party websites. As such, any Contributions you transmit may be treated as non-confidential and non-proprietary. When you create or make available any Contributions, you thereby represent and warrant that:\n\nthe creation, distribution, transmission, public display, or performance, and the accessing, downloading, or copying of your Contributions do not and will not infringe the proprietary rights, including but not limited to the copyright, patent, trademark, trade secret, or moral rights of any third party.\nyou are the creator and owner of or have the necessary licenses, rights, consents, releases, and permissions to use and to authorize us, the Site, and other users of the Site to use your Contributions in any manner contemplated by the Site and these Terms of Service.\nyou have the written consent, release, and/or permission of each and every identifiable individual person in your Contributions to use the name or likeness of each and every such identifiable individual person to enable inclusion and use of your Contributions in any manner contemplated by the Site and these Terms of Service.\nyour Contributions are not false, inaccurate, or misleading.\nyour Contributions are not unsolicited or unauthorized advertising, promotional materials, pyramid schemes, chain letters, spam, mass mailings, or other forms of solicitation.\nyour Contributions are not obscene, lewd, lascivious, filthy, violent, harassing, libelous, slanderous, or otherwise objectionable (as determined by us).\nyour Contributions do not ridicule, mock, disparage, intimidate, or abuse anyone.\nyour Contributions do not advocate the violent overthrow of any government or incite, encourage, or threaten physical harm against another.\nyour Contributions do not violate any applicable law, regulation, or rule.\nyour Contributions do not violate the privacy or publicity rights of any third party.\nyour Contributions do not contain any material that solicits personal information from anyone under the age of 18 or exploits people under the age of 18 in a sexual or violent manner.\nyour Contributions do not violate any federal or state law concerning child pornography, or otherwise intended to protect the health or well-being of minors;\nyour Contributions do not include any offensive comments that are connected to race, national origin, gender, sexual preference, or physical handicap.\nyour Contributions do not otherwise violate, or link to material that violates, any provision of these Terms of Service, or any applicable law or regulation.\nAny use of the Site in violation of the foregoing violates these Terms of Service and may result in, among other things, termination or suspension of your rights to use the Site.\n\nCONTRIBUTION LICENSE\n\nBy posting your Contributions to any part of the Site, or making Contributions accessible to the Site by linking your account from the Site to any of your social networking accounts, you automatically grant, and you represent and warrant that you have the right to grant, to us an unrestricted, unlimited, irrevocable, perpetual, non-exclusive, transferable, royalty-free, fully-paid, worldwide right, and license to host, use, copy, reproduce, disclose, sell, resell, publish, broadcast, retitle, archive, store, cache, publicly perform, publicly display, reformat, translate, transmit, excerpt (in whole or in part), and distribute such Contributions (including, without limitation, your image and voice) for any purpose, commercial, advertising, or otherwise, and to prepare derivative works of, or incorporate into other works, such Contributions, and grant and authorize sublicenses of the foregoing. The use and distribution may occur in any media formats and through any media channels.\n\nThis license will apply to any form, media, or technology now known or hereafter developed, and includes our use of your name, company name, and franchise name, as applicable, and any of the trademarks, service marks, trade names, logos, and personal and commercial images you provide. You waive all moral rights in your Contributions, and you warrant that moral rights have not otherwise been asserted in your Contributions.\n\nWe do not assert any ownership over your Contributions. You retain full ownership of all of your Contributions and any intellectual property rights or other proprietary rights associated with your Contributions. We are not liable for any statements or representations in your Contributions provided by you in any area on the Site.\n\nYou are solely responsible for your Contributions to the Site and you expressly agree to exonerate us from any and all responsibility and to refrain from any legal action against us regarding your Contributions.\n\nWe have the right, in our sole and absolute discretion, (1) to edit, redact, or otherwise change any Contributions; (2) to re-categorize any Contributions to place them in more appropriate locations on the Site; and (3) to pre-screen or delete any Contributions at any time and for any reason, without notice. We have no obligation to monitor your Contributions.\n\nSOCIAL MEDIA\n\nAs part of the functionality of the Site, you may link your account with online accounts you have with third-party service providers (each such account, a “Third-Party Account”) by either: (1) providing your Third-Party Account login information through the Site; or (2) allowing us to access your Third-Party Account, as is permitted under the applicable Terms of Service that govern your use of each Third-Party Account.\n\nYou represent and warrant that you are entitled to disclose your Third-Party Account login information to us and/or grant us access to your Third-Party Account, without breach by you of any of the Terms of Service that govern your use of the applicable Third-Party Account, and without obligating us to pay any fees or making us subject to any usage limitations imposed by the third-party service provider of the Third-Party Account.\n\nBy granting us access to any Third-Party Accounts, you understand that (1) we may access, make available, and store (if applicable) any content that you have provided to and stored in your Third-Party Account (the “Social Network Content”) so that it is available on and through the Site via your account, including without limitation any friend lists and (2) we may submit to and receive from your Third-Party Account additional information to the extent you are notified when you link your account with the Third-Party Account.\n\nDepending on the Third-Party Accounts you choose and subject to the privacy settings that you have set in such Third-Party Accounts, personally identifiable information that you post to your Third-Party Accounts may be available on and through your account on the Site.\n\nPlease note that if a Third-Party Account or associated service becomes unavailable or our access to such Third-Party Account is terminated by the third-party service provider, then Social Network Content may no longer be available on and through the Site. You will have the ability to disable the connection between your account on the Site and your Third-Party Accounts at any time.\n\nPLEASE NOTE THAT YOUR RELATIONSHIP WITH THE THIRD-PARTY SERVICE PROVIDERS ASSOCIATED WITH YOUR THIRD-PARTY ACCOUNTS IS GOVERNED SOLELY BY YOUR AGREEMENT(S) WITH SUCH THIRD-PARTY SERVICE PROVIDERS.\n\nWe make no effort to review any Social Network Content for any purpose, including but not limited to, for accuracy, legality, or non-infringement, and we are not responsible for any Social Network Content.\n\nYou acknowledge and agree that we may access your email address book associated with a Third-Party Account and your contacts list stored on your mobile device or tablet computer solely for purposes of identifying and informing you of those contacts who have also registered to use the Site.\n\nYou can deactivate the connection between the Site and your Third-Party Account by contacting us using the contact information below or through your account settings (if applicable). We will attempt to delete any information stored on our servers that was obtained through such Third-Party Account, except the username and profile picture that become associated with your account.\n\nSUBMISSIONS\n\nYou acknowledge and agree that any questions, comments, suggestions, ideas, feedback, or other information regarding the Site (“Submissions”) provided by you to us are non-confidential and shall become our sole property. We shall own exclusive rights, including all intellectual property rights, and shall be entitled to the unrestricted use and dissemination of these Submissions for any lawful purpose, commercial or otherwise, without acknowledgment or compensation to you.\n\nYou hereby waive all moral rights to any such Submissions, and you hereby warrant that any such Submissions are original with you or that you have the right to submit such Submissions. You agree there shall be no recourse against us for any alleged or actual infringement or misappropriation of any proprietary right in your Submissions.\n\nTHIRD-PARTY WEBSITES AND CONTENT\n\nThe Site may contain (or you may be sent via the Site) links to other websites (“Third-Party Websites”) as well as articles, photographs, text, graphics, pictures, designs, music, sound, video, information, applications, software, and other content or items belonging to or originating from third parties (“Third-Party Content”).\n\nSuch Third-Party Websites and Third-Party Content are not investigated, monitored, or checked for accuracy, appropriateness, or completeness by us, and we are not responsible for any Third-Party Websites accessed through the Site or any Third-Party Content posted on, available through, or installed from the Site, including the content, accuracy, offensiveness, opinions, reliability, privacy practices, or other policies of or contained in the Third-Party Websites or the Third-Party Content.\n\nInclusion of, linking to, or permitting the use or installation of any Third-Party Websites or any Third-Party Content does not imply approval or endorsement thereof by us. If you decide to leave the Site and access the Third-Party Websites or to use or install any Third-Party Content, you do so at your own risk, and you should be aware these Terms of Service no longer govern.\n\nYou should review the applicable terms and policies, including privacy and data gathering practices, of any website to which you navigate from the Site or relating to any applications you use or install from the Site. Any purchases you make through Third-Party Websites will be through other websites and from other companies, and we take no responsibility whatsoever in relation to such purchases which are exclusively between you and the applicable third party.\n\nYou agree and acknowledge that we do not endorse the products or services offered on Third-Party Websites and you shall hold us harmless from any harm caused by your purchase of such products or services. Additionally, you shall hold us harmless from any losses sustained by you or harm caused to you relating to or resulting in any way from any Third-Party Content or any contact with Third-Party Websites.\n\nSITE MANAGEMENT\n\nWe reserve the right, but not the obligation, to:\n\n(1) monitor the Site for violations of these Terms of Service;\n\n(2) take appropriate legal action against anyone who, in our sole discretion, violates the law or these Terms of Service, including without limitation, reporting such user to law enforcement authorities;\n\n(3) in our sole discretion and without limitation, refuse, restrict access to, limit the availability of, or disable (to the extent technologically feasible) any of your Contributions or any portion thereof;\n\n(4) in our sole discretion and without limitation, notice, or liability, to remove from the Site or otherwise disable all files and content that are excessive in size or are in any way burdensome to our systems;\n\n(5) otherwise manage the Site in a manner designed to protect our rights and property and to facilitate the proper functioning of the Site.\n\nPRIVACY POLICY\n\nWe care about data privacy and security. Please review our Privacy Policy https://unfollow-monkey.com/privacy-policy.txt.\nBy using the Site, you agree to be bound by our Privacy Policy, which is incorporated into these Terms of Service. Please be advised the Site is hosted in France.\n\nIf you access the Site from any region of the world with laws or other requirements governing personal data collection, use, or disclosure that differ from applicable laws in France, then through your continued use of the Site, you are transferring your data to France, and you expressly consent to have your data transferred to and processed in France.\n\nFurther, we do not knowingly accept, request, or solicit information from children or knowingly market to children. Therefore, in accordance with the U.S. Children’s Online Privacy Protection Act, if we receive actual knowledge that anyone under the age of 13 has provided personal information to us without the requisite and verifiable parental consent, we will delete that information from the Site as quickly as is reasonably practical.\n\nCOPYRIGHT INFRINGEMENTS\n\nWe respect the intellectual property rights of others. If you believe that any material available on or through the Site infringes upon any copyright you own or control, please immediately notify us using the contact information provided below (a “Notification”). A copy of your Notification will be sent to the person who posted or stored the material addressed in the Notification.\n\nPlease be advised that pursuant to federal law you may be held liable for damages if you make material misrepresentations in a Notification. Thus, if you are not sure that material located on or linked to by the Site infringes your copyright, you should consider first contacting an attorney.\n\nTERM AND TERMINATION\n\nThese Terms of Service shall remain in full force and effect while you use the Site. WITHOUT LIMITING ANY OTHER PROVISION OF THESE TERMS OF SERVICE, WE RESERVE THE RIGHT TO, IN OUR SOLE DISCRETION AND WITHOUT NOTICE OR LIABILITY, DENY ACCESS TO AND USE OF THE SITE (INCLUDING BLOCKING CERTAIN IP ADDRESSES), TO ANY PERSON FOR ANY REASON OR FOR NO REASON, INCLUDING WITHOUT LIMITATION FOR BREACH OF ANY REPRESENTATION, WARRANTY, OR COVENANT CONTAINED IN THESE TERMS OF SERVICE OR OF ANY APPLICABLE LAW OR REGULATION. WE MAY TERMINATE YOUR USE OR PARTICIPATION IN THE SITE OR DELETE YOUR ACCOUNT AND ANY CONTENT OR INFORMATION THAT YOU POSTED AT ANY TIME, WITHOUT WARNING, IN OUR SOLE DISCRETION.\n\nIf we terminate or suspend your account for any reason, you are prohibited from registering and creating a new account under your name, a fake or borrowed name, or the name of any third party, even if you may be acting on behalf of the third party.\n\nIn addition to terminating or suspending your account, we reserve the right to take appropriate legal action, including without limitation pursuing civil, criminal, and injunctive redress.\n\nMODIFICATIONS AND INTERRUPTIONS\n\nWe reserve the right to change, modify, or remove the contents of the Site at any time or for any reason at our sole discretion without notice. However, we have no obligation to update any information on our Site. We also reserve the right to modify or discontinue all or part of the Site without notice at any time.\n\nWe will not be liable to you or any third party for any modification, price change, suspension, or discontinuance of the Site.\n\nWe cannot guarantee the Site will be available at all times. We may experience hardware, software, or other problems or need to perform maintenance related to the Site, resulting in interruptions, delays, or errors.\n\nWe reserve the right to change, revise, update, suspend, discontinue, or otherwise modify the Site at any time or for any reason without notice to you. You agree that we have no liability whatsoever for any loss, damage, or inconvenience caused by your inability to access or use the Site during any downtime or discontinuance of the Site.\n\nNothing in these Terms of Service will be construed to obligate us to maintain and support the Site or to supply any corrections, updates, or releases in connection therewith.\n\nGOVERNING LAW\n\nThese terms and conditions are governed by and construed in accordance with the laws of France and you irrevocably submit to the exclusive jurisdiction of the courts in that State or location.\n\nDISPUTE RESOLUTION\n\nOption 1: Any legal action of whatever nature brought by either you or us (collectively, the “Parties” and individually, a “Party”) shall be commenced or prosecuted in the courts located in France, and the Parties hereby consent to, and waive all defenses of lack of personal jurisdiction and forum non conveniens with respect to venue and jurisdiction in such state and federal courts.\n\nCORRECTIONS\n\nThere may be information on the Site that contains typographical errors, inaccuracies, or omissions that may relate to the Site, including descriptions, pricing, availability, and various other information. We reserve the right to correct any errors, inaccuracies, or omissions and to change or update the information on the Site at any time, without prior notice.\n\nDISCLAIMER\n\nTHE SITE IS PROVIDED ON AN AS-IS AND AS-AVAILABLE BASIS. YOU AGREE THAT YOUR USE OF THE SITE AND OUR SERVICES WILL BE AT YOUR SOLE RISK. TO THE FULLEST EXTENT PERMITTED BY LAW, WE DISCLAIM ALL WARRANTIES, EXPRESS OR IMPLIED, IN CONNECTION WITH THE SITE AND YOUR USE THEREOF, INCLUDING, WITHOUT LIMITATION, THE IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. WE MAKE NO WARRANTIES OR REPRESENTATIONS ABOUT THE ACCURACY OR COMPLETENESS OF THE SITE’S CONTENT OR THE CONTENT OF ANY WEBSITES LINKED TO THE SITE AND WE WILL ASSUME NO LIABILITY OR RESPONSIBILITY FOR ANY (1) ERRORS, MISTAKES, OR INACCURACIES OF CONTENT AND MATERIALS, (2) PERSONAL INJURY OR PROPERTY DAMAGE, OF ANY NATURE WHATSOEVER, RESULTING FROM YOUR ACCESS TO AND USE OF THE SITE, (3) ANY UNAUTHORIZED ACCESS TO OR USE OF OUR SECURE SERVERS AND/OR ANY AND ALL PERSONAL INFORMATION AND/OR FINANCIAL INFORMATION STORED THEREIN, (4) ANY INTERRUPTION OR CESSATION OF TRANSMISSION TO OR FROM THE SITE, (5) ANY BUGS, VIRUSES, TROJAN HORSES, OR THE LIKE WHICH MAY BE TRANSMITTED TO OR THROUGH THE SITE BY ANY THIRD PARTY, AND/OR (6) ANY ERRORS OR OMISSIONS IN ANY CONTENT AND MATERIALS OR FOR ANY LOSS OR DAMAGE OF ANY KIND INCURRED AS A RESULT OF THE USE OF ANY CONTENT POSTED, TRANSMITTED, OR OTHERWISE MADE AVAILABLE VIA THE SITE. WE DO NOT WARRANT, ENDORSE, GUARANTEE, OR ASSUME RESPONSIBILITY FOR ANY PRODUCT OR SERVICE ADVERTISED OR OFFERED BY A THIRD PARTY THROUGH THE SITE, ANY HYPERLINKED WEBSITE, OR ANY WEBSITE OR MOBILE APPLICATION FEATURED IN ANY BANNER OR OTHER ADVERTISING, AND WE WILL NOT BE A PARTY TO OR IN ANY WAY BE RESPONSIBLE FOR MONITORING ANY TRANSACTION BETWEEN YOU AND ANY THIRD-PARTY PROVIDERS OF PRODUCTS OR SERVICES.\n\nAS WITH THE PURCHASE OF A PRODUCT OR SERVICE THROUGH ANY MEDIUM OR IN ANY ENVIRONMENT, YOU SHOULD USE YOUR BEST JUDGMENT AND EXERCISE CAUTION WHERE APPROPRIATE.\n\nLIMITATIONS OF LIABILITY\n\nIN NO EVENT WILL WE BE LIABLE TO YOU OR ANY THIRD PARTY FOR ANY DIRECT, INDIRECT, CONSEQUENTIAL, EXEMPLARY, INCIDENTAL, SPECIAL, OR PUNITIVE DAMAGES, INCLUDING LOST PROFIT, LOST REVENUE, LOSS OF DATA, OR OTHER DAMAGES ARISING FROM YOUR USE OF THE SITE, EVEN IF WE HAVE BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.\n\nIF THESE LAWS APPLY TO YOU, SOME OR ALL OF THE ABOVE DISCLAIMERS OR LIMITATIONS MAY NOT APPLY TO YOU, AND YOU MAY HAVE ADDITIONAL RIGHTS.]\n\nINDEMNIFICATION\n\nYou agree to defend, indemnify, and hold us harmless, including our subsidiaries, affiliates, and all of our respective officers, agents, partners, and employees, from and against any loss, damage, liability, claim, or demand, including reasonable attorneys’ fees and expenses.\n\nNotwithstanding the foregoing, we reserve the right, at your expense, to assume the exclusive defense and control of any matter for which you are required to indemnify us, and you agree to cooperate, at your expense, with our defense of such claims. We will use reasonable efforts to notify you of any such claim, action, or proceeding which is subject to this indemnification upon becoming aware of it.\n\nUSER DATA\n\nWe will maintain certain data that you transmit to the Site for the purpose of managing the Site, as well as data relating to your use of the Site. Although we perform regular routine backups of data, you are solely responsible for all data that you transmit or that relates to any activity you have undertaken using the Site.\n\nYou agree that we shall have no liability to you for any loss or corruption of any such data, and you hereby waive any right of action against us arising from any such loss or corruption of such data.\n\nELECTRONIC COMMUNICATIONS, TRANSACTIONS, AND SIGNATURES\n\nVisiting the Site, sending us emails, and completing online forms constitute electronic communications. You consent to receive electronic communications, and you agree that all agreements, notices, disclosures, and other communications we provide to you electronically, via email and on the Site, satisfy any legal requirement that such communication be in writing.\n\nYOU HEREBY AGREE TO THE USE OF ELECTRONIC SIGNATURES, CONTRACTS, ORDERS, AND OTHER RECORDS, AND TO ELECTRONIC DELIVERY OF NOTICES, POLICIES, AND RECORDS OF TRANSACTIONS INITIATED OR COMPLETED BY US OR VIA THE SITE.\n\nYou hereby waive any rights or requirements under any statutes, regulations, rules, ordinances, or other laws in any jurisdiction which require an original signature or delivery or retention of non-electronic records, or to payments or the granting of credits by any means other than electronic means.\n\nCALIFORNIA USERS AND RESIDENTS\n\nIf any complaint with us is not satisfactorily resolved, you can contact the Complaint Assistance Unit of the Division of Consumer Services of the California Department of Consumer Affairs in writing at 1625 North Market Blvd., Suite N 112, Sacramento, California 95834 or by telephone at (800) 952-5210 or (916) 445-1254.\n\nMISCELLANEOUS\n\nThese Terms of Service and any policies or operating rules posted by us on the Site constitute the entire agreement and understanding between you and us. Our failure to exercise or enforce any right or provision of these Terms of Service shall not operate as a waiver of such right or provision.\n\nThese Terms of Service operate to the fullest extent permissible by law. We may assign any or all of our rights and obligations to others at any time. We shall not be responsible or liable for any loss, damage, delay, or failure to act caused by any cause beyond our reasonable control.\n\nIf any provision or part of a provision of these Terms of Service is determined to be unlawful, void, or unenforceable, that provision or part of the provision is deemed severable from these Terms of Service and does not affect the validity and enforceability of any remaining provisions.\n\nThere is no joint venture, partnership, employment or agency relationship created between you and us as a result of these Terms of Service or use of the Site. You agree that these Terms of Service will not be construed against us by virtue of having drafted them.\n\nYou hereby waive any and all defenses you may have based on the electronic form of these Terms of Service and the lack of signing by the parties hereto to execute these Terms of Service.\n\nCONTACT US\n\nIn order to resolve a complaint regarding the Site or to receive further information regarding use of the Site, please contact us at: help (at) unfollow-monkey.com"
  },
  {
    "path": "unfollow-monkey-ui/src/App.js",
    "content": "import React from 'react';\n\nimport './style.scss';\n\nimport {Box, Grommet, Heading, Image, Paragraph, Text} from 'grommet';\nimport GithubCorner from \"react-github-corner\";\n\nimport { Faq, Link, MiniApp, Navbar, Section, Repo }  from \"./components\";\nimport * as Images from './images';\n\nconst theme = {\n  global: {\n    font: {\n      family: 'Open Sans',\n    },\n    colors: {\n      doc: '#4c4e6e',\n      dark: '#1a1b46',\n      lightPink: '#ffeeed',\n      brand: '#70B7FD',\n    },\n  },\n  heading: {\n    font: {\n      family: 'Quicksand',\n    },\n    weight: 700,\n  },\n  paragraph: {\n    large: {\n      height: '32px',\n    },\n    medium: {\n      maxWidth: '800px',\n    },\n  },\n};\n\nfunction App() {\n  return (\n      <Grommet theme={theme}>\n        <Section>\n          <Navbar/>\n        </Section>\n        <Section>\n          <Box direction='row' wrap={true} margin={{vertical: 'large'}}>\n            <Box basis='medium' flex={true} pad='medium'>\n              <Heading level={1} color='dark'>Get notified when your Twitter account loses a follower</Heading>\n              <Paragraph size='large'>Unfollow Monkey sends you a direct message as soon as a twitter user unfollows you, blocks you, or leave Twitter, within seconds.</Paragraph>\n\t\t\t  <MiniApp/>\n            </Box>\n            <Box basis='medium' flex={true} pad='medium'>\n              <Image width='100%' title='Example of notification' src={Images.DmScreenshot}/>\n            </Box>\n          </Box>\n        </Section>\n        <Section background='lightPink' sloped={true}>\n          <Box direction='row' wrap={true} align='center' justify='center'>\n            <Box direction='row' basis='300px' flex='shrink' pad='medium' style={{maxWidth: '50vw'}}>\n              <Image title='dog playing' fit='contain' src={Images.Dog}/>\n            </Box>\n            <Box basis='medium' flex={true} pad='medium' >\n              <Heading level={2} color='dark'>Unfollow Monkey is free for everyone</Heading>\n              <Paragraph>Unfollow Monkey is based on the <Link href='https://github.com/PLhery/unfollowNinja'>open-source</Link> project <Link href='https://unfollow.ninja'>unfollowNinja</Link>, hosted by <Link href='https://pulseheberg.com/'>PulseHeberg</Link> & <Link href='https://startup.ovhcloud.com/fr/'>OVHCloud startup program</Link> and maintained by <Link href='https://twitter.com/plhery'>@plhery</Link>.</Paragraph>\n              <Paragraph>Thanks to PulseHeberg for helping the project to remain substainable, efficient, free, supporting 300 000+ users. They also provide <Link href='https://pulseheberg.com/'>great and affordable servers and web hosting solutions</Link>, if you'd like to have a look!</Paragraph>\n\t\t\t  <Paragraph>UnfollowMonkey is powered by the <i>twitter-api-v2</i> node library, by the same author.</Paragraph>\n\t\t\t  <Box gap='small' alignSelf='center' direction='row'>\n\t\t\t\t<Repo title='unfollowNinja' description='Get notified when your Twitter account loses a follower.' stars={186} forks={22}/>\n\t\t\t\t<Repo title='node-twitter-api-v2' description='Strongly typed, full-featured, light, versatile yet powerful Twitter API v1.1 and v2 client for Node.js.' stars={723} forks={88}/>\n              </Box>\n            </Box>\n          </Box>\n        </Section>\n        <Section background={`url(${Images.useAlaska()})`}>\n          <Faq/>\n        </Section>\n        <Section>\n          <Box direction='row' align='center' alignSelf='center' gap='small'>\n            <Image title='logo' height={30} src={Images.Logo}/>\n            <Text size='small' textAlign='center' style={{fontFamily: 'quicksand'}}>\n              © 2020 UnfollowMonkey · <Link href='/tos.txt'>TOS</Link> · <Link href='/privacy-policy.txt'>Privacy</Link> ·\n              Discover also <Link href='https://unfollow.ninja/?utm_source=unfollowmonkey_footer'><Image title='unfollowNinja' height={21} src={Images.UnfollowNinja}/></Link> UnfollowNinja <Link href='https://uzzy.me/en?utm_source=unfollowmonkey'><Image title='uzzy' src={Images.Uzzy} height={18}/></Link> Uzzy and <Link href='https://affinitweet.com/?utm_source=unfollowmonkey'><Image title='affinitweet' src={Images.Affinitweet} height={18}/></Link> Affinitweet ·\n              Made with ♥ by <Link href='https://twitter.com/plhery'>@plhery</Link> · Available on <Link href='https://github.com/PLhery/unfollowNinja'>GitHub</Link>\n            </Text>\n          </Box>\n        </Section>\n        <GithubCorner href=\"https://github.com/PLhery/unfollowNinja\" bannerColor=\"#b742a0\"/>\n      </Grommet>\n  );\n}\n\nexport default App;\n"
  },
  {
    "path": "unfollow-monkey-ui/src/components/Faq.js",
    "content": "import React from 'react';\nimport { Box, Heading, Paragraph } from \"grommet/es6\";\nimport Emojis from '../twemojis/Emojis';\nimport Styles from './Faq.module.scss';\n\nfunction Faq(props) {\n    return <Box alignSelf='center' pad='medium' margin='medium' className={Styles.container} {...props}>\n            <Heading level={1} color='dark'>Frequently Asked Questions</Heading>\n\n            <Heading level={3} color='dark'>A friend of mine unfollowed me, but I wasn't told</Heading>\n            <Paragraph>To avoid disturbing you too often, several filters apply to the notifications sent.\n                To be sure to get the notification, the person must have followed you for 24 hours and unfollow 20 minutes.</Paragraph>\n\n            <Heading level={3} color='dark'>Will you publish tweets without my consent?</Heading>\n            <Paragraph>We will never publish a tweet without your agreement! Only the DM account gives permission to send tweets.\n                This is due to the way Twitter permissions work: there are only 3 sets of permissions, and we can't ask permission to send DMs without permission to send tweets.\n                You can create a separate Twitter account dedicated to sending these messages if you wish.</Paragraph>\n\n            <Heading level={3} color='dark'>Why does step 2 require so many permissions?</Heading>\n            <Paragraph>As described above, this is due to the way Twitter permissions work: there are only 3 sets of permissions, and we can't request permission to send DMs without the others.\n                We have never extracted these tokens to use them other than in this open-source application.\n                You can create a separate Twitter account dedicated to sending these messages if you wish, for more serenity. A possibility of chrome notifications is under consideration :).\n            </Paragraph>\n\n            <Heading level={3} color='dark'>What do the different messages and emojis mean?</Heading>\n            <ul>\n                <li>The following messages speak for themselves:<ul>\n                    <li><b>@username</b> unfollowed you <Emojis.WavingHand/></li>\n                    <li><b>@username</b> has been suspended <Emojis.SeeNoEvil/></li>\n                    <li><b>@username</b> blocked you <Emojis.NoEntry/></li>\n                    <li>You have blocked <b>@username</b> <Emojis.Poo/></li>\n                </ul></li>\n                <li><b>@username</b> has left Twitter <Emojis.SeeNoEvil/> may mean that the person has been suspended for a few minutes, closed their account, or has been removed from Twitter for example because of the age limit.</li>\n                <li>The emoji is a broken hear <Emojis.BrokenHeart/> if this person is a mutual, a person that you follow.</li>\n                <li>If more than 20 twittos unfollow you in less than two minutes, you will only be informed of the first 20, as well as the total number of lost followers.</li>\n                <li>\"A twitto has left Twitter <Emojis.SeeNoEvil/>\": when the username of the person who closed his account was not saved (may take 48 hours), you don't receive their username, but are informed of this lost follower.</li>\n                <li>\"This account followed you before you signed up to <b>@unfollowmonkey</b>!\" : we can't always find the exact follow date of each unfollower.\n                    If we can't find it, we give you the first time we saw it on your account. However, if they were already following you when you subscribed, we can't give you any date.</li>\n            </ul>\n        </Box>;\n}\nexport default Faq;"
  },
  {
    "path": "unfollow-monkey-ui/src/components/Faq.module.scss",
    "content": ".container {\n  background-color: rgba(255, 255, 255, 0.5);\n  p, ul {\n    text-align: justify;\n  }\n  h3 {\n    margin-bottom: 0;\n    max-width: 630px;\n  }\n  ul {\n    max-width: 760px;\n  }\n}\n"
  },
  {
    "path": "unfollow-monkey-ui/src/components/Link.js",
    "content": "import React from 'react';\n\nconst Link = (props) => (\n    <a target='_blank' rel='noopener noreferrer' style={{color: 'inherit', fontWeight: 600}} {...props} >\n      {props.children}\n    </a>\n);\nexport default Link;\n"
  },
  {
    "path": "unfollow-monkey-ui/src/components/MiniApp/LanguageSelector.js",
    "content": "import React from 'react';\nimport {Paragraph, Select} from \"grommet\";\n\nimport Styles from './LanguageSelector.module.scss';\n\nimport * as Flags from '../../images/flags'\nimport Link from \"../Link\";\nimport { Link as IconLink } from 'grommet-icons';\n\n\nfunction LanguageSelector(props) {\n  const LANGUAGES = [\n    {label: 'Arabic', code: 'ar'},\n\t{label: 'Chinese', code: 'zh_Hans'},\n\t{label: 'Dutch', code: 'nl'},\n\t{label: 'English', code: 'en'},\n\t{label: 'French', code: 'fr'},\n\t{label: 'German', code: 'de'},\n\t{label: 'Indonesian', code: 'id'},\n\t{label: 'Polish', code: 'pl'},\n  \t{label: 'Portuguese', code: 'pt'},\n    {label: 'Portug (br)', code: 'pt_BR'},\n\t{label: 'Spanish', code: 'es'},\n\t{label: 'Thai', code: 'th'},\n\t{label: 'Turkish', code: 'tr'},\n\t{label: 'Ukrainian', code: 'uk'},\n\t{label: 'Tamazight', code: 'zgh'},\n  ];\n\n  const addYours = <span><Link href='https://hosted.weblate.org/projects/unfollow-monkey/notifications/'><IconLink size='small'/> Add yours</Link></span>\n\n  const options = LANGUAGES.map((lang) =>\n\t<span className={Styles.element}><img alt={'flag-' + lang.code} className={Styles.flag} src={Flags[lang.code]}/> {lang.label}</span>);\n\n  options.push(addYours);\n\n  return <Paragraph>\n\tReceive your notifications in:\n\t<span className={Styles.languageSelector}>\n\t\t<Select\n\t\t  options={options}\n\t\t  size='medium'\n\t\t  value={options[LANGUAGES.findIndex((lang) => lang.code === props.value)]}\n\t\t  onChange={(e) => props.onChange(LANGUAGES[e.selected].code)}\n\t\t  disabled={[addYours]}\n\t\t>\n\t\t{(option) => option}\n\t  </Select>\n\t</span>\n  </Paragraph>\n}\n\nexport default LanguageSelector;\n"
  },
  {
    "path": "unfollow-monkey-ui/src/components/MiniApp/LanguageSelector.module.scss",
    "content": ".languageSelector {\n  padding-left: 10px;\n}\n\ndiv[role=menubar] button:disabled {\n  opacity: 0.8;\n\n  a {\n    svg {\n      margin-left: 11px;\n      margin-right: 5px;\n    }\n    text-decoration: none;\n    font-weight: inherit!important;\n  }\n}\n\n.element {\n  display: flex;\n  align-items: center;\n\n  .flag {\n    height: 17px;\n    width: 23px;\n    margin: 0 5px;\n    box-shadow: rgb(204, 204, 204) 1px 1px 3px;\n  }\n}\n"
  },
  {
    "path": "unfollow-monkey-ui/src/components/MiniApp/ProCard.js",
    "content": "import React, {useState} from 'react';\nimport {Card, Paragraph, TextInput} from \"grommet\";\n\nimport Styles from './ProCard.module.scss';\n// import {applePay, googlePay, mastercard} from \"../../images\";\nimport { API_URL } from \"../MiniApp\";\nimport Link from \"../Link\";\n\nfunction ProCard(props) {\n  const { user, setUserInfo, setHasError } = props;\n\n  const [isWrongCode, setIsWrongCode] = useState(false);\n\n  const codeChanged = (event) => {\n\tconst newCode = event.target.value;\n\tif (isWrongCode) {\n\t  setIsWrongCode(false);\n\t}\n\tif (newCode.length !== 6) {\n\t  return;\n\t}\n\tfetch(API_URL + '/user/registerFriendCode', {\n\t  method: 'put',\n\t  credentials: 'include',\n\t  headers: { 'Content-Type': 'application/json' },\n\t  body: JSON.stringify({code: newCode.toUpperCase()})\n\t})\n\t  .then(response => {\n\t\tconsole.log(response);\n\t\tif (response.ok) {\n\t\t  setUserInfo({\n\t\t\t...user,\n\t\t\tisPro: true,\n\t\t  });\n\t\t} else if (response.status === 404) {\n\t\t  setIsWrongCode(true);\n\t  \t} else {\n\t\t  return Promise.reject();\n\t\t}\n\t  })\n\t  .catch((e) => {\n\t\tsetHasError(true);\n\t  });\n\twindow.$crisp?.push(['set', 'session:event', [[['tryCode', { code: newCode}]]]]);\n  }\n\n  if (user.isPro) {\n\treturn <Card className={Styles.checkoutCard}>\n\t  <Paragraph>\n\t\tCongratulations, you can now enjoy UMonkey <b className={Styles.pro}>pro</b><br/>\n\t\tYou will be notified in <b>30sec</b> instead of 30min 🚀<br/>\n\t\t{user.hasSubscription && <Link href={`${API_URL}/user/manage-subscription`} className={Styles.subscriptionLink}>Manage subscription</Link>}\n\t  </Paragraph>\n\t  { user.friendCodes && <>\n\t\t<Paragraph margin={{bottom: 'none'}}>You can also share these codes, thx to the <b className={Styles.pro}>friends</b> plan:</Paragraph>\n\t\t<ul style={{marginTop: 0}}>\n\t\t{\n\t\t  user.friendCodes?.map(code =>\n\t\t\t<li><b>{code.code}</b> - {\n\t\t\t  code.friendUsername ?\n\t\t\t\t<i>used by <b>@{code.friendUsername}</b>{/*<Button plain={true} icon={<Trash size='small'/>}/>*/}</i> :\n\t\t\t\t<i>not used yet</i>\n\t\t\t}</li>\n\t\t  )\n\t\t}\n\t\t</ul>\n\t  </>}\n\t</Card>\n  } else {\n\treturn <Card className={Styles.checkoutCard}>\n\t\t<Paragraph>\n\t\t\tYou currently receive your notifications in <b>1 hour</b><br/>\n\t\t\tBecome <b className={Styles.pro}>pro</b> to be notified in <b>30 seconds</b>!<br/>\n\t\t</Paragraph>\n\t\t<Paragraph>(Not available right now)</Paragraph>\n\t\t{/*<Paragraph>Valid on <b>5 Twitter accounts</b> - {user.priceTags.friends}/year</Paragraph>\n\t\t<small>10 days trial - easily cancel online</small>\n\t\t<Button\n\t\t  className={Styles.checkoutButton}\n\t\t  href={`${API_URL}/user/buy-friends`}\n\t\t  label={<>\n\t\t  <Image className={Styles.googlePayIcon} src={googlePay} alt='Google pay'/>\n\t\t  <Image height={26} src={applePay} alt='Apple pay'/>\n\t\t  <Image height={26} src={mastercard} alt='Mastercard'/>\n\t\t  <span>Pay with Stripe</span>\n\t\t</>}/>*/}\n\t\t<div className={Styles.friendCodeLine + (isWrongCode ? ' ' + Styles.error : '')}>\n\t\t  <Paragraph>Use a friend code:</Paragraph><TextInput maxLength={6} onChange={codeChanged}/>\n\t\t</div>\n\t</Card>\n  }\n\n}\n\nexport default ProCard;"
  },
  {
    "path": "unfollow-monkey-ui/src/components/MiniApp/ProCard.module.scss",
    "content": ".pro {\n  color: #b742a0;\n}\n\n.googlePayIcon {\n  height: 26px;\n  margin-right: 5px;\n}\n\n.checkoutCard {\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n}\n\n.checkoutButton {\n  padding-left: 5px!important;\n  margin-top: 15px!important;\n}\n\n.subscriptionLink {\n  font-size: 15px;\n}\n\n.friendCodeLine {\n  p{\n    display: inline-block;\n    margin-right: 10px;\n  }\n  div { // input container\n    display: inline;\n  }\n  input {\n    padding: 3px;\n    width: 5em;\n  }\n}\n\n.friendCodeLine.error {\n  color: red;\n  input {\n    border-color: red;\n  }\n}"
  },
  {
    "path": "unfollow-monkey-ui/src/components/MiniApp.js",
    "content": "import React, {useEffect, useState} from 'react';\nimport {\n\tAccordion,\n\tAccordionPanel,\n\tBox,\n\tButton,\n\tParagraph,\n\tSpinner,\n\tTable,\n\tTableBody,\n\tTableCell, TableHeader,\n\tTableRow\n} from \"grommet\";\nimport {Alert, Twitter, ChatOption, UserExpert, Validate} from \"grommet-icons\";\nimport Confetti from 'react-dom-confetti';\n\nimport Styles from './MiniApp.module.scss';\nimport Link from \"./Link\";\nimport LanguageSelector from \"./MiniApp/LanguageSelector\";\nimport ProCard from \"./MiniApp/ProCard\";\n\nexport const API_URL = 'https://api.unfollow-monkey.com';\n\nconst LoggedInIntro = ({ user, logout, removeDMs, changeLang, setUserInfo, setHasError }) => {\n    if (!user?.username) return null; // not logged in\n\n    let message = <Paragraph>One more step to activate the service:<br/> Choose an account to send you notifications</Paragraph>;\n\tif (user.category === 2) { // revoked tokens\n\t  \tmessage = <Paragraph>It seems that your Twitter account has been deactivated:<br/> click on \"Enable DM notifications\" to reactivate the service.</Paragraph>;\n\t}\n    if (user.dmUsername) {\n        message = <Paragraph>All clear! Don't forget to follow <Link href='https://twitter.com/unfollowmonkey' source='logged-in-intro'>@unfollowMonkey</Link></Paragraph>;\n    }\n    if (user.dmUsername && user.dmUsername !== user.username) {\n        message = <Paragraph>All clear, <b>@{user.dmUsername}</b> will notify you by DM!\n            Don't forget to follow <Link href='https://twitter.com/unfollowmonkey' source='logged-in-intro'>@unfollowMonkey</Link></Paragraph>;\n    }\n    return <div className={Styles.loggedInDetails}>\n        <Paragraph>{user.dmUsername && <Validate color='neutral-1' className={Styles.centerIcon}/>} Welcome, <b>@{user.username}</b>!</Paragraph>\n        {message}\n\t  {user.dmUsername && <LanguageSelector value={user.lang} onChange={changeLang}/> }\n\t  {user.dmUsername && <ProCard user={user} setUserInfo={setUserInfo} setHasError={setHasError}/> }\n\t  {user.dmUsername && <Paragraph><small>Any issue with your pro subscription? <br/> Email us: <i>love@unfollow-monkey.com</i></small></Paragraph> }\n\t\t{user.dmUsername && <Paragraph><small>You don't receive your DMs? Read <Link href={'https://twitter.com/UnfollowMonkey/status/1589955137480818688'}>this thread</Link></small></Paragraph> }\n\n        <Paragraph>\n            <Link href='#' onClick={e => {logout();e.preventDefault();}} source='disconnect'>Log out</Link>\n            {user.dmUsername && <> — <Link href='#' onClick={e => {removeDMs();e.preventDefault();}} source='disable'>Disable the service</Link></>}\n        </Paragraph>\n    </div>\n};\n\n/**\n * Adds html newlines, and bold twitter usernames\n */\nfunction formatMessage(message) {\n\tconst result = [];\n\tconst parts = message.split(/(@\\w{1,15})/g);\n\tfor(let i = 0; i < parts.length; i+=2) {\n\t\tresult.push(parts[i].split('\\n').map(line => <>{line}<br/></>));\n\t\tresult.push(<b>{parts[i+1]}</b>);\n\t}\n\treturn result;\n}\n\nconst MessagesAccordion = (latestMessages) => {\n\treturn <Accordion>\n\t\t<AccordionPanel label=\"Last notifications sent\">\n\t\t\t<Table>\n\t\t\t\t<TableHeader>\n\t\t\t\t\t<TableRow>\n\t\t\t\t\t\t<TableCell scope=\"col\" border=\"bottom\">\n\t\t\t\t\t\t\tDate and Time\n\t\t\t\t\t\t</TableCell>\n\t\t\t\t\t\t<TableCell scope=\"col\" border=\"bottom\">\n\t\t\t\t\t\t\tMessage\n\t\t\t\t\t\t</TableCell>\n\t\t\t\t\t</TableRow>\n\t\t\t\t</TableHeader>\n\t\t\t\t<TableBody>\n\t\t\t\t\t{latestMessages ? latestMessages.map((message) =>\n\t\t\t\t\t\t<TableRow>\n\t\t\t\t\t\t\t<TableCell>\n\t\t\t\t\t\t\t\t<i>{new Date(message.sentAt).toLocaleString()}</i>\n\t\t\t\t\t\t\t</TableCell>\n\t\t\t\t\t\t\t<TableCell>{formatMessage(message.message)}</TableCell>\n\t\t\t\t\t\t</TableRow>\n\t\t\t\t\t) : <TableRow>\n\t\t\t\t\t\t<TableCell className={Styles.spinnerCell}>\n\t\t\t\t\t\t\t<Spinner />\n\t\t\t\t\t\t</TableCell>\n\t\t\t\t\t\t<TableCell><i>Chargement des messages...</i></TableCell>\n\t\t\t\t\t</TableRow>}\n\t\t\t\t\t{latestMessages?.length === 0 ?\n\t\t\t\t\t\t<TableRow>\n\t\t\t\t\t\t\t<TableCell>\n\t\t\t\t\t\t\t</TableCell>\n\t\t\t\t\t\t\t<TableCell><i>Aucune notification envoyée. Revenez plus tard!</i></TableCell>\n\t\t\t\t\t\t</TableRow> : null}\n\t\t\t\t</TableBody>\n\t\t\t</Table>\n\t\t</AccordionPanel>\n\t</Accordion>;\n}\n\nfunction MiniApp(props) {\n  const [userInfo, setUserInfo] = useState(null);\n  const [latestMessages, setLatestMessages] = useState(null);\n  const [hasError, setHasError] = useState(false);\n\n  // persist the userInfo in sessionStorage\n  useEffect(() => { userInfo && sessionStorage.setItem('userInfo', JSON.stringify(userInfo)) }, [userInfo]);\n\n  useEffect(() => {\n\tif (navigator.userAgent !== \"ReactSnap\") { // We don't want to risk hasError=true on ReactSnap\n\t  // first: load userInfo from the sessionStorage\n\t  const storedUserInfo = JSON.parse(sessionStorage.getItem('userInfo'));\n\t  if (storedUserInfo?.username) { // logged in user\n\t\tsetUserInfo(storedUserInfo);\n\t  }\n\n\t  // second: load it more accurately from the server\n\t  fetch(API_URL + '/get-status', {credentials: 'include'})\n\t\t.then(response => response.ok ? response.json() : null)\n\t\t.then(data => data || Promise.reject())\n\t\t.then(data => setUserInfo(data))\n\t\t.catch(() => {\n\t\t  setHasError(true)\n\t\t});\n\t}\n  }, []);\n\n\tuseEffect(() => {\n\t\tif (userInfo?.username) {\n\t\t\tfetch(API_URL + '/user/latest-notifications', {credentials: 'include'})\n\t\t\t\t.then(response => response.ok ? response.json() : null)\n\t\t\t\t.then(data => data || Promise.reject())\n\t\t\t\t.then(data => setLatestMessages(data))\n\t\t\t\t.catch((error) => {\n\t\t\t\t\tsetLatestMessages(null);\n\t\t\t\t\tconsole.error(error);\n\t\t\t\t})\n\t\t}\n\t}, [userInfo?.username]);\n\n  const logout = () => {\n\tsetUserInfo({});\n    fetch(API_URL + '/user/logout', {method: 'post',credentials: 'include'})\n\t  .then(response => response.ok || Promise.reject())\n\t  .catch(() => {\n\t    setUserInfo(userInfo);\n\t    setHasError(true)\n\t  })\n  };\n  const removeDMs = () => {\n\tsetUserInfo({\n\t  ...userInfo,\n\t  dmUsername: null,\n\t  category: 3, // disabled\n\t});\n\tfetch(API_URL + '/user/disable', {method: 'post',credentials: 'include'})\n\t  .then(response => response.ok || Promise.reject())\n\t  .catch(() => {\n\t\tsetUserInfo(userInfo);\n\t\tsetHasError(true)\n\t  });\n  };\n  const changeLang = (newLang) => {\n\tsetUserInfo({\n\t  ...userInfo,\n\t  lang: newLang,\n\t});\n\tfetch(API_URL + '/user/lang', {\n\t  method: 'put',\n\t  credentials: 'include',\n\t  headers: { 'Content-Type': 'application/json' },\n\t  body: JSON.stringify({lang: newLang})\n\t})\n\t  .then(response => response.ok || Promise.reject())\n\t  .catch(() => {\n\t\tsetUserInfo(userInfo);\n\t\tsetHasError(true)\n\t  });\n  }\n  // Listen and process postmessages from the API\n  // (these are sent in the log in callback page)\n  useEffect(() => {\n    const processMessage = (event) => {\n      if (event.origin !== API_URL) {\n\t\treturn;\n\t  }\n      const content = JSON.parse(decodeURI(event.data.content));\n\t  setUserInfo(content);\n\t}\n\n\twindow.addEventListener('message', processMessage);\n    return function cleanup() {\n      window.removeEventListener('message', processMessage);\n\t};\n  }, [])\n\n  const step0 = !userInfo?.username; // not logged in\n  const step1 = userInfo?.username && !userInfo.dmUsername; // logged in but no DM account\n  const step2 = !!userInfo?.dmUsername; // logged in and have a DM account\n\n  return (\n      <Box gap='small' margin={{horizontal: 'small', vertical: 'medium'}} {...props}>\n        {hasError ?\n\t\t  <Paragraph textAlign='center'><Alert/><br/>Unable to reach the server, try again later...</Paragraph> :\n\t\t  <>\n\t\t\t<Confetti active={step2} className={Styles.confettis}/>\n\t\t\t<LoggedInIntro {...{user: userInfo, setUserInfo, changeLang, removeDMs, logout, setHasError}}/>\n\t\t    {userInfo?.username ? MessagesAccordion(latestMessages) : null}\n\t\t  </>\n        }\n        <Button\n            icon={<Twitter color={step0 ? 'white' : null}/>}\n            label='Login to your account'\n            primary={step0}\n            style={step0 ? {color: 'white'} : {}}\n            disabled={!step0 || hasError}\n            href={`${API_URL}/auth/step-1`}\n\t\t\ttarget='_blank'\n\t\t\trel='opener'\n        />\n        <Button\n            icon={<ChatOption color={step1 ? 'white' : null}/>}\n            label={step2 ? 'Change the account that sends the DMs' : 'Enable DM notifications'}\n            primary={step1}\n            style={step1 ? {color: 'white'} : {}}\n            disabled={step0 || hasError}\n\t\t\thref={(step0 || hasError) ? null : (step2 ? `${API_URL}/auth/step-2?force_login=true` : `${API_URL}/auth/step-2`)}\n\t\t\ttarget='_blank'\n\t\t\trel='opener'\n        />\n        <Button\n            icon={<UserExpert color={step2 ? 'white' : null}/>}\n            label={'Follow @UnfollowMonkey'}\n            primary={step2}\n            style={step2 ? {color: 'white'} : {}}\n            href='https://twitter.com/unfollowmonkey'\n            target='_blank'\n            rel='noopener'\n        />\n      </Box>\n  );\n}\n\nexport default MiniApp;\n"
  },
  {
    "path": "unfollow-monkey-ui/src/components/MiniApp.module.scss",
    "content": ".centerIcon {\n  vertical-align: sub;\n}\n\n.confettis {\n  margin: 0 auto;\n}\n\n.loggedInDetails {\n  text-align: center;\n}"
  },
  {
    "path": "unfollow-monkey-ui/src/components/Navbar.js",
    "content": "import React from 'react';\nimport {Box, Heading, Image} from \"grommet/es6\";\nimport * as Images from \"../images\";\nimport Styles from './Navbar.module.scss';\nimport Link from \"./Link\";\n\nconst Navbar = (props) => (\n    <header className={Styles.navbar} {...props}>\n      <Link href='https://twitter.com/unfollowMonkey' source='navbar'>\n        <Box\n            direction='row'\n            pad={{horizontal: 'medium', vertical: 'small'}}\n        >\n\t\t  <div className={Styles.logoContainer}>\n            <Image className={Styles.logo} title='logo' margin={{horizontal: 'xsmall'}} src={Images.Logo}/>\n\t\t  </div>\n\t\t  <Image className={Styles.logoHands} title='logo-hands' margin={{horizontal: 'xsmall'}} src={Images.UnfollowMonkeyHands}/>\n\t\t  <Heading level={4} color='dark' margin={{vertical: 'small'}} style={{fontWeight: 500}}>UnfollowMonkey</Heading>\n        </Box>\n      </Link>\n    </header>\n);\nexport default Navbar;"
  },
  {
    "path": "unfollow-monkey-ui/src/components/Navbar.module.scss",
    "content": ".navbar {\n  a {\n    text-decoration: none;\n  }\n\n  transition: margin .2s ease;\n\n  .logoContainer {\n    overflow: hidden;\n    position: absolute;\n    .logo {\n      transition: margin .6s ease;\n      height: 40px;\n    }\n  }\n\n  .logoHands {\n    height: 40px;\n    box-shadow: 0 0 0 1px #ffffff; // better hide background logo during shakes\n    z-index: 1;\n    pointer-events: none;\n  }\n\n  &:hover {\n    margin-top: -5px;\n    margin-bottom: 5px;\n\n    .logo {\n      margin-top: 7px;\n      margin-bottom: -7px;\n\n      animation: shake 0.4s;\n      animation-iteration-count: infinite;\n    }\n  }\n}\n\n@keyframes shake {\n  0% { transform: rotate(-2deg)}\n  50% { transform: rotate(2deg) }\n  100% { transform: rotate(-2deg)}\n}"
  },
  {
    "path": "unfollow-monkey-ui/src/components/Repo.js",
    "content": "import React from 'react';\nimport Styles from './Repo.module.scss';\nimport Link from \"./Link\";\n\nfunction Repo(props) {\n  const {title, description, stars, forks} = props;\n    return <div className={Styles.card}>\n\t\t<div className={Styles.section}>\n\t\t  <svg className={Styles.svg} style={{marginRight: 8}} viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" height=\"16\"\n\t\t\t   aria-hidden=\"true\">\n\t\t\t<path fillRule=\"evenodd\"\n  d=\"M2 2.5A2.5 2.5 0 014.5 0h8.75a.75.75 0 01.75.75v12.5a.75.75 0 01-.75.75h-2.5a.75.75 0 110-1.5h1.75v-2h-8a1 1 0 00-.714 1.7.75.75 0 01-1.072 1.05A2.495 2.495 0 012 11.5v-9zm10.5-1V9h-8c-.356 0-.694.074-1 .208V2.5a1 1 0 011-1h8zM5 12.25v3.25a.25.25 0 00.4.2l1.45-1.087a.25.25 0 01.3 0L8.6 15.7a.25.25 0 00.4-.2v-3.25a.25.25 0 00-.25-.25h-3.5a.25.25 0 00-.25.25z\"/>\n\t\t  </svg>\n\t\t  <span>\n          \t<Link className={Styles.title} href={\"https://github.com/PLhery/\" + title}>{title}</Link>\n\t\t  </span>\n\t\t</div>\n\t\t<div className={Styles.description}>{description}</div>\n\t\t<div className={Styles.footer}>\n\t\t  <div style={{marginRight: 16}}>\n\t\t\t<span className={Styles.languageName}/>\n\t\t\t<span>TypeScript</span>\n\t\t  </div>\n\t\t  <div  className={Styles.section} style={{marginRight: 16}}>\n\t\t\t<svg className={Styles.svg} aria-label=\"stars\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" height=\"16\"\n\t\t\t\t role=\"img\">\n\t\t\t  <path fillRule=\"evenodd\" d=\"M8 .25a.75.75 0 01.673.418l1.882 3.815 4.21.612a.75.75 0 01.416 1.279l-3.046 2.97.719 4.192a.75.75 0 01-1.088.791L8 12.347l-3.766 1.98a.75.75 0 01-1.088-.79l.72-4.194L.818 6.374a.75.75 0 01.416-1.28l4.21-.611L7.327.668A.75.75 0 018 .25zm0 2.445L6.615 5.5a.75.75 0 01-.564.41l-3.097.45 2.24 2.184a.75.75 0 01.216.664l-.528 3.084 2.769-1.456a.75.75 0 01.698 0l2.77 1.456-.53-3.084a.75.75 0 01.216-.664l2.24-2.183-3.096-.45a.75.75 0 01-.564-.41L8 2.694v.001z\"/>\n\t\t\t</svg>\n\t\t\t&nbsp; <span>{stars}</span>\n\t\t  </div>\n\t\t  <div  className={Styles.section}>\n\t\t\t<svg className={Styles.svg} aria-label=\"fork\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" height=\"16\"\n\t\t\t\t role=\"img\">\n\t\t\t  <path fillRule=\"evenodd\" d=\"M5 3.25a.75.75 0 11-1.5 0 .75.75 0 011.5 0zm0 2.122a2.25 2.25 0 10-1.5 0v.878A2.25 2.25 0 005.75 8.5h1.5v2.128a2.251 2.251 0 101.5 0V8.5h1.5a2.25 2.25 0 002.25-2.25v-.878a2.25 2.25 0 10-1.5 0v.878a.75.75 0 01-.75.75h-4.5A.75.75 0 015 6.25v-.878zm3.75 7.378a.75.75 0 11-1.5 0 .75.75 0 011.5 0zm3-8.75a.75.75 0 100-1.5.75.75 0 000 1.5z\"/>\n\t\t\t</svg>\n\t\t\t&nbsp; <span>{forks}</span>\n\t\t  </div>\n\t\t</div>\n\t  </div>;\n}\nexport default Repo;"
  },
  {
    "path": "unfollow-monkey-ui/src/components/Repo.module.scss",
    "content": ".card {\n  font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji;\n  border: 1px solid #e1e4e8;\n  border-radius: 6px;\n  background: white;\n  padding: 16px;\n  font-size: 14px;\n  line-height: 1.5;\n  color: #24292e;\n  max-width: 300px;\n}\n\n\n.section {\n  display: flex;\n  align-items: center\n}\n\n.title {\n  text-decoration: none;\n  font-weight: 600;\n  color: #0366d6!important;\n}\n\n.description {\n  font-size: 12px;\n  margin-bottom: 16px;\n  margin-top: 8px;\n  color: #586069;\n}\n\n.footer {\n  font-size: 12px;\n  color: #586069;\n  display: flex;\n}\n.languageName {\n  width: 12px;\n  height: 12px;\n  border-radius: 100%;\n  background-color: #2b7489;\n  display: inline-block; top: 1px;\n  position: relative;\n}\n\n.svg {\n  fill: #586069;\n}"
  },
  {
    "path": "unfollow-monkey-ui/src/components/Section.js",
    "content": "import React from 'react';\nimport {Box} from \"grommet/es6\";\nimport Styles from './Section.module.scss';\n\nconst Section = (props) => (\n    <Box align='center' className={props.sloped ? Styles.sloped : ''} {...props}>\n      <Box width='xlarge'>\n        {props.children}\n      </Box>\n    </Box>\n);\nexport default Section;"
  },
  {
    "path": "unfollow-monkey-ui/src/components/Section.module.scss",
    "content": ".sloped {\n  padding-top:80px!important;\n  clip-path: polygon(\n                  0 0,\n                  100% 80px,\n                  100% 100%,\n                  0 100%\n  );\n}"
  },
  {
    "path": "unfollow-monkey-ui/src/components/index.js",
    "content": "export {default as Faq} from './Faq';\nexport {default as Link} from './Link';\nexport {default as MiniApp} from './MiniApp';\nexport {default as Navbar} from './Navbar';\nexport {default as Repo} from './Repo';\nexport {default as Section} from './Section';"
  },
  {
    "path": "unfollow-monkey-ui/src/images/flags/index.js",
    "content": "export {default as fr} from './fr.svg';\nexport {default as en} from './en.svg';\nexport {default as es} from './es.svg';\nexport {default as pt} from './pt.svg';\nexport {default as id} from './id.svg';\nexport {default as de} from './de.svg';\nexport {default as th} from './th.svg';\nexport {default as pl} from './pl.svg';\nexport {default as zh_Hans} from './cn.svg';\nexport {default as nl} from './nl.svg';\nexport {default as tr} from './tr.svg';\nexport {default as uk} from './ua.svg';\nexport {default as pt_BR} from './br.svg';\nexport {default as ar} from './eg.svg';\nexport {default as zgh} from './ma.svg';\n"
  },
  {
    "path": "unfollow-monkey-ui/src/images/index.js",
    "content": "import { useState, useEffect } from 'react';\nimport Alaska from './alaska.jpg';\nimport AlaskaWebp from './alaska.webp';\n\nexport {default as Dog} from './dog.svg'\nexport {default as Logo}from './logo.svg';\nexport {default as DmScreenshot} from './dmscreenshot.png'\nexport {default as Affinitweet} from './affinitweet.png'\nexport {default as Uzzy} from './uzzy.svg'\nexport {default as UnfollowNinja} from './unfollowninja.svg'\nexport {default as UnfollowMonkeyHands} from './unfollowmonkey-hands.svg'\nexport {default as applePay} from './apple-pay.svg'\nexport {default as googlePay} from './google-pay.svg'\nexport {default as mastercard} from './mastercard.svg'\n\nexport function useAlaska() { // react hook to get the right alaska image url\n    const [supportsWebP, setWebP] = useState(null); // true if supports, otherwise false\n    useEffect(() => {\n        const img = new window.Image();\n        img.onload = () => setWebP((img.width > 0) && (img.height > 0));\n        img.onerror = () => setWebP(false);\n        img.src = 'data:image/webp;base64,UklGRiIAAABXRUJQVlA4IBYAAAAwAQCdASoBAAEADsD+JaQAA3AAAAAA';\n    }, []);\n    if (supportsWebP === true) {\n        return AlaskaWebp;\n    } else if (supportsWebP === false) {\n        return Alaska;\n    } else {\n        return null;\n    }\n}\n\n"
  },
  {
    "path": "unfollow-monkey-ui/src/index.js",
    "content": "import React from 'react';\nimport { hydrate, render } from \"react-dom\";\nimport App from './App';\nimport * as Sentry from '@sentry/browser';\nimport * as serviceWorker from './serviceWorkerRegistration';\n\nconst DSN = process.env.REACT_APP_SENTRY_DSN;\nif (DSN) {\n  Sentry.init({dsn: DSN});\n}\n\nconst rootElement = document.getElementById(\"root\");\nif (rootElement.hasChildNodes()) {\n  hydrate(<App />, rootElement);\n} else {\n  render(<App />, rootElement);\n}\n\n// ReactDOM.render(<App />, document.getElementById('root'));\n\n// If you want your app to work offline and load faster, you can change\n// unregister() to register() below. Note this comes with some pitfalls.\n// Learn more about service workers: https://bit.ly/CRA-PWA\nserviceWorker.unregister();\n/*serviceWorker.register({\n  onUpdate: registration => {\n    // reload the page if there is an update\n    if (registration && registration.waiting) {\n      registration.waiting.postMessage({ type: 'SKIP_WAITING' });\n    }\n    window.location.reload();\n  }\n});*/\n"
  },
  {
    "path": "unfollow-monkey-ui/src/reportWebVitals.js",
    "content": "const reportWebVitals = (onPerfEntry) => {\n  if (onPerfEntry && onPerfEntry instanceof Function) {\n    import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {\n      getCLS(onPerfEntry);\n      getFID(onPerfEntry);\n      getFCP(onPerfEntry);\n      getLCP(onPerfEntry);\n      getTTFB(onPerfEntry);\n    });\n  }\n};\n\nexport default reportWebVitals;\n"
  },
  {
    "path": "unfollow-monkey-ui/src/service-worker.js",
    "content": "/* eslint-disable no-restricted-globals */\n\n// This service worker can be customized!\n// See https://developers.google.com/web/tools/workbox/modules\n// for the list of available Workbox modules, or add any other\n// code you'd like.\n// You can also remove this file if you'd prefer not to use a\n// service worker, and the Workbox build step will be skipped.\n\nimport { clientsClaim } from 'workbox-core';\nimport { ExpirationPlugin } from 'workbox-expiration';\nimport { precacheAndRoute, createHandlerBoundToURL } from 'workbox-precaching';\nimport { registerRoute } from 'workbox-routing';\nimport { StaleWhileRevalidate } from 'workbox-strategies';\n\nclientsClaim();\n\n// Precache all of the assets generated by your build process.\n// Their URLs are injected into the manifest variable below.\n// This variable must be present somewhere in your service worker file,\n// even if you decide not to use precaching. See https://cra.link/PWA\nprecacheAndRoute(self.__WB_MANIFEST);\n\n// Set up App Shell-style routing, so that all navigation requests\n// are fulfilled with your index.html shell. Learn more at\n// https://developers.google.com/web/fundamentals/architecture/app-shell\nconst fileExtensionRegexp = new RegExp('/[^/?]+\\\\.[^/]+$');\nregisterRoute(\n  // Return false to exempt requests from being fulfilled by index.html.\n  ({ request, url }) => {\n    // If this isn't a navigation, skip.\n    if (request.mode !== 'navigate') {\n      return false;\n    } // If this is a URL that starts with /_, skip.\n\n    if (url.pathname.startsWith('/_')) {\n      return false;\n    } // If this looks like a URL for a resource, because it contains // a file extension, skip.\n\n    if (url.pathname.match(fileExtensionRegexp)) {\n      return false;\n    } // Return true to signal that we want to use the handler.\n\n    return true;\n  },\n  createHandlerBoundToURL(process.env.PUBLIC_URL + '/index.html')\n);\n\n// An example runtime caching route for requests that aren't handled by the\n// precache, in this case same-origin .png requests like those from in public/\nregisterRoute(\n  // Add in any other file extensions or routing criteria as needed.\n    // Customize this strategy as needed, e.g., by changing to CacheFirst.\n  ({ url }) => url.origin === self.location.origin &&\n      (url.pathname.endsWith('.png') || url.pathname.endsWith('.jpg') || url.pathname.endsWith('.svg')),\n  new StaleWhileRevalidate({\n    cacheName: 'images',\n    plugins: [\n      // Ensure that once this runtime cache reaches a maximum size the\n      // least-recently used images are removed.\n      new ExpirationPlugin({ maxEntries: 50 }),\n    ],\n  })\n);\n\n// This allows the web app to trigger skipWaiting via\n// registration.waiting.postMessage({type: 'SKIP_WAITING'})\nself.addEventListener('message', (event) => {\n  if (event.data && event.data.type === 'SKIP_WAITING') {\n    self.skipWaiting();\n  }\n});\n\n// Any other custom service worker logic can go here.\n"
  },
  {
    "path": "unfollow-monkey-ui/src/serviceWorkerRegistration.js",
    "content": "// This optional code is used to register a service worker.\n// register() is not called by default.\n\n// This lets the app load faster on subsequent visits in production, and gives\n// it offline capabilities. However, it also means that developers (and users)\n// will only see deployed updates on subsequent visits to a page, after all the\n// existing tabs open on the page have been closed, since previously cached\n// resources are updated in the background.\n\n// To learn more about the benefits of this model and instructions on how to\n// opt-in, read https://cra.link/PWA\n\nconst isLocalhost = Boolean(\n  window.location.hostname === 'localhost' ||\n    // [::1] is the IPv6 localhost address.\n    window.location.hostname === '[::1]' ||\n    // 127.0.0.0/8 are considered localhost for IPv4.\n    window.location.hostname.match(/^127(?:\\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/)\n);\n\nexport function register(config) {\n  if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {\n    // The URL constructor is available in all browsers that support SW.\n    const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);\n    if (publicUrl.origin !== window.location.origin) {\n      // Our service worker won't work if PUBLIC_URL is on a different origin\n      // from what our page is served on. This might happen if a CDN is used to\n      // serve assets; see https://github.com/facebook/create-react-app/issues/2374\n      return;\n    }\n\n    window.addEventListener('load', () => {\n      const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;\n\n      if (isLocalhost) {\n        // This is running on localhost. Let's check if a service worker still exists or not.\n        checkValidServiceWorker(swUrl, config);\n\n        // Add some additional logging to localhost, pointing developers to the\n        // service worker/PWA documentation.\n        navigator.serviceWorker.ready.then(() => {\n          console.log(\n            'This web app is being served cache-first by a service ' +\n              'worker. To learn more, visit https://cra.link/PWA'\n          );\n        });\n      } else {\n        // Is not localhost. Just register service worker\n        registerValidSW(swUrl, config);\n      }\n    });\n  }\n}\n\nfunction registerValidSW(swUrl, config) {\n  navigator.serviceWorker\n    .register(swUrl)\n    .then((registration) => {\n      registration.onupdatefound = () => {\n        const installingWorker = registration.installing;\n        if (installingWorker == null) {\n          return;\n        }\n        installingWorker.onstatechange = () => {\n          if (installingWorker.state === 'installed') {\n            if (navigator.serviceWorker.controller) {\n              // At this point, the updated precached content has been fetched,\n              // but the previous service worker will still serve the older\n              // content until all client tabs are closed.\n              console.log(\n                'New content is available and will be used when all ' +\n                  'tabs for this page are closed. See https://cra.link/PWA.'\n              );\n\n              // Execute callback\n              if (config && config.onUpdate) {\n                config.onUpdate(registration);\n              }\n            } else {\n              // At this point, everything has been precached.\n              // It's the perfect time to display a\n              // \"Content is cached for offline use.\" message.\n              console.log('Content is cached for offline use.');\n\n              // Execute callback\n              if (config && config.onSuccess) {\n                config.onSuccess(registration);\n              }\n            }\n          }\n        };\n      };\n    })\n    .catch((error) => {\n      console.error('Error during service worker registration:', error);\n    });\n}\n\nfunction checkValidServiceWorker(swUrl, config) {\n  // Check if the service worker can be found. If it can't reload the page.\n  fetch(swUrl, {\n    headers: { 'Service-Worker': 'script' },\n  })\n    .then((response) => {\n      // Ensure service worker exists, and that we really are getting a JS file.\n      const contentType = response.headers.get('content-type');\n      if (\n        response.status === 404 ||\n        (contentType != null && contentType.indexOf('javascript') === -1)\n      ) {\n        // No service worker found. Probably a different app. Reload the page.\n        navigator.serviceWorker.ready.then((registration) => {\n          registration.unregister().then(() => {\n            window.location.reload();\n          });\n        });\n      } else {\n        // Service worker found. Proceed as normal.\n        registerValidSW(swUrl, config);\n      }\n    })\n    .catch(() => {\n      console.log('No internet connection found. App is running in offline mode.');\n    });\n}\n\nexport function unregister() {\n  if ('serviceWorker' in navigator) {\n    navigator.serviceWorker.ready\n      .then((registration) => {\n        registration.unregister();\n      })\n      .catch((error) => {\n        console.error(error.message);\n      });\n  }\n}\n"
  },
  {
    "path": "unfollow-monkey-ui/src/style.scss",
    "content": "body {\n  margin: 0;\n  color: #4c4e6e;\n}\nimg {\n  vertical-align: middle;\n}"
  },
  {
    "path": "unfollow-monkey-ui/src/twemojis/Emojis.js",
    "content": "import React from 'react';\nimport Styles from './Emojis.module.scss';\n\nimport ImgWavingHand from './1f44b.png';\nimport ImgSeeNoEvil from './1f648.png';\nimport ImgNoEntry from './26d4.png';\nimport ImgPoo from './1f4a9.png';\nimport ImgBrokenHeart from './1f494.png';\n\nconst EmoImg = ({alt, src}) => <img draggable=\"false\" className={Styles.emoji} alt={alt} src={src}/>;\nexport const WavingHand = () => <EmoImg alt=\"👋\" src={ImgWavingHand}/>;\nexport const SeeNoEvil = () => <EmoImg alt=\"🙈\" src={ImgSeeNoEvil}/>;\nexport const NoEntry = () => <EmoImg alt=\"⛔\" src={ImgNoEntry}/>;\nexport const Poo = () => <EmoImg alt=\"💩\" src={ImgPoo}/>;\nexport const BrokenHeart = () => <EmoImg alt=\"💔\" src={ImgBrokenHeart}/>;\n\nconst Emojis = { WavingHand, SeeNoEvil, NoEntry, Poo, BrokenHeart };\nexport default Emojis;"
  },
  {
    "path": "unfollow-monkey-ui/src/twemojis/Emojis.module.scss",
    "content": "img.emoji {\n  height: 1em;\n  width: 1em;\n  margin: 0 .05em 0 .1em;\n  vertical-align: -0.1em;\n}"
  },
  {
    "path": "unfollow-ninja-server/.dockerignore",
    "content": "node_modules\nnpm-debug.log\n"
  },
  {
    "path": "unfollow-ninja-server/.eslintrc.json",
    "content": "{\n    \"env\": {\n        \"browser\": false,\n        \"commonjs\": true,\n        \"es2021\": true,\n        \"jest/globals\": true\n    },\n    \"extends\": [\"eslint:recommended\", \"plugin:@typescript-eslint/recommended\", \"plugin:jest/recommended\", \"prettier\"],\n    \"parser\": \"@typescript-eslint/parser\",\n    \"parserOptions\": {\n        \"ecmaVersion\": \"latest\",\n        \"project\": \"./tsconfig.json\"\n    },\n    \"plugins\": [\"@typescript-eslint\", \"jest\"],\n    \"rules\": {\n        \"@typescript-eslint/unbound-method\": \"off\",\n        \"jest/unbound-method\": \"error\",\n        \"@typescript-eslint/no-floating-promises\": \"error\"\n    },\n    \"ignorePatterns\": [\"dist\", \"jest.config.js\"]\n}\n"
  },
  {
    "path": "unfollow-ninja-server/.prettierignore",
    "content": "dist\nnode_modules"
  },
  {
    "path": "unfollow-ninja-server/.prettierrc.json",
    "content": "{\n    \"singleQuote\": true,\n    \"tabWidth\": 4,\n    \"printWidth\": 120\n}\n"
  },
  {
    "path": "unfollow-ninja-server/Dockerfile",
    "content": "# -- For workers only, this does not launch the API\nFROM node:18\n\n# Create app directory\nWORKDIR /usr/src/app\n\n# Install app dependencies\nCOPY package*.json ./\nRUN npm ci\n\n# Bundle app source\nCOPY src src\nCOPY *.json ./\nCOPY *.js ./\nRUN npm run build\n\nCOPY tests tests\nCOPY locales locales\nCOPY .env .env\n\nCMD [ \"node\", \"./dist/workers.js\" ]\n"
  },
  {
    "path": "unfollow-ninja-server/docker-compose.yml",
    "content": "version: '3'\n\nservices:\n    workers:\n        restart: always\n        build: .\n        depends_on:\n            - postgres\n            - postgres-logs\n            - postgres-followers\n            - redis-bull\n            - redis\n        volumes:\n            - /data/workers-logs:/usr/src/app/logs\n        environment:\n            POSTGRES_URI: postgres://postgres:unfollowninja@postgres/postgres\n            POSTGRES_LOGS_URI: postgres://postgres:unfollowninja@postgres-logs/postgres\n            POSTGRES_FOLLOWERS_URI: postgres://postgres:unfollowninja@postgres-followers/postgres\n            REDIS_URI: redis://redis\n            REDIS_BULL_URI: redis://redis-bull\n    api:\n        restart: always\n        build: .\n        command: ['node', './dist/api.js']\n        ports:\n            - '127.0.0.1:4000:4000'\n        depends_on:\n            - postgres\n            - postgres-logs\n            - postgres-followers\n            - redis-bull\n            - redis\n        environment:\n            POSTGRES_URI: postgres://postgres:unfollowninja@postgres/postgres\n            POSTGRES_LOGS_URI: postgres://postgres:unfollowninja@postgres-logs/postgres\n            POSTGRES_FOLLOWERS_URI: postgres://postgres:unfollowninja@postgres-followers/postgres\n            REDIS_URI: redis://redis\n            REDIS_BULL_URI: redis://redis-bull\n    postgres:\n        restart: always\n        image: postgres:15\n        command: postgres -c 'max_connections=200'\n        environment:\n            POSTGRES_PASSWORD: 'unfollowninja'\n        volumes:\n            - /data/postgres:/var/lib/postgresql/data\n    postgres-logs:\n        restart: always\n        image: postgres:15\n        command: postgres -c 'max_connections=200'\n        environment:\n            POSTGRES_PASSWORD: 'unfollowninja'\n        volumes:\n            - /data/postgres-logs:/var/lib/postgresql/data\n    postgres-followers:\n        restart: always\n        image: postgres:15\n        command: postgres -c 'max_connections=200'\n        environment:\n            POSTGRES_PASSWORD: 'unfollowninja'\n        volumes:\n            - /data/postgres-logs:/var/lib/postgresql/data\n    redis-bull:\n        restart: always\n        image: redis:6\n        command: ['redis-server', '--appendonly', 'yes']\n        volumes:\n            - /data/redis-bull:/data\n    redis:\n        restart: always\n        image: redis:6\n        command: ['redis-server', '--appendonly', 'yes']\n        volumes:\n            - /data/redis:/data\n"
  },
  {
    "path": "unfollow-ninja-server/jest.config.js",
    "content": "module.exports = {\n    clearMocks: true,\n    collectCoverage: false,\n    collectCoverageFrom: ['src/tasks/*.ts'],\n    coverageDirectory: 'test-results/coverage',\n    coveragePathIgnorePatterns: ['index.ts'],\n    coverageReporters: ['lcov'],\n    globals: {\n        'ts-jest': {\n            tsconfig: 'tsconfig.json',\n        },\n    },\n    moduleFileExtensions: ['js', 'ts', 'tsx'],\n    reporters: ['default'],\n    testEnvironment: 'node',\n    testMatch: ['**/tests/**/*.spec.+(ts|tsx|js)'],\n    verbose: true,\n    preset: 'ts-jest',\n};\n"
  },
  {
    "path": "unfollow-ninja-server/locales/ar.json",
    "content": "{\n    \"This account followed you for {{duration}} ({{{time}}}).\": \"هذا الحساب قام بمتابعتك منذ {{duration}} ({{{time}}}).\",\n    \"and {{nbLeftovers}} more.\": \"و{{nbLeftovers}} أكثر.\",\n    \"{{nbUnfollows}} Twitter users unfollowed you:\": \"قام {{nbUnfollows}} مستخدمي تويتر بإلغاء متابعتك:\",\n    \"one of your followers\": \"واحد من متابعينك\",\n    \"{{username}} unfollowed you {{emoji}}.\": \"{{username}} ألغى مُتابعتك {{emoji}}.\",\n    \"You blocked {{username}} {{emoji}}.\": \"قمت بحظر {{username}} {{emoji}}.\",\n    \"This account followed you before you signed up to @{{twitterAccount}}!\": \"هذا الحساب قام بمتابعتك من قبل تسجيلك في @{{twitterAccount}}!\",\n    \"{{username}}'s account has been locked {{emoji}}.\": \"حساب {{username}} قد تم إغلاقه {{emoji}}.\",\n    \"Congratulations, can now enjoy @{{twitterAccount}} pro {{emoji}}!\": \"تهانينا، يُمكنك الآن الاستمتاع بـ @{{twitterAccount}} المميز {{emoji}}!\",\n    \"{{username}} has left Twitter {{emoji}}.\": \"قام {{username} بمُغادرة تويتر {{emoji}}.\",\n    \"{{username}} has been suspended {{emoji}}.\": \"{{username}} توقف حسابه {{emoji}}.\",\n    \"All set, welcome to @{{twitterAccount}} {{emoji}}!\\nYou will soon know all about your unfollowers here!\": \"كل شيء جاهز، مرحبًا بك في @{{twitterAccount}} {{emoji}}!\\nستعرف قريبًا كل شيء عن غير متابعينك هنا!\",\n    \"{{username}} blocked you {{emoji}}.\": \"قام {{username}} بحظرك {{emoji}}.\"\n}\n"
  },
  {
    "path": "unfollow-ninja-server/locales/de.json",
    "content": "{\n    \"{{username}} has been suspended {{emoji}}.\": \"{{username}} ist gesperrt worden {{emoji}}.\",\n    \"{{username}} has left Twitter {{emoji}}.\": \"{{username}} hat Twitter verlassen {{emoji}}.\",\n    \"{{nbUnfollows}} Twitter users unfollowed you:\": \"{{nbUnfollows}} Twitter-Nutzer haben dir entfolgt:\",\n    \"{{username}} blocked you {{emoji}}.\": \"{{username}} hat dich blockiert {{emoji}}.\",\n    \"one of your followers\": \"einer deiner Follower\",\n    \"{{username}} unfollowed you {{emoji}}.\": \"{{username}} hat dich entfolgt {{emoji}}.\",\n    \"This account followed you for {{duration}} ({{{time}}}).\": \"Dieses Konto ist dir seit {{duration}} ({{{time}}}) gefolgt.\",\n    \"and {{nbLeftovers}} more.\": \"und {{nbLeftovers}} mehr.\",\n    \"All set, welcome to @{{twitterAccount}} {{emoji}}!\\nYou will soon know all about your unfollowers here!\": \"Alles bereit, willkommen bei @{{twitterAccount}} {{emoji}}!\\nHier wirst du bald alles über deine Unfollower erfahren!\",\n    \"You blocked {{username}} {{emoji}}.\": \"Du hast {{username}} blockiert {{emoji}}.\",\n    \"This account followed you before you signed up to @{{twitterAccount}}!\": \"Dieses Konto ist dir gefolgt, bevor du dich bei @{{twitterAccount}} angemeldet hast!\",\n    \"{{username}}'s account has been locked {{emoji}}.\": \"Das Konto von {{username}} ist gesperrt worden {{emoji}}.\"\n}\n"
  },
  {
    "path": "unfollow-ninja-server/locales/en.json",
    "content": "{\n    \"{{nbUnfollows}} Twitter users unfollowed you:\": \"{{nbUnfollows}} Twitter users unfollowed you:\",\n    \"one of your followers\": \"one of your followers\",\n    \"{{username}} unfollowed you {{emoji}}.\": \"{{username}} unfollowed you {{emoji}}.\",\n    \"{{username}} has been suspended {{emoji}}.\": \"{{username}} has been suspended {{emoji}}.\",\n    \"{{username}} has left Twitter {{emoji}}.\": \"{{username}} has left Twitter {{emoji}}.\",\n    \"{{username}} blocked you {{emoji}}.\": \"{{username}} blocked you {{emoji}}.\",\n    \"You blocked {{username}} {{emoji}}.\": \"You blocked {{username}} {{emoji}}.\",\n    \"This account followed you for {{duration}} ({{{time}}}).\": \"This account followed you for {{duration}} ({{{time}}}).\",\n    \"This account followed you before you signed up to @{{twitterAccount}}!\": \"This account followed you before you signed up to @{{twitterAccount}}!\",\n    \"and {{nbLeftovers}} more.\": \"and {{nbLeftovers}} more.\",\n    \"All set, welcome to @{{twitterAccount}} {{emoji}}!\\nYou will soon know all about your unfollowers here!\": \"All set, welcome to @{{twitterAccount}} {{emoji}}!\\nYou will soon know all about your unfollowers here!\",\n    \"{{username}}'s account has been locked {{emoji}}.\": \"{{username}}'s account has been locked {{emoji}}.\",\n    \"Congratulations, can now enjoy @{{twitterAccount}} pro {{emoji}}!\": \"Congratulations, can now enjoy @{{twitterAccount}} pro {{emoji}}!\"\n}\n"
  },
  {
    "path": "unfollow-ninja-server/locales/es.json",
    "content": "{\n    \"{{nbUnfollows}} Twitter users unfollowed you:\": \"{{nbUnfollows}} usuarios de twitter te han dejado de seguir:\",\n    \"one of your followers\": \"Uno de tus seguidores\",\n    \"{{username}} unfollowed you {{emoji}}.\": \"{{username}} te dejó de seguir {{emoji}}.\",\n    \"{{username}} has been suspended {{emoji}}.\": \"{{username}} ha sido suspendido {{emoji}}.\",\n    \"{{username}} has left Twitter {{emoji}}.\": \"{{username}} dejó Twitter {{emoji}}.\",\n    \"{{username}} blocked you {{emoji}}.\": \"{{username}} te bloqueó {{emoji}}.\",\n    \"You blocked {{username}} {{emoji}}.\": \"Has bloqueado a {{username}} {{emoji}}.\",\n    \"This account followed you for {{duration}} ({{{time}}}).\": \"Esta cuenta te siguió por {{duration}} ({{{time}}}).\",\n    \"This account followed you before you signed up to @{{twitterAccount}}!\": \"Esta cuenta te seguía antes de que te registrases en @{{twitterAccount}}!\",\n    \"and {{nbLeftovers}} more.\": \"y {{nbLeftovers}} más.\",\n    \"All set, welcome to @{{twitterAccount}} {{emoji}}!\\nYou will soon know all about your unfollowers here!\": \"Todo listo, bienvenido a @{{twitterAccount}} {{emoji}}!\\nPronto conocerás todo sobre tus unfollowers!\",\n    \"{{username}}'s account has been locked {{emoji}}.\": \"La cuenta de {{username}} ha sido limitada {{emoji}}.\",\n    \"Congratulations, can now enjoy @{{twitterAccount}} pro {{emoji}}!\": \"Enhorabuena, ahora puedes disfrutar @{{twitterAccount}} pro {{emoji}}!\"\n}\n"
  },
  {
    "path": "unfollow-ninja-server/locales/fr.json",
    "content": "{\n    \"{{nbUnfollows}} Twitter users unfollowed you:\": \"{{nbUnfollows}} twittos vous ont unfollow :\",\n    \"one of your followers\": \"Un twitto\",\n    \"{{username}} unfollowed you {{emoji}}.\": \"{{username}} vous a unfollow {{emoji}}.\",\n    \"{{username}} has been suspended {{emoji}}.\": \"{{username}} a été suspendu {{emoji}}.\",\n    \"{{username}} has left Twitter {{emoji}}.\": \"{{username}} a quitté Twitter {{emoji}}.\",\n    \"{{username}} blocked you {{emoji}}.\": \"{{username}} vous a bloqué {{emoji}}.\",\n    \"You blocked {{username}} {{emoji}}.\": \"Vous avez bloqué {{username}} {{emoji}}.\",\n    \"This account followed you for {{duration}} ({{{time}}}).\": \"Ce compte vous a suivi pendant {{duration}} ({{{time}}}).\",\n    \"This account followed you before you signed up to @{{twitterAccount}}!\": \"Ce compte vous suivait avant votre inscription à @{{twitterAccount}} !\",\n    \"and {{nbLeftovers}} more.\": \"et {{nbLeftovers}} autres twittos.\",\n    \"All set, welcome to @{{twitterAccount}} {{emoji}}!\\nYou will soon know all about your unfollowers here!\": \"Tout est prêt, bienvenue sur @{{twitterAccount}} {{emoji}} !\\nVous allez bientot tout savoir sur vos unfollowers ici !\",\n    \"{{username}}'s account has been locked {{emoji}}.\": \"Le compte de {{username}} a été verrouillé {{emoji}}.\",\n    \"Congratulations, can now enjoy @{{twitterAccount}} pro {{emoji}}!\": \"Félicitations, vous pouvez maintenant profiter de @{{twitterAccount}} pro {{emoji}} !\"\n}\n"
  },
  {
    "path": "unfollow-ninja-server/locales/hy.json",
    "content": "{}\n"
  },
  {
    "path": "unfollow-ninja-server/locales/id.json",
    "content": "{\n    \"This account followed you before you signed up to @{{twitterAccount}}!\": \"Akun ini sudah mengikuti kamu sebelum kamu mendaftar ke @{{twitterAccount}}!\",\n    \"{{username}} unfollowed you {{emoji}}.\": \"{{username}} berhenti mengikuti kamu {{emoji}}.\",\n    \"one of your followers\": \"salah satu pengikut kamu\",\n    \"{{username}} blocked you {{emoji}}.\": \"{{username}} memblokir kamu {{emoji}}.\",\n    \"You blocked {{username}} {{emoji}}.\": \"Kamu memblokir {{username}} {{emoji}}.\",\n    \"{{username}} has been suspended {{emoji}}.\": \"{{username}} sudah ditangguhkan {{emoji}}.\",\n    \"{{username}} has left Twitter {{emoji}}.\": \"{{username}} sudah meninggalkan Twitter {{emoji}}.\",\n    \"This account followed you for {{duration}} ({{{time}}}).\": \"Akun ini sudah mengikuti kamu selama {{duration}} ({{{time}}}).\",\n    \"and {{nbLeftovers}} more.\": \"dan {{nbLeftovers}} lainnya.\",\n    \"{{username}}'s account has been locked {{emoji}}.\": \"{{username}} akunnya sudah dikunci {{emoji}}.\",\n    \"All set, welcome to @{{twitterAccount}} {{emoji}}!\\nYou will soon know all about your unfollowers here!\": \"Selesai, selamat datang di @{{twitterAccount}} {{emoji}}!\\nKamu akan segera tahu tentang semua pengikutmu di sini!\",\n    \"{{nbUnfollows}} Twitter users unfollowed you:\": \"{{nbUnfollows}} pengguna twitter berhenti mengikuti kamu:\",\n    \"Congratulations, can now enjoy @{{twitterAccount}} pro {{emoji}}!\": \"Selamat, sekarang kamu bisa menggunakan @{{twitterAccount}} pro {{emoji}}!\"\n}\n"
  },
  {
    "path": "unfollow-ninja-server/locales/nl.json",
    "content": "{\n    \"{{nbUnfollows}} Twitter users unfollowed you:\": \"{{nbUnfollows}} Twittergebruikers die je ontvolgden:\",\n    \"one of your followers\": \"een van je volgers\",\n    \"{{username}} unfollowed you {{emoji}}.\": \"{{username}} heeft je ontvolgt {{emoji}}.\",\n    \"{{username}} has been suspended {{emoji}}.\": \"{{username}} is opgeschort {{emoji}}.\",\n    \"{{username}} has left Twitter {{emoji}}.\": \"{{username}} heeft Twitter verlaten {{emoji}}.\",\n    \"{{username}} blocked you {{emoji}}.\": \"{{username}} heeft je geblokkeerd {{emoji}}.\",\n    \"You blocked {{username}} {{emoji}}.\": \"Je blokkeerde {{username}} {{emoji}}.\",\n    \"This account followed you for {{duration}} ({{{time}}}).\": \"Dit account heeft je gevolgd voor {{duration}} ({{{time}}}).\",\n    \"This account followed you before you signed up to @{{twitterAccount}}!\": \"Dit account volgde je voordat je je aanmeldde bij @{{twitterAccount}}!\",\n    \"Congratulations, can now enjoy @{{twitterAccount}} pro {{emoji}}!\": \"Gefeliciteerd! Je kan nu van @{{twitterAccount}} pro {{emoji}} genieten!\",\n    \"and {{nbLeftovers}} more.\": \"en {{nbLeftovers}} meer.\",\n    \"All set, welcome to @{{twitterAccount}} {{emoji}}!\\nYou will soon know all about your unfollowers here!\": \"Alles gedaan, welkom tot @{{twitterAccount}} {{emoji}}!\\nOver een beetje kan je zien wie je heeft geontvolgt!\",\n    \"{{username}}'s account has been locked {{emoji}}.\": \"{{username}}'s account werd vergrendeld {{emoji}}.\"\n}\n"
  },
  {
    "path": "unfollow-ninja-server/locales/pl.json",
    "content": "{\n    \"{{username}} has been suspended {{emoji}}.\": \"{{username}} został zawieszony {{emoji}}.\",\n    \"{{username}} has left Twitter {{emoji}}.\": \"{{username}} opuścił Twittera {{emoji}}.\",\n    \"{{username}} blocked you {{emoji}}.\": \"{{username}} cię zablokował {{emoji}}.\",\n    \"and {{nbLeftovers}} more.\": \"oraz {{nbLeftovers}} innych.\",\n    \"All set, welcome to @{{twitterAccount}} {{emoji}}!\\nYou will soon know all about your unfollowers here!\": \"Wszystko ustawione, witamy w @{{twitterAccount}} {{emoji}}!\\nNiedługo, będziesz wiedział wszystko o osobach, które już cię nie obserwują!\",\n    \"{{username}} unfollowed you {{emoji}}.\": \"{{username}} przestał cię obserwować {{emoji}}.\",\n    \"one of your followers\": \"jeden z twoich obserwujących\",\n    \"{{nbUnfollows}} Twitter users unfollowed you:\": \"{{nbUnfollows}} użytkowników przestało cię obserwować:\",\n    \"You blocked {{username}} {{emoji}}.\": \"Zablokowałeś {{username}} {{emoji}}.\",\n    \"This account followed you for {{duration}} ({{{time}}}).\": \"Te konto obserwowało cię przez {{duration}} ({{{time}}}).\",\n    \"This account followed you before you signed up to @{{twitterAccount}}!\": \"Te konto zaobserwowało cię przed tym jak się zarejestrowałeś na @{{twitterAccount}}!\",\n    \"{{username}}'s account has been locked {{emoji}}.\": \"Konto {{username}} zostało zablokowane {{emoji}}.\",\n    \"Congratulations, can now enjoy @{{twitterAccount}} pro {{emoji}}!\": \"Gratulacje, możesz teraz korzystać z @{{twitterAccount} pro {{emoji}}!\"\n}\n"
  },
  {
    "path": "unfollow-ninja-server/locales/pt.json",
    "content": "{\n    \"{{nbUnfollows}} Twitter users unfollowed you:\": \"{{nbUnfollows}} utilizadores do twitter deixaram de te seguir:\",\n    \"one of your followers\": \"um dos teus seguidores\",\n    \"{{username}} unfollowed you {{emoji}}.\": \"{{username}} deixou de te seguir {{emoji}}.\",\n    \"{{username}} has been suspended {{emoji}}.\": \"{{username}} foi suspenso {{emoji}}.\",\n    \"{{username}} has left Twitter {{emoji}}.\": \"{{username}} deixou o Twitter {{emoji}}.\",\n    \"{{username}} blocked you {{emoji}}.\": \"{{username}} bloqueou-te {{emoji}}.\",\n    \"You blocked {{username}} {{emoji}}.\": \"Tu bloqueaste o {{username}} {{emoji}}.\",\n    \"This account followed you for {{duration}} ({{{time}}}).\": \"Esta conta seguia-te por {{duration}} ({{{time}}}).\",\n    \"This account followed you before you signed up to @{{twitterAccount}}!\": \"Esta conta seguiu-te antes de te inscreveres no @{{twitterAccount}}!\",\n    \"and {{nbLeftovers}} more.\": \"e {{nbLeftovers}} mais.\",\n    \"All set, welcome to @{{twitterAccount}} {{emoji}}!\\nYou will soon know all about your unfollowers here!\": \"Tudo pronto, bem vindo ao @{{twitterAccount}} {{emoji}}!\\nBrevemente vais saber tudo sobre aqueles que te deixam de seguir aqui!\",\n    \"{{username}}'s account has been locked {{emoji}}.\": \"A conta do {{username}} foi temporariamente trancada {{emoji}}.\",\n    \"Congratulations, can now enjoy @{{twitterAccount}} pro {{emoji}}!\": \"Parabéns, agora pode desfrutar do @{{twitterAccount}} pro {{emoji}}!\"\n}\n"
  },
  {
    "path": "unfollow-ninja-server/locales/pt_BR.json",
    "content": "{\n    \"{{nbUnfollows}} Twitter users unfollowed you:\": \"{{nbUnfollows}} Usuários do Twitter deixaram de te seguir:\",\n    \"{{username}} unfollowed you {{emoji}}.\": \"{{username}} Deixou de seguir você {{emoji}}.\",\n    \"{{username}} has been suspended {{emoji}}.\": \"{{username}} foi suspenso {{emoji}}.\",\n    \"{{username}} has left Twitter {{emoji}}.\": \"{{username}} deixou o Twitter {{emoji}}.\",\n    \"{{username}} blocked you {{emoji}}.\": \"{{username}} te bloqueou {{emoji}}.\",\n    \"This account followed you for {{duration}} ({{{time}}}).\": \"Essa conta te seguiu por {{duration}} ({{{time}}}).\",\n    \"This account followed you before you signed up to @{{twitterAccount}}!\": \"Essa conta te seguiu antes de você se inscrever em @{{twitterAccount}}!\",\n    \"and {{nbLeftovers}} more.\": \"e {{nbLeftovers}} mais.\",\n    \"{{username}}'s account has been locked {{emoji}}.\": \"A conta {{username}} foi temporariamente trancada {{emoji}}.\",\n    \"Congratulations, can now enjoy @{{twitterAccount}} pro {{emoji}}!\": \"Parabéns, agora você pode aproveitar @{{twitterAccount}} pro {{emoji}}!\",\n    \"one of your followers\": \"Um dos seus seguidores\",\n    \"You blocked {{username}} {{emoji}}.\": \"Você bloqueou {{username}} {{emoji}}.\",\n    \"All set, welcome to @{{twitterAccount}} {{emoji}}!\\nYou will soon know all about your unfollowers here!\": \"Tudo pronto, bem vindo ao @{{twitterAccount}} {{emoji}}!\\nEm breve você saberá quem deixou de te seguir aqui!\"\n}\n"
  },
  {
    "path": "unfollow-ninja-server/locales/ru.json",
    "content": "{\n    \"one of your followers\": \"один из ваших подписчиков\",\n    \"{{username}} blocked you {{emoji}}.\": \"{{username}} заблокировал вас {{emoji}}.\",\n    \"and {{nbLeftovers}} more.\": \"и {{nbLeftovers}} многое другое.\",\n    \"You blocked {{username}} {{emoji}}.\": \"Вы заблокировали {{username}} {{emoji}}.\",\n    \"This account followed you for {{duration}} ({{{time}}}).\": \"Этот аккаунт читал вас с {{duration}} ({{{time}}}).\",\n    \"{{username}} unfollowed you {{emoji}}.\": \"{{username}} отписался от вас {{emoji}}.\",\n    \"This account followed you before you signed up to @{{twitterAccount}}!\": \"Этот аккаунт начал читать вас до того, как вы подключили @{{twitterAccount}}!\",\n    \"{{username}}'s account has been locked {{emoji}}.\": \"Учетная запись {{username}} была заблокирована {{emoji}}.\",\n    \"{{username}} has left Twitter {{emoji}}.\": \"{{username}} удалил аккаунт {{emoji}}.\",\n    \"Congratulations, can now enjoy @{{twitterAccount}} pro {{emoji}}!\": \"Поздравляем, теперь вы можете наслаждаться @{{twitterAccount}} pro {{emoji}}!\",\n    \"{{nbUnfollows}} Twitter users unfollowed you:\": \"{{nbUnfollows}} Пользователи твиттера, отписавшиеся от вас:\",\n    \"{{username}} has been suspended {{emoji}}.\": \"{{username}} был приостановлен {{emoji}}.\",\n    \"All set, welcome to @{{twitterAccount}} {{emoji}}!\\nYou will soon know all about your unfollowers here!\": \"Всё готово, добро пожаловать в @{{twitterAccount}} {{emoji}}! \\nСкоро вы узнаете о тех, кто отписался!\"\n}\n"
  },
  {
    "path": "unfollow-ninja-server/locales/sk.json",
    "content": "{\n    \"{{nbUnfollows}} Twitter users unfollowed you:\": \"{{nbUnfollows}} Twitter užívatelia/užívateľov ťa prestalo sledovať\",\n    \"one of your followers\": \"jeden z tvojich sledovateľov\",\n    \"{{username}} unfollowed you {{emoji}}.\": \"{{username}} ťa prestal sledovať {{emoji}}.\",\n    \"{{username}} has been suspended {{emoji}}.\": \"Účet {{username}} bol pozastavený {{emoji}}.\",\n    \"{{username}} has left Twitter {{emoji}}.\": \"{{username}} opustil Twitter {{emoji}}.\",\n    \"{{username}} blocked you {{emoji}}.\": \"{{username}} si ťa zablokoval {{emoji}}.\",\n    \"You blocked {{username}} {{emoji}}.\": \"Zablokoval si {{username}} {{emoji}}.\",\n    \"This account followed you for {{duration}} ({{{time}}}).\": \"Tento účet ťa sledoval {{duration}} ({{{time}}}).\"\n}\n"
  },
  {
    "path": "unfollow-ninja-server/locales/th.json",
    "content": "{\n    \"{{username}} has been suspended {{emoji}}.\": \"{{username}} ได้ถูกระงับการใช้งาน {{emoji}}.\",\n    \"{{username}} blocked you {{emoji}}.\": \"{{username}} ทำการบล็อคคุณ {{emoji}}.\",\n    \"You blocked {{username}} {{emoji}}.\": \"คุณได้บล็อค {{username}} {{emoji}}.\",\n    \"This account followed you before you signed up to @{{twitterAccount}}!\": \"บัญชีผู้ใช้นี้ติดตามคุณ ก่อนที่คุณจะลงชื่อสมัครใช้ @{{twitterAccount}}!\",\n    \"{{username}}'s account has been locked {{emoji}}.\": \"{{username}}'s บัญชีผู้ใช้นี้ได้ทำการล็อคบัญชี {{emoji}}.\",\n    \"one of your followers\": \"หนึ่งในผู้ติดตามของคุณ\",\n    \"{{nbUnfollows}} Twitter users unfollowed you:\": \"{{nbUnfollows}} บัญชีผู้ใช้ทวิตเตอร์นี้ได้เลิกติดตามคุณ:\",\n    \"{{username}} unfollowed you {{emoji}}.\": \"{{username}} เลิกติดตามคุณ {{emoji}}.\",\n    \"{{username}} has left Twitter {{emoji}}.\": \"{{username}} ได้ปิดบัญชีทวิตเตอร์ {{emoji}}.\",\n    \"This account followed you for {{duration}} ({{{time}}}).\": \"บัญชีผู้ใช้นี้ติดตามคุณเมื่อ {{duration}} ({{{time}}}).\",\n    \"and {{nbLeftovers}} more.\": \"และ {{nbLeftovers}} อื่นๆ.\",\n    \"All set, welcome to @{{twitterAccount}} {{emoji}}!\\nYou will soon know all about your unfollowers here!\": \"ขั้นตอนเสร็จสิ้น, ยินดีต้อนรับสู่ @{{twitterAccount}} {{emoji}}!\\nในไม่ช้าคุณจะรู้ทุกอย่างเกี่ยวกับผู้ที่ติดตามของคุณอยู่!\"\n}\n"
  },
  {
    "path": "unfollow-ninja-server/locales/tr.json",
    "content": "{\n    \"one of your followers\": \"takipçilerinden biri\",\n    \"{{username}} unfollowed you {{emoji}}.\": \"{{username}} seni takip etmeyi bıraktı {{emoji}}.\",\n    \"{{username}} has been suspended {{emoji}}.\": \"{{username}} isimli hesap askıya alındı {{emoji}}.\",\n    \"{{username}} has left Twitter {{emoji}}.\": \"{{username}} Twitter'dan ayrıldı {{emoji}}.\",\n    \"{{username}} blocked you {{emoji}}.\": \"{{username}} seni engelledi {{emoji}}.\",\n    \"You blocked {{username}} {{emoji}}.\": \"{{username}} kişisini engelledin {{emoji}}.\",\n    \"This account followed you for {{duration}} ({{{time}}}).\": \"Bu hesap seni {{duration}} boyunca takip etmişti ({{{time}}}).\",\n    \"This account followed you before you signed up to @{{twitterAccount}}!\": \"Bu hesap sen @{{twitterAccount}} hesabına giriş yapmadan takip etti!\",\n    \"and {{nbLeftovers}} more.\": \"ve {{nbLeftovers}} fazlası.\",\n    \"All set, welcome to @{{twitterAccount}} {{emoji}}!\\nYou will soon know all about your unfollowers here!\": \"Tamamdır! Hoş geldin @{{twitterAccount}} {{emoji}}!\\nYakında burada seni takipten çıkaran herkesi öğreneceksin!\",\n    \"{{username}}'s account has been locked {{emoji}}.\": \"{{username}} kişisinin hesabı kilitlendi {{emoji}}.\",\n    \"Congratulations, can now enjoy @{{twitterAccount}} pro {{emoji}}!\": \"Tebrikler! Şimdi @{{twitterAccount}} pro ayrıcalıklarından faydalanabilirsin {{emoji}}!\",\n    \"{{nbUnfollows}} Twitter users unfollowed you:\": \"{{nbUnfollows}} Twitter kullanıcısı seni takip etmeyi bıraktı:\"\n}\n"
  },
  {
    "path": "unfollow-ninja-server/locales/uk.json",
    "content": "{\n    \"one of your followers\": \"один з ваших підписників\",\n    \"{{username}} unfollowed you {{emoji}}.\": \"{{username}} відписався від вас {{emoji}}.\",\n    \"You blocked {{username}} {{emoji}}.\": \"Ви заблокували {{username}} {{emoji}}.\",\n    \"This account followed you for {{duration}} ({{{time}}}).\": \"Цей акаунт читав вас впродовж {{duration}} ({{{time}}}).\",\n    \"and {{nbLeftovers}} more.\": \"та {{nbLeftovers}} ще.\",\n    \"{{username}} blocked you {{emoji}}.\": \"{{username}} заблокував вас {{emoji}}.\",\n    \"All set, welcome to @{{twitterAccount}} {{emoji}}!\\nYou will soon know all about your unfollowers here!\": \"Все готово, вітаємо на @{{twitterAccount}} {{emoji}}!\\nСкоро ви дізнаєтесь про всіх ваших відписників тут!\",\n    \"{{username}}'s account has been locked {{emoji}}.\": \"{{username}} було заблоковано {{emoji}}.\",\n    \"Congratulations, can now enjoy @{{twitterAccount}} pro {{emoji}}!\": \"Вітаю, тепер можете насолодитись @{{twitterAccount}} pro {{emoji}}!\",\n    \"{{nbUnfollows}} Twitter users unfollowed you:\": \"{{nbUnfollows}} користувачів Твіттеру відписались від вас:\",\n    \"This account followed you before you signed up to @{{twitterAccount}}!\": \"Цей акаунт читав вас до того як ви підписались на @{{twitterAccount}}!\",\n    \"{{username}} has been suspended {{emoji}}.\": \"{{username}} призупинив дію свого акаунту {{emoji}}.\",\n    \"{{username}} has left Twitter {{emoji}}.\": \"{{username}} покинув Твіттер {{emoji}}.\"\n}\n"
  },
  {
    "path": "unfollow-ninja-server/locales/zgh.json",
    "content": "{\n    \"{{nbUnfollows}} Twitter users unfollowed you:\": \"ⴽⴽⵙⵏ {{nbUnfollows}} ⵉⵏⵙⵙⵎⵔⴰⵙ ⵏ ⵜⵡⵉⵜⵔ ⵜⵉⴹⴼⵕⵉ ⵏⵏⴽ:\",\n    \"one of your followers\": \"ⵉⵊⵊ ⵙⴳ ⵉⵎⴹⴼⴰⵕⵏ ⵏⵏⴽ\",\n    \"{{username}} unfollowed you {{emoji}}.\": \"{{username}} ⵉⵙⵙⵔ ⵜⵉⴹⴼⵕⵉ ⵏⵏⴽ {{emoji}}.\",\n    \"{{username}} has been suspended {{emoji}}.\": \"{{username}} ⵉⴱⴷⴷ ⵓⵎⵉⴹⴰⵏ ⵏⵏⵙ {{emoji}}.\",\n    \"{{username}} has left Twitter {{emoji}}.\": \"ⵉⴼⴼⵖ {{username} ⵙⴳ ⵜⵡⵉⵜⵔ {{emoji}}.\",\n    \"You blocked {{username}} {{emoji}}.\": \"ⵜⴽⴽⵙⴷ {{username}} {{emoji}}.\",\n    \"This account followed you for {{duration}} ({{{time}}}).\": \"ⵉⴹⴼⴰⵕ ⴽ ⵓⵎⵉⴹⴰⵏ ⴰⴷ ⵙⴳ {{duration}} ({{{time}}}).\",\n    \"This account followed you before you signed up to @{{twitterAccount}}!\": \"ⵉⴹⴼⴰⵕ ⴽ ⵓⵎⵉⴹⴰⵏ ⴰⴷ ⴷⴰⵜ ⵏ ⵓⵣⵎⵎⴻⵎ ⵏⵏⴽ ⴳ @{{twitterAccount}}!\",\n    \"{{username}}'s account has been locked {{emoji}}.\": \"ⴰⵎⵉⴹⴰⵏ ⵏ {{username}} ⵉⵜⵜⵡⴰⵇⵇⵏ {{emoji}}.\",\n    \"Congratulations, can now enjoy @{{twitterAccount}} pro {{emoji}}!\": \"ⴰⵢⵢⵓⵣ ⵏⵏⴽ, ⵜⵣⵎⵔⴷ ⴰⴷ ⵜⵖⴱⴱⵉⴷ ⵙ @{{twitterAccount}} ⵉⵖⵓⴷⴰⵏ {{emoji}}!\",\n    \"{{username}} blocked you {{emoji}}.\": \"{{username}} ⵉⴽⴽⵙ ⴽ {{emoji}}.\",\n    \"and {{nbLeftovers}} more.\": \"ⴷ {{nbLeftovers}} ⵢⴰⴹⵏ.\",\n    \"All set, welcome to @{{twitterAccount}} {{emoji}}!\\nYou will soon know all about your unfollowers here!\": \"ⵎⴰⵕⵕⴰ ⵉⵙⴽⴽⵉⵏⵏ ⵓⵊⴷⵏ, ⴱⵔⵔⴽⴰⵜ ⴳ @{{twitterAccount}} {{emoji}}!\\nⵔⴰⴷ ⵜⵉⵙⵉⵏⴷ ⵎⴰⵕⵕⴰ ⵉⵙⴽⴽⵉⵏⵏ ⵅⴼ ⵉⵔⴰⵎⴹⴼⴰⵕⵏ ⵏⵏⴽ ⴷⴰ!\"\n}\n"
  },
  {
    "path": "unfollow-ninja-server/locales/zh_Hans.json",
    "content": "{\n    \"one of your followers\": \"你的其中的一个关注者\",\n    \"{{username}} unfollowed you {{emoji}}.\": \"{{username}} 取消关注你 {{emoji}}.\",\n    \"{{username}} blocked you {{emoji}}.\": \"{{username}} 屏蔽了你 {{emoji}}.\",\n    \"You blocked {{username}} {{emoji}}.\": \"你屏蔽了 {{username}} {{emoji}}.\",\n    \"{{username}} has been suspended {{emoji}}.\": \"{{username}} 被冻结 {{emoji}}.\",\n    \"This account followed you before you signed up to @{{twitterAccount}}!\": \"这个用户在你注册@{{twitterAccount}} 之前就关注了你!\",\n    \"This account followed you for {{duration}} ({{{time}}}).\": \"这个用户已经关注你{{duration}} ({{{time}}})了.\",\n    \"Congratulations, can now enjoy @{{twitterAccount}} pro {{emoji}}!\": \"祝贺，你现在可以好好地使用了! @{{twitterAccount}} {{emoji}}!\",\n    \"{{username}} has left Twitter {{emoji}}.\": \"{{username}} 注销账号 {{emoji}}.\",\n    \"All set, welcome to @{{twitterAccount}} {{emoji}}!\\nYou will soon know all about your unfollowers here!\": \"好了，欢迎@{{twitterAccount}} {{emoji}}!\\n在这里，你可以知道谁取关了你！\",\n    \"{{nbUnfollows}} Twitter users unfollowed you:\": \"{{nbUnfollows}} 个用户取消关注你:\",\n    \"{{username}}'s account has been locked {{emoji}}.\": \"{{username}} 账号受限 {{emoji}}.\",\n    \"and {{nbLeftovers}} more.\": \"和{{nbLeftovers}}等。\"\n}\n"
  },
  {
    "path": "unfollow-ninja-server/package.json",
    "content": "{\n    \"name\": \"unfollowninja\",\n    \"version\": \"2.0.0\",\n    \"description\": \"Receive a direct message in a few seconds when someone unfollows you on Twitter\",\n    \"main\": \"./dist/workers.js\",\n    \"scripts\": {\n        \"start\": \"node ./dist/workers.js\",\n        \"start-api\": \"node ./dist/api.js\",\n        \"test\": \"npm run lint && npm run specs\",\n        \"specs\": \"jest\",\n        \"jest\": \"jest\",\n        \"lint\": \"prettier --check . && eslint .\",\n        \"watch\": \"tsc -p tsconfig-build.json --watch\",\n        \"build\": \"tsc -p tsconfig-build.json\"\n    },\n    \"repository\": {\n        \"type\": \"git\",\n        \"url\": \"git+https://github.com/plhery/unfollowNinja.git\"\n    },\n    \"keywords\": [\n        \"twitter\",\n        \"unfollow\",\n        \"ninja\",\n        \"unfollowninja\"\n    ],\n    \"author\": \"plhery (https://twitter.com/plhery)\",\n    \"license\": \"ISC\",\n    \"bugs\": {\n        \"url\": \"https://github.com/plhery/unfollowNinja/issues\"\n    },\n    \"homepage\": \"https://github.com/plhery/unfollowNinja#readme\",\n    \"devDependencies\": {\n        \"@types/jest\": \"^29.2.2\",\n        \"@typescript-eslint/eslint-plugin\": \"^5.42.1\",\n        \"@typescript-eslint/parser\": \"^5.42.1\",\n        \"eslint\": \"^8.27.0\",\n        \"eslint-config-prettier\": \"^8.5.0\",\n        \"eslint-plugin-jest\": \"^27.1.5\",\n        \"ioredis-mock\": \"^8.2.2\",\n        \"jest\": \"^29.3.1\",\n        \"prettier\": \"^2.7.1\",\n        \"sqlite3\": \"npm:@vscode/sqlite3@^5.0.8\",\n        \"ts-jest\": \"^29.0.3\",\n        \"typescript\": \"^4.8.4\"\n    },\n    \"dependencies\": {\n        \"@koa/cors\": \"^4.0.0\",\n        \"@sentry/node\": \"^7.19.0\",\n        \"@sentry/tracing\": \"^7.19.0\",\n        \"@types/geoip-country\": \"^4.0.0\",\n        \"@types/i18n\": \"^0.13.5\",\n        \"@types/koa-router\": \"^7.4.4\",\n        \"@types/koa-session\": \"^5.10.4\",\n        \"@types/node\": \"^18.11.9\",\n        \"@types/oauth\": \"^0.9.1\",\n        \"@types/twit\": \"^2.2.31\",\n        \"big-integer\": \"^1.6.48\",\n        \"bull\": \"^4.1.0\",\n        \"dotenv\": \"^16.0.3\",\n        \"geoip-country\": \"^4.1.30\",\n        \"hot-shots\": \"^9.3.0\",\n        \"i18n\": \"^0.15.1\",\n        \"ioredis\": \"^5.2.4\",\n        \"iso-country-currency\": \"^0.6.0\",\n        \"koa\": \"^2.13.1\",\n        \"koa-bodyparser\": \"^4.3.0\",\n        \"koa-router\": \"^12.0.0\",\n        \"koa-session\": \"^6.2.0\",\n        \"moment\": \"^2.29.4\",\n        \"moment-timezone\": \"^0.5.38\",\n        \"p-limit\": \"^3.1.0\",\n        \"pg\": \"^8.8.0\",\n        \"pg-hstore\": \"^2.3.4\",\n        \"sequelize\": \"^6.25.5\",\n        \"stripe\": \"^10.17.0\",\n        \"twit\": \"^2.2.11\",\n        \"twitter-api-v2\": \"^1.12.9\",\n        \"winston\": \"^3.8.2\"\n    },\n    \"//\": [\n        \"p-limit stuck at v3 because of esm module incompatibility\"\n    ]\n}\n"
  },
  {
    "path": "unfollow-ninja-server/pm2.yml",
    "content": "apps:\n    - script: ./dist/workers.js\n      name: ninja-workers\n      kill_timeout: 10000\n    - script: ./dist/api.js\n      name: ninja-api\n      exec_mode: cluster\n      instances: 2\n"
  },
  {
    "path": "unfollow-ninja-server/src/api/admin.ts",
    "content": "import Router from 'koa-router';\nimport type { Queue } from 'bull';\n\nimport type Dao from '../dao/dao';\nimport type { NinjaSession } from '../api';\nimport { UserCategory } from '../dao/dao';\nimport { WebEvent } from '../dao/userEventDao';\nimport { disablePro, enablePro } from './stripe';\n\nexport function createAdminRouter(dao: Dao, queue: Queue) {\n    return new Router()\n        .use(async (ctx, next) => {\n            const session = ctx.session as NinjaSession;\n            if (!process.env.ADMIN_USERID || session.userId !== process.env.ADMIN_USERID) {\n                await ctx.throw(401);\n                return;\n            }\n            await next();\n        })\n        .get('/user/:usernameOrId', async (ctx) => {\n            const userId = await getUserId(ctx.params.usernameOrId);\n            const userDao = dao.getUserDao(userId);\n\n            const [\n                params,\n                username,\n                category,\n                friendCodes,\n                registeredFriendCode,\n                notificationEvents,\n                categoryEvents,\n                webEvents,\n                unfollowerEvents,\n                followEvents,\n                followers,\n                uncachables,\n            ] = await Promise.all([\n                userDao.getUserParams(),\n                userDao.getUsername(),\n                userDao.getCategory(),\n                userDao.getFriendCodes(),\n                userDao.getRegisteredFriendCode(),\n                dao.userEventDao.getNotificationEvents(userId),\n                dao.userEventDao.getCategoryEvents(userId),\n                dao.userEventDao.getWebEvents(userId),\n                dao.userEventDao.getUnfollowerEvents(userId),\n                dao.userEventDao.getFollowEvent(userId),\n                userDao.getFollowers(),\n                userDao.getUncachableFollowers(),\n            ]);\n\n            await dao.userEventDao.logWebEvent(ctx.session.userId, WebEvent.adminFetchUser, ctx.ip, username, userId);\n\n            ctx.body = JSON.stringify(\n                {\n                    id: userId,\n                    username,\n                    category,\n                    categoryStr: UserCategory[category],\n                    addedAt: params.added_at,\n                    lang: params.lang,\n                    dmId: params.dmId,\n                    dmUsername: await dao.getCachedUsername(params.dmId),\n                    pro: params.pro,\n                    customerId: params.customerId,\n                    friendCodes,\n                    registeredFriendCode,\n                    notificationEvents,\n                    categoryEvents,\n                    webEvents,\n                    unfollowerEvents,\n                    followEvents,\n                    followers,\n                    uncachables,\n                },\n                null,\n                2\n            );\n            ctx.response.type = 'json';\n        })\n        .get('/set-pro/:usernameOrId', async (ctx) => {\n            const session = ctx.session as NinjaSession;\n            const userId = await getUserId(ctx.params.usernameOrId);\n            const username = await dao.getCachedUsername(userId);\n\n            await dao.userEventDao.logWebEvent(session.userId, WebEvent.enablePro, ctx.ip, username, userId);\n            await enablePro(dao, queue, userId, 'pro', ctx.ip, 'admin-' + session.userId);\n            ctx.status = 204;\n        })\n        .get('/set-friends/:usernameOrId', async (ctx) => {\n            const session = ctx.session as NinjaSession;\n            const userId = await getUserId(ctx.params.usernameOrId);\n            const username = await dao.getCachedUsername(userId);\n\n            await dao.userEventDao.logWebEvent(session.userId, WebEvent.enableFriends, ctx.ip, username, userId);\n            await enablePro(dao, queue, userId, 'friends', ctx.ip, 'admin-' + session.userId);\n            ctx.status = 204;\n        })\n        .get('/remove-pro/:usernameOrId', async (ctx) => {\n            const session = ctx.session as NinjaSession;\n            const userId = await getUserId(ctx.params.usernameOrId);\n            const username = await dao.getCachedUsername(userId);\n\n            await dao.userEventDao.logWebEvent(session.userId, WebEvent.disablePro, ctx.ip, username, userId);\n            await disablePro(dao, userId, ctx.ip, 'admin-' + session.userId);\n\n            ctx.status = 204;\n        })\n        .get('/update-params/:usernameOrId', async (ctx) => {\n            const userId = await getUserId(ctx.params.usernameOrId);\n            await dao.getUserDao(userId).setUserParams(ctx.request.query);\n            ctx.status = 204;\n        });\n\n    async function getUserId(usernameOrId: string) {\n        if (!Number.isNaN(Number(usernameOrId))) {\n            // usernameOrId is an ID\n            return usernameOrId;\n        } else {\n            // usernameOrId is a username, look for the ID\n            const client = await dao.getUserDao(process.env.ADMIN_USERID).getTwitterApi();\n            const result = await client.v1.user({ screen_name: usernameOrId });\n            return result.id_str;\n        }\n    }\n}\n"
  },
  {
    "path": "unfollow-ninja-server/src/api/auth.ts",
    "content": "import TwitterApi from 'twitter-api-v2';\nimport Router from 'koa-router';\nimport type { Queue } from 'bull';\nimport geoip from 'geoip-country';\n\nimport logger from '../utils/logger';\nimport type Dao from '../dao/dao';\nimport { UserCategory } from '../dao/dao';\nimport type { NinjaSession } from '../api';\nimport { Lang } from '../utils/types';\nimport { WebEvent } from '../dao/userEventDao';\nimport { getPriceTags } from './stripe';\n\nconst authRouter = new Router();\n\nif (\n    !process.env.CONSUMER_KEY ||\n    !process.env.CONSUMER_SECRET ||\n    !process.env.DM_CONSUMER_KEY ||\n    !process.env.DM_CONSUMER_SECRET\n) {\n    logger.error('Some required environment variables are missing ((DM_)CONSUMER_KEY/CONSUMER_SECRET).');\n    logger.error('Make sure you added them in a .env file in you cwd or that you defined them.');\n    process.exit();\n}\n\nconst _STEP1_CREDENTIALS = {\n    appKey: process.env.CONSUMER_KEY,\n    appSecret: process.env.CONSUMER_SECRET,\n} as const;\nconst _STEP2_CREDENTIALS = {\n    appKey: process.env.DM_CONSUMER_KEY,\n    appSecret: process.env.DM_CONSUMER_SECRET,\n} as const;\n\nif (!process.env.API_URL || !process.env.WEB_URL) {\n    logger.error('Some required environment variables are missing (API_URL/WEB_URL).');\n    logger.error('Make sure you added them in a .env file in you cwd or that you defined them.');\n    process.exit();\n}\n\nconst DEFAULT_LANGUAGE = (process.env.DEFAULT_LANGUAGE || 'en') as Lang;\n\nexport function createAuthRouter(dao: Dao, queue: Queue) {\n    return authRouter\n        .get('/step-1', async (ctx) => {\n            // Generate an authentication URL\n            const { url, oauth_token, oauth_token_secret } = await new TwitterApi(_STEP1_CREDENTIALS).generateAuthLink(\n                process.env.API_URL + '/auth/step-1-callback'\n            );\n\n            const session = ctx.session as NinjaSession;\n            // store the relevant information in the session\n            session.twitterTokenSecret = ctx.session.twitterTokenSecret || {};\n            session.twitterTokenSecret[oauth_token] = oauth_token_secret;\n\n            // redirect to the authentication URL\n            ctx.redirect(url);\n        })\n        .get('/step-1-callback', async (ctx) => {\n            // check query params and session data\n            const { oauth_token, oauth_verifier } = ctx.query;\n            if (typeof oauth_token !== 'string' || typeof oauth_verifier !== 'string') {\n                ctx.body = { status: 'Oops, it looks like you refused to log in..' };\n                ctx.status = 401;\n                return;\n            }\n            const oauthTokenSecret = ctx.session.twitterTokenSecret?.[oauth_token];\n            if (typeof oauthTokenSecret !== 'string') {\n                ctx.body = {\n                    status: 'Oops, it looks like your session has expired.. Try again!',\n                };\n                ctx.status = 401;\n                return;\n            }\n\n            // fetch the token / secret / account infos (from the temporary one)\n            const loginResult = await new TwitterApi({\n                ..._STEP1_CREDENTIALS,\n                accessToken: oauth_token,\n                accessSecret: oauthTokenSecret,\n            }).login(oauth_verifier);\n\n            // fetch user info (and refresh the username cache)\n            let [params, category] = await Promise.all([\n                dao.getUserDao(loginResult.userId).getUserParams(),\n                dao.getUserDao(loginResult.userId).getCategory(),\n                dao.addTwittoToCache({\n                    id: loginResult.userId,\n                    username: loginResult.screenName,\n                }),\n            ]);\n\n            if (!params.token) {\n                // params = {} => the user doesn't exists, let's create it\n                params = {\n                    added_at: Date.now(),\n                    lang: DEFAULT_LANGUAGE,\n                    token: loginResult.accessToken,\n                    tokenSecret: loginResult.accessSecret,\n                };\n                category = UserCategory.disabled;\n                await dao.addUser({\n                    category,\n                    id: loginResult.userId,\n                    username: loginResult.screenName,\n                    ...params,\n                });\n\n                void dao.userEventDao.logWebEvent(\n                    loginResult.userId,\n                    WebEvent.createAccount,\n                    ctx.ip,\n                    loginResult.screenName\n                );\n            } else {\n                // not a new user\n                if (params.tokenSecret !== loginResult.accessSecret) {\n                    // after a revoked token => refresh the token\n                    await dao.getUserDao(loginResult.userId).setUserParams({\n                        token: loginResult.accessToken,\n                        tokenSecret: loginResult.accessSecret,\n                    });\n                }\n            }\n            const session = ctx.session as NinjaSession;\n            session.userId = loginResult.userId;\n            session.username = loginResult.screenName;\n\n            void dao.userEventDao.logWebEvent(loginResult.userId, WebEvent.signIn, ctx.ip, loginResult.screenName);\n            const country = geoip.lookup(ctx.ip)?.country;\n            const msgContent = encodeURI(\n                JSON.stringify({\n                    userId: loginResult.userId,\n                    username: loginResult.screenName,\n                    dmUsername:\n                        params.dmId && [UserCategory.enabled, UserCategory.vip].includes(category)\n                            ? await dao.getCachedUsername(params.dmId)\n                            : null,\n                    category,\n                    lang: params.lang,\n                    country,\n                    priceTags: getPriceTags(country),\n                    isPro: Number(params.pro) > 0,\n                    friendCodes:\n                        params.pro === '2' ? await dao.getUserDao(session.userId).getFriendCodesWithUsername() : null,\n                    hasSubscription: Boolean(params.customerId),\n                })\n            );\n\n            ctx.type = 'html';\n            ctx.body = `You successfully logged in! closing this window...\n      <script>\n        window.opener && window.opener.postMessage({msg: 'step1', content: \"${msgContent}\"}, '${process.env.WEB_URL}');\n        close();\n      </script>`;\n        })\n        .get('/step-2', async (ctx) => {\n            // Generate an authentication URL\n            const forceLogin = Boolean(ctx.query.force_login);\n            const { url, oauth_token, oauth_token_secret } = await new TwitterApi(_STEP2_CREDENTIALS).generateAuthLink(\n                process.env.API_URL + '/auth/step-2-callback',\n                {\n                    forceLogin,\n                }\n            );\n\n            // store the relevant information in the session\n            const session = ctx.session as NinjaSession;\n            session.twitterTokenSecret = ctx.session.twitterTokenSecret || {};\n            session.twitterTokenSecret[oauth_token] = oauth_token_secret;\n\n            // redirect to the authentication URL\n            ctx.redirect(url);\n        })\n        .get('/step-2-callback', async (ctx) => {\n            // check query params and session data\n            const { oauth_token, oauth_verifier } = ctx.query;\n            if (typeof oauth_token !== 'string' || typeof oauth_verifier !== 'string') {\n                ctx.body = { status: 'Oops, it looks like you refused to log in..' };\n                ctx.status = 401;\n                return;\n            }\n\n            const session = ctx.session as NinjaSession;\n            const oauthTokenSecret = session.twitterTokenSecret?.[oauth_token];\n            const userId = session.userId;\n            if (typeof oauthTokenSecret !== 'string' || typeof userId !== 'string') {\n                ctx.body = {\n                    status: 'Oops, it looks like your session has expired.. Try again!',\n                };\n                ctx.status = 401;\n                return;\n            }\n\n            // fetch the token / secret / account infos (from the temporary one)\n            const loginResult = await new TwitterApi({\n                ..._STEP2_CREDENTIALS,\n                accessToken: oauth_token,\n                accessSecret: oauthTokenSecret,\n            }).login(oauth_verifier);\n\n            // Add info about the DM account to the user's params\n            await Promise.all([\n                dao.getUserDao(userId).setUserParams({\n                    dmId: loginResult.userId,\n                    dmToken: loginResult.accessToken,\n                    dmTokenSecret: loginResult.accessSecret,\n                }),\n                dao.addTwittoToCache({\n                    id: loginResult.userId,\n                    username: loginResult.screenName,\n                }),\n            ]);\n            const category = await dao.getUserDao(userId).enable();\n\n            void dao.userEventDao.logWebEvent(\n                userId,\n                WebEvent.addDmAccount,\n                ctx.ip,\n                loginResult.screenName,\n                loginResult.userId\n            );\n            if (userId !== loginResult.userId) {\n                void dao.userEventDao.logWebEvent(\n                    loginResult.userId,\n                    WebEvent.addedAsSomeonesDmAccount,\n                    ctx.ip,\n                    session.username,\n                    userId\n                );\n            }\n\n            await queue.add(\n                'sendWelcomeMessage',\n                {\n                    id: Date.now(), // otherwise some seem stuck??\n                    userId,\n                    username: ctx.session.username,\n                },\n                { delay: 500 }\n            ); // otherwise it looks like it weirdly may start before setUserParams finished :/\n\n            const params = await dao.getUserDao(session.userId).getUserParams();\n            const country = geoip.lookup(ctx.ip)?.country;\n            const msgContent = encodeURI(\n                JSON.stringify({\n                    userId: session.userId,\n                    username: session.username,\n                    dmUsername: loginResult.screenName,\n                    category,\n                    lang: params.lang,\n                    country,\n                    priceTags: getPriceTags(country),\n                    isPro: Number(params.pro) > 0,\n                    friendCodes:\n                        params.pro === '2' ? await dao.getUserDao(session.userId).getFriendCodesWithUsername() : null,\n                    hasSubscription: Boolean(params.customerId),\n                })\n            );\n\n            ctx.type = 'html';\n            ctx.body = `You successfully logged in! closing this window...\n      <script>\n        window.opener && window.opener.postMessage({msg: 'step2', content: \"${msgContent}\"}, '${process.env.WEB_URL}');\n        close();\n      </script>`;\n        });\n}\n"
  },
  {
    "path": "unfollow-ninja-server/src/api/stripe.ts",
    "content": "import type { Queue } from 'bull';\nimport type { ParameterizedContext } from 'koa';\nimport Stripe from 'stripe';\nimport { getAllInfoByISO } from 'iso-country-currency';\n\nimport { WebEvent } from '../dao/userEventDao';\nimport { UserCategory } from '../dao/dao';\nimport type Dao from '../dao/dao';\n\nconst stripe = process.env.STRIPE_SK\n    ? new Stripe(process.env.STRIPE_SK, {\n          apiVersion: '2022-08-01',\n      })\n    : null;\n\nexport const handleWebhook = async (ctx: ParameterizedContext, dao: Dao, bullQueue: Queue) => {\n    const endpointSecret = process.env.STRIPE_WH_SECRET;\n    if (!stripe || !endpointSecret) {\n        return ctx.throw(404);\n    }\n    const signature = ctx.request.headers['stripe-signature'];\n    let event;\n    try {\n        event = stripe.webhooks.constructEvent(ctx.request['rawBody'], signature, endpointSecret);\n    } catch (err) {\n        return ctx.throw(400);\n    }\n    const subscription = event.data.object as Stripe.Subscription;\n    const { userId } = subscription.metadata;\n\n    switch (event.type) {\n        case 'customer.subscription.created':\n        case 'customer.subscription.updated':\n            if (subscription.status === 'active' || subscription.status === 'trialing') {\n                // enable pro\n                const plan = 'prod_KjHvT2vhr2F7tA' === subscription.items.data[0].plan.product ? 'friends' : 'pro';\n                await enablePro(dao, bullQueue, userId, plan, ctx.ip, subscription.id);\n                const customerId = subscription.customer as string;\n                await dao.getUserDao(userId).setUserParams({ customerId });\n                await stripe.customers.update(customerId, {\n                    metadata: subscription.metadata,\n                });\n            } else {\n                await disablePro(dao, userId, ctx.ip, subscription.id);\n            }\n            break;\n        case 'customer.subscription.deleted':\n            await disablePro(dao, userId, ctx.ip, subscription.id);\n            break;\n    }\n    ctx.status = 204;\n};\n\nexport const enablePro = async (\n    dao: Dao,\n    queue: Queue,\n    userId: string,\n    plan: 'friends' | 'pro',\n    ip: string,\n    subscriptionId: string\n) => {\n    const userDao = dao.getUserDao(userId);\n    const username = await dao.getCachedUsername(userId);\n\n    const wasPro = userDao.isPro();\n    if (plan === 'friends') {\n        void dao.userEventDao.logWebEvent(userId, WebEvent.enableFriends, ip, username, subscriptionId);\n        await userDao.setUserParams({ pro: '2' });\n        await userDao.addFriendCodes();\n    } else {\n        void dao.userEventDao.logWebEvent(userId, WebEvent.enablePro, ip, username, subscriptionId);\n        await userDao.setUserParams({ pro: '1' });\n        await disableFriendCodes(dao, userId, ip); // in case the update is a downgrade\n    }\n    if (UserCategory.enabled === (await userDao.getCategory())) {\n        await userDao.setCategory(UserCategory.vip);\n    }\n\n    if (!wasPro) {\n        await queue.add('sendWelcomeMessage', {\n            id: Date.now(),\n            userId,\n            username,\n            isPro: true,\n        });\n    }\n};\n\nexport const disablePro = async (dao: Dao, userId: string, ip: string, subscriptionId: string) => {\n    const userDao = dao.getUserDao(userId);\n    const username = await dao.getCachedUsername(userId);\n\n    await dao.userEventDao.logWebEvent(userId, WebEvent.disablePro, ip, username, subscriptionId);\n    await userDao.setUserParams({ pro: '0' });\n    await disableFriendCodes(dao, userId, ip);\n    if (UserCategory.vip === (await userDao.getCategory())) {\n        await userDao.setCategory(UserCategory.enabled);\n    }\n};\n\nconst disableFriendCodes = async (dao: Dao, userId: string, ip: string) => {\n    const userDao = dao.getUserDao(userId);\n    for (const code of await userDao.getFriendCodes()) {\n        if (code.friendId) {\n            // disable pro for friends too\n            const friendUsername = await dao.getCachedUsername(code.friendId);\n            void dao.userEventDao.logWebEvent(\n                userId,\n                WebEvent.disableFriendRegistration,\n                ip,\n                friendUsername,\n                code.friendId\n            );\n            void dao.userEventDao.logWebEvent(\n                code.friendId,\n                WebEvent.disableFriendRegistration,\n                ip,\n                friendUsername,\n                code.userId\n            );\n            await dao.getUserDao(code.friendId).setUserParams({ pro: '0' });\n            if (UserCategory.vip === (await dao.getUserDao(code.friendId).getCategory())) {\n                await dao.getUserDao(code.friendId).setCategory(UserCategory.enabled);\n            }\n        }\n        await userDao.deleteFriendCodes(code.code);\n    }\n};\n\nexport const generateProCheckoutUrl = async (\n    countryCode: string,\n    plan: 'pro' | 'friends',\n    userId: string,\n    username: string\n) => {\n    if (!stripe) {\n        return null;\n    }\n    const price = getPrice(countryCode);\n    const stripeSession = await stripe.checkout.sessions.create({\n        billing_address_collection: 'auto',\n        line_items: [\n            {\n                price: plan === 'friends' ? price.friendsId : price.proId,\n                quantity: 1,\n            },\n        ],\n        mode: 'subscription',\n        subscription_data: {\n            metadata: {\n                userId,\n                username,\n            },\n            trial_period_days: 10,\n        },\n        metadata: {\n            userId,\n            username,\n        },\n        allow_promotion_codes: false,\n        success_url: `${process.env.WEB_URL}`,\n        cancel_url: `${process.env.WEB_URL}`,\n    });\n    return stripeSession.url;\n};\n\nexport const getManageSubscriptionUrl = async (dao: Dao, userId: string) => {\n    if (!stripe) {\n        return null;\n    }\n    const customer = (await dao.getUserDao(userId).getUserParams()).customerId;\n    if (!customer) {\n        return null;\n    }\n    return (await stripe.billingPortal.sessions.create({ customer })).url;\n};\n\ninterface Price {\n    pro: number | string;\n    proId: string;\n    friends: number | string;\n    friendsId: string;\n    name: string;\n}\n\nconst PRICES: Record<string, Price> = {\n    USD: {\n        pro: 3,\n        proId: 'price_1K2j6qEwrjMfujSGZiTUPDH9',\n        friends: '5.99',\n        friendsId: 'price_1MEILOEwrjMfujSGgFOJapDB',\n        name: 'dollars',\n    },\n    EUR: {\n        pro: '2.50',\n        proId: 'price_1K58S6EwrjMfujSGdHu2h1k8',\n        friends: '4.99',\n        friendsId: 'price_1MEIN4EwrjMfujSGl6qMokU5',\n        name: 'euros',\n    },\n    IDR: {\n        pro: '10 000',\n        proId: 'price_1KBRhYEwrjMfujSGy1mftPTe',\n        friends: '29 000',\n        friendsId: 'price_1MEIPkEwrjMfujSGHk9vU8JU',\n        name: 'rupiah',\n    },\n    PHP: {\n        pro: 120,\n        proId: 'price_1K58ddEwrjMfujSGGoga3KB7',\n        friends: 190,\n        friendsId: 'price_1K58dsEwrjMfujSGyAXvzoMz',\n        name: 'pesos',\n    },\n    BRL: {\n        pro: 10,\n        proId: 'price_1KBRgzEwrjMfujSGy158RWWu',\n        friends: 15,\n        friendsId: 'price_1KBRdPEwrjMfujSGxfIMGqYq',\n        name: 'reais',\n    },\n    GBP: {\n        pro: 2,\n        proId: 'price_1K58isEwrjMfujSGhUcEq77E',\n        friends: '3.50',\n        friendsId: 'price_1K58jcEwrjMfujSG5qXVO8mB',\n        name: 'pounds',\n    },\n};\n\nconst getPrice = (countryCode: string) => {\n    let currency;\n    try {\n        currency = getAllInfoByISO(countryCode).currency;\n    } catch {\n        currency = 'USD';\n    }\n    return PRICES[currency] || PRICES.USD;\n};\n\nexport const getPriceTags = (countryCode: string) => {\n    const price = getPrice(countryCode);\n    return {\n        pro: price.pro + ' ' + price.name,\n        friends: price.friends + ' ' + price.name,\n    };\n};\n"
  },
  {
    "path": "unfollow-ninja-server/src/api/user.ts",
    "content": "import Router from 'koa-router';\nimport type { Queue } from 'bull';\nimport geoip from 'geoip-country';\n\nimport type Dao from '../dao/dao';\nimport type { NinjaSession } from '../api';\nimport { UserCategory } from '../dao/dao';\nimport { WebEvent } from '../dao/userEventDao';\nimport { SUPPORTED_LANGUAGES_CONST } from '../utils/utils';\nimport { generateProCheckoutUrl, getManageSubscriptionUrl } from './stripe';\n\nexport function createUserRouter(dao: Dao, queue: Queue) {\n    return (\n        new Router()\n            .use(async (ctx, next) => {\n                const session = ctx.session as NinjaSession;\n                if (!session.userId) {\n                    await ctx.throw(401);\n                    return;\n                }\n                await next();\n            })\n            .post('/disable', async (ctx) => {\n                const session = ctx.session as NinjaSession;\n                await dao.getUserDao(session.userId).setCategory(UserCategory.disabled);\n                await dao.getUserDao(session.userId).setUserParams({\n                    dmId: null,\n                    dmToken: null,\n                    dmTokenSecret: null,\n                });\n                void dao.userEventDao.logWebEvent(session.userId, WebEvent.disable, ctx.ip, session.username);\n                ctx.status = 204;\n            })\n            .post('/logout', async (ctx) => {\n                const session = ctx.session as NinjaSession;\n                void dao.userEventDao.logWebEvent(session.userId, WebEvent.logout, ctx.ip, session.username);\n                session.userId = null;\n                session.username = null;\n                ctx.status = 204;\n            })\n            .put('/lang', async (ctx) => {\n                const session = ctx.session as NinjaSession;\n                const lang = ctx.request['body']?.['lang'];\n                if (!SUPPORTED_LANGUAGES_CONST.includes(lang)) {\n                    await ctx.throw(400);\n                    return;\n                }\n                await dao.getUserDao(session.userId).setUserParams({ lang });\n                void dao.userEventDao.logWebEvent(session.userId, WebEvent.setLang, ctx.ip, session.username, lang);\n\n                await queue.add('sendWelcomeMessage', {\n                    id: Date.now(), // otherwise some seem stuck??\n                    userId: session.userId,\n                    username: session.username,\n                });\n\n                ctx.status = 204;\n            })\n            .put('/registerFriendCode', async (ctx) => {\n                // /!\\ to rate limit\n                const session = ctx.session as NinjaSession;\n                const code = ctx.request['body']?.['code'];\n\n                void dao.userEventDao.logWebEvent(\n                    session.userId,\n                    WebEvent.tryFriendCode,\n                    ctx.ip,\n                    session.username,\n                    code\n                );\n                if (code.length !== 6) {\n                    ctx.throw(400);\n                    return;\n                }\n                const success = await dao.getUserDao(session.userId).registerFriendCode(code);\n                if (!success) {\n                    ctx.throw(404);\n                    return;\n                }\n\n                void dao.userEventDao.logWebEvent(\n                    session.userId,\n                    WebEvent.registeredAsFriend,\n                    ctx.ip,\n                    session.username,\n                    code\n                );\n                await dao.getUserDao(session.userId).setUserParams({ pro: '3' });\n                await dao.getUserDao(session.userId).setCategory(UserCategory.vip);\n\n                await queue.add('sendWelcomeMessage', {\n                    id: Date.now(), // otherwise some seem stuck??\n                    userId: session.userId,\n                    username: session.username,\n                    isPro: true,\n                });\n\n                ctx.status = 204;\n            })\n            /*.get('/buy-pro', async (ctx) => {\n            const session = ctx.session as NinjaSession;\n            const country = geoip.lookup(ctx.ip)?.country;\n            const checkoutUrl = await generateProCheckoutUrl(country, 'pro', session.userId, session.username);\n            if (!checkoutUrl) {\n                // stripe disabled\n                ctx.throw(404);\n                return;\n            }\n\n            ctx.redirect(checkoutUrl);\n        })*/\n            .get('/buy-friends', async (ctx) => {\n                const session = ctx.session as NinjaSession;\n                const country = geoip.lookup(ctx.ip)?.country;\n                const checkoutUrl = await generateProCheckoutUrl(country, 'friends', session.userId, session.username);\n                if (!checkoutUrl) {\n                    // stripe disabled\n                    ctx.throw(404);\n                    return;\n                }\n\n                ctx.redirect(checkoutUrl);\n            })\n            .get('/manage-subscription', async (ctx) => {\n                const session = ctx.session as NinjaSession;\n                const manageSubscriptionUrl = await getManageSubscriptionUrl(dao, session.userId);\n                if (!manageSubscriptionUrl) {\n                    // stripe disabled or no customerId\n                    ctx.body = 'No subscription could be found on this account.';\n                    ctx.status = 404;\n                    return;\n                }\n\n                ctx.redirect(manageSubscriptionUrl);\n            })\n            .get('/latest-notifications', async (ctx) => {\n                const session = ctx.session as NinjaSession;\n                const events = await dao.userEventDao.getNotificationEvents(session.userId, 30, 0);\n\n                ctx.body = events.map((event) => ({\n                    sentAt: event.createdAt,\n                    message: event.message,\n                }));\n            })\n    );\n}\n"
  },
  {
    "path": "unfollow-ninja-server/src/api.ts",
    "content": "import 'dotenv/config';\n\nimport * as Sentry from '@sentry/node';\nimport '@sentry/tracing';\nimport Koa from 'koa';\nimport Router from 'koa-router';\nimport koaSession from 'koa-session';\nimport koaBodyParser from 'koa-bodyparser';\nimport koaCors from '@koa/cors';\nimport Bull from 'bull';\nimport geoip from 'geoip-country';\n\nimport Dao, { UserCategory } from './dao/dao';\nimport logger, { setLoggerPrefix } from './utils/logger';\nimport { createAuthRouter } from './api/auth';\nimport { createAdminRouter } from './api/admin';\nimport { createUserRouter } from './api/user';\nimport { getPriceTags, handleWebhook } from './api/stripe';\n\nfunction assertEnvVariable(name: string) {\n    if (typeof process.env[name] === 'undefined') {\n        logger.error(`Some required environment variables are missing (${name}).`);\n        logger.error('Make sure you added them in a .env file in you cwd or that you defined them.');\n        process.exit();\n    }\n}\nassertEnvVariable('WEB_URL');\nassertEnvVariable('COOKIE_SIGNING_KEY');\n\nsetLoggerPrefix('api');\n\nconst SENTRY_DSN = process.env.SENTRY_DSN_API || undefined;\nif (SENTRY_DSN) {\n    Sentry.init({ dsn: SENTRY_DSN, tracesSampleRate: 0.1 });\n}\n\nassertEnvVariable('REDIS_URI');\nassertEnvVariable('POSTGRES_URI');\nconst dao = new Dao();\nconst bullQueue = new Bull('ninja', process.env.REDIS_BULL_URI, {\n    defaultJobOptions: {\n        attempts: 3,\n        backoff: 60000,\n        removeOnComplete: true,\n        removeOnFail: true,\n    },\n});\nbullQueue.on('error', (err) => {\n    logger.error('Bull error: ' + err.stack);\n    Sentry.captureException(err);\n});\n\nconst authRouter = createAuthRouter(dao, bullQueue);\nconst userRouter = createUserRouter(dao, bullQueue);\nconst adminRouter = createAdminRouter(dao, bullQueue);\n\nexport interface NinjaSession {\n    twitterTokenSecret?: Record<string, string>;\n    userId?: string;\n    username?: string;\n}\n\nconst router = new Router()\n    .get('/', (ctx) => {\n        ctx.body = { status: 'ᕕ( ᐛ )ᕗ Hello, fellow human' };\n    })\n    .get('/robots.txt', (ctx) => {\n        ctx.body = 'User-agent: *\\nDisallow: /';\n    })\n    .use('/auth', authRouter.routes(), authRouter.allowedMethods())\n    .use('/admin', adminRouter.routes(), adminRouter.allowedMethods())\n    .all(\n        '/(.*)',\n        koaCors({\n            // only routes below are allowed for CORS\n            origin: process.env.WEB_URL,\n            credentials: true,\n        })\n    )\n    .use('/user', userRouter.routes(), userRouter.allowedMethods())\n    .get('/get-status', async (ctx) => {\n        const session = ctx.session as NinjaSession;\n        if (!session.userId) {\n            ctx.body = {\n                country: geoip.lookup(ctx.ip)?.country,\n            };\n        } else {\n            const [params, category] = await Promise.all([\n                dao.getUserDao(session.userId).getUserParams(),\n                dao.getUserDao(session.userId).getCategory(),\n            ]);\n            const country = geoip.lookup(ctx.ip)?.country;\n            ctx.body = {\n                userId: session.userId,\n                username: session.username,\n                dmUsername:\n                    params.dmId && [UserCategory.enabled, UserCategory.vip].includes(category)\n                        ? await dao.getCachedUsername(params.dmId)\n                        : null,\n                category,\n                lang: params.lang,\n                country,\n                priceTags: getPriceTags(country),\n                isPro: Number(params.pro) > 0,\n                friendCodes:\n                    params.pro === '2' ? await dao.getUserDao(session.userId).getFriendCodesWithUsername() : null,\n                hasSubscription: Boolean(params.customerId),\n            };\n        }\n    })\n    .post('/stripe-webhook', (ctx) => handleWebhook(ctx, dao, bullQueue));\n\n// Create the server app with its router/log/session and error management\nconst app = new Koa();\napp.keys = [process.env.COOKIE_SIGNING_KEY]; // random key used to sign cookies\napp.proxy = true;\napp.use(async (ctx, next) => {\n    const start = Date.now();\n    await next();\n    logger.info(`${ctx.method} ${ctx.url} - ${Date.now() - start}ms`);\n})\n    .use(\n        koaSession(\n            {\n                store: {\n                    get: (key) => dao.getSession(key),\n                    set: (key, sess) => dao.setSession(key, sess),\n                    destroy: (key) => dao.deleteSession(key),\n                },\n                maxAge: 3600000, // 1h\n            },\n            app\n        )\n    )\n    .use(koaBodyParser())\n    .use(router.routes())\n    .use(router.allowedMethods())\n    .on('error', (err, ctx) => {\n        logger.error(err.stack);\n        Sentry.withScope((scope) => {\n            scope.addEventProcessor((event) => {\n                return Sentry.Handlers.parseRequest(event, ctx.request);\n            });\n            Sentry.captureException(err);\n        });\n    });\n\nlogger.info('Connecting to the databases...');\ndao.load()\n    .then(() => {\n        app.listen(4000);\n        logger.info(`🚀 Server ready at http://localhost:4000`);\n    })\n    .catch((err) => {\n        Sentry.captureException(err);\n        logger.error(err);\n    });\n"
  },
  {
    "path": "unfollow-ninja-server/src/dao/dao.ts",
    "content": "import Redis from 'ioredis';\nimport { DataTypes, Model, Sequelize } from 'sequelize';\nimport type { ModelStatic } from 'sequelize/types/model';\nimport cluster from 'cluster';\n\nimport { ITwittoInfo, IUserEgg, IUserParams, Session } from '../utils/types';\nimport UserDao from './userDao';\nimport UserEventDao from './userEventDao';\n\nexport enum UserCategory {\n    enabled,\n    suspended,\n    revoked,\n    disabled,\n    dmclosed,\n    accountClosed,\n    vip,\n}\n\ninterface ICachedUsername extends Model {\n    twitterId: string;\n    username: string;\n}\nexport interface IFriendCode extends Model {\n    code: string;\n    userId: string;\n    friendId?: string;\n}\n\nexport default class Dao {\n    public readonly redis: Redis;\n    public readonly sequelize: Sequelize;\n    public readonly sequelizeLogs: Sequelize;\n    public readonly sequelizeFollowers: Sequelize;\n    public readonly userEventDao: UserEventDao;\n\n    private readonly CachedUsername: ModelStatic<ICachedUsername>;\n    public readonly FriendCode: ModelStatic<IFriendCode>;\n\n    constructor(\n        redis = new Redis(process.env.REDIS_URI, { lazyConnect: true }),\n        sequelize = new Sequelize(process.env.POSTGRES_URI, {\n            logging: false,\n            dialectOptions: {\n                application_name: 'UnfollowMonkey - ' + (cluster.worker ? `worker ${cluster.worker.id}` : 'master'),\n                statement_timeout: 30000,\n            },\n        }),\n        sequelizeLogs = new Sequelize(process.env.POSTGRES_LOGS_URI, {\n            logging: false,\n            dialectOptions: {\n                application_name: 'UnfollowMonkey - ' + (cluster.worker ? `worker ${cluster.worker.id}` : 'master'),\n                statement_timeout: 30000,\n            },\n        }),\n        sequelizeFollowers = new Sequelize(process.env.POSTGRES_FOLLOWERS_URI, {\n            logging: false,\n            dialectOptions: {\n                application_name: 'UnfollowMonkey - ' + (cluster.worker ? `worker ${cluster.worker.id}` : 'master'),\n                statement_timeout: 30000,\n            },\n        })\n    ) {\n        this.redis = redis;\n        this.sequelize = sequelize;\n        this.sequelizeLogs = sequelizeLogs;\n        this.sequelizeFollowers = sequelizeFollowers;\n\n        this.CachedUsername = this.sequelize.define('CachedUsername', {\n            twitterId: {\n                type: DataTypes.STRING(30),\n                allowNull: false,\n                primaryKey: true,\n            },\n            username: { type: DataTypes.STRING(20), allowNull: false },\n        });\n        this.FriendCode = this.sequelize.define(\n            'FriendCode',\n            {\n                code: { type: DataTypes.STRING(6), allowNull: false, primaryKey: true },\n                userId: { type: DataTypes.STRING(30), allowNull: false },\n                friendId: { type: DataTypes.STRING(30), allowNull: true },\n            },\n            {\n                indexes: [{ fields: ['userId'] }, { fields: ['friendId'] }],\n            }\n        );\n        this.userEventDao = new UserEventDao(this);\n    }\n\n    /**\n     * Wait for the databases to be connected, and create the tables if necessary\n     */\n    public async load(): Promise<Dao> {\n        await Promise.all([\n            // check that postgresql is connected\n            await this.sequelize.authenticate(),\n            await this.sequelizeLogs.authenticate(),\n        ]);\n        await this.CachedUsername.sync(); // create the missing postgresql tables\n        await this.FriendCode.sync();\n        await this.userEventDao.createTables();\n        await this.getUserDao('').createTables();\n        await this.redis.connect(); // wait for redis to load its data\n        return this;\n    }\n\n    public async disconnect() {\n        this.redis.disconnect();\n        await Promise.all([this.sequelize.close(), this.sequelizeLogs.close()]);\n    }\n\n    public getUserDao(userId: string) {\n        return new UserDao(userId, this);\n    }\n\n    public async addUser(userEgg: IUserEgg): Promise<void> {\n        const {\n            id,\n            category,\n            username,\n            added_at,\n            lang,\n            token,\n            tokenSecret,\n            dmId,\n            dmToken,\n            dmTokenSecret,\n            pro,\n            customerId,\n        } = userEgg;\n        const params: IUserParams = {\n            added_at,\n            lang,\n            token,\n            tokenSecret,\n            dmId,\n            dmToken,\n            dmTokenSecret,\n            pro,\n            customerId,\n        };\n        await Promise.all([\n            this.redis.zadd('users', category.toString(), id),\n            this.redis.hmset(`user:${id}`, params),\n            this.addTwittoToCache({ id, username }),\n        ]);\n    }\n\n    public async getUserIds(): Promise<string[]> {\n        return this.redis.zrange('users', 0, -1);\n    }\n\n    public async getUserIdsByCategory(category: UserCategory): Promise<string[]> {\n        return this.redis.zrangebyscore('users', category, category);\n    }\n\n    public async getUserCountByCategory(): Promise<Record<UserCategory, number>> {\n        const nbCategory = Object.keys(UserCategory).length / 2; // not super clean but I have no better idea\n        const counts = await Promise.all(\n            new Array(nbCategory).fill(null).map((_, category) => this.redis.zcount('users', category, category))\n        );\n        return Object.fromEntries(counts.map((count, category) => [category, count])) as Record<UserCategory, number>;\n    }\n\n    public async getCachedUsername(userId: string): Promise<string> {\n        return (await this.CachedUsername.findByPk(userId, { attributes: ['username'] }))?.username || null;\n    }\n\n    public async getCachedUserId(username: string): Promise<string> {\n        return (\n            (await this.CachedUsername.findOne({ where: { username }, attributes: ['twitterId'] }))?.twitterId || null\n        );\n    }\n\n    public async addTwittoToCache(twittoInfo: ITwittoInfo): Promise<void> {\n        const { id, username } = twittoInfo;\n        if (username.length > 20 && username.startsWith('erased_')) {\n            return; // these are weird deleted users 'erased_{userid}'\n        }\n        await this.CachedUsername.upsert({ twitterId: id, username }, { returning: false });\n    }\n\n    public async getSession(uid: string): Promise<Session> {\n        return JSON.parse((await this.redis.get(`session:${uid}`)) || '{}');\n    }\n\n    public async setSession(uid: string, params: Record<string, string>): Promise<void> {\n        await this.redis.set(`session:${uid}`, JSON.stringify(params));\n        await this.redis.expire(`session:${uid}`, 3600); // 1h sessions\n    }\n\n    public async deleteSession(uid: string): Promise<void> {\n        await this.redis.del(`session:${uid}`);\n    }\n\n    public async getTokenSecret(token: string): Promise<string> {\n        return (await this.redis.get(`tokensecret:${token}`)) || null;\n    }\n\n    public async setTokenSecret(token: string, secret: string): Promise<void> {\n        await this.redis.set(`tokensecret:${token}`, secret);\n        await this.redis.expire(`tokensecret:${token}`, 1200); // 20min memory (lasts <10min on twitter side)\n    }\n}\n"
  },
  {
    "path": "unfollow-ninja-server/src/dao/userDao.ts",
    "content": "import Redis from 'ioredis';\nimport Twit from 'twit';\nimport { TwitterApi } from 'twitter-api-v2';\nimport crypto from 'crypto';\nimport { DataTypes, InferAttributes, InferCreationAttributes, Model, Op } from 'sequelize';\nimport type { ModelStatic } from 'sequelize/types/model';\n\nimport type { default as Dao, IFriendCode } from './dao';\nimport { UserCategory } from './dao';\nimport type { IUserParams, Lang } from '../utils/types';\nimport { twitterCursorToTime } from '../utils/utils';\n\ninterface ITemporaryFollowerList\n    extends Model<InferAttributes<ITemporaryFollowerList>, InferCreationAttributes<ITemporaryFollowerList>> {\n    userId: string;\n    nextCursor: string;\n    followers: string;\n}\n\ninterface IFollowersDetail extends Model<InferAttributes<IFollowersDetail>, InferCreationAttributes<IFollowersDetail>> {\n    userId: string;\n    followerId: string;\n    followDetected: number;\n    snowflakeId: string;\n    uncachable: boolean;\n}\n\nexport default class UserDao {\n    private readonly redis: Redis;\n    private readonly dao: Dao;\n    private readonly userId: string;\n\n    private readonly temporaryFollowerList: ModelStatic<ITemporaryFollowerList>;\n    private readonly followersDetail: ModelStatic<IFollowersDetail>;\n\n    constructor(userId: string, dao: Dao) {\n        this.userId = userId;\n        this.dao = dao;\n        this.redis = dao.redis;\n\n        this.temporaryFollowerList = dao.sequelize.define(\n            'temporaryFollowerList',\n            {\n                userId: { type: DataTypes.STRING(30), allowNull: false, primaryKey: true },\n                nextCursor: { type: DataTypes.STRING(20), allowNull: false },\n                followers: { type: DataTypes.TEXT, allowNull: false },\n            },\n            {\n                timestamps: true,\n            }\n        );\n\n        this.followersDetail = dao.sequelizeFollowers.define(\n            'followersDetail',\n            {\n                userId: { type: DataTypes.STRING(30), allowNull: false, primaryKey: true },\n                followerId: { type: DataTypes.STRING(30), allowNull: false, primaryKey: true },\n                followDetected: { type: DataTypes.INTEGER, allowNull: true },\n                snowflakeId: { type: DataTypes.STRING(30), allowNull: true },\n                uncachable: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false },\n            },\n            {\n                timestamps: false,\n                indexes: [{ fields: ['userId'] }],\n            }\n        );\n    }\n\n    public async createTables() {\n        await this.temporaryFollowerList.sync();\n        await this.followersDetail.sync();\n    }\n\n    public getUsername(): Promise<string> {\n        return this.dao.getCachedUsername(this.userId);\n    }\n\n    public async getCategory(): Promise<UserCategory> {\n        return Number((await this.redis.zscore('users', this.userId)) ?? 3); // default = disabled\n    }\n\n    public async setCategory(category: UserCategory): Promise<void> {\n        await Promise.all([\n            this.dao.userEventDao.logCategoryEvent(this.userId, category, await this.getCategory()),\n            this.redis.zadd('users', category.toString(), this.userId),\n        ]);\n    }\n\n    public async enable(): Promise<UserCategory.enabled | UserCategory.vip> {\n        const proParam = await this.redis.hget(`user:${this.userId}`, 'pro');\n        if (Number(proParam) > 0) {\n            await this.setCategory(UserCategory.vip);\n            return UserCategory.vip;\n        } else {\n            await this.setCategory(UserCategory.enabled);\n            return UserCategory.enabled;\n        }\n    }\n\n    // get the minimum timestamp required to do the next followers check\n    // e.g if there are not enough requests left, it's twitter's next reset time\n    // e.g if a check needs 4 requests, it's probably in 3min30 (twitter limit = 15/15min)\n    // (default: 0)\n    public async getNextCheckTime(): Promise<number> {\n        return this.redis.get(`nextCheckTime:${this.userId}`).then((nextCheckTime) => Number(nextCheckTime));\n    }\n\n    // see above\n    public async setNextCheckTime(nextCheckTime: number | string): Promise<void> {\n        await this.redis.set(`nextCheckTime:${this.userId}`, nextCheckTime.toString());\n    }\n\n    // for big accounts (>150k), we need to scrap the followers in multiple chunks every 15min\n    public async getTemporaryFollowerList(): Promise<{ nextCursor: string; followers: string[] } | null> {\n        const followerList = await this.temporaryFollowerList.findByPk(this.userId, {\n            attributes: ['nextCursor', 'followers'],\n        });\n        return followerList && { nextCursor: followerList.nextCursor, followers: followerList.followers.split(',') };\n    }\n\n    // see above\n    public async setTemporaryFollowerList(nextCursor: string, followers: string[]): Promise<void> {\n        await this.temporaryFollowerList.upsert(\n            { userId: this.userId, nextCursor, followers: followers.join(',') },\n            { returning: false }\n        );\n    }\n\n    // see above\n    public async deleteTemporaryFollowerList(): Promise<void> {\n        await this.temporaryFollowerList.destroy({ where: { userId: this.userId } });\n    }\n\n    public async getUserParams(): Promise<IUserParams> {\n        const stringUserParams = (await this.redis.hgetall(`user:${this.userId}`)) as Record<keyof IUserParams, string>;\n        return {\n            ...stringUserParams,\n            added_at: parseInt(stringUserParams.added_at, 10),\n            lang: stringUserParams.lang as Lang,\n            pro: (stringUserParams.pro || '0') as '3' | '2' | '1' | '0',\n        };\n    }\n\n    public async setUserParams(userParams: Partial<IUserParams>): Promise<void> {\n        await this.redis.hmset(`user:${this.userId}`, userParams);\n    }\n\n    public async getTwit(): Promise<Twit> {\n        const [token, tokenSecret] = await this.redis.hmget(`user:${this.userId}`, 'token', 'tokenSecret');\n        if (!token || !tokenSecret) {\n            throw new Error(\"Tried to create a new Twit client but the user didn't have any credentials stored\");\n        }\n        return new Twit({\n            access_token: token,\n            access_token_secret: tokenSecret,\n            consumer_key: process.env.CONSUMER_KEY,\n            consumer_secret: process.env.CONSUMER_SECRET,\n        });\n    }\n\n    public async getTwitterApi(): Promise<TwitterApi> {\n        const [token, tokenSecret] = await this.redis.hmget(`user:${this.userId}`, 'token', 'tokenSecret');\n        if (!token || !tokenSecret) {\n            throw new Error(\"Tried to create a new twitter client but the user didn't have any credentials stored\");\n        }\n        return new TwitterApi({\n            accessToken: token,\n            accessSecret: tokenSecret,\n            appKey: process.env.CONSUMER_KEY,\n            appSecret: process.env.CONSUMER_SECRET,\n        });\n    }\n\n    public async getDmTwit(): Promise<Twit> {\n        const [dmToken, dmTokenSecret] = await this.redis.hmget(`user:${this.userId}`, 'dmToken', 'dmTokenSecret');\n        if (!dmToken || !dmTokenSecret) {\n            throw new Error(\"Tried to create a new Twit DM client but the user didn't have any DM credentials stored\");\n        }\n        return new Twit({\n            access_token: dmToken,\n            access_token_secret: dmTokenSecret,\n            consumer_key: process.env.DM_CONSUMER_KEY,\n            consumer_secret: process.env.DM_CONSUMER_SECRET,\n        });\n    }\n\n    public async getDmTwitterApi(): Promise<TwitterApi> {\n        const [dmToken, dmTokenSecret] = await this.redis.hmget(`user:${this.userId}`, 'dmToken', 'dmTokenSecret');\n        if (!dmToken || !dmTokenSecret) {\n            throw new Error(\"Tried to create a new Twit DM client but the user didn't have any DM credentials stored\");\n        }\n        return new TwitterApi({\n            accessToken: dmToken,\n            accessSecret: dmTokenSecret,\n            appKey: process.env.DM_CONSUMER_KEY,\n            appSecret: process.env.DM_CONSUMER_SECRET,\n        });\n    }\n\n    public async getLang(): Promise<Lang> {\n        return (await this.redis.hget(`user:${this.userId}`, 'lang')) as Lang;\n    }\n\n    public async isPro(): Promise<boolean> {\n        return Number(await this.redis.hget(`user:${this.userId}`, 'pro')) > 0;\n    }\n\n    public getDmId(): Promise<string> {\n        return this.redis.hget(`user:${this.userId}`, 'dmId');\n    }\n\n    // list of follower IDs stored during last checkFollowers (in Twitter's order)\n    // return null if there are no IDs\n    public async getFollowers(): Promise<string[]> {\n        return JSON.parse(await this.redis.get(`followers:${this.userId}`));\n    }\n\n    public async updateFollowers(\n        followers: string[], // every follower, in Twitter's order\n        newFollowers: string[], // followers to add\n        unfollowers: string[], // followers to remove\n        addedTime: number // timestamp in ms for new followers\n    ): Promise<void> {\n        // insert chunks of 100 new followers\n        const newFollowersChunks = Array.from({ length: Math.ceil(newFollowers.length / 100) }, (v, i) =>\n            newFollowers.slice(i * 100, i * 100 + 100)\n        );\n        for (const chunk of newFollowersChunks) {\n            await this.followersDetail.bulkCreate(\n                chunk.map((followerId) => ({\n                    userId: this.userId,\n                    followerId,\n                    followDetected: addedTime / 1000 || null,\n                })),\n                { returning: false, ignoreDuplicates: true }\n            );\n        }\n\n        // remove chunks of 100 unfollowers\n        const unfollowersChunks = Array.from({ length: Math.ceil(unfollowers.length / 100) }, (v, i) =>\n            unfollowers.slice(i * 100, i * 100 + 100)\n        );\n        for (const chunk of unfollowersChunks) {\n            await this.followersDetail.destroy({ where: { userId: this.userId, followerId: chunk } });\n        }\n\n        await Promise.all([\n            this.redis.set(`followers:${this.userId}`, JSON.stringify(followers)),\n            this.redis.set(`followers:count:${this.userId}`, followers.length.toString()),\n            unfollowers.length > 0 && this.redis.incrby('total-unfollowers', unfollowers.length),\n        ]);\n    }\n\n    public async setFollowerSnowflakeId(followerId: string, snowflakeId: string): Promise<void> {\n        await this.followersDetail.update(\n            { snowflakeId },\n            { where: { userId: this.userId, followerId }, returning: false }\n        );\n    }\n\n    // get twitter cached snowflakeId (containing the follow timing information)\n    // returns null if not cached yet\n    public async getFollowerSnowflakeId(followerId: string): Promise<string | null> {\n        return (\n            (\n                await this.followersDetail.findOne({\n                    where: { userId: this.userId, followerId },\n                    attributes: ['snowflakeId'],\n                })\n            )?.snowflakeId || null\n        );\n    }\n\n    // Some followers ids weirdly can't be cached (disabled?)\n    public async getUncachableFollowers(): Promise<string[]> {\n        return (\n            await this.followersDetail.findAll({\n                where: { userId: this.userId, uncachable: true },\n                attributes: ['followerId'],\n            })\n        ).map((row) => row.followerId);\n    }\n\n    public async addUncachableFollower(followerId: string): Promise<void> {\n        await this.followersDetail.update(\n            { uncachable: true },\n            { where: { userId: this.userId, followerId }, returning: false }\n        );\n    }\n\n    // get the timestamp (in ms) when the follower followed the user.\n    // determined from the cached snowflakeId or from the time it was added in DB\n    public async getFollowTime(followerId: string): Promise<number> {\n        return (\n            twitterCursorToTime(await this.getFollowerSnowflakeId(followerId)) || this.getFollowDetectedTime(followerId)\n        );\n    }\n\n    // get the timestamp when the follower was added to the db (in ms)\n    public async getFollowDetectedTime(followerId: string): Promise<number | null> {\n        return (\n            (\n                await this.followersDetail.findOne({\n                    where: { userId: this.userId, followerId },\n                    attributes: ['followDetected'],\n                })\n            )?.followDetected * 1000 || null\n        );\n    }\n\n    // return true if some followers were never cached by cacheFollowers\n    public async getHasNotCachedFollowers(): Promise<boolean> {\n        return (\n            Number(await this.redis.get(`followers:count:${this.userId}`)) < 30000 &&\n            Boolean(\n                await this.followersDetail.findOne({\n                    where: { userId: this.userId, snowflakeId: { [Op.is]: null }, uncachable: false },\n                    attributes: ['userId'],\n                    limit: 1,\n                })\n            )\n        );\n    }\n\n    public async getCachedFollowers(): Promise<string[]> {\n        const cachedFollowers = (\n            await this.followersDetail.findAll({\n                where: { userId: this.userId, snowflakeId: { [Op.not]: null } },\n                order: ['followerId'],\n                offset: 0,\n                limit: 5000,\n                attributes: ['followerId'],\n            })\n        ).map((row) => row.followerId);\n\n        // iterate if > 5000 followers (to avoid long queries)\n        let nextCachedFollowers = cachedFollowers;\n        let offset = 5000;\n        while (nextCachedFollowers.length === 5000) {\n            nextCachedFollowers = (\n                await this.followersDetail.findAll({\n                    where: { userId: this.userId, snowflakeId: { [Op.not]: null } },\n                    order: ['followerId'],\n                    offset,\n                    limit: 5000,\n                    attributes: ['followerId'],\n                })\n            ).map((row) => row.followerId);\n            cachedFollowers.push(...nextCachedFollowers);\n            offset += 5000;\n        }\n        return cachedFollowers;\n    }\n\n    public async getFriendCodes(): Promise<IFriendCode[]> {\n        return await this.dao.FriendCode.findAll({\n            where: { userId: this.userId },\n        });\n    }\n\n    public async getFriendCodesWithUsername(): Promise<{ code: string; friendUsername: string }[]> {\n        return Promise.all(\n            (await this.dao.FriendCode.findAll({ where: { userId: this.userId } })).map(async (code) => ({\n                code: code.code,\n                friendUsername: code.friendId && (await this.dao.getCachedUsername(code.friendId)),\n            }))\n        );\n    }\n\n    // Add friend codes until there are 5 of them\n    public async addFriendCodes(): Promise<void> {\n        const nbCodes = (await this.getFriendCodes()).length;\n        if (nbCodes > 5) {\n            throw new Error(this.userId + ' has more than 5 friend codes - should not happen');\n        }\n        for (let i = 0; i < 5 - nbCodes; ++i) {\n            const code = crypto.randomBytes(3).toString('hex').toUpperCase();\n            await this.dao.FriendCode.create({ userId: this.userId, code });\n        }\n    }\n\n    public async deleteFriendCodes(code: string): Promise<void> {\n        await this.dao.FriendCode.destroy({ where: { userId: this.userId, code } });\n    }\n\n    public async registerFriendCode(code: string): Promise<boolean> {\n        const [nbUpdates] = await this.dao.FriendCode.update(\n            { friendId: this.userId },\n            { where: { code, friendId: null } }\n        );\n        return nbUpdates === 1;\n    }\n\n    public async getRegisteredFriendCode(): Promise<IFriendCode> {\n        return await this.dao.FriendCode.findOne({\n            where: { friendId: this.userId },\n        });\n    }\n\n    public async getAllUserData() {\n        const [\n            username,\n            category,\n            nextCheckTime,\n            userParams,\n            followers,\n            friendCodes,\n            registeredFriendCode,\n            temporaryFollowerList,\n            followersDetail,\n        ] = await Promise.all([\n            this.getUsername(),\n            this.getCategory(),\n            this.getNextCheckTime(),\n            this.getUserParams(),\n            this.getFollowers(),\n            this.getFriendCodes(),\n            this.getRegisteredFriendCode(),\n            this.getTemporaryFollowerList(),\n            this.followersDetail.findAll({ where: { userId: this.userId }, raw: true }),\n        ]);\n\n        // while running unit tests, with sqlite, booleans are numbers.\n        followersDetail.forEach((detail) => (detail.uncachable = Boolean(detail.uncachable)));\n\n        return {\n            username,\n            category,\n            nextCheckTime,\n            userParams,\n            followers,\n            friendCodes,\n            registeredFriendCode,\n            temporaryFollowerList,\n            followersDetail,\n        };\n    }\n\n    // delete follower data about revoked users\n    // to save some RAM space\n    public async cleanUser(): Promise<void> {\n        await this.redis.del(\n            `nextCheckTime:${this.userId}`,\n            `followers:${this.userId}`,\n            `followers:count:${this.userId}`\n        );\n    }\n\n    // Not safe (some tasks for that user may still exist)\n    // But can be used for disabled account\n    public async deleteUser(): Promise<void> {\n        await Promise.all([\n            this.redis.zrem(`users`, this.userId),\n            this.redis.del(\n                `nextCheckTime:${this.userId}`,\n                `user:${this.userId}`,\n                `followers:${this.userId}`,\n                `followers:count:${this.userId}`\n            ),\n            this.dao.FriendCode.destroy({ where: { userId: this.userId } }),\n            this.followersDetail.destroy({ where: { userId: this.userId } }),\n            this.deleteTemporaryFollowerList(),\n        ]);\n    }\n}\n"
  },
  {
    "path": "unfollow-ninja-server/src/dao/userEventDao.ts",
    "content": "import type { InferAttributes, InferCreationAttributes, Sequelize } from 'sequelize';\nimport { DataTypes, Model } from 'sequelize';\nimport type { ModelStatic } from 'sequelize/types/model';\nimport * as Sentry from '@sentry/node';\n\nimport type { default as Dao } from './dao';\nimport type { IUnfollowerInfo } from '../utils/types';\nimport { UserCategory } from './dao';\nimport Logger from '../utils/logger';\n\nexport enum WebEvent {\n    createAccount,\n    signIn,\n    addDmAccount,\n    addedAsSomeonesDmAccount,\n    disable,\n    logout,\n    setLang,\n    adminFetchUser,\n    tryFriendCode,\n    enablePro,\n    enableFriends,\n    registeredAsFriend,\n    disablePro,\n    disableFriendRegistration,\n}\n\ninterface IWebEvent extends Model<InferAttributes<IWebEvent>, InferCreationAttributes<IWebEvent>> {\n    userId: string;\n    username: string;\n    event: WebEvent;\n    ip: string;\n    extraInfo?: string;\n}\n\nexport enum FollowEvent {\n    unfollowDetected,\n    followDetected,\n    accountCreatedAndFollowersLoaded,\n}\n\ninterface IFollowEvent extends Model<InferAttributes<IFollowEvent>, InferCreationAttributes<IFollowEvent>> {\n    userId: string;\n    event: FollowEvent;\n    followerId: string;\n    nbFollowers: number;\n}\n\ninterface IUnfollowerEvent extends Model<InferAttributes<IUnfollowerEvent>, InferCreationAttributes<IUnfollowerEvent>> {\n    userId: string;\n    followerId: string;\n    followTime: number;\n    followDetectedTime: number;\n    blocking: boolean;\n    blockedBy: boolean;\n    suspended: boolean;\n    locked: boolean;\n    deleted: boolean;\n    following: boolean;\n    followedBy: boolean;\n    skippedBecauseGlitchy: boolean;\n    isSecondCheck: boolean;\n}\n\nexport enum NotificationEvent {\n    welcomeMessage,\n    unfollowersMessage,\n}\n\ninterface INotificationEvent\n    extends Model<InferAttributes<INotificationEvent>, InferCreationAttributes<INotificationEvent>> {\n    userId: string;\n    event: NotificationEvent;\n    fromId?: string;\n    message: string;\n    createdAt?: string;\n}\n\ninterface ICategoryEvent extends Model<InferAttributes<ICategoryEvent>, InferCreationAttributes<ICategoryEvent>> {\n    userId: string;\n    category: UserCategory;\n    formerCategory: UserCategory;\n}\n\nexport default class UserEventDao {\n    public readonly sequelizeLogs: Sequelize;\n    private readonly dao: Dao;\n    private webEvent: ModelStatic<IWebEvent>;\n    private followEvent: ModelStatic<IFollowEvent>;\n    private unfollowerEvent: ModelStatic<IUnfollowerEvent>;\n    private notificationEvent: ModelStatic<INotificationEvent>;\n    private categoryEvent: ModelStatic<ICategoryEvent>;\n\n    constructor(dao: Dao) {\n        this.dao = dao;\n        this.sequelizeLogs = dao.sequelizeLogs;\n\n        this.webEvent = dao.sequelizeLogs.define(\n            'WebEvent',\n            {\n                userId: { type: DataTypes.STRING(30), allowNull: false },\n                event: { type: DataTypes.SMALLINT, allowNull: false },\n                ip: { type: DataTypes.STRING(45), allowNull: false },\n                username: { type: DataTypes.STRING(20), allowNull: false }, // either the user's or the extraInfo one\n                extraInfo: { type: DataTypes.STRING(30), allowNull: true }, // lang, DM account ID or admin fetched ID\n            },\n            {\n                timestamps: true,\n                updatedAt: false, // We'll never update these fields\n                indexes: [{ fields: ['userId'] }],\n            }\n        );\n\n        this.followEvent = dao.sequelizeLogs.define(\n            'FollowEvent',\n            {\n                userId: { type: DataTypes.STRING(30), allowNull: false },\n                event: { type: DataTypes.SMALLINT, allowNull: false },\n                followerId: { type: DataTypes.STRING(30), allowNull: false },\n                nbFollowers: { type: DataTypes.INTEGER, allowNull: false },\n            },\n            {\n                timestamps: true,\n                updatedAt: false, // We'll never update these fields\n                indexes: [{ fields: ['userId'] }, { fields: ['followerId'] }],\n            }\n        );\n\n        this.unfollowerEvent = dao.sequelizeLogs.define(\n            'UnfollowerEvent',\n            {\n                userId: { type: DataTypes.STRING(30), allowNull: false },\n                followerId: { type: DataTypes.STRING(30), allowNull: false },\n                followTime: { type: DataTypes.INTEGER, allowNull: false },\n                followDetectedTime: { type: DataTypes.INTEGER, allowNull: false },\n                blocking: { type: DataTypes.BOOLEAN, defaultValue: false },\n                blockedBy: { type: DataTypes.BOOLEAN, defaultValue: false },\n                suspended: { type: DataTypes.BOOLEAN, defaultValue: false },\n                locked: { type: DataTypes.BOOLEAN, defaultValue: false },\n                deleted: { type: DataTypes.BOOLEAN, defaultValue: false },\n                following: { type: DataTypes.BOOLEAN, defaultValue: false },\n                followedBy: { type: DataTypes.BOOLEAN, defaultValue: false },\n                skippedBecauseGlitchy: { type: DataTypes.BOOLEAN, defaultValue: false },\n                isSecondCheck: { type: DataTypes.BOOLEAN, defaultValue: false },\n            },\n            {\n                timestamps: true,\n                updatedAt: false, // We'll never update these fields\n                indexes: [{ fields: ['userId'] }, { fields: ['followerId'] }],\n            }\n        );\n\n        this.notificationEvent = dao.sequelizeLogs.define(\n            'NotificationEvent',\n            {\n                userId: { type: DataTypes.STRING(30), allowNull: false },\n                event: { type: DataTypes.SMALLINT, allowNull: false },\n                fromId: { type: DataTypes.STRING(30), allowNull: true }, // null would mean an error would follow\n                message: { type: DataTypes.STRING(5000), allowNull: false },\n            },\n            {\n                timestamps: true,\n                updatedAt: false, // We'll never update these fields\n                indexes: [{ fields: ['userId'] }, { fields: ['fromId'] }],\n            }\n        );\n\n        this.categoryEvent = dao.sequelizeLogs.define(\n            'CategoryEvent',\n            {\n                userId: { type: DataTypes.STRING(30), allowNull: false },\n                category: { type: DataTypes.SMALLINT, allowNull: false },\n                formerCategory: { type: DataTypes.SMALLINT, allowNull: false },\n            },\n            {\n                timestamps: true,\n                updatedAt: false, // We'll never update these fields\n                indexes: [{ fields: ['userId'] }],\n            }\n        );\n    }\n\n    public async createTables() {\n        await this.webEvent.sync();\n        await this.followEvent.sync();\n        await this.unfollowerEvent.sync();\n        await this.notificationEvent.sync();\n        await this.categoryEvent.sync();\n    }\n\n    public async logWebEvent(userId: string, event: WebEvent, ip: string, username: string, extraInfo?: string) {\n        await this.webEvent.create({ userId, event, ip, username, extraInfo }).catch((error) => {\n            Logger.error(error);\n            Sentry.captureException(error);\n        });\n    }\n\n    public async getWebEvents(userId: string, limit = 500, offset = 0) {\n        return (\n            await this.webEvent.findAll({\n                where: { userId },\n                order: [['id', 'desc']],\n                limit,\n                offset,\n            })\n        ).map((event) => ({\n            eventName: WebEvent[event.event],\n            ...event.get(),\n        }));\n    }\n\n    public async logFollowEvent(userId: string, event: FollowEvent, followerId: string, nbFollowers: number) {\n        await this.followEvent.create({ userId, event, followerId, nbFollowers }).catch((error) => {\n            Logger.error(error);\n            Sentry.captureException(error);\n        });\n    }\n\n    public async getFollowEvent(userId: string, limit = 500, offset = 0) {\n        return (\n            await this.followEvent.findAll({\n                where: { userId },\n                order: [['id', 'desc']],\n                limit,\n                offset,\n            })\n        ).map((event) => ({\n            eventName: FollowEvent[event.event],\n            ...event.get(),\n        }));\n    }\n\n    public async logUnfollowerEvent(userId: string, isSecondCheck: boolean, info: IUnfollowerInfo) {\n        const {\n            followTime,\n            followDetectedTime,\n            blocking,\n            blocked_by,\n            suspended,\n            locked,\n            deleted,\n            following,\n            followed_by,\n            skippedBecauseGlitchy,\n        } = info;\n\n        await this.unfollowerEvent\n            .create({\n                userId,\n                followerId: info.id,\n                followTime: Math.floor(followTime / 1000),\n                followDetectedTime: Math.floor(followDetectedTime / 1000),\n                blocking,\n                blockedBy: blocked_by,\n                suspended,\n                locked,\n                deleted,\n                following,\n                followedBy: followed_by,\n                skippedBecauseGlitchy,\n                isSecondCheck,\n            })\n            .catch((error) => {\n                Logger.error(error);\n                Sentry.captureException(error);\n            });\n    }\n\n    public async getUnfollowerEvents(userId: string, limit = 500, offset = 0) {\n        return await this.unfollowerEvent.findAll({\n            where: { userId },\n            order: [['id', 'desc']],\n            limit,\n            offset,\n        });\n    }\n\n    public async logNotificationEvent(userId: string, event: NotificationEvent, fromId: string, message: string) {\n        await this.notificationEvent.create({ userId, event, fromId, message }).catch((error) => {\n            Logger.error(error);\n            Sentry.captureException(error);\n        });\n    }\n\n    public async getNotificationEvents(userId: string, limit = 500, offset = 0) {\n        return (\n            await this.notificationEvent.findAll({\n                where: { userId },\n                order: [['id', 'desc']],\n                limit,\n                offset,\n            })\n        ).map((event) => ({\n            eventName: NotificationEvent[event.event],\n            ...event.get(),\n        }));\n    }\n\n    public async logCategoryEvent(userId: string, category: UserCategory, formerCategory: UserCategory) {\n        await this.categoryEvent.create({ userId, category, formerCategory }).catch((error) => {\n            Logger.error(error);\n            Sentry.captureException(error);\n        });\n    }\n\n    public async getCategoryEvents(userId: string, limit = 500, offset = 0) {\n        return (\n            await this.categoryEvent.findAll({\n                where: { userId },\n                order: [['id', 'desc']],\n                limit,\n                offset,\n            })\n        ).map((event) => ({\n            categoryName: UserCategory[event.category],\n            formerCategoryName: UserCategory[event.formerCategory],\n            ...event.get(),\n        }));\n    }\n}\n"
  },
  {
    "path": "unfollow-ninja-server/src/jobs/cleanUsersWithRevokedTokens.ts",
    "content": "import Dao, { UserCategory } from '../dao/dao';\nimport logger from '../utils/logger';\n\n// Backup and delete for Redis users that revoked their tokens\nasync function runJob() {\n    const dao = await new Dao().load();\n    const userIds = await dao.getUserIdsByCategory(UserCategory.revoked);\n    userIds.push(...(await dao.getUserIdsByCategory(UserCategory.disabled)));\n    logger.info(`${userIds.length}`);\n    let i = 0;\n    for (const userId of userIds) {\n        logger.info(`processing ${userId} (${++i}/${userIds.length}...`);\n        const userDao = dao.getUserDao(userId);\n        await userDao.cleanUser();\n    }\n    await dao.disconnect();\n}\n\nrunJob().catch((err) => console.error(err));\n"
  },
  {
    "path": "unfollow-ninja-server/src/jobs/deleteRedisSnowflakeIds.ts",
    "content": "import Redis from 'ioredis';\n\nimport Dao from '../dao/dao';\nimport logger from '../utils/logger';\nimport pLimit from 'p-limit';\n\n// These 3 redis dictionaries have been migrated to postgres\nasync function run() {\n    const redis = new Redis(process.env.REDIS_URI, { lazyConnect: true });\n    const dao = new Dao(redis);\n    await dao.load();\n\n    const userIds = await dao.getUserIds();\n\n    // handle 15 userIds at a time\n    const limit = pLimit(15);\n    const limitPromises = userIds.map((userId, progress) =>\n        limit(async () => {\n            if (progress < 0) {\n                return;\n            }\n\n            await Promise.all([\n                redis.del(`followers:follow-time:${userId}`),\n                redis.del(`followers:uncachable:${userId}`),\n                redis.del(`followers:snowflake-ids:${userId}`),\n            ]);\n\n            if (progress % 10 === 0) {\n                logger.info(`${progress}/${userIds.length} twittos migrated`);\n            }\n        })\n    );\n\n    await Promise.all(limitPromises);\n\n    await dao.disconnect();\n}\nrun().catch((error) => logger.error(error));\n"
  },
  {
    "path": "unfollow-ninja-server/src/jobs/emptyQueue.ts",
    "content": "import logger from '../utils/logger';\nimport Bull from 'bull';\nimport * as Sentry from '@sentry/node';\n\nconst bullQueue = new Bull('ninja', process.env.REDIS_BULL_URI, {\n    defaultJobOptions: {\n        attempts: 3,\n        backoff: 60000,\n        removeOnComplete: true,\n        removeOnFail: true,\n    },\n});\nbullQueue.on('error', (err) => {\n    logger.error('Bull error: ' + err.stack);\n    Sentry.captureException(err);\n});\n\n// remove failed and completed jobs from the queue (not supposed to be there anyway)\nasync function runJob() {\n    console.log('completed: ', await bullQueue.getCompletedCount());\n    await bullQueue.clean(0, 'completed');\n\n    console.log('failed: ', await bullQueue.getCompletedCount());\n    await bullQueue.clean(0, 'failed');\n\n    await bullQueue.close();\n}\n\nrunJob().catch(console.error);\n"
  },
  {
    "path": "unfollow-ninja-server/src/jobs/migrateCachedUsernamesFromRedisToPostres.ts",
    "content": "import Redis from 'ioredis';\nimport { DataTypes, Sequelize } from 'sequelize';\n\nimport Dao from '../dao/dao';\nimport logger from '../utils/logger';\n\n// migrate cachedTwittos from redis to postgresql\nasync function run() {\n    const redis = new Redis(process.env.REDIS_URI, { lazyConnect: true });\n    const sequelize = new Sequelize(process.env.POSTGRES_URI, { logging: false });\n    const dao = new Dao(redis, sequelize);\n    await dao.load();\n\n    const CachedUsername = sequelize.define('CachedUsername', {\n        twitterId: {\n            type: DataTypes.STRING(30),\n            allowNull: false,\n            primaryKey: true,\n        },\n        username: { type: DataTypes.STRING(15), allowNull: false },\n    });\n\n    let cursor = '0';\n    const total = await redis.hlen('cachedTwittos');\n    let progress = 0;\n    do {\n        // const [nextCursor, results] = ['0', ['123:username', 'pl', '124:username', 'pl2']];\n        const [nextCursor, results] = await redis.hscan('cachedTwittos', cursor);\n        cursor = nextCursor;\n\n        const usersToAdd = [];\n        for (let i = 0; i < results.length; i += 2) {\n            const twitterId = results[i].slice(0, -9); // remove the trailing :username\n            const username = results[i + 1];\n            usersToAdd.push({ twitterId, username });\n        }\n        await CachedUsername.bulkCreate(usersToAdd, { ignoreDuplicates: true });\n\n        progress += usersToAdd.length;\n        logger.info(`${progress}/${total} twittos migrated`);\n    } while (cursor !== '0');\n}\nrun().catch((error) => logger.error(error));\n"
  },
  {
    "path": "unfollow-ninja-server/src/jobs/migrateFollowersFromRedisToPostres.ts",
    "content": "import Redis from 'ioredis';\nimport { DataTypes, InferAttributes, InferCreationAttributes, Model } from 'sequelize';\n\nimport Dao from '../dao/dao';\nimport logger from '../utils/logger';\nimport { ModelStatic } from 'sequelize/types/model';\nimport pLimit from 'p-limit';\n\ninterface IFollowersDetail extends Model<InferAttributes<IFollowersDetail>, InferCreationAttributes<IFollowersDetail>> {\n    userId: string;\n    followerId: string;\n    followDetected: number;\n    snowflakeId: string;\n    uncachable: boolean;\n}\n\n// migrate followersDetail from redis to postgresql\nasync function run() {\n    const redis = new Redis(process.env.REDIS_URI, { lazyConnect: true });\n    const dao = new Dao(redis);\n    await dao.load();\n\n    const followersDetail: ModelStatic<IFollowersDetail> = dao.sequelize.define(\n        'followersDetail',\n        {\n            userId: { type: DataTypes.STRING(30), allowNull: false, primaryKey: true },\n            followerId: { type: DataTypes.STRING(30), allowNull: false, primaryKey: true },\n            followDetected: { type: DataTypes.INTEGER, allowNull: true },\n            snowflakeId: { type: DataTypes.STRING(30), allowNull: true },\n            uncachable: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false },\n        },\n        {\n            timestamps: false,\n            indexes: [{ fields: ['userId'] }],\n        }\n    );\n\n    const userIds = await dao.getUserIds();\n\n    // handle 15 userIds at a time\n    const limit = pLimit(15);\n    const limitPromises = userIds.map((userId, progress) =>\n        limit(async () => {\n            if (progress < 0) {\n                return;\n            }\n            const userDao = dao.getUserDao(userId);\n            const followers = await userDao.getFollowers();\n\n            // insert by chunk of 50 followers\n            const chunks = Array.from({ length: Math.ceil(followers.length / 100) }, (v, i) =>\n                followers.slice(i * 100, i * 100 + 100)\n            );\n\n            for (const chunk of chunks) {\n                const rows = await Promise.all(\n                    chunk.map(async (followerId) => {\n                        return {\n                            userId,\n                            followerId,\n                            followDetected: (await userDao.getFollowDetectedTime(followerId)) / 1000 || null,\n                            snowflakeId: await redis.hget(`followers:snowflake-ids:${userId}`, followerId),\n                            uncachable: false,\n                        };\n                    })\n                );\n                await followersDetail.bulkCreate(rows, { returning: false, ignoreDuplicates: true });\n            }\n\n            logger.info(`${progress}/${userIds.length} twittos migrated`);\n        })\n    );\n\n    await Promise.all(limitPromises);\n}\nrun().catch((error) => logger.error(error));\n"
  },
  {
    "path": "unfollow-ninja-server/src/jobs/resetCachedSnowflakeIds.ts",
    "content": "import Redis from 'ioredis';\nimport Dao from '../dao/dao';\n\n// ran once (while it was in beta) to fix an inconsistency in the DB\nasync function run() {\n    const redis = new Redis();\n    const dao = new Dao(redis);\n    const userIds = await dao.getUserIds();\n    for (const userId of userIds) {\n        await redis.del(`followers:follow-time::${userId}`); // typo\n        await redis.del(`followers:snowflake-ids:${userId}`); // inconsistency\n    }\n    redis.disconnect();\n}\nrun().catch((err) => console.error(err));\n"
  },
  {
    "path": "unfollow-ninja-server/src/jobs/setUsersLanguage.ts",
    "content": "import Dao from '../dao/dao';\nimport logger from '../utils/logger';\n\n// Following a bug, some unfollowMonkey users have language: fr in their settings. Set it back to fr\nasync function runJob() {\n    const dao = await new Dao().load();\n    const userIds = await dao.getUserIds();\n    logger.info(`${userIds.length}`);\n    let i = 0;\n    for (const userId of userIds) {\n        logger.info(`processing ${userId} (${++i}/${userIds.length}...`);\n        const userDao = dao.getUserDao(userId);\n        const lang = await userDao.getLang();\n        if (lang !== 'en') {\n            logger.info('overriding lang for user ' + userId);\n            await userDao.setUserParams({ lang: 'en' });\n        }\n    }\n    await dao.disconnect();\n}\n\nrunJob().catch((err) => console.error(err));\n"
  },
  {
    "path": "unfollow-ninja-server/src/jobs/twitExperiment.ts",
    "content": "import 'dotenv/config';\n\nimport Dao from '../dao/dao';\nimport logger from '../utils/logger';\n\nasync function run() {\n    const dao = await new Dao().load();\n    const userDao = dao.getUserDao('290981389'); // plhery\n    // eslint-disable-next-line @typescript-eslint/no-unused-vars\n    const twit = await userDao.getTwit();\n    // play with twit\n}\nrun().catch((error) => logger.error(error.stack));\n"
  },
  {
    "path": "unfollow-ninja-server/src/tasks/index.ts",
    "content": "import notifyUser from './notifyUser';\nimport reenableFollowers from './reenableFollowers';\nimport sendWelcomeMessage from './sendWelcomeMessage';\nimport updateMetrics from './updateMetrics';\nimport type { TaskClass } from './task';\n\nconst tasks: { [taskName: string]: TaskClass } = {\n    notifyUser,\n    reenableFollowers,\n    sendWelcomeMessage,\n    updateMetrics,\n};\n\nexport default tasks;\n"
  },
  {
    "path": "unfollow-ninja-server/src/tasks/notifyUser.ts",
    "content": "import i18n from 'i18n';\nimport type { Job } from 'bull';\nimport moment from 'moment-timezone';\nimport { Params, Twitter } from 'twit';\nimport { UserCategory } from '../dao/dao';\nimport logger from '../utils/logger';\nimport { IUnfollowerInfo, Lang } from '../utils/types';\nimport Task from './task';\nimport metrics from '../utils/metrics';\nimport { NotificationEvent } from '../dao/userEventDao';\nimport { SUPPORTED_LANGUAGES } from '../utils/utils';\n\ni18n.configure({\n    locales: SUPPORTED_LANGUAGES,\n    directory: __dirname + '/../../locales',\n});\n\nmoment.tz.setDefault(process.env.TIMEZONE || 'UTC');\n\nconst MINUTES_BETWEEN_CHECKS = Number(process.env.MINUTES_BETWEEN_CHECKS) || 2;\nconst TWITTER_ACCOUNT = process.env.TWITTER_ACCOUNT || 'unfollowninja';\n\n// friendships/show can be called 180 times/15min\n// with this limit it can be called max 200 times/15min (if checked 8 times)\nconst MAX_UNFOLLOWERS = 25;\n\nexport default class extends Task {\n    public async run(job: Job) {\n        const { userId, isSecondTry } = job.data;\n        const userDao = this.dao.getUserDao(userId);\n        const username = await userDao.getUsername();\n\n        const twit = await userDao.getTwit();\n        const unfollowersInfo: IUnfollowerInfo[] = job.data.unfollowersInfo;\n        let stopThere = false;\n\n        const leftovers = unfollowersInfo.splice(MAX_UNFOLLOWERS);\n\n        unfollowersInfo.forEach((u) => (u.suspended = true));\n        const unfollowersMap = new Map(unfollowersInfo.map((info) => [info.id, info]));\n\n        // cache twittos and know who's suspended\n        const usersLookup = (await twit\n            .post('users/lookup', {\n                user_id: unfollowersInfo.map((u) => u.id).join(','),\n                include_entities: false,\n            })\n            .catch((err) =>\n                this.manageTwitterErrors(err, username, userId).then((stop) =>\n                    stop ? (stopThere = true) : { data: [] }\n                )\n            )) as { data: Twitter.User[] };\n\n        if (stopThere) {\n            return;\n        }\n\n        usersLookup.data.forEach((user) => {\n            const unfollowerInfo = unfollowersMap.get(user.id_str);\n            unfollowerInfo.suspended = false;\n            unfollowerInfo.locked = user.friends_count === 0;\n            unfollowerInfo.username = user.screen_name;\n        });\n\n        await Promise.all(\n            usersLookup.data.map((user) =>\n                this.dao.addTwittoToCache({\n                    id: user.id_str,\n                    username: user.screen_name,\n                })\n            )\n        );\n\n        // know who you're blocking or blocked you\n        await Promise.all(\n            unfollowersInfo.map(async (unfollower) => {\n                // know if we're blocked / if we blocked them\n                let friendship;\n                try {\n                    // in @types/twit 2.2.23 target_id must be a number, however it's safer to send a string TODO PR @types/twit\n                    friendship = await twit.get('friendships/show', {\n                        target_id: unfollower.id,\n                    } as object as Params);\n                } catch (err) {\n                    await this.manageTwitterErrors(err, username, userId);\n                    const errorCode = err?.twitterReply?.errors?.[0]?.code;\n                    unfollower.friendship_error_code = errorCode;\n                    if (errorCode === 50) {\n                        unfollower.suspended = false;\n                        unfollower.deleted = true;\n                    }\n                    return;\n                }\n                if (friendship?.data?.relationship) {\n                    if (!unfollower.username) {\n                        const { id_str, screen_name } = friendship.data.relationship.target;\n                        unfollower.username = screen_name;\n                        await this.dao.addTwittoToCache({\n                            id: id_str,\n                            username: screen_name,\n                        });\n                    }\n                    const { blocking, blocked_by, following, followed_by } = friendship.data.relationship.source;\n                    unfollower.blocking = blocking;\n                    unfollower.blocked_by = blocked_by;\n                    unfollower.following = following;\n                    unfollower.followed_by = followed_by;\n                }\n            })\n        );\n\n        // get missing usernames from the cache\n        await Promise.all(\n            unfollowersInfo.map(async (unfollower) => {\n                if (!unfollower.username) {\n                    const cachedUsername = await this.dao.getCachedUsername(unfollower.id);\n                    if (cachedUsername) {\n                        unfollower.username = cachedUsername;\n                    }\n                }\n            })\n        );\n\n        // we remove unfollowers that followed the user < 24h and that \"left twitter\" (glitches very probably)\n        let realUnfollowersInfo = unfollowersInfo.filter((unfollowerInfo) => {\n            const followDuration = unfollowerInfo.unfollowTime - (unfollowerInfo.followDetectedTime || 0);\n            unfollowerInfo.skippedBecauseGlitchy = !(\n                unfollowerInfo.followed_by !== true &&\n                !(unfollowerInfo.deleted && followDuration < 24 * 60 * 60 * 1000) &&\n                !(followDuration < 60 * 60 * 1000) &&\n                followDuration > Math.max(MINUTES_BETWEEN_CHECKS * 2 + 1, 7) * 60 * 1000 &&\n                !(process.env.GLITCHY_USERS?.split(',') || []).includes(unfollowerInfo.id)\n            );\n            return !unfollowerInfo.skippedBecauseGlitchy;\n        });\n\n        unfollowersInfo.forEach((unfollower) =>\n            this.dao.userEventDao.logUnfollowerEvent(userId, isSecondTry, unfollower)\n        );\n\n        if (!isSecondTry) {\n            const potentialGlitches = realUnfollowersInfo.filter((unfollowerInfo) => unfollowerInfo.deleted);\n\n            // If it's the first check, check them again in 15min to be sure that they were really glitches\n            if (potentialGlitches.length > 0) {\n                await this.queue.add(\n                    'notifyUser',\n                    {\n                        userId,\n                        username,\n                        unfollowersInfo: potentialGlitches,\n                        isSecondTry: true,\n                    },\n                    { delay: 15 * 60 * 1000 }\n                );\n\n                const potentialGlitchesSet = new Set(potentialGlitches);\n                realUnfollowersInfo = realUnfollowersInfo.filter((value) => !potentialGlitchesSet.has(value));\n            }\n            metrics.increment('notifyUser.nbFirstTry');\n        }\n        metrics.increment('notifyUser.count');\n\n        if (realUnfollowersInfo.length > 0) {\n            const message = this.generateMessage(realUnfollowersInfo, await userDao.getLang(), leftovers.length);\n\n            await this.dao.userEventDao.logNotificationEvent(\n                userId,\n                NotificationEvent.unfollowersMessage,\n                await userDao.getDmId(),\n                message\n            );\n\n            const dmTwit = await userDao.getDmTwit();\n            logger.info('sending a DM to @%s', username);\n\n            await dmTwit\n                .post('direct_messages/events/new', {\n                    event: {\n                        type: 'message_create',\n                        message_create: {\n                            target: { recipient_id: userId },\n                            message_data: { text: message },\n                        },\n                    },\n                } as Params)\n                .catch((err) => this.manageTwitterErrors(err, username, userId));\n\n            metrics.increment('notifyUser.dmsSent');\n            metrics.increment('notifyUser.nbUnfollowers', realUnfollowersInfo.length + leftovers.length);\n        }\n    }\n\n    private generateMessage(unfollowersInfo: IUnfollowerInfo[], lang: Lang, nbLeftovers: number): string {\n        i18n.setLocale(lang);\n        const messages: string[] = unfollowersInfo.map((unfollower) => {\n            let username = unfollower.username ? '@' + unfollower.username : i18n.__('one of your followers');\n\n            let customEmoji = '';\n            if (unfollower.username === 'louanben') {\n                // https://twitter.com/louanben/status/1246045006529531910\n                customEmoji = '\\n👁👄👁';\n                username += ' 👦🏽';\n            }\n            let action;\n            if (unfollower.suspended) {\n                const emoji = '🙈' + customEmoji;\n                action = i18n.__('{{username}} has been suspended {{emoji}}.', {\n                    username,\n                    emoji,\n                });\n            } else if (unfollower.deleted) {\n                const emoji = '🙈' + customEmoji;\n                action = i18n.__('{{username}} has left Twitter {{emoji}}.', {\n                    username,\n                    emoji,\n                });\n            } else if (unfollower.blocked_by) {\n                const emoji = '⛔️' + customEmoji;\n                action = i18n.__('{{username}} blocked you {{emoji}}.', {\n                    username,\n                    emoji,\n                });\n            } else if (unfollower.blocking) {\n                const emoji = '💩💩💩' + customEmoji;\n                action = i18n.__('You blocked {{username}} {{emoji}}.', {\n                    username,\n                    emoji,\n                });\n            } else if (unfollower.locked) {\n                const emoji = '🔒' + (unfollower.following ? customEmoji || '💔' : customEmoji);\n                action = i18n.__(\"{{username}}'s account has been locked {{emoji}}.\", {\n                    username,\n                    emoji,\n                });\n            } else if (unfollower.following) {\n                const emoji = customEmoji || '💔';\n                action = i18n.__('{{username}} unfollowed you {{emoji}}.', {\n                    username,\n                    emoji,\n                });\n            } else {\n                const emoji = customEmoji || '👋';\n                action = i18n.__('{{username}} unfollowed you {{emoji}}.', {\n                    username,\n                    emoji,\n                });\n            }\n\n            let followTimeMsg;\n            if (unfollower.followTime > 0) {\n                const duration = moment(unfollower.followTime).locale(lang).to(unfollower.unfollowTime, true);\n                const time = moment(unfollower.followTime).locale(lang).calendar();\n                followTimeMsg = i18n.__('This account followed you for {{duration}} ({{{time}}}).', { duration, time });\n            } else {\n                followTimeMsg = i18n.__('This account followed you before you signed up to @{{twitterAccount}}!', {\n                    twitterAccount: TWITTER_ACCOUNT,\n                });\n            }\n\n            return action + '\\n' + followTimeMsg;\n        });\n\n        if (messages.length === 1) {\n            return messages[0];\n        }\n        const nbUnfollows = (messages.length + nbLeftovers).toString();\n        let message = i18n.__('{{nbUnfollows}} Twitter users unfollowed you:', {\n            nbUnfollows,\n        });\n        for (const unfollowerMessage of messages) {\n            message += '\\n  • ' + unfollowerMessage;\n        }\n        if (nbLeftovers > 0) {\n            message +=\n                '\\n  • ' +\n                i18n.__('and {{nbLeftovers}} more.', {\n                    nbLeftovers: nbLeftovers.toString(),\n                });\n        }\n        return message;\n    }\n\n    // throw an error if it's a twitter problem\n    // return true if we can't continue to process the user\n    private async manageTwitterErrors(err: unknown, username: string, userId: string): Promise<boolean> {\n        if (!err['twitterReply']) {\n            throw err;\n        }\n        const twitterReply: Twitter.Errors = err['twitterReply'];\n\n        const userDao = this.dao.getUserDao(userId);\n\n        for (const { code, message } of twitterReply.errors) {\n            switch (code) {\n                case 17: // no user matches the specified terms (users/lookup)\n                case 50: // user not found (friendship/show)\n                    break;\n                // app-related\n                case 32:\n                    throw new Error(\n                        'Authentication problems.' + 'Please check that your consumer key & secret are correct.'\n                    );\n                case 416:\n                    throw new Error('Oops, it looks like the application has been suspended :/...');\n                // user-related\n                case 89:\n                    logger.warn('@%s revoked the token. removing them from the list...', username);\n                    await userDao.setCategory(UserCategory.revoked);\n                    return true;\n                case 326:\n                case 64:\n                    logger.warn('@%s is suspended. removing them from the list...', username);\n                    await userDao.setCategory(UserCategory.suspended);\n                    return true;\n                case 150: // dm closed to non-followers\n                case 349: // user blocked?\n                    logger.warn('@%s does not accept DMs. removing them from the list...', username);\n                    await userDao.setCategory(UserCategory.dmclosed);\n                    return true;\n                case 292:\n                    throw new Error('Notification blocked because \"it seems automated\".');\n                // twitter errors\n                case 130: // over capacity\n                case 131: // internal error`\n                    throw new Error('Twitter has problems at the moment, skipping this action.');\n                case 88: // rate limit\n                    throw new Error('the user reached its rate-limit (notifyUser)');\n                default:\n                    throw new Error(`An unexpected twitter error occured: ${code} ${message}`);\n            }\n        }\n        return false;\n    }\n}\n"
  },
  {
    "path": "unfollow-ninja-server/src/tasks/reenableFollowers.ts",
    "content": "import * as Sentry from '@sentry/node';\nimport type { Job } from 'bull';\n\nimport { UserCategory } from '../dao/dao';\nimport logger from '../utils/logger';\nimport metrics from '../utils/metrics';\nimport Task from './task';\nif (process.env.SENTRY_DSN) {\n    Sentry.init({ dsn: process.env.SENTRY_DSN });\n}\n\nconst CATEGORIES_TO_CHECK = [\n    UserCategory.suspended,\n    // UserCategory.revoked, shouldnt be useful\n    // UserCategory.dmclosed TODO\n];\n\n// reenable followers disabled because they were suspended or had a token issue\nexport default class extends Task {\n    public run(job: Job) {\n        return Promise.all(\n            CATEGORIES_TO_CHECK.map(async (category) => {\n                for (const userId of await this.dao.getUserIdsByCategory(category)) {\n                    await this.checkAccountValid(userId).catch((err) => {\n                        logger.error(err);\n                        Sentry.withScope((scope) => {\n                            scope.setTag('task-name', 'reenableFollowers');\n                            scope.setUser({ id: userId });\n                            Sentry.captureException(err);\n                        });\n                    });\n                }\n            })\n        ).then(() => {\n            metrics.gauge('reenableFollowers.duration', Date.now() - job.processedOn);\n        });\n    }\n\n    private async checkAccountValid(userId: string) {\n        const userDao = this.dao.getUserDao(userId);\n        const [twit, twitDM] = await Promise.all([\n            userDao.getTwit(),\n            userDao.getDmTwit(),\n            this.dao.getCachedUsername(userId),\n        ]);\n\n        await twit\n            .get('followers/ids')\n            .then(() => twitDM.get('followers/ids'))\n            .then(() => {\n                metrics.increment('reenableFollowers.reenabled');\n                return userDao.enable();\n            })\n            .catch(() => null);\n    }\n}\n"
  },
  {
    "path": "unfollow-ninja-server/src/tasks/sendWelcomeMessage.ts",
    "content": "import * as i18n from 'i18n';\nimport type { Job } from 'bull';\nimport { Params, Twitter } from 'twit';\nimport { UserCategory } from '../dao/dao';\nimport logger from '../utils/logger';\nimport Task from './task';\nimport { NotificationEvent } from '../dao/userEventDao';\nimport { SUPPORTED_LANGUAGES } from '../utils/utils';\n\ni18n.configure({\n    locales: SUPPORTED_LANGUAGES,\n    directory: __dirname + '/../../locales',\n});\n\nconst TWITTER_ACCOUNT = process.env.TWITTER_ACCOUNT || 'unfollowninja';\n\nexport default class extends Task {\n    public async run(job: Job) {\n        const { username, userId, isPro } = job.data;\n        const userDao = this.dao.getUserDao(userId);\n        const dmTwit = await userDao.getDmTwit();\n        i18n.setLocale(await userDao.getLang());\n\n        let message;\n        if (isPro) {\n            message = i18n.__('Congratulations, can now enjoy @{{twitterAccount}} pro {{emoji}}!', {\n                twitterAccount: TWITTER_ACCOUNT,\n                emoji: '🚀',\n            });\n        } else {\n            message = i18n.__(\n                'All set, welcome to @{{twitterAccount}} {{emoji}}!\\n' +\n                    'You will soon know all about your unfollowers here!',\n                { twitterAccount: TWITTER_ACCOUNT, emoji: '🙌' }\n            );\n        }\n\n        await this.dao.userEventDao.logNotificationEvent(\n            userId,\n            NotificationEvent.welcomeMessage,\n            await userDao.getDmId(),\n            message\n        );\n\n        await dmTwit\n            .post('direct_messages/events/new', {\n                event: {\n                    type: 'message_create',\n                    message_create: {\n                        target: { recipient_id: userId },\n                        message_data: { text: message },\n                    },\n                },\n            } as Params)\n            .catch((err) => this.manageTwitterErrors(err, username, userId));\n    }\n\n    private async manageTwitterErrors(err: unknown, username: string, userId: string): Promise<void> {\n        if (!err['twitterReply']) {\n            throw err;\n        }\n        const twitterReply: Twitter.Errors = err['twitterReply'];\n\n        const userDao = this.dao.getUserDao(userId);\n\n        for (const { code, message } of twitterReply.errors) {\n            switch (code) {\n                // app-related\n                case 32:\n                    throw new Error(\n                        'Authentication problems.' + 'Please check that your consumer key & secret are correct.'\n                    );\n                case 416:\n                    throw new Error('Oops, it looks like the application has been suspended :/...');\n                // user-related\n                case 89:\n                    logger.warn('@%s revoked the token. removing them from the list...', username);\n                    await userDao.setCategory(UserCategory.revoked);\n                    break;\n                case 326:\n                case 64:\n                    logger.warn('@%s is suspended. removing them from the list...', username);\n                    await userDao.setCategory(UserCategory.suspended);\n                    break;\n                // twitter errors\n                case 130: // over capacity\n                case 131: // internal error`\n                case 88: // rate limit\n                    // retry in 15 minutes\n                    await this.queue.add(\n                        'sendWelcomeMessage',\n                        {\n                            id: Date.now(), // otherwise some seem stuck??\n                            userId,\n                            username,\n                        },\n                        { delay: 15 * 60 * 1000 }\n                    );\n                    break;\n                default:\n                    throw new Error(`An unexpected twitter error occured: ${code} ${message}`);\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "unfollow-ninja-server/src/tasks/task.ts",
    "content": "import type { Job, Queue } from 'bull';\nimport Dao from '../dao/dao';\n\nexport default abstract class Task {\n    protected dao: Dao;\n    protected queue: Queue;\n\n    constructor(dao: Dao, queue: Queue) {\n        this.dao = dao;\n        this.queue = queue;\n    }\n\n    public abstract run(job: Job): Promise<Error | void>;\n}\n\nexport type TaskClass = new (dao: Dao, queue: Queue) => Task;\n"
  },
  {
    "path": "unfollow-ninja-server/src/tasks/updateMetrics.ts",
    "content": "import { UserCategory } from '../dao/dao';\nimport Task from './task';\nimport metrics from '../utils/metrics';\n\nexport default class extends Task {\n    public async run() {\n        for (const [category, count] of Object.entries(await this.dao.getUserCountByCategory())) {\n            metrics.gauge(`users.${UserCategory[category]}`, count);\n        }\n        metrics.gauge(`queue.count.waiting`, await this.queue.getWaitingCount());\n        metrics.gauge(`queue.count.active`, await this.queue.getActiveCount());\n        metrics.gauge(`queue.count.delayed`, await this.queue.getDelayedCount());\n        metrics.gauge(`queue.count.failed`, await this.queue.getFailedCount());\n    }\n}\n"
  },
  {
    "path": "unfollow-ninja-server/src/utils/logger.ts",
    "content": "import cluster from 'cluster';\nimport * as fs from 'fs';\nimport { createLogger, format, transports } from 'winston';\n\nlet workerInfo = cluster.worker ? `work ${cluster.worker.id}` : 'master';\nexport const setLoggerPrefix = (prefix: string) => (workerInfo = prefix + ' '.repeat(6 - prefix.length));\n\nconst customFormat = format.combine(\n    format.timestamp(),\n    format.splat(),\n    format.errors({ stack: true }),\n    format.printf((info) => `${info.timestamp} ${workerInfo} ${info.level}: ${info.message}`)\n);\n\nconst fileParams = {\n    maxsize: 50000000, // 50MB\n    maxFiles: 10,\n    tailable: true,\n};\n\nconst testEnv = typeof it === 'function'; // jest\n\nfs.exists('./logs', (exists) => !exists && fs.mkdir('./logs', () => null));\n\nconst logger = createLogger({\n    format: customFormat,\n    transports: [\n        new transports.Console({\n            format: format.combine(format.colorize(), customFormat),\n            level: testEnv ? 'warn' : 'info',\n        }),\n        !testEnv &&\n            new transports.File({\n                ...fileParams,\n                filename: 'logs/error.log',\n                level: 'error',\n            }),\n        !testEnv &&\n            new transports.File({\n                ...fileParams,\n                filename: 'logs/warn.log',\n                level: 'warn',\n            }),\n        !testEnv &&\n            new transports.File({\n                ...fileParams,\n                filename: 'logs/info.log',\n                level: 'info',\n            }),\n        !testEnv &&\n            new transports.File({\n                ...fileParams,\n                filename: 'logs/debug.log',\n                level: 'debug',\n            }),\n    ].filter((t) => t),\n    exceptionHandlers: [\n        new transports.Console(),\n        !testEnv &&\n            new transports.File({\n                ...fileParams,\n                filename: 'logs/exceptions.log',\n            }),\n    ].filter((t) => t),\n});\n\nexport default logger;\n"
  },
  {
    "path": "unfollow-ninja-server/src/utils/metrics.ts",
    "content": "import { StatsD } from 'hot-shots';\n\nconst STATSD_HOST = process.env.STATSD_HOST || undefined;\nconst DD_AGENT_HOST = process.env.DD_AGENT_HOST || undefined;\nconst METRICS_PREFIX = process.env.METRICS_PREFIX || 'uninja';\n\nexport default class Metrics {\n    private static statsDClients: StatsD[] = [];\n\n    public static addStatsdHost(host: string, port = 8125) {\n        this.statsDClients.push(new StatsD({ host, port }));\n    }\n\n    public static gauge(metric: string, value: number) {\n        this.statsDClients.forEach((client) => client.gauge(METRICS_PREFIX + '.' + metric, value));\n    }\n\n    public static increment(metric: string, value = 1) {\n        this.statsDClients.forEach((client) => client.increment(METRICS_PREFIX + '.' + metric, value));\n    }\n\n    public static kill() {\n        this.statsDClients.forEach((client) => client.close(null));\n    }\n}\nif (STATSD_HOST) {\n    Metrics.addStatsdHost(STATSD_HOST);\n}\nif (DD_AGENT_HOST) {\n    // datadog\n    Metrics.addStatsdHost(DD_AGENT_HOST, Number(process.env.DD_DOGSTATSD_PORT || 8125));\n}\n"
  },
  {
    "path": "unfollow-ninja-server/src/utils/types.ts",
    "content": "import type { UserCategory } from '../dao/dao';\nimport { SUPPORTED_LANGUAGES_CONST } from './utils';\n\nexport type Lang = typeof SUPPORTED_LANGUAGES_CONST[number];\n\nexport interface IFollowerInfo {\n    id: string;\n    followTime: number;\n}\n\nexport interface IUnfollowerInfo extends IFollowerInfo {\n    unfollowTime: number;\n    followDetectedTime: number | null; // first time the system saw the follower\n    blocking?: boolean;\n    blocked_by?: boolean;\n    suspended?: boolean;\n    locked?: boolean;\n    deleted?: boolean;\n    following?: boolean;\n    followed_by?: boolean;\n    friendship_error_code?: number;\n    notified_time?: number;\n    username?: string;\n    skippedBecauseGlitchy?: boolean;\n}\n\nexport interface ITwittoInfo {\n    id: string;\n    username: string;\n}\n\nexport interface IUserParams {\n    added_at: number; // in ms\n    lang: Lang;\n    token: string;\n    tokenSecret: string;\n    dmId?: string;\n    dmToken?: string;\n    dmTokenSecret?: string;\n    pro?: '3' | '2' | '1' | '0'; // friendcode-friends-pro-normal\n    friendCodes?: string;\n    customerId?: string;\n}\n\nexport interface IUserEgg extends ITwittoInfo, IUserParams {\n    category: UserCategory;\n}\n\nexport interface Session {\n    user?: IUserEgg & { dmUsername?: string };\n}\n"
  },
  {
    "path": "unfollow-ninja-server/src/utils/utils.ts",
    "content": "// Convert an ID generated with snowflake (e.g some cursors) to a timestamp in ms\nimport bigInt from 'big-integer';\n\n/**\n * get the timestamp associated with a Twitter's cursor\n */\nexport function twitterCursorToTime(cursor: string): number {\n    return cursor ? bigInt(cursor).abs().shiftRight(20).valueOf() : null;\n}\n\nexport const SUPPORTED_LANGUAGES_CONST = [\n    'en',\n    'fr',\n    'es',\n    'pt',\n    'id',\n    'de',\n    'th',\n    'pl',\n    'zh_Hans',\n    'nl',\n    'tr',\n    'uk',\n    'pt_BR',\n    'ar',\n    'zgh',\n] as const;\nexport const SUPPORTED_LANGUAGES = SUPPORTED_LANGUAGES_CONST as unknown as string[];\n"
  },
  {
    "path": "unfollow-ninja-server/src/workers/cacheAllFollowers.ts",
    "content": "import pLimit from 'p-limit';\nimport * as Sentry from '@sentry/node';\nimport { Twitter } from 'twit';\n\nimport Dao, { UserCategory } from '../dao/dao';\nimport logger from '../utils/logger';\nimport metrics from '../utils/metrics';\n\nconst CACHE_WORKER_RATE_LIMIT =\n    Number(process.env.CACHE_WORKER_RATE_LIMIT) || Number(process.env.WORKER_RATE_LIMIT) || 15;\n\n/**\n * Check 1/nbWorkers users for uncached follower's username and follow date\n * @param workerId a number between 1 and nbWorkers included\n * @param nbWorkers the number of workers\n * @param dao\n */\nexport async function cacheAllFollowers(workerId: number, nbWorkers: number, dao: Dao) {\n    const limit = pLimit(CACHE_WORKER_RATE_LIMIT);\n    const startedAt = Date.now();\n\n    try {\n        const promises = (await dao.getUserIdsByCategory(UserCategory.enabled))\n            .concat(await dao.getUserIdsByCategory(UserCategory.vip))\n            .filter((userId) => hashCode(userId) % nbWorkers === workerId - 1) // we process 1/x users\n            .map((userId) =>\n                limit(async () => {\n                    try {\n                        if (await dao.getUserDao(userId).getHasNotCachedFollowers()) {\n                            await cacheFollowers(userId, dao);\n                        }\n                    } catch (error) {\n                        const username: string = (await dao.getCachedUsername(userId).catch(() => userId)) || userId;\n                        logger.error(`An error happened with checkFollowers / @${username}: ${error.stack}`);\n                        Sentry.withScope((scope) => {\n                            scope.setTag('task-name', 'cacheFollowers');\n                            scope.setUser({ username });\n                            Sentry.captureException(error);\n                        });\n                    }\n                })\n            );\n\n        await Promise.all(promises);\n        metrics.gauge(`cache-duration.worker.${workerId}`, Date.now() - startedAt);\n    } catch (error) {\n        try {\n            Sentry.captureException(error);\n        } catch (sentryError) {\n            logger.error(sentryError);\n        }\n        logger.error(error);\n    }\n\n    // check every minute minimum (Twitter's limit for the followers/list API requests)\n    setTimeout(() => cacheAllFollowers(workerId, nbWorkers, dao), Math.max(0, 60 * 1000 + startedAt - Date.now()));\n}\n\n// inspired from https://gist.github.com/hyamamoto/fd435505d29ebfa3d9716fd2be8d42f0 / abs(java's string.hashcode)\nfunction hashCode(s: string) {\n    let h = 0;\n    for (let i = 0; i < s.length; i++)\n        // tslint:disable-next-line:no-bitwise\n        h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;\n    return Math.abs(h);\n}\n\nasync function cacheFollowers(userId: string, dao: Dao) {\n    const userDao = dao.getUserDao(userId);\n\n    const [cachedFollowers, followers, uncachables] = await Promise.all([\n        userDao.getCachedFollowers(),\n        userDao.getFollowers(),\n        userDao.getUncachableFollowers(),\n    ]);\n\n    if (typeof followers !== 'object') {\n        // followers have not been fetched yet\n        return;\n    }\n\n    const uncachablesSet = new Set(uncachables);\n    const cachedFollowersSet = new Set(cachedFollowers);\n\n    const cachableFollowers = followers.filter((value) => !uncachablesSet.has(value));\n    const targetIndex = cachableFollowers.findIndex((value) => !cachedFollowersSet.has(value)); // most recent not cached follower\n    const targetId = cachableFollowers[targetIndex];\n\n    if (typeof targetId !== 'string') {\n        // no uncached follower\n        return;\n    }\n\n    const cursor = targetIndex > 0 ? await userDao.getFollowerSnowflakeId(cachableFollowers[targetIndex - 1]) : '-1';\n\n    if (cursor === '0' || typeof cursor !== 'string') {\n        // there was a problem somewhere...\n        throw new Error('An unexpected error happened: no valid cursor found.');\n    }\n\n    // optimisation: if we're not at the beginning/end,\n    // we can use nextCursor AND previousCursor to get 2 snowflake IDs\n    const count = targetIndex > 0 ? 2 : 1;\n\n    const twit = await userDao.getTwit();\n\n    try {\n        const result = await twit.get('followers/list', {\n            cursor,\n            count,\n            skip_status: true,\n            include_user_entities: false,\n        });\n        if (!result.data && result.resp.statusCode === 503) {\n            // noinspection ExceptionCaughtLocallyJS\n            throw new Error('[cacheFollowers] Twitter services overloaded / unavailable (503)');\n        }\n\n        const users: Twitter.User[] = result.data['users'];\n        const next_cursor_str: string = result.data['next_cursor_str'];\n        const previous_cursor_str: string = result.data['previous_cursor_str'];\n\n        await Promise.all(\n            users.map((user) =>\n                dao.addTwittoToCache({\n                    id: user.id_str,\n                    username: user.screen_name,\n                })\n            )\n        );\n\n        let targetUpdated = false;\n        if (previous_cursor_str !== '0' && users.length > 0) {\n            const user = users[0];\n            await userDao.setFollowerSnowflakeId(user.id_str, previous_cursor_str.substr(1));\n            users.shift();\n            targetUpdated = targetUpdated || targetId === user.id_str;\n        }\n        if (next_cursor_str !== '0' && users.length > 0) {\n            const user = users[users.length - 1];\n            await userDao.setFollowerSnowflakeId(user.id_str, next_cursor_str);\n            targetUpdated = targetUpdated || targetId === user.id_str;\n        }\n        if (!targetUpdated) {\n            // some IDs weirdly can't be reached / disabled account?\n            // we add them to uncachable set to avoid infinite caching loop\n            await userDao.addUncachableFollower(targetId);\n        }\n    } catch (err) {\n        // ignore twitter errors, already managed by checkFollowers\n        if (!err.twitterReply) {\n            throw err;\n        } else {\n            // twitter errors (account disabled...)\n            return;\n        }\n    }\n\n    return;\n}\n"
  },
  {
    "path": "unfollow-ninja-server/src/workers/checkAllFollowers.ts",
    "content": "import type { Queue } from 'bull';\nimport pLimit from 'p-limit';\nimport * as Sentry from '@sentry/node';\nimport * as Twit from 'twit';\n\nimport Dao, { UserCategory } from '../dao/dao';\nimport logger from '../utils/logger';\nimport metrics from '../utils/metrics';\nimport { IUnfollowerInfo } from '../utils/types';\nimport UserDao from '../dao/userDao';\nimport { FollowEvent } from '../dao/userEventDao';\n\nconst WORKER_RATE_LIMIT = Number(process.env.WORKER_RATE_LIMIT) || 15;\nconst VIP_WORKER_RATE_LIMIT = Number(process.env.VIP_WORKER_RATE_LIMIT) || 1;\n\n/**\n * Check 1/nbWorkers users for unfollowers\n * @param workerId a number between 1 and nbWorkers included\n * @param nbWorkers the number of workers\n * @param dao\n * @param queue\n */\nexport async function checkAllFollowers(workerId: number, nbWorkers: number, dao: Dao, queue: Queue) {\n    const limit = pLimit(WORKER_RATE_LIMIT);\n    const startedAt = Date.now();\n\n    try {\n        const promises = (await dao.getUserIdsByCategory(UserCategory.enabled))\n            .filter((userId) => hashCode(userId) % nbWorkers === workerId - 1) // we process 1/x users\n            .map((userId) => limit(() => checkFollowersWithTimeout(userId, dao, queue)));\n\n        await Promise.all(promises);\n\n        metrics.gauge(`check-duration.worker.${workerId}`, Date.now() - startedAt);\n    } catch (error) {\n        logger.error(error);\n        Sentry.captureException(error);\n    }\n\n    // check every minute minimum (Twitter's limit for the followers/ids API requests)\n    setTimeout(\n        () => checkAllFollowers(workerId, nbWorkers, dao, queue),\n        Math.max(0, 60 * 1000 + startedAt - Date.now())\n    );\n}\n\nexport async function checkAllVipFollowers(workerId: number, nbWorkers: number, dao: Dao, queue: Queue) {\n    const limit = pLimit(VIP_WORKER_RATE_LIMIT);\n    const startedAt = Date.now();\n\n    try {\n        const promises = (await dao.getUserIdsByCategory(UserCategory.vip))\n            .filter((userId) => hashCode(userId) % nbWorkers === workerId - 1) // we process 1/x users\n            .map((userId) => limit(() => checkFollowersWithTimeout(userId, dao, queue)));\n\n        await Promise.all(promises);\n\n        metrics.gauge(`check-vip-duration.worker.${workerId}`, Date.now() - startedAt);\n    } catch (error) {\n        logger.error(error);\n        Sentry.captureException(error);\n    }\n\n    // check every minute minimum (Twitter's limit for the followers/ids API requests)\n    setTimeout(\n        () => checkAllVipFollowers(workerId, nbWorkers, dao, queue),\n        Math.max(0, 60 * 1000 + startedAt - Date.now())\n    );\n}\n\n// inspired from https://gist.github.com/hyamamoto/fd435505d29ebfa3d9716fd2be8d42f0 / abs(java's string.hashcode)\nfunction hashCode(s: string) {\n    let h = 0;\n    for (let i = 0; i < s.length; i++)\n        // tslint:disable-next-line:no-bitwise\n        h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;\n    return Math.abs(h);\n}\n\n/**\n * Check Followers, making sure errors are logged into sentry and with a 2min timeout\n * @param userId the ID of the user we want to check followers from\n * @param dao the global dao object\n * @param queue the global queue object\n */\nasync function checkFollowersWithTimeout(userId: string, dao: Dao, queue: Queue) {\n    try {\n        let watchdogTimeout;\n        await Promise.race([\n            checkFollowers(userId, dao, queue),\n            new Promise(\n                (_, reject) =>\n                    (watchdogTimeout = setTimeout(\n                        () => reject(new Error('Timeout - checkFollowers took more than 10mins')),\n                        10 * 60 * 1000\n                    ))\n            ),\n        ]);\n        clearTimeout(watchdogTimeout);\n    } catch (error) {\n        const username: string = (await dao.getCachedUsername(userId).catch(() => userId)) || userId;\n        logger.error(`An error happened with checkFollowers / @${username}: ${error.stack}`);\n        Sentry.withScope((scope) => {\n            scope.setTag('task-name', 'checkFollowers');\n            scope.setUser({ username });\n            Sentry.captureException(error);\n        });\n    }\n}\n\nasync function checkFollowers(userId: string, dao: Dao, queue: Queue) {\n    const userDao = dao.getUserDao(userId);\n\n    if (Date.now() < (await userDao.getNextCheckTime())) {\n        return;\n    } // don't check every minute if the user has more than 5000 followers : we can only get 5000 followers/minute\n\n    let twit = await userDao.getTwit();\n    let useDmTwit = false;\n    let twitResetTime = 0;\n\n    let requests = 0;\n    let cursor = '-1';\n    let followers: string[] = [];\n\n    // For big account (>150k), maybe we got the 150k first accounts previously, we'll continue the scrapping\n    const scrappedFollowers = await userDao.getTemporaryFollowerList();\n    if (scrappedFollowers) {\n        followers = scrappedFollowers.followers;\n        cursor = scrappedFollowers.nextCursor;\n    }\n    try {\n        let remainingRequests: number;\n        let resetTime: number;\n        while (cursor !== '0') {\n            if (remainingRequests === 0) {\n                if (useDmTwit) {\n                    // this may happen for 150 000+ followers\n                    // We'll save what we scrapped and will continue in 15min (or sooner if we can)\n                    await userDao.setNextCheckTime(Math.max(resetTime, twitResetTime)); // main and DM app reset times\n                    await userDao.setTemporaryFollowerList(cursor, followers);\n                    return;\n                } else {\n                    // this may happen for 75 000+ followers\n                    useDmTwit = true;\n                    twitResetTime = resetTime;\n                    twit = await userDao.getDmTwit();\n                }\n            }\n            const result = await twit.get('followers/ids', {\n                cursor,\n                stringify_ids: true,\n                user_id: userId,\n            });\n            if (!result.data && result.resp.statusCode === 503) {\n                // noinspection ExceptionCaughtLocallyJS\n                throw new Error('[checkFollowers] Twitter services overloaded / unavailable (503)');\n            }\n            cursor = result.data['next_cursor_str'];\n            requests++;\n\n            remainingRequests = Number(result.resp.headers['x-rate-limit-remaining']);\n            resetTime = Number(result.resp.headers['x-rate-limit-reset']) * 1000;\n            followers.push(...result.data['ids']);\n        }\n        if (scrappedFollowers) {\n            await userDao.deleteTemporaryFollowerList();\n        }\n        // Compute next time we can do the X requests\n        const remainingChecks = Math.floor(remainingRequests / requests);\n        // if we have 10 requests left and each check is 4 requests then we have 2 checks left\n        if (remainingChecks > 0) {\n            await userDao.setNextCheckTime(\n                Math.floor(Date.now() + (resetTime - Date.now()) / (remainingChecks + 1) - 30000)\n            ); // minus 30s because we can trigger it a bit before the ideal time\n        } else {\n            await userDao.setNextCheckTime(resetTime);\n        }\n\n        await detectUnfollows(userId, followers, dao, queue);\n    } catch (err) {\n        if (!err.twitterReply) {\n            // network error\n            if (err.code === 'EAI_AGAIN' || err.code === 'ETIMEDOUT') {\n                logger.warn('check skipped because of a network error.');\n                return;\n            }\n            throw err;\n        } else {\n            await manageTwitterErrors(err.twitterReply, userDao);\n        }\n    }\n}\n\nasync function detectUnfollows(userId: string, followers: string[], dao: Dao, queue: Queue) {\n    const userDao = dao.getUserDao(userId);\n\n    let newUser = false;\n    let formerFollowers: string[] = await userDao.getFollowers();\n    if (formerFollowers === null) {\n        newUser = true;\n        formerFollowers = [];\n    }\n\n    const followersSet = new Set(followers);\n    const formerFollowersSet = new Set(formerFollowers);\n\n    const newFollowers = followers.filter((value) => !formerFollowersSet.has(value));\n    const unfollowers = formerFollowers.filter((value) => !followersSet.has(value));\n\n    const limit = pLimit(5); // make no more than 5 userDao calls at a time\n\n    if (!newUser) {\n        await Promise.all(\n            newFollowers.map((fid) =>\n                limit(() => dao.userEventDao.logFollowEvent(userId, FollowEvent.followDetected, fid, followers.length))\n            )\n        );\n        await Promise.all(\n            unfollowers.map((fid) =>\n                limit(() =>\n                    dao.userEventDao.logFollowEvent(userId, FollowEvent.unfollowDetected, fid, followers.length)\n                )\n            )\n        );\n    } else {\n        await dao.userEventDao.logFollowEvent(\n            userId,\n            FollowEvent.accountCreatedAndFollowersLoaded,\n            '',\n            followers.length\n        );\n    }\n\n    if (unfollowers.length > 0) {\n        const limit = pLimit(5); // make no more than 5 userDao calls at a time\n\n        // remove unfollowers\n        const unfollowersInfo = await Promise.all(\n            unfollowers.map(async (unfollowerId): Promise<IUnfollowerInfo> => {\n                return limit(async () => {\n                    return {\n                        id: unfollowerId,\n                        followTime: await userDao.getFollowTime(unfollowerId),\n                        unfollowTime: Date.now(),\n                        followDetectedTime: await userDao.getFollowDetectedTime(unfollowerId),\n                    };\n                });\n            })\n        );\n\n        await queue.add('notifyUser', {\n            userId,\n            unfollowersInfo,\n        });\n        await userDao.updateFollowers(followers, newFollowers, unfollowers, newUser ? 0 : Date.now());\n    } else if (newFollowers.length > 0) {\n        await userDao.updateFollowers(followers, newFollowers, unfollowers, newUser ? 0 : Date.now());\n    }\n}\n\n// Manage or rethrow twitter errors\nasync function manageTwitterErrors(twitterReply: Twit.Twitter.Errors, userDao: UserDao): Promise<void> {\n    for (const { code, message } of twitterReply.errors) {\n        switch (code) {\n            // app-related\n            case 32:\n                throw new Error(`[checkFollowers] Authentication problems. Please check that your consumer key.`);\n            case 416:\n                throw new Error(`[checkFollowers] Oops, it looks like the application has been suspended :/...`);\n            // user-related\n            case 89:\n                logger.warn('@%s revoked the token. Removing them from the list...', await userDao.getUsername());\n                await userDao.setCategory(UserCategory.revoked);\n                break;\n            case 326:\n                logger.warn('@%s is suspended. Removing them from the list...', await userDao.getUsername());\n                await userDao.setCategory(UserCategory.suspended);\n                break;\n            case 34: // 404 - the user closed his account?\n                logger.warn(\n                    \"@%s this account doesn't exist. Removing them from the list...\",\n                    await userDao.getUsername()\n                );\n                await userDao.setCategory(UserCategory.accountClosed);\n                break;\n            // twitter errors\n            case 130: // over capacity\n            case 131: // internal error\n                throw new Error(`[checkFollowers] internal Twitter error`);\n            case 88: // rate limit\n                throw new Error(`[checkFollowers] the user reached its rate-limit.`);\n            default:\n                throw new Error(`[checkFollowers] An unexpected twitter error occured: ${code} ${message}`);\n        }\n    }\n}\n"
  },
  {
    "path": "unfollow-ninja-server/src/workers.ts",
    "content": "import 'dotenv/config';\n\nimport * as Sentry from '@sentry/node';\nimport '@sentry/tracing';\nimport cluster from 'cluster';\nimport { cpus } from 'os';\nimport Bull from 'bull';\nimport Dao from './dao/dao';\nimport { checkAllFollowers, checkAllVipFollowers } from './workers/checkAllFollowers';\nimport { cacheAllFollowers } from './workers/cacheAllFollowers';\nimport tasks from './tasks';\nimport logger from './utils/logger';\nimport Metrics from './utils/metrics';\n\n// parsing process.env variables\nconst CLUSTER_SIZE = Number(process.env.CLUSTER_SIZE) || cpus().length;\nconst WORKER_RATE_LIMIT = Number(process.env.WORKER_RATE_LIMIT) || 15;\nconst SENTRY_DSN = process.env.SENTRY_DSN || undefined;\n\nif (SENTRY_DSN) {\n    Sentry.init({ dsn: SENTRY_DSN, tracesSampleRate: 0.1 });\n}\n\nif (!process.env.CONSUMER_KEY || !process.env.CONSUMER_SECRET) {\n    logger.error('Some required environment variables are missing (CONSUMER_KEY / CONSUMER_SECRET).');\n    logger.error('Make sure you added them in a .env file in you cwd or that you defined them.');\n    process.exit();\n}\nif (!process.env.DM_CONSUMER_KEY || !process.env.DM_CONSUMER_SECRET) {\n    logger.error('Some required environment variables are missing (DM_CONSUMER_KEY / DM_CONSUMER_SECRET).');\n    logger.error('Make sure you added them in a .env file in you cwd or that you defined them.');\n    process.exit();\n}\n\nconst bullQueue = new Bull('ninja', process.env.REDIS_BULL_URI, {\n    defaultJobOptions: {\n        attempts: 3,\n        backoff: 60000,\n        removeOnComplete: true,\n        removeOnFail: true,\n    },\n});\nbullQueue.on('error', (err) => {\n    logger.error('Bull error: ' + err.stack);\n    Sentry.captureException(err);\n});\n\nconst dao = new Dao();\nif (cluster.isMaster) {\n    logger.info('Unfollow ninja - Server');\n    logger.info('Connecting to the databases..');\n    dao.load()\n        .then(async () => {\n            logger.info('Launching the 2*%s workers...', CLUSTER_SIZE);\n            for (let i = 0; i < 2 * CLUSTER_SIZE; i++) {\n                cluster.fork();\n            }\n\n            // reenable suspended followers every 3h\n            await bullQueue.add('reenableFollowers', {}, { repeat: { cron: '0 * * * *' } });\n\n            // update nbUsers metrics every minute\n            await bullQueue.add('updateMetrics', {}, { repeat: { cron: '* * * * *' } });\n        })\n        .catch((error) => {\n            logger.error(error.stack);\n            Sentry.captureException(error);\n        });\n} else {\n    // if CLUSTER_SIZE=3, we'll create 6 workers\n    // workers 1,2,3 will be used to check new unfollowers, workers 4,5,6 to process new bull tasks\n    if (cluster.worker.id <= CLUSTER_SIZE) {\n        // start checking the worker's followers\n        checkAllFollowers(cluster.worker.id, CLUSTER_SIZE, dao, bullQueue).catch((err) => Sentry.captureException(err));\n        checkAllVipFollowers(cluster.worker.id, CLUSTER_SIZE, dao, bullQueue).catch((err) =>\n            Sentry.captureException(err)\n        );\n        // Also start caching its follower's username and follow time\n        cacheAllFollowers(cluster.worker.id, CLUSTER_SIZE, dao).catch((err) => Sentry.captureException(err));\n    } else {\n        for (const taskName in tasks) {\n            const task = new tasks[taskName](dao, bullQueue);\n            bullQueue\n                .process(taskName, WORKER_RATE_LIMIT, (job) =>\n                    task.run(job).catch(async (err) => {\n                        const username = job.data.userId\n                            ? await dao.getCachedUsername(job.data.userId)\n                            : job.data.userId;\n                        logger.error(`An error happened with ${taskName} / @${username}: ${err.stack}`);\n                        Sentry.withScope((scope) => {\n                            scope.setTag('task-name', taskName);\n                            scope.setUser({ username });\n                            Sentry.captureException(err);\n                        });\n                        throw err;\n                    })\n                )\n                .catch((err) => Sentry.captureException(err));\n        }\n    }\n\n    logger.info('Worker %d ready', cluster.worker.id);\n}\n\nasync function death() {\n    process.removeAllListeners(); // be sure death is not called twice (sigterm & sigint)\n    logger.info('Queue closing..');\n    await bullQueue.close();\n    logger.info('Queue closed..');\n    dao.disconnect().catch((error) => Sentry.captureException(error));\n    Metrics.kill();\n    if (cluster.isWorker) {\n        process.exit(0);\n    }\n}\nprocess.once('SIGTERM', death);\nprocess.once('SIGINT', death);\n"
  },
  {
    "path": "unfollow-ninja-server/tests/dao/__snapshots__/userDao.spec.ts.snap",
    "content": "// Jest Snapshot v1, https://goo.gl/fbAQLP\n\nexports[`Test userDao should get a stable getAllUserData 1`] = `\n{\n  \"category\": 0,\n  \"followers\": [\n    \"1\",\n    \"4\",\n  ],\n  \"followersDetail\": [\n    {\n      \"followDetected\": 123,\n      \"followerId\": \"1\",\n      \"snowflakeId\": \"1654482657084000\",\n      \"uncachable\": false,\n      \"userId\": \"1\",\n    },\n    {\n      \"followDetected\": 456,\n      \"followerId\": \"4\",\n      \"snowflakeId\": null,\n      \"uncachable\": true,\n      \"userId\": \"1\",\n    },\n  ],\n  \"nextCheckTime\": 123,\n  \"temporaryFollowerList\": null,\n  \"userParams\": {\n    \"added_at\": 1234,\n    \"customerId\": \"\",\n    \"dmId\": \"\",\n    \"dmToken\": \"\",\n    \"dmTokenSecret\": \"\",\n    \"lang\": \"fr\",\n    \"pro\": \"0\",\n    \"token\": \"t0k3n\",\n    \"tokenSecret\": \"s3cr3t\",\n  },\n  \"username\": \"user 1\",\n}\n`;\n"
  },
  {
    "path": "unfollow-ninja-server/tests/dao/dao.spec.ts",
    "content": "import Redis from 'ioredis';\nimport { Sequelize } from 'sequelize';\nimport RedisMock from 'ioredis-mock'; // @types/ioredis-mock doesn't exist yet\nimport Dao, { UserCategory } from '../../src/dao/dao';\nimport { IUserEgg } from '../../src/utils/types';\n\nconst redis = process.env.REDIS_TEST_URI\n    ? new Redis(process.env.REDIS_TEST_URI, { lazyConnect: true })\n    : new RedisMock({ lazyConnect: true });\n\nconst sequelize = process.env.POSTGRES_TEST_URI\n    ? new Sequelize(process.env.POSTGRES_TEST_URI, { logging: false })\n    : new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false });\n\nconst sequelizeLogs = process.env.POSTGRES_LOGS_TEST_URI\n    ? new Sequelize(process.env.POSTGRES_LOGS_TEST_URI, { logging: false })\n    : new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false });\n\nconst sequelizeFollowers = process.env.POSTGRES_FOLLOWERS_TEST_URI\n    ? new Sequelize(process.env.POSTGRES_FOLLOWERS_TEST_URI, { logging: false })\n    : new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false });\n\nconst dao = new Dao(redis, sequelize, sequelizeLogs, sequelizeFollowers);\n\ndescribe('Test DAO', () => {\n    beforeAll(async () => {\n        await sequelize.drop();\n        await dao.load();\n    });\n\n    beforeEach(async () => {\n        await redis.flushdb();\n    });\n\n    afterAll(async () => {\n        await redis.flushdb();\n        await sequelize.drop();\n        await dao.disconnect();\n    });\n\n    test('should manage users', async () => {\n        const user1: IUserEgg = {\n            id: '1000000000000001',\n            username: 'user 1',\n            added_at: 1234,\n            lang: 'fr',\n            token: 't0k3n',\n            tokenSecret: 's3cr3t',\n            category: UserCategory.enabled,\n        };\n        const user2: IUserEgg = {\n            ...user1,\n            id: '2',\n            category: UserCategory.disabled,\n        };\n        await dao.addUser(user1);\n        await dao.addUser(user2);\n\n        expect(await dao.getUserIds()).toStrictEqual(['1000000000000001', '2']);\n        expect(await dao.getUserIdsByCategory(UserCategory.enabled)).toStrictEqual(['1000000000000001']);\n        expect(await dao.getUserIdsByCategory(UserCategory.disabled)).toStrictEqual(['2']);\n        expect(await dao.getUserIdsByCategory(UserCategory.suspended)).toStrictEqual([]);\n        expect(await dao.getCachedUsername('2')).toBe('user 1');\n        expect(await dao.getUserCountByCategory()).toStrictEqual({\n            0: 1,\n            1: 0,\n            2: 0,\n            3: 1,\n            4: 0,\n            5: 0,\n            6: 0,\n        });\n    });\n\n    test('should manage username cache', async () => {\n        await dao.addTwittoToCache({ id: '3', username: 'boule' });\n        await dao.addTwittoToCache({ id: '4', username: 'et' });\n        await dao.addTwittoToCache({ id: '4', username: 'bill' });\n        expect(await dao.getCachedUsername('3')).toBe('boule');\n        expect(await dao.getCachedUsername('4')).toBe('bill');\n        expect(await dao.getCachedUsername('5')).toBeNull();\n    });\n\n    test('should manage sessions', async () => {\n        expect(await dao.getSession('12345')).toEqual({});\n        await dao.setSession('12345', { hello: 'world1' });\n        await dao.setSession('23456', { hello: 'world2' });\n        expect(await dao.getSession('12345')).toEqual({ hello: 'world1' });\n    });\n\n    test('should manage API s tokenSecret', async () => {\n        expect(await dao.getTokenSecret('12345')).toBeNull();\n        await dao.setTokenSecret('12345', 'secret1');\n        await dao.setTokenSecret('23456', 'secret2');\n        expect(await dao.getTokenSecret('12345')).toEqual('secret1');\n    });\n});\n"
  },
  {
    "path": "unfollow-ninja-server/tests/dao/userDao.spec.ts",
    "content": "import Redis from 'ioredis';\nimport { Sequelize } from 'sequelize';\nimport RedisMock from 'ioredis-mock';\n\nimport Dao, { UserCategory } from '../../src/dao/dao';\nimport { IUserEgg, IUserParams } from '../../src/utils/types';\n\nconst redis: Redis = process.env.REDIS_TEST_URI\n    ? new Redis(process.env.REDIS_TEST_URI, { lazyConnect: true })\n    : new RedisMock({ lazyConnect: true });\n\nconst sequelize = process.env.POSTGRES_TEST_URI\n    ? new Sequelize(process.env.POSTGRES_TEST_URI, { logging: false })\n    : new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false });\n\nconst sequelizeLogs = process.env.POSTGRES_LOGS_TEST_URI\n    ? new Sequelize(process.env.POSTGRES_LOGS_TEST_URI, { logging: false })\n    : new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false });\n\nconst sequelizeFollowers = process.env.POSTGRES_FOLLOWERS_TEST_URI\n    ? new Sequelize(process.env.POSTGRES_FOLLOWERS_TEST_URI, { logging: false })\n    : new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false });\n\nconst dao = new Dao(redis, sequelize, sequelizeLogs, sequelizeFollowers);\n\nconst uDao1 = dao.getUserDao('1');\nconst uDao2 = dao.getUserDao('2');\n\nconst USER_PARAMS_1: IUserParams = {\n    added_at: 1234,\n    lang: 'fr',\n    token: 't0k3n',\n    tokenSecret: 's3cr3t',\n};\nconst USER_PARAMS_2: IUserParams = {\n    added_at: 2345,\n    lang: 'en',\n    token: 't0k4n',\n    tokenSecret: 's2cr2t',\n    dmId: '3',\n    dmToken: 'token',\n    dmTokenSecret: 'secret',\n    pro: '1',\n    customerId: 'cus_xXx1fff000999fY',\n};\n\ndescribe('Test userDao', () => {\n    afterAll(async () => {\n        await redis.flushdb();\n        await sequelize.drop();\n        await dao.disconnect();\n    });\n\n    beforeAll(async () => {\n        await sequelize.drop();\n        await dao.load();\n        await redis.flushdb();\n\n        const user1: IUserEgg = {\n            ...USER_PARAMS_1,\n            id: '1',\n            username: 'user 1',\n            category: UserCategory.enabled,\n        };\n        const user2: IUserEgg = {\n            ...USER_PARAMS_2,\n            id: '2',\n            username: 'user 2',\n            category: UserCategory.disabled,\n        };\n        await dao.addUser(user1);\n        await dao.addUser(user2);\n    });\n\n    test('should get the usernames', async () => {\n        expect(await uDao1.getUsername()).toBe('user 1');\n        expect(await uDao2.getUsername()).toBe('user 2');\n    });\n\n    test('should be able to read and edit the category', async () => {\n        expect(await dao.getUserIdsByCategory(UserCategory.enabled)).toStrictEqual(['1']);\n        expect(await dao.getUserIdsByCategory(UserCategory.disabled)).toStrictEqual(['2']);\n        expect(await dao.getUserIdsByCategory(UserCategory.revoked)).toStrictEqual([]);\n        expect(await uDao1.getCategory()).toBe(UserCategory.enabled);\n        expect(await uDao2.getCategory()).toBe(UserCategory.disabled);\n\n        await uDao1.setCategory(UserCategory.revoked);\n\n        expect(await dao.getUserIdsByCategory(UserCategory.enabled)).toStrictEqual([]);\n        expect(await dao.getUserIdsByCategory(UserCategory.disabled)).toStrictEqual(['2']);\n        expect(await dao.getUserIdsByCategory(UserCategory.revoked)).toStrictEqual(['1']);\n        expect(await uDao1.getCategory()).toBe(UserCategory.revoked);\n        expect(await uDao2.getCategory()).toBe(UserCategory.disabled);\n\n        await uDao1.enable();\n        expect(await uDao1.getCategory()).toBe(UserCategory.enabled);\n    });\n\n    test('should be able to save the next time to check', async () => {\n        expect(await uDao1.getNextCheckTime()).toBe(0); // defaults to 0\n        await uDao1.setNextCheckTime(123);\n        await uDao2.setNextCheckTime(234);\n        await uDao2.setNextCheckTime(345);\n        expect(await uDao1.getNextCheckTime()).toBe(123);\n        expect(await uDao2.getNextCheckTime()).toBe(345);\n    });\n\n    test('should be able to fetch and edit user params', async () => {\n        const uParamsStr1 = {\n            ...USER_PARAMS_1,\n            added_at: 1234,\n            dmId: '',\n            dmToken: '',\n            dmTokenSecret: '',\n            customerId: '',\n            pro: '0',\n        };\n        const uParamsStr2 = { ...USER_PARAMS_2, added_at: 2345, pro: '1' };\n        expect(await uDao1.getUserParams()).toStrictEqual(uParamsStr1);\n        expect(await uDao2.getUserParams()).toStrictEqual(uParamsStr2);\n\n        const newParams = {\n            dmId: '3030',\n            dmToken: 'token2',\n            dmTokenSecret: 'secret2',\n            token: 't0k4n2',\n            tokenSecret: 's2cr2t2',\n        };\n        await uDao2.setUserParams(newParams);\n        expect(await uDao2.getUserParams()).toStrictEqual({\n            ...uParamsStr2,\n            ...newParams,\n        });\n        await uDao2.setUserParams(USER_PARAMS_2);\n        expect(await uDao2.getUserParams()).toStrictEqual(uParamsStr2);\n    });\n\n    test('should be able to get a Twit instance for each user', async () => {\n        process.env.CONSUMER_KEY = 'ckey';\n        process.env.CONSUMER_SECRET = 'csecret';\n        const twit1 = await uDao1.getTwit();\n        const twit2 = await uDao2.getTwit();\n\n        expect(twit1.getAuth()).toStrictEqual({\n            consumer_key: 'ckey',\n            consumer_secret: 'csecret',\n            access_token: USER_PARAMS_1.token,\n            access_token_secret: USER_PARAMS_1.tokenSecret,\n        });\n        expect(twit2.getAuth()).toStrictEqual({\n            consumer_key: 'ckey',\n            consumer_secret: 'csecret',\n            access_token: USER_PARAMS_2.token,\n            access_token_secret: USER_PARAMS_2.tokenSecret,\n        });\n    });\n\n    test('should be able to get a dmTwit instance for each user', async () => {\n        process.env.DM_CONSUMER_KEY = 'dmckey';\n        process.env.DM_CONSUMER_SECRET = 'dmcsecret';\n        const dmTwit2 = await uDao2.getDmTwit();\n\n        expect(dmTwit2.getAuth()).toStrictEqual({\n            consumer_key: 'dmckey',\n            consumer_secret: 'dmcsecret',\n            access_token: USER_PARAMS_2.dmToken,\n            access_token_secret: USER_PARAMS_2.dmTokenSecret,\n        });\n        await expect(uDao1.getDmTwit()).rejects.toThrow(\"the user didn't have any DM credentials stored\");\n    });\n\n    test('should be able to get the language', async () => {\n        expect(await uDao1.getLang()).toBe('fr');\n        expect(await uDao2.getLang()).toBe('en');\n    });\n\n    test('isPro', async () => {\n        expect(await uDao1.isPro()).toBe(false);\n        expect(await uDao2.isPro()).toBe(true);\n    });\n\n    test('should store and retrieve a list of followers', async () => {\n        expect(await uDao1.getFollowers()).toBeNull();\n\n        await uDao1.updateFollowers(['1', '2', '3'], ['1', '2', '3'], [], 123000);\n        await uDao1.updateFollowers(['1', '4'], ['4'], ['2', '3'], 456000);\n\n        expect(await uDao1.getFollowers()).toStrictEqual(['1', '4']);\n        expect(await redis.get('followers:count:1')).toBe('2');\n        expect(await uDao1.getFollowDetectedTime('1')).toBe(123000);\n        expect(await uDao1.getFollowDetectedTime('2')).toBeNull();\n        expect(await uDao1.getFollowDetectedTime('3')).toBeNull();\n        expect(await uDao1.getFollowDetectedTime('4')).toBe(456000);\n        expect(await uDao1.getFollowTime('4')).toBe(456000);\n        expect(await redis.get('total-unfollowers')).toBe('2');\n    });\n\n    test('should store snowflake IDs', async () => {\n        await uDao1.setFollowerSnowflakeId('1', '1654482657084000');\n        await uDao1.setFollowerSnowflakeId('4', '1654482657084000');\n        expect(await uDao1.getFollowerSnowflakeId('1')).toBe('1654482657084000');\n        expect(await uDao1.getCachedFollowers()).toEqual(['1', '4']);\n        expect(await uDao1.getFollowTime('1')).toBe(1577837617); // uses snowflake this time\n\n        await uDao1.updateFollowers(['1'], [], ['4'], 456000);\n        expect(await uDao1.getCachedFollowers()).toEqual(['1']);\n        expect(await uDao1.getFollowerSnowflakeId('4')).toBeNull();\n        await uDao1.updateFollowers(['1', '4'], ['4'], [], 456000);\n        expect(await uDao1.getCachedFollowers()).toEqual(['1']);\n        expect(await uDao1.getFollowerSnowflakeId('4')).toBeNull();\n    });\n\n    test('should manage cached/uncached followers', async () => {\n        expect(await uDao1.getHasNotCachedFollowers()).toBe(true);\n\n        await uDao1.setFollowerSnowflakeId('1', '1654482657084000');\n        expect(await uDao1.getHasNotCachedFollowers()).toBe(true);\n\n        await uDao1.addUncachableFollower('4');\n        expect(await uDao1.getHasNotCachedFollowers()).toBe(false);\n        expect(await uDao1.getUncachableFollowers()).toEqual(['4']);\n    });\n\n    test('should be able to store scrapped followers', async () => {\n        expect(await uDao1.getTemporaryFollowerList()).toBe(null);\n\n        await uDao1.setTemporaryFollowerList('1', ['2', '3']);\n        expect(await uDao1.getTemporaryFollowerList()).toStrictEqual({ nextCursor: '1', followers: ['2', '3'] });\n\n        await uDao1.deleteTemporaryFollowerList();\n        expect(await uDao1.getTemporaryFollowerList()).toBe(null);\n    });\n\n    test('should manage friend codes', async () => {\n        expect(await uDao1.getFriendCodes()).toHaveLength(0);\n        await uDao1.addFriendCodes();\n        await uDao2.addFriendCodes();\n        await uDao1.addFriendCodes(); // the second call should not do anything\n        const codes = await uDao1.getFriendCodes();\n        expect(codes).toHaveLength(5);\n        expect(codes[0].userId).toBe('1');\n        expect(codes[0].code).toHaveLength(6);\n\n        expect(await uDao2.registerFriendCode('AAAAAA')).toBe(false);\n        expect(await uDao2.registerFriendCode(codes[1].code)).toBe(true);\n        expect(await uDao2.registerFriendCode(codes[1].code)).toBe(false);\n        expect((await uDao2.getRegisteredFriendCode())?.userId).toBe('1');\n\n        await uDao2.deleteFriendCodes(codes[1].code);\n        expect(await uDao1.getFriendCodes()).toHaveLength(5);\n        expect(await uDao2.getFriendCodes()).toHaveLength(5);\n\n        await uDao1.deleteFriendCodes(codes[1].code);\n        expect(await uDao1.getFriendCodes()).toHaveLength(4);\n        expect(await uDao2.getFriendCodes()).toHaveLength(5);\n        expect(await uDao2.getRegisteredFriendCode()).toBe(null);\n    });\n\n    // depends heavily on other tests\n    test('should get a stable getAllUserData', async () => {\n        const data = await uDao1.getAllUserData();\n        delete data.friendCodes; // not stable\n        delete data.registeredFriendCode; // not stable\n        expect(data).toMatchSnapshot();\n    });\n\n    test('should completely delete data about the user', async () => {\n        expect(await redis.dbsize()).toBe(8);\n        await uDao1.deleteUser();\n        await uDao2.deleteUser();\n        expect(await redis.zcard('users')).toBe(0);\n        await redis.del('users'); // empty users appears as a key on ioredis-mock but not on actual redis 6\n        expect((await redis.keys('*')).sort()).toEqual(['total-unfollowers']);\n    });\n});\n"
  },
  {
    "path": "unfollow-ninja-server/tests/dao/userEventDao.spec.ts",
    "content": "import Redis from 'ioredis';\nimport { Sequelize } from 'sequelize';\nimport RedisMock from 'ioredis-mock';\n\nimport Dao, { UserCategory } from '../../src/dao/dao';\nimport { FollowEvent, NotificationEvent, WebEvent } from '../../src/dao/userEventDao'; // @types/ioredis-mock doesn't exist yet\n\nconst redis = process.env.REDIS_TEST_URI\n    ? new Redis(process.env.REDIS_TEST_URI, { lazyConnect: true })\n    : new (RedisMock as typeof Redis)({ lazyConnect: true });\n\nconst sequelize = process.env.POSTGRES_TEST_URI\n    ? new Sequelize(process.env.POSTGRES_TEST_URI, { logging: false })\n    : new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false });\n\nconst sequelizeLogs = process.env.POSTGRES_LOGS_TEST_URI\n    ? new Sequelize(process.env.POSTGRES_LOGS_TEST_URI, { logging: false })\n    : new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false });\n\nconst sequelizeFollowers = process.env.POSTGRES_FOLLOWERS_TEST_URI\n    ? new Sequelize(process.env.POSTGRES_FOLLOWERS_TEST_URI, { logging: false })\n    : new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false });\n\nconst dao = new Dao(redis, sequelize, sequelizeLogs, sequelizeFollowers);\nconst userEventDao = dao.userEventDao;\n\ndescribe('Test UserEventDAO', () => {\n    beforeAll(async () => {\n        await sequelizeLogs.drop();\n        await dao.load();\n    });\n\n    afterAll(async () => {\n        await sequelizeLogs.drop();\n        await dao.disconnect();\n    });\n\n    // because logWebEvent doesn't have any await, these tests can be flacky when run on docker\n    jest.retryTimes(5);\n\n    test('should log webEvents', async () => {\n        await userEventDao.logWebEvent('user1', WebEvent.signIn, '127.0.0.1', 'username1');\n        await userEventDao.logWebEvent('user1', WebEvent.addDmAccount, '127.0.0.2', 'username2', 'user2');\n        await userEventDao.logWebEvent('user2', WebEvent.signIn, '127.0.0.3', 'username2');\n\n        expect(await userEventDao.getWebEvents('user0')).toEqual([]);\n        expect(await userEventDao.getWebEvents('user1')).toEqual([\n            expect.objectContaining({\n                id: 2,\n                userId: 'user1',\n                event: WebEvent.addDmAccount,\n                eventName: 'addDmAccount',\n                extraInfo: 'user2',\n                ip: '127.0.0.2',\n                username: 'username2',\n            }),\n            expect.objectContaining({\n                id: 1,\n                userId: 'user1',\n                event: WebEvent.signIn,\n                eventName: 'signIn',\n                extraInfo: null,\n                ip: '127.0.0.1',\n                username: 'username1',\n            }),\n        ]);\n        expect(await userEventDao.getWebEvents('user2')).toHaveLength(1);\n    });\n\n    test('should log followEvent', async () => {\n        await userEventDao.logFollowEvent('user1', FollowEvent.followDetected, 'user2', 20);\n\n        expect(await userEventDao.getFollowEvent('user0')).toEqual([]);\n        expect(await userEventDao.getFollowEvent('user1')).toEqual([\n            expect.objectContaining({\n                id: 1,\n                userId: 'user1',\n                event: FollowEvent.followDetected,\n                eventName: 'followDetected',\n                followerId: 'user2',\n                nbFollowers: 20,\n            }),\n        ]);\n    });\n\n    test('should log unfollowerEvent', async () => {\n        await userEventDao.logUnfollowerEvent('user1', false, {\n            id: 'user2',\n            followTime: 3000,\n            unfollowTime: 0,\n            followDetectedTime: 5000,\n            blocking: true,\n            blocked_by: false,\n            suspended: true,\n            locked: false,\n            deleted: true,\n            following: false,\n            followed_by: true,\n            skippedBecauseGlitchy: false,\n        });\n\n        expect(await userEventDao.getUnfollowerEvents('user0')).toEqual([]);\n        expect(await userEventDao.getUnfollowerEvents('user1')).toEqual([\n            expect.objectContaining({\n                id: 1,\n                userId: 'user1',\n                isSecondCheck: false,\n                followerId: 'user2',\n                followTime: 3,\n                followDetectedTime: 5,\n                blocking: true,\n                blockedBy: false,\n                suspended: true,\n                locked: false,\n                deleted: true,\n                following: false,\n                followedBy: true,\n                skippedBecauseGlitchy: false,\n            }),\n        ]);\n    });\n\n    test('should log notificationEvent', async () => {\n        await userEventDao.logNotificationEvent(\n            'user1',\n            NotificationEvent.unfollowersMessage,\n            'user2',\n            'someone unfollowed you :('\n        );\n\n        expect(await userEventDao.getNotificationEvents('user0')).toEqual([]);\n        expect(await userEventDao.getNotificationEvents('user1')).toEqual([\n            expect.objectContaining({\n                id: 1,\n                event: NotificationEvent.unfollowersMessage,\n                eventName: 'unfollowersMessage',\n                userId: 'user1',\n                fromId: 'user2',\n                message: 'someone unfollowed you :(',\n            }),\n        ]);\n    });\n\n    test('should log categoryEvent', async () => {\n        await dao.getUserDao('user1').setCategory(UserCategory.enabled);\n        await dao.getUserDao('user1').setCategory(UserCategory.revoked);\n\n        expect(await userEventDao.getCategoryEvents('user0')).toEqual([]);\n        expect(await userEventDao.getCategoryEvents('user1')).toEqual([\n            expect.objectContaining({\n                id: 2,\n                category: UserCategory.revoked,\n                categoryName: 'revoked',\n                formerCategory: UserCategory.enabled,\n                formerCategoryName: 'enabled',\n            }),\n            expect.objectContaining({\n                id: 1,\n                category: UserCategory.enabled,\n                categoryName: 'enabled',\n                formerCategory: UserCategory.disabled,\n                formerCategoryName: 'disabled',\n            }),\n        ]);\n    });\n});\n"
  },
  {
    "path": "unfollow-ninja-server/tests/docker-compose.yml",
    "content": "version: '3'\n\nservices:\n    tests:\n        build: ..\n        command: 'npm run specs'\n        volumes:\n            - ..:/usr/app/\n            - /usr/app/node_modules\n        depends_on:\n            - postgres\n            - postgres-logs\n            - postgres-followers\n            - redis\n        environment:\n            REDIS_TEST_URI: redis://redis\n            POSTGRES_TEST_URI: postgres://postgres:unfollowninja@postgres/postgres\n            POSTGRES_LOGS_TEST_URI: postgres://postgres:unfollowninja@postgres-logs/postgres\n            POSTGRES_FOLLOWERS_TEST_URI: postgres://postgres:unfollowninja@postgres-followers/postgres\n    postgres:\n        image: postgres:15\n        command: postgres -c 'max_connections=200'\n        environment:\n            POSTGRES_PASSWORD: 'unfollowninja'\n    postgres-logs:\n        image: postgres:15\n        command: postgres -c 'max_connections=200'\n        environment:\n            POSTGRES_PASSWORD: 'unfollowninja'\n    postgres-followers:\n        image: postgres:15\n        command: postgres -c 'max_connections=200'\n        environment:\n            POSTGRES_PASSWORD: 'unfollowninja'\n    redis:\n        image: redis:6\n"
  },
  {
    "path": "unfollow-ninja-server/tests/tasks/cacheFollowers.spec.ts.disabled",
    "content": "// disabled as there will be experimental changes to come\nimport { Job } from 'kue';\nimport CacheFollowers from '../../src/tasks/cacheFollowers';\nimport { daoMock, queueMock } from '../utils';\n\nprocess.env.CONSUMER_KEY = 'consumerKey';\nprocess.env.CONSUMER_SECRET = 'consumerSecret';\n\nconst queue = queueMock();\nconst dao = daoMock();\nconst userDao = dao.userDao['01'];\nconst job = new Job('cacheFollowers', {username: 'testUsername', userId: '01'});\njob.started_at = Date.now();\n// @ts-ignore\nconst task = new CacheFollowers(dao, queue);\n\n// reset time in sec (and not ms)\nfunction mockTwitterReply(users: any[], nextCursorStr = '0', previousCursorStr = '0') {\n    userDao.twit.get.mockResolvedValueOnce({\n        data: {\n            users,\n            next_cursor_str: nextCursorStr,\n            previous_cursor_str: previousCursorStr,\n        },\n    });\n}\n\nconst user1 = {id_str: '123', profile_image_url_https: 'pic1', screen_name: 'user1'};\nconst user2 = {id_str: '234', profile_image_url_https: 'pic2', screen_name: 'user2'};\nconst user3 = {id_str: '345', profile_image_url_https: 'pic3', screen_name: 'user3'};\n\ndescribe('cacheFollowers task', () => {\n    beforeEach(() => {\n        userDao.getFollowers.mockResolvedValue(['123', '234', '345']);\n        userDao.getUncachableFollowers.mockResolvedValue([]);\n    });\n\n    test('every followers are already cached', async () => {\n        userDao.getCachedFollowers.mockResolvedValue(['123', '234', '345']);\n        await task.run(job);\n        expect(userDao.getTwit).toHaveBeenCalledTimes(0);\n        expect(userDao.setFollowerSnowflakeId).toHaveBeenCalledTimes(0);\n        expect(userDao.addUncachableFollower).toHaveBeenCalledTimes(0);\n    });\n\n    test('last follower not cached', async () => {\n        userDao.getCachedFollowers.mockResolvedValue(['234', '345']);\n        mockTwitterReply([user1], '100');\n        await task.run(job);\n        expect(userDao.twit.get.mock.calls[0][1].cursor).toBe('-1');\n        expect(dao.addTwittoToCache).toHaveBeenCalledTimes(1);\n        expect(userDao.setFollowerSnowflakeId).toHaveBeenCalledTimes(1);\n        expect(userDao.setFollowerSnowflakeId).toBeCalledWith('123', '100');\n        expect(userDao.addUncachableFollower).toHaveBeenCalledTimes(0);\n    });\n\n    test('first follower not cached', async () => {\n        userDao.getCachedFollowers.mockResolvedValue(['123', '234']);\n        userDao.getFollowerSnowflakeId.mockResolvedValue('200');\n        mockTwitterReply([user3], '0', '-300');\n        await task.run(job);\n        expect(userDao.getFollowerSnowflakeId.mock.calls[0][0]).toBe('234');\n        expect(userDao.twit.get.mock.calls[0][1].cursor).toBe('200');\n        expect(dao.addTwittoToCache).toHaveBeenCalledTimes(1);\n        expect(userDao.setFollowerSnowflakeId).toHaveBeenCalledTimes(1);\n        expect(userDao.setFollowerSnowflakeId).toBeCalledWith('345', '300');\n        expect(userDao.addUncachableFollower).toHaveBeenCalledTimes(0);\n    });\n\n    test('no followers cached', async () => {\n        userDao.getCachedFollowers.mockResolvedValue([]);\n        mockTwitterReply([user1], '100');\n        await task.run(job);\n        expect(userDao.twit.get.mock.calls[0][1].cursor).toBe('-1');\n        expect(dao.addTwittoToCache).toHaveBeenCalledTimes(1);\n        expect(userDao.setFollowerSnowflakeId).toHaveBeenCalledTimes(1);\n        expect(userDao.setFollowerSnowflakeId).toBeCalledWith('123', '100');\n        expect(userDao.addUncachableFollower).toHaveBeenCalledTimes(0);\n    });\n\n    test('2 cached at a time (only last cached)', async () => {\n        userDao.getCachedFollowers.mockResolvedValue(['123']);\n        userDao.getFollowerSnowflakeId.mockResolvedValue('100');\n        mockTwitterReply([user2, user3], '300', '-200');\n        await task.run(job);\n        expect(userDao.getFollowerSnowflakeId.mock.calls[0][0]).toBe('123');\n        expect(userDao.twit.get.mock.calls[0][1].cursor).toBe('100');\n        expect(dao.addTwittoToCache).toHaveBeenCalledTimes(2);\n        expect(userDao.setFollowerSnowflakeId).toHaveBeenCalledTimes(2);\n        expect(userDao.setFollowerSnowflakeId).toBeCalledWith('234', '200');\n        expect(userDao.setFollowerSnowflakeId).toBeCalledWith('345', '300');\n        expect(userDao.addUncachableFollower).toHaveBeenCalledTimes(0);\n    });\n\n    test('first follower is uncachable', async () => {\n        userDao.getCachedFollowers.mockResolvedValue(['123', '234']);\n        userDao.getFollowerSnowflakeId.mockResolvedValue('200');\n        mockTwitterReply([], '0', '0');\n        await task.run(job);\n        expect(dao.addTwittoToCache).toHaveBeenCalledTimes(0);\n        expect(userDao.setFollowerSnowflakeId).toHaveBeenCalledTimes(0);\n        expect(userDao.addUncachableFollower).toHaveBeenCalledTimes(1);\n        expect(userDao.addUncachableFollower).toBeCalledWith('345');\n    });\n});\n"
  },
  {
    "path": "unfollow-ninja-server/tests/tasks/checkFollowers.spec.ts.disabled",
    "content": "// disabled as there will be experimental changes to come\nimport { Job } from 'kue';\nimport CheckFollowers from '../../src/tasks/checkFollowers';\nimport { daoMock, queueMock } from '../utils';\n\nprocess.env.CONSUMER_KEY = 'consumerKey';\nprocess.env.CONSUMER_SECRET = 'consumerSecret';\n\nconst queue = queueMock();\nconst dao = daoMock();\nconst userDao = dao.userDao['01'];\nconst job = new Job('checkFollowers', {username: 'testUsername', userId: '01'});\njob.started_at = Date.now();\n// @ts-ignore\nconst task = new CheckFollowers(dao, queue);\n\n// reset time in sec (and not ms)\nfunction mockTwitterReply(ids: string[], nextCursorStr = '0', remaining = '15', resetTime = '0', twit = userDao.twit) {\n    twit.get.mockResolvedValueOnce({\n        data: {\n            ids,\n            next_cursor_str: nextCursorStr,\n        },\n        resp: {\n            headers: {\n                'x-rate-limit-remaining': remaining,\n                'x-rate-limit-reset': resetTime,\n            },\n        },\n    });\n}\n\ndescribe('checkFollowers task', () => {\n    beforeEach(() => {\n        userDao.getFollowers.mockResolvedValue(['123', '234']);\n    });\n\n    test('no follow or unfollow no pagination', async () => {\n        mockTwitterReply(['123', '234']);\n        await task.run(job);\n        expect(queue.save).toHaveBeenCalledTimes(0);\n        expect(userDao.updateFollowers).toHaveBeenCalledTimes(0);\n        expect(userDao.setNextCheckTime).toBeCalled();\n    });\n\n    test('two new followers', async () => {\n        mockTwitterReply(['345', '456', '123', '234']);\n        await task.run(job);\n        expect(queue.save).toHaveBeenCalledTimes(0);\n        expect(userDao.updateFollowers).toHaveBeenCalledTimes(1);\n        expect(userDao.updateFollowers).toBeCalledWith(\n            ['345', '456', '123', '234'], ['345', '456'], [], job.started_at,\n        );\n        expect(userDao.setNextCheckTime).toBeCalled();\n    });\n\n    test('two unfollowers', async () => {\n        mockTwitterReply([]);\n        await task.run(job);\n        expect(queue.save).toHaveBeenCalledTimes(1);\n        expect(queue.create).toBeCalledWith('notifyUser', expect.any(Object));\n\n        expect(userDao.updateFollowers).toHaveBeenCalledTimes(1);\n        expect(userDao.updateFollowers).toBeCalledWith(\n            [], [], ['123', '234'], job.started_at,\n        );\n        expect(userDao.setNextCheckTime).toBeCalled();\n    });\n\n    test('new user - two new followers', async () => {\n        userDao.getFollowers.mockResolvedValue(null);\n        mockTwitterReply(['123', '234']);\n        await task.run(job);\n        expect(queue.save).toHaveBeenCalledTimes(0);\n        expect(userDao.updateFollowers).toHaveBeenCalledTimes(1);\n        expect(userDao.updateFollowers).toBeCalledWith(\n            ['123', '234'], ['123', '234'], [], 0,\n        );\n        expect(userDao.setNextCheckTime).toBeCalled();\n    });\n\n    test('two new followers, two unfollowers', async () => {\n        mockTwitterReply(['345', '456']);\n        await task.run(job);\n        expect(queue.save).toHaveBeenCalledTimes(1);\n\n        expect(userDao.updateFollowers).toHaveBeenCalledTimes(1);\n        expect(userDao.updateFollowers).toBeCalledWith(\n            ['345', '456'], ['345', '456'], ['123', '234'], job.started_at,\n        );\n        expect(userDao.setNextCheckTime).toBeCalled();\n    });\n\n    test('three pages', async () => {\n        mockTwitterReply(['123', '234'], '200');\n        mockTwitterReply(['345', '456'], '100');\n        mockTwitterReply(['567'], '0');\n        await task.run(job);\n        expect(queue.save).toHaveBeenCalledTimes(0);\n        expect(userDao.updateFollowers).toBeCalledWith(\n            ['123', '234', '345', '456', '567'], ['345', '456', '567'], [], job.started_at,\n        );\n        expect(userDao.setNextCheckTime).toHaveBeenCalledTimes(1);\n    });\n\n    test('skip the check if nextCheckTime is in the future', async () => {\n        userDao.getNextCheckTime.mockResolvedValue(job.started_at as number + 5000);\n        await task.run(job);\n        expect(userDao.twit.get).toHaveBeenCalledTimes(0);\n        expect(queue.save).toHaveBeenCalledTimes(0);\n        expect(userDao.setNextCheckTime).toHaveBeenCalledTimes(0);\n    });\n\n    test('keep the check if nextCheckTime is in the past', async () => {\n        userDao.getNextCheckTime.mockResolvedValue(job.started_at as number - 5000);\n        mockTwitterReply(['123', '234']);\n        await task.run(job);\n        expect(userDao.twit.get).toHaveBeenCalledTimes(1);\n        expect(queue.save).toHaveBeenCalledTimes(0);\n        expect(userDao.setNextCheckTime).toHaveBeenCalledTimes(1);\n    });\n\n    test('If there are 3 pages, nextCheckTime is in 3 minutes', async () => {\n        const resetTime = Math.floor(Number(job.started_at) / 1000 + 15 * 60);\n        mockTwitterReply(['123', '234'], '200', '14', resetTime.toString());\n        mockTwitterReply(['345', '456'], '100', '13', resetTime.toString());\n        mockTwitterReply(['567'], '0', '12', resetTime.toString());\n        await task.run(job);\n        // should not be ok in 2 mins but ok in 3 mins\n        expect(userDao.setNextCheckTime.mock.calls[0][0]).toBeGreaterThan(job.started_at as number + 2 * 60 * 1000);\n        expect(userDao.setNextCheckTime.mock.calls[0][0]).toBeLessThan(job.started_at as number + 3 * 60 * 1000);\n    });\n\n    test('If there is 1 page, nextCheckTime is next minutes', async () => {\n        const resetTime = Math.floor(Number(job.started_at) / 1000 + 15 * 60);\n        mockTwitterReply(['123', '234'], '0', '14', resetTime.toString());\n        await task.run(job);\n        expect(userDao.setNextCheckTime.mock.calls[0][0]).toBeLessThan(Number(job.started_at) + 60 * 1000);\n    });\n\n    test('If there is no requests remaining (75 000+ followers), use DM tokens', async () => {\n        const resetTime = Math.floor(Number(job.started_at) / 1000 + 15 * 60);\n        mockTwitterReply(['123', '234'], '100', '0', resetTime.toString());\n        mockTwitterReply(['345', '456'], '0', '0', resetTime.toString(), userDao.dmTwit);\n        await task.run(job);\n        expect(userDao.twit.get).toBeCalled();\n        expect(userDao.dmTwit.get).toBeCalled();\n    });\n\n    test('If there is no requests remaining on any token, nextCheckTime is max(next reset times)', async () => {\n        const resetTime1 = Math.floor(Number(job.started_at) / 1000 + 14 * 60);\n        const resetTime2 = Math.floor(Number(job.started_at) / 1000 + 13 * 60);\n        mockTwitterReply(['123', '234'], '100', '0', resetTime1.toString());\n        mockTwitterReply(['345', '456'], '50', '0', resetTime2.toString(), userDao.dmTwit);\n        await expect(task.run(job)).rejects.toThrowError('No twitter requests remaining to pursue the job.');\n        expect(userDao.setNextCheckTime).toBeCalledWith(resetTime1 * 1000); // max(rt1,rt2)\n    });\n});\n"
  },
  {
    "path": "unfollow-ninja-server/tests/tasks/notifyUser.spec.ts",
    "content": "import type { Job } from 'bull';\nimport NotifyUser from '../../src/tasks/notifyUser';\nimport { IUnfollowerInfo } from '../../src/utils/types';\nimport { daoMock, queueMock } from '../utils';\n\nprocess.env.CONSUMER_KEY = 'consumerKey';\nprocess.env.CONSUMER_SECRET = 'consumerSecret';\n\nconst queue = queueMock();\nconst dao = daoMock();\nconst userDao = dao.userDao['01'];\nconst unfollowersInfo: IUnfollowerInfo[] = [];\n\nconst job = {\n    data: { username: 'testUsername', userId: '01', unfollowersInfo },\n    processedOn: Date.now(),\n} as Job;\n\nconst task = new NotifyUser(dao, queue);\n\nfunction mockUsersLookupReply(ids: string[], screenNames: string[], friendsCounts?: number[]) {\n    userDao.twit.post.mockResolvedValueOnce({\n        data: screenNames.map((screenName, i) => ({\n            id_str: ids[i],\n            screen_name: screenName,\n            friends_count: friendsCounts ? friendsCounts[i] : 100,\n        })),\n    });\n}\n\nfunction mockFriendshipShowReply(\n    blocking = false,\n    blockedBy = false,\n    following = false,\n    followedBy = false,\n    id: string = null,\n    screenName: string = null\n) {\n    userDao.twit.get.mockResolvedValueOnce({\n        data: {\n            relationship: {\n                source: {\n                    blocking,\n                    blocked_by: blockedBy,\n                    following,\n                    followed_by: followedBy,\n                },\n                target: {\n                    id_str: id,\n                    screen_name: screenName,\n                },\n            },\n        },\n    });\n}\n\nfunction mockFriendshipShowReplyNotFound() {\n    userDao.twit.get.mockRejectedValueOnce({\n        twitterReply: { errors: [{ code: 50 }] },\n    });\n}\n\ndescribe('notifyUser task', () => {\n    beforeEach(() => {\n        unfollowersInfo.splice(0); // empty array\n        userDao.getLang.mockResolvedValue('en');\n        userDao.dmTwit.post.mockResolvedValue(null);\n        userDao.getFollowDetectedTime.mockResolvedValue(0);\n    });\n\n    test('one classic unfollower', async () => {\n        unfollowersInfo.push({\n            id: '123',\n            followTime: 100,\n            followDetectedTime: 100,\n            unfollowTime: 5000000,\n        });\n        mockUsersLookupReply(['123'], ['twitto123']);\n        mockFriendshipShowReply();\n        await task.run(job);\n        expect(queue.add).toHaveBeenCalledTimes(0);\n        expect(dao.userEventDao.logUnfollowerEvent).toHaveBeenCalledTimes(1);\n        expect(userDao.dmTwit.post).toHaveBeenCalledTimes(1);\n        expect(userDao.dmTwit.post.mock.calls[0][1].event.message_create.message_data.text).toBe(\n            '@twitto123 unfollowed you 👋.\\n' + 'This account followed you for an hour (01/01/1970).'\n        );\n    });\n\n    test('two unfollowers', async () => {\n        unfollowersInfo.push({\n            id: '123',\n            followTime: 100,\n            followDetectedTime: 100,\n            unfollowTime: 5000000,\n        });\n        unfollowersInfo.push({\n            id: '234',\n            followTime: 0,\n            followDetectedTime: 0,\n            unfollowTime: 5000000,\n        });\n        mockUsersLookupReply(['123', '234'], ['twitto123', 'twitto234']);\n        mockFriendshipShowReply();\n        mockFriendshipShowReply();\n        await task.run(job);\n        expect(queue.add).toHaveBeenCalledTimes(0);\n        expect(dao.userEventDao.logUnfollowerEvent).toHaveBeenCalledTimes(2);\n        expect(userDao.dmTwit.post).toHaveBeenCalledTimes(1);\n        expect(userDao.dmTwit.post.mock.calls[0][1].event.message_create.message_data.text).toBe(\n            '2 Twitter users unfollowed you:\\n' +\n                '  • @twitto123 unfollowed you 👋.\\n' +\n                'This account followed you for an hour (01/01/1970).\\n' +\n                '  • @twitto234 unfollowed you 👋.\\n' +\n                'This account followed you before you signed up to @unfollowninja!'\n        );\n    });\n\n    test('one unfollower, one twitter glitch (followed_by=true)', async () => {\n        unfollowersInfo.push({\n            id: '123',\n            followTime: 100,\n            followDetectedTime: 100,\n            unfollowTime: 5000000,\n        });\n        unfollowersInfo.push({\n            id: '234',\n            followTime: 200,\n            followDetectedTime: 200,\n            unfollowTime: 5000000,\n        });\n        mockUsersLookupReply(['123'], ['twitto123']);\n        mockFriendshipShowReply();\n        mockFriendshipShowReply(false, false, false, true, 'twitto234');\n        await task.run(job);\n        expect(dao.userEventDao.logUnfollowerEvent).toHaveBeenCalledTimes(2);\n        expect(userDao.dmTwit.post).toHaveBeenCalledTimes(1);\n        expect(userDao.dmTwit.post.mock.calls[0][1].event.message_create.message_data.text).toBe(\n            '@twitto123 unfollowed you 👋.\\n' + 'This account followed you for an hour (01/01/1970).'\n        );\n    });\n\n    test('one unfollower, one potential twitter glitch (user not found)', async () => {\n        unfollowersInfo.push({\n            id: '123',\n            followTime: 100,\n            followDetectedTime: 100,\n            unfollowTime: 5000000,\n        });\n        unfollowersInfo.push({\n            id: '234',\n            followTime: 200,\n            followDetectedTime: 200,\n            unfollowTime: 2000000000,\n        });\n        mockUsersLookupReply(['123'], ['twitto123']);\n        mockFriendshipShowReply();\n        mockFriendshipShowReplyNotFound();\n        await task.run(job);\n        expect(queue.add).toHaveBeenCalledTimes(1);\n        expect(dao.userEventDao.logUnfollowerEvent).toHaveBeenCalledTimes(2);\n        expect(userDao.dmTwit.post).toHaveBeenCalledTimes(1);\n        expect(userDao.dmTwit.post.mock.calls[0][1].event.message_create.message_data.text).toBe(\n            '@twitto123 unfollowed you 👋.\\n' + 'This account followed you for an hour (01/01/1970).'\n        );\n    });\n\n    test('one unfollower, one potential twitter glitch, second try', async () => {\n        unfollowersInfo.push({\n            id: '123',\n            followTime: 100,\n            followDetectedTime: 100,\n            unfollowTime: 5000000,\n        });\n        unfollowersInfo.push({\n            id: '234',\n            followTime: 200,\n            followDetectedTime: 200,\n            unfollowTime: 2000000000,\n        });\n        mockUsersLookupReply(['123'], ['twitto123']);\n        mockFriendshipShowReply();\n        mockFriendshipShowReply(false, false, false, true, 'twitto234');\n        job.data.isSecondTry = true;\n        await task.run(job);\n        job.data.isSecondTry = false;\n        expect(queue.add).toHaveBeenCalledTimes(0);\n        expect(dao.userEventDao.logUnfollowerEvent).toHaveBeenCalledTimes(2);\n        expect(userDao.dmTwit.post).toHaveBeenCalledTimes(1);\n    });\n\n    test('one unfollower, one suspended', async () => {\n        unfollowersInfo.push({\n            id: '123',\n            followTime: 100,\n            followDetectedTime: 100,\n            unfollowTime: 5000000,\n        });\n        unfollowersInfo.push({\n            id: '234',\n            followTime: 200,\n            followDetectedTime: 200,\n            unfollowTime: 5000000,\n        });\n        mockUsersLookupReply(['123'], ['twitto123']);\n        mockFriendshipShowReply();\n        mockFriendshipShowReply();\n        dao.getCachedUsername.mockResolvedValue('twitto234');\n        await task.run(job);\n        expect(queue.add).toHaveBeenCalledTimes(0);\n        expect(dao.userEventDao.logUnfollowerEvent).toHaveBeenCalledTimes(2);\n        expect(userDao.dmTwit.post).toHaveBeenCalledTimes(1);\n        expect(userDao.dmTwit.post.mock.calls[0][1].event.message_create.message_data.text).toBe(\n            '2 Twitter users unfollowed you:\\n' +\n                '  • @twitto123 unfollowed you 👋.\\n' +\n                'This account followed you for an hour (01/01/1970).\\n' +\n                '  • @twitto234 has been suspended 🙈.\\n' +\n                'This account followed you for an hour (01/01/1970).'\n        );\n    });\n\n    test('one unfollower, one locked account', async () => {\n        unfollowersInfo.push({\n            id: '123',\n            followTime: 100,\n            followDetectedTime: 100,\n            unfollowTime: 5000000,\n        });\n        unfollowersInfo.push({\n            id: '234',\n            followTime: 200,\n            followDetectedTime: 200,\n            unfollowTime: 5000000,\n        });\n        mockUsersLookupReply(['123', '234'], ['twitto123', 'twitto234'], [100, 0]);\n        mockFriendshipShowReply();\n        mockFriendshipShowReply();\n        await task.run(job);\n        expect(queue.add).toHaveBeenCalledTimes(0);\n        expect(dao.userEventDao.logUnfollowerEvent).toHaveBeenCalledTimes(2);\n        expect(userDao.dmTwit.post).toHaveBeenCalledTimes(1);\n        expect(userDao.dmTwit.post.mock.calls[0][1].event.message_create.message_data.text).toBe(\n            '2 Twitter users unfollowed you:\\n' +\n                '  • @twitto123 unfollowed you 👋.\\n' +\n                'This account followed you for an hour (01/01/1970).\\n' +\n                \"  • @twitto234's account has been locked 🔒.\\n\" +\n                'This account followed you for an hour (01/01/1970).'\n        );\n    });\n\n    test('i18n', async () => {\n        userDao.getLang.mockResolvedValue('fr');\n        unfollowersInfo.push({\n            id: '123',\n            followTime: 100,\n            followDetectedTime: 100,\n            unfollowTime: 5000000,\n        });\n        mockUsersLookupReply(['123'], ['twitto123']);\n        mockFriendshipShowReply();\n        await task.run(job);\n        expect(userDao.dmTwit.post.mock.calls[0][1].event.message_create.message_data.text).toBe(\n            '@twitto123 vous a unfollow 👋.\\n' + 'Ce compte vous a suivi pendant une heure (01/01/1970).'\n        );\n    });\n\n    test('louan', async () => {\n        const louanBenId = '941331337759346688';\n        unfollowersInfo.push({\n            id: louanBenId,\n            followTime: 100,\n            followDetectedTime: 100,\n            unfollowTime: 5000000,\n            username: 'louanben',\n        });\n        mockUsersLookupReply([louanBenId], ['louanben']);\n        mockFriendshipShowReply();\n        await task.run(job);\n        expect(queue.add).toHaveBeenCalledTimes(0);\n        expect(dao.userEventDao.logUnfollowerEvent).toHaveBeenCalledTimes(1);\n        expect(userDao.dmTwit.post).toHaveBeenCalledTimes(1);\n        expect(userDao.dmTwit.post.mock.calls[0][1].event.message_create.message_data.text).toBe(\n            '@louanben 👦🏽 unfollowed you \\n👁👄👁.\\n' + 'This account followed you for an hour (01/01/1970).'\n        );\n    });\n});\n"
  },
  {
    "path": "unfollow-ninja-server/tests/tasks/sendWelcomeMessage.spec.ts",
    "content": "import type { Job } from 'bull';\nimport SendWelcomeMessage from '../../src/tasks/sendWelcomeMessage';\nimport { daoMock, queueMock } from '../utils';\nimport { NotificationEvent } from '../../src/dao/userEventDao';\n\nprocess.env.CONSUMER_KEY = 'consumerKey';\nprocess.env.CONSUMER_SECRET = 'consumerSecret';\n\nconst queue = queueMock();\nconst dao = daoMock();\nconst userDao = dao.userDao['01'];\n\nconst job = {\n    data: { username: 'testUsername', userId: '01' },\n    processedOn: Date.now(),\n} as Job;\n\nconst task = new SendWelcomeMessage(dao, queue);\n\ndescribe('sendWelcomeMessage task', () => {\n    beforeEach(() => {\n        userDao.getLang.mockResolvedValue('en');\n        userDao.dmTwit.post.mockResolvedValue(null);\n    });\n\n    test('send the welcome message', async () => {\n        await task.run(job);\n        expect(userDao.dmTwit.post).toHaveBeenCalledTimes(1);\n        expect(userDao.dmTwit.post.mock.calls[0][1].event.message_create.message_data.text.startsWith('All set')).toBe(\n            true\n        );\n        expect(dao.userEventDao.logNotificationEvent).toHaveBeenCalledTimes(1);\n        expect(dao.userEventDao.logNotificationEvent).toHaveBeenCalledWith(\n            '01',\n            NotificationEvent.welcomeMessage,\n            'user0',\n            'All set, welcome to @unfollowninja 🙌!\\nYou will soon know all about your unfollowers here!'\n        );\n    });\n\n    test('i18n', async () => {\n        userDao.getLang.mockResolvedValue('fr');\n        await task.run(job);\n        expect(\n            userDao.dmTwit.post.mock.calls[0][1].event.message_create.message_data.text.startsWith('Tout est prêt')\n        ).toBe(true);\n    });\n});\n"
  },
  {
    "path": "unfollow-ninja-server/tests/utils.ts",
    "content": "import type { Queue } from 'bull';\nimport type Dao from '../src/dao/dao';\nimport type Twit from 'twit';\n\nexport function userDaoMock() {\n    const twit = twitMock();\n    const dmTwit = twitMock();\n    return {\n        twit,\n        dmTwit,\n        getTwit: jest.fn().mockResolvedValue(twit),\n        getDmTwit: jest.fn().mockResolvedValue(dmTwit),\n        getLang: jest.fn(),\n        getDmId: jest.fn().mockResolvedValue('user0'),\n        getUsername: jest.fn(),\n\n        setNextCheckTime: jest.fn(),\n        updateFollowers: jest.fn(),\n        setFollowerSnowflakeId: jest.fn(),\n        addUncachableFollower: jest.fn(),\n\n        getNextCheckTime: jest.fn(),\n        getFollowers: jest.fn(),\n        getFollowerSnowflakeId: jest.fn(),\n        getFollowTime: jest.fn(),\n        getHasNotCachedFollowers: jest.fn(),\n        getCachedFollowers: jest.fn(),\n        getUncachableFollowers: jest.fn(),\n        getFollowDetectedTime: jest.fn(),\n    };\n}\n\nfunction _daoMock() {\n    const userDao = {\n        '01': userDaoMock(),\n        '02': userDaoMock(),\n        '03': userDaoMock(),\n    };\n    return {\n        userDao,\n        getUserDao: jest.fn().mockImplementation((id: '01' | '02' | '03') => userDao[id]),\n        userEventDao: {\n            logNotificationEvent: jest.fn(),\n            logUnfollowerEvent: jest.fn(),\n        },\n        addTwittoToCache: jest.fn(),\n        getUserIdsByCategory: jest.fn().mockResolvedValue(['01', '02', '03']),\n        getUserCountByCategory: jest.fn().mockResolvedValue({}),\n        getCachedUsername: jest.fn(),\n    };\n}\n\nexport function daoMock() {\n    return _daoMock() as ReturnType<typeof _daoMock> & Dao;\n}\n\nexport function queueMock() {\n    return {\n        add: jest.fn().mockResolvedValue({}),\n    } as object as Queue;\n}\n\ninterface DmSendParams {\n    event: { message_create: { message_data: { text: string } } };\n}\n\nexport function twitMock() {\n    return {\n        get: jest.fn(),\n        post: jest.fn<Promise<Partial<Twit.PromiseResponse>>, [string, Twit.Params & DmSendParams]>(),\n    };\n}\n"
  },
  {
    "path": "unfollow-ninja-server/tsconfig-build.json",
    "content": "{\n    \"extends\": \"./tsconfig.json\",\n    \"exclude\": [\"tests/**/*.ts\"]\n}\n"
  },
  {
    "path": "unfollow-ninja-server/tsconfig.json",
    "content": "{\n    \"include\": [\"src/**/*.ts\", \"tests/**/*.ts\"],\n    \"compilerOptions\": {\n        \"module\": \"commonjs\",\n        \"esModuleInterop\": true,\n        \"target\": \"es2019\",\n        \"outDir\": \"dist\",\n        \"incremental\": true\n    }\n}\n"
  },
  {
    "path": "unfollow-ninja-ui/.gitignore",
    "content": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\n/node_modules\n/.pnp\n.pnp.js\n\n# testing\n/coverage\n\n# production\n/build\n\n# misc\n.DS_Store\n.env.local\n.env.development.local\n.env.test.local\n.env.production.local\n\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n"
  },
  {
    "path": "unfollow-ninja-ui/package.json",
    "content": "{\n  \"name\": \"unfollow-ninja-ui\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"dependencies\": {\n    \"@sentry/browser\": \"^7.3.0\",\n    \"grommet\": \"^2.17.5\",\n    \"grommet-icons\": \"^4.6.2\",\n    \"react\": \"^18.2.0\",\n    \"react-dom\": \"^18.2.0\",\n    \"react-dom-confetti\": \"^0.2.0\",\n    \"react-github-corner\": \"^2.5.0\",\n    \"react-scripts\": \"^5.0.0\",\n    \"react-snap\": \"^1.23.0\",\n    \"sass\": \"^1.49.8\",\n    \"styled-components\": \"^5.3.1\",\n    \"web-vitals\": \"^2.1.0\"\n  },\n  \"scripts\": {\n    \"start\": \"react-scripts start\",\n    \"build\": \"react-scripts build\",\n    \"postbuild\": \"react-snap\",\n    \"test\": \"react-scripts test\",\n    \"eject\": \"react-scripts eject\"\n  },\n  \"eslintConfig\": {\n    \"extends\": \"react-app\"\n  },\n  \"browserslist\": {\n    \"production\": [\n      \">1%\",\n      \"not dead\",\n      \"not op_mini all\"\n    ],\n    \"development\": [\n      \"last 1 chrome version\",\n      \"last 1 firefox version\",\n      \"last 1 safari version\"\n    ]\n  }\n}\n"
  },
  {
    "path": "unfollow-ninja-ui/public/_redirects",
    "content": "/1 /index.html 200\n/2 /index.html 200"
  },
  {
    "path": "unfollow-ninja-ui/public/favicon/site.webmanifest",
    "content": "{\n    \"name\": \"Unfollow Ninja\",\n    \"short_name\": \"Unfollow Ninja\",\n    \"icons\": [\n        {\n            \"src\": \"/favicon/android-chrome-192x192.png\",\n            \"sizes\": \"192x192\",\n            \"type\": \"image/png\"\n        },\n        {\n            \"src\": \"/favicon/android-chrome-512x512.png\",\n            \"sizes\": \"512x512\",\n            \"type\": \"image/png\"\n        }\n    ],\n    \"theme_color\": \"#70b7fd\",\n    \"background_color\": \"#ffffff\",\n    \"display\": \"standalone\"\n}\n"
  },
  {
    "path": "unfollow-ninja-ui/public/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"utf-8\" />\n    <link rel=\"shortcut icon\" href=\"%PUBLIC_URL%/favicon.ico\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n\n    <!-- Icons -->\n    <link rel=\"apple-touch-icon\" sizes=\"180x180\" href=\"%PUBLIC_URL%/favicon/apple-touch-icon.png\">\n    <link rel=\"icon\" type=\"image/png\" sizes=\"32x32\" href=\"%PUBLIC_URL%/favicon/favicon-32x32.png\">\n    <link rel=\"icon\" type=\"image/png\" sizes=\"16x16\" href=\"%PUBLIC_URL%/favicon/favicon-16x16.png\">\n    <link rel=\"manifest\" href=\"%PUBLIC_URL%/favicon/site.webmanifest\">\n    <link rel=\"mask-icon\" href=\"%PUBLIC_URL%/favicon/safari-pinned-tab.svg\" color=\"#70b7fd\">\n    <meta name=\"theme-color\" content=\"#70b7fd\">\n\n    <!-- Primary Meta Tags -->\n    <title>Unfollow Ninja</title>\n    <meta name=\"title\" content=\"Unfollow Ninja\">\n    <meta name=\"description\" content=\"Soyez prévenus rapidement de vos unfollowers Twitter\">\n\n    <!-- Open Graph / Facebook -->\n    <meta property=\"og:type\" content=\"website\">\n    <meta property=\"og:url\" content=\"https://unfollow.ninja/\">\n    <meta property=\"og:title\" content=\"Unfollow Ninja\">\n    <meta property=\"og:description\" content=\"Soyez prévenus rapidement de vos unfollowers Twitter\">\n    <meta property=\"og:image\" content=\"https://unfollow.ninja/preview.png\">\n\n    <!-- Twitter -->\n    <meta property=\"twitter:card\" content=\"summary_large_image\">\n    <meta property=\"twitter:url\" content=\"https://unfollow.ninja/\">\n    <meta property=\"twitter:title\" content=\"Unfollow Ninja\">\n    <meta property=\"twitter:description\" content=\"Soyez prévenus rapidement de vos unfollowers Twitter\">\n    <meta property=\"twitter:image\" content=\"https://unfollow.ninja/preview.png\">\n\n    <link href=\"https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;600&family=Quicksand:wght@400;600&display=swap\" rel=\"stylesheet\">  </head>\n  <body>\n    <noscript>You need to enable JavaScript to run this app.</noscript>\n    <div id=\"root\"></div>\n  </body>\n</html>\n"
  },
  {
    "path": "unfollow-ninja-ui/public/manifest.json",
    "content": "{\n  \"short_name\": \"Unfollow Ninja\",\n  \"name\": \"Unfollow Ninja\",\n  \"icons\": [\n    {\n      \"src\": \"favicon.ico\",\n      \"sizes\": \"64x64 32x32 24x24 16x16\",\n      \"type\": \"image/x-icon\"\n    }\n  ],\n  \"start_url\": \".\",\n  \"display\": \"standalone\",\n  \"theme_color\": \"#70B7FD\",\n  \"background_color\": \"#ffffff\"\n}\n"
  },
  {
    "path": "unfollow-ninja-ui/public/robots.txt",
    "content": "User-agent: *\nDisallow: /cgu.pdf\nSitemap: https://unfollow.ninja/sitemap.xml"
  },
  {
    "path": "unfollow-ninja-ui/public/sitemap.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n    <url>\n        <loc>https://unfollow.ninja/</loc>\n    </url>\n</urlset>"
  },
  {
    "path": "unfollow-ninja-ui/src/App.js",
    "content": "import React from 'react';\n\nimport './style.scss';\n\nimport {Box, Grommet, Heading, Image, Paragraph, Text} from 'grommet';\nimport GithubCorner from \"react-github-corner\";\n\nimport { Faq, Link, MiniApp, Navbar, Section, Repo }  from \"./components\";\nimport * as Images from './images';\n\nconst theme = {\n  global: {\n    font: {\n      family: 'Open Sans',\n    },\n    colors: {\n      doc: '#4c4e6e',\n      dark: '#1a1b46',\n      lightPink: '#ffeeed',\n      brand: '#70B7FD',\n    },\n  },\n  heading: {\n    font: {\n      family: 'Quicksand',\n    },\n    weight: 700,\n  },\n  paragraph: {\n    large: {\n      height: '32px',\n    },\n    medium: {\n      maxWidth: '800px',\n    },\n  },\n  accordion: {\n    panel: {\n      border: {\n        style: 'hidden'\n      }\n    },\n    border: {\n      color: 'white'\n    }\n  }\n};\n\nfunction App() {\n  const isForeigner = !navigator.language?.startsWith?.('fr') && navigator.userAgent !== 'ReactSnap';\n  return (\n      <Grommet theme={theme}>\n        <Section>\n          <Navbar/>\n        </Section>\n        <Section>\n          <Box direction='row' wrap={true} margin={{vertical: 'large'}}>\n            <Box basis='medium' flex={true} pad='medium'>\n              <Heading level={1} color='dark'>Soyez prévenus rapidement de vos unfollowers Twitter</Heading>\n              <Paragraph size='large'>Unfollow Ninja vous envoie une notification dès qu'un twitto se désabonne de votre compte, en quelques secondes.</Paragraph>\n              {isForeigner ? <Paragraph margin={{top: 'xsmall'}}>\n                English speaker? An international version is available at <Link href='https://unfollow-monkey.com/?utm_source=unfollowninja'>https://unfollow-monkey.com</Link>\n              </Paragraph> : null}\n\t\t\t  <MiniApp/>\n            </Box>\n            <Box basis='medium' flex={true} pad='medium'>\n              <Image width='100%' title='Exemple de notification' src={Images.DmScreenshot}/>\n            </Box>\n          </Box>\n        </Section>\n        <Section background='lightPink' sloped={true}>\n          <Box direction='row' wrap={true} align='center' justify='center'>\n            <Box direction='row' basis='300px' flex='shrink' pad='medium' style={{maxWidth: '50vw'}}>\n              <Image title='dog playing' fit='contain' src={Images.Dog}/>\n            </Box>\n            <Box basis='medium' flex={true} pad='medium' >\n              <Heading level={2} color='dark'>UnfollowNinja est libre et gratuit</Heading>\n              <Paragraph>UnfollowNinja est un projet <Link href='https://github.com/PLhery/unfollowNinja'>open-source</Link>, maintenu par <Link href='https://twitter.com/plhery'>@plhery</Link> et hébergé par <Link href='https://twitter.com/hivanenetwork'>HivaneNetwork</Link>.</Paragraph>\n              <Paragraph>Merci à Hivane d'aider le projet à rester performant, libre, et gratuit, soutenant 100 000+ utilisateurs actifs.</Paragraph>\n\t\t\t  <Paragraph>UnfollowNinja s'appuie sur la librairie node <i>twitter-api-v2</i>, par le même auteur.</Paragraph>\n              <Box gap='small' alignSelf='center' direction='row'>\n\t\t\t\t<Repo title='unfollowNinja' description='Get notified when your Twitter account loses a follower.' stars={175} forks={18}/>\n\t\t\t\t<Repo title='node-twitter-api-v2' description='Strongly typed, full-featured, light, versatile yet powerful Twitter API v1.1 and v2 client for Node.js.' stars={603} forks={75}/>\n              </Box>\n            </Box>\n          </Box>\n        </Section>\n        <Section background={`url(${Images.useAlaska()})`}>\n          <Faq/>\n        </Section>\n        <Section>\n          <Box direction='row' align='center' alignSelf='center' gap='small'>\n            <Image title='logo' height={30} src={Images.Logo}/>\n            <Text size='small' textAlign='center' style={{fontFamily: 'quicksand'}}>\n              © 2020 UnfollowNinja · <Link href='/cgu.pdf'>CGU</Link> ·\n              Découvrez aussi <Link href='https://unfollow-monkey.com/?utm_source=unfollowninja_footer'><Image title='unfollowmonkey' src={Images.UnfollowMonkey} height={18}/></Link> UnfollowMonkey <Link href='https://uzzy.me/?utm_source=unfollowninja'><Image title='uzzy' src={Images.Uzzy} height={18}/></Link> Uzzy et <Link href='https://affinitweet.com/?utm_source=unfollowninja'><Image title='affinitweet' src={Images.Affinitweet} height={18}/></Link> Affinitweet ·\n              Proposé par <Link href='https://twitter.com/plhery'>@plhery</Link> · Disponible sur <Link href='https://github.com/PLhery/unfollowNinja'>GitHub</Link>\n            </Text>\n          </Box>\n        </Section>\n        <GithubCorner href=\"https://github.com/PLhery/unfollowNinja\" bannerColor=\"#70B7FD\"/>\n      </Grommet>\n  );\n}\n\nexport default App;\n"
  },
  {
    "path": "unfollow-ninja-ui/src/components/Faq.js",
    "content": "import React from 'react';\nimport { Box, Heading, Paragraph } from \"grommet/es6\";\nimport Emojis from '../twemojis/Emojis';\nimport Styles from './Faq.module.scss';\n\nfunction Faq(props) {\n    return <Box alignSelf='center' pad='medium' margin='medium' className={Styles.container} {...props}>\n            <Heading level={1} color='dark'>Foire aux questions</Heading>\n\n            <Heading level={3} color='dark'>Un ami m'a unfollow mais je n'ai pas été prévenu</Heading>\n            <Paragraph>Pour éviter de vous déranger trop souvent, plusieurs filtres s'appliquent sur les notifications envoyées. Pour être certain d'avoir la notification, la personne doit vous avoir suivie 24h et unfollow 20 minutes.</Paragraph>\n\n            <Heading level={3} color='dark'>Publierez-vous des tweets sans mon accord ?</Heading>\n            <Paragraph>Nous ne publierons jamais de tweet sans votre accord ! Seul le compte d'envoi de messages privés donne la permission d'envoi de tweets.\n                Cela est dû au fonctionnement des permissions Twitter : il n'y a que 3 ensembles de permissions, et nous ne pouvons demander la permission d'envoi de DMs sans celle d'envoi de tweets.\n                Vous pouvez créer un compte Twitter séparé dédié à l'envoi de ces messages si vous le souhaitez.</Paragraph>\n\n            <Heading level={3} color='dark'>Pourquoi l'étape 2 demande tant de permissions ?</Heading>\n            <Paragraph>Comme décrit précédemment, cela est dû au fonctionnement des permissions Twitter : il n'y a que 3 ensembles de permissions, et nous ne pouvons demander la permission d'envoi de DMs sans les autres.\n                Jamais nous n'avons extrait ces jetons pour les utiliser autrement que dans le cadre de cette application open-source.\n                Vous pouvez créer un compte Twitter séparé dédié à l'envoi de ces messages si vous le souhaitez, pour plus de sérénité. Une possibilité de notifications chrome est à l'étude :).</Paragraph>\n\n\n            <Heading level={3} color='dark'>Que signifient les différents messages et emojis ?</Heading>\n            <ul>\n                <li>Les messages suivants parlent d'eux-meme :<ul>\n                    <li><b>@username</b> vous a unfollow <Emojis.WavingHand/></li>\n                    <li><b>@username</b> a été suspendu <Emojis.SeeNoEvil/></li>\n                    <li><b>@username</b> vous a bloqué  <Emojis.NoEntry/></li>\n                    <li>Vous avez bloqué <b>@username</b> <Emojis.Poo/></li>\n                </ul></li>\n                <li><b>@username</b> a quitté Twitter <Emojis.SeeNoEvil/> peut signifier que la personne a été suspendue quelques minutes, a fermé son compte, ou a été retirée de Twitter par exemple à cause de la limite d'âge.</li>\n                <li>L'emoji est un coeur brisé <Emojis.BrokenHeart/> si cette personne est un mutual, que vous la suiviez.</li>\n                <li>Si plus de 20 twittos vous unfollowent en moins de deux minutes, vous ne serez informé que des 20 premiers, ainsi que du nombre total de followers perdus.</li>\n                <li>\"Un twitto a quitté Twitter <Emojis.SeeNoEvil/>\" : quand le nom d'utilisateur de la personne qui a fermé son compte n'a pas eu le temps d'être sauvegardé (peut prendre 48h), vous ne recevez pas son pseudo, mais êtes informé de cet abonné en moins.</li>\n                <li>\"Ce compte vous suivait avant votre inscription à <b>@unfollowninja</b> !\" : nous n'arrivons pas toujours à retrouver la date de follow exacte de chaque unfollower. Si on ne la trouve pas, nous vous donnons la première fois que nous l'avons vu sur votre compte. Mais s'il était déjà sur votre compte lors de votre inscription, nous ne pouvons vous donner de date.</li>\n            </ul>\n\n            <Heading level={3} color='dark'>Pourquoi le service est-il gratuit ?</Heading>\n            <Paragraph>Ce projet est maintenu sur mon temps libre, et me permet d'avoir un projet sur lequel je peux librement experimenter, en parallèle de mon travail. L'association Hivane Network permet au service d'exister à moindres frais grâce au prêt d'un serveur virtuel.</Paragraph>\n        </Box>;\n}\nexport default Faq;"
  },
  {
    "path": "unfollow-ninja-ui/src/components/Faq.module.scss",
    "content": ".container {\n  background-color: rgba(255, 255, 255, 0.5);\n  p, ul {\n    text-align: justify;\n  }\n  h3 {\n    margin-bottom: 0;\n    max-width: 630px;\n  }\n  ul {\n    max-width: 760px;\n  }\n}\n"
  },
  {
    "path": "unfollow-ninja-ui/src/components/Link.js",
    "content": "import React from 'react';\n\nconst Link = (props) => (\n    <a target='_blank' rel='noopener noreferrer' style={{color: 'inherit', fontWeight: 600}} {...props} >\n      {props.children}\n    </a>\n);\nexport default Link;\n"
  },
  {
    "path": "unfollow-ninja-ui/src/components/MiniApp.js",
    "content": "import React from 'react';\nimport {\n    Box, Paragraph,\n} from 'grommet';\nimport {Alert} from \"grommet-icons\";\n\nimport './MiniApp.module.scss';\nimport Link from \"./Link\";\n\nfunction MiniApp(props) {\n  return (\n      <Box gap='small' margin={{horizontal: 'small', vertical: 'medium'}} {...props}>\n\t\t  <Paragraph textAlign='center'><Alert/><br/>\n              [Avril 2023] Elon Musk a décidé de désactiver les applications Twitter gratuites, y compris <Link href=\"https://twitter.com/unfollowninja\">@UnfollowNinja</Link>.<br/><br/>\n              Il n'est donc plus possible de se connecter.<br/>\n              Merci pour votre soutien depuis 2013, c'était fun ♥️<br/><br/>\n              [Juin 2023] La version anglaise que je maintiens, <Link href='https://unfollow-monkey.com'>Unfollow Monkey</Link>, est de nouveau disponible!\n          </Paragraph>\n      </Box>\n  );\n}\n\nexport default MiniApp;\n"
  },
  {
    "path": "unfollow-ninja-ui/src/components/MiniApp.module.scss",
    "content": ".centerIcon {\n  vertical-align: sub;\n}\n\n.confettis {\n  margin: 0 auto;\n}\n\n.loggedInDetails {\n  text-align: center;\n}\n\n.spinnerCell {\n  display: flex;\n  justify-content: center;\n}"
  },
  {
    "path": "unfollow-ninja-ui/src/components/Navbar.js",
    "content": "import React from 'react';\nimport {Box, Heading, Image} from \"grommet/es6\";\nimport * as Images from \"../images\";\nimport Styles from './Navbar.module.scss';\nimport Link from \"./Link\";\n\nconst Navbar = (props) => (\n    <header className={Styles.navbar} {...props}>\n      <Link href='https://twitter.com/unfollowNinja' source='navbar'>\n        <Box\n            direction='row'\n            pad={{horizontal: 'medium', vertical: 'small'}}\n        >\n            <Image title='logo' height={40} margin={{horizontal: 'xsmall'}} src={Images.Logo}/>\n            <Heading level={4} color='dark' margin={{vertical: 'small'}} style={{fontWeight: 500}}>UnfollowNinja</Heading>\n        </Box>\n      </Link>\n    </header>\n);\nexport default Navbar;"
  },
  {
    "path": "unfollow-ninja-ui/src/components/Navbar.module.scss",
    "content": ".navbar {\n  a {\n    text-decoration: none;\n  }\n\n  transition: margin .2s ease;\n  img {\n    transition: transform .4s ease;\n  }\n\n  &:hover {\n    margin-top: -5px;\n    margin-bottom: 5px;\n    img {\n      transform: rotate(180deg);\n    }\n  }\n}\n"
  },
  {
    "path": "unfollow-ninja-ui/src/components/Repo.js",
    "content": "import React from 'react';\nimport Styles from './Repo.module.scss';\nimport Link from \"./Link\";\n\nfunction Repo(props) {\n  const {title, description, stars, forks} = props;\n    return <div className={Styles.card}>\n\t\t<div className={Styles.section}>\n\t\t  <svg className={Styles.svg} style={{marginRight: 8}} viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" height=\"16\"\n\t\t\t   aria-hidden=\"true\">\n\t\t\t<path fillRule=\"evenodd\"\n  d=\"M2 2.5A2.5 2.5 0 014.5 0h8.75a.75.75 0 01.75.75v12.5a.75.75 0 01-.75.75h-2.5a.75.75 0 110-1.5h1.75v-2h-8a1 1 0 00-.714 1.7.75.75 0 01-1.072 1.05A2.495 2.495 0 012 11.5v-9zm10.5-1V9h-8c-.356 0-.694.074-1 .208V2.5a1 1 0 011-1h8zM5 12.25v3.25a.25.25 0 00.4.2l1.45-1.087a.25.25 0 01.3 0L8.6 15.7a.25.25 0 00.4-.2v-3.25a.25.25 0 00-.25-.25h-3.5a.25.25 0 00-.25.25z\"/>\n\t\t  </svg>\n\t\t  <span>\n          \t<Link className={Styles.title} href={\"https://github.com/PLhery/\" + title}>{title}</Link>\n\t\t  </span>\n\t\t</div>\n\t\t<div className={Styles.description}>{description}</div>\n\t\t<div className={Styles.footer}>\n\t\t  <div style={{marginRight: 16}}>\n\t\t\t<span className={Styles.languageName}/>\n\t\t\t<span>TypeScript</span>\n\t\t  </div>\n\t\t  <div  className={Styles.section} style={{marginRight: 16}}>\n\t\t\t<svg className={Styles.svg} aria-label=\"stars\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" height=\"16\"\n\t\t\t\t role=\"img\">\n\t\t\t  <path fillRule=\"evenodd\" d=\"M8 .25a.75.75 0 01.673.418l1.882 3.815 4.21.612a.75.75 0 01.416 1.279l-3.046 2.97.719 4.192a.75.75 0 01-1.088.791L8 12.347l-3.766 1.98a.75.75 0 01-1.088-.79l.72-4.194L.818 6.374a.75.75 0 01.416-1.28l4.21-.611L7.327.668A.75.75 0 018 .25zm0 2.445L6.615 5.5a.75.75 0 01-.564.41l-3.097.45 2.24 2.184a.75.75 0 01.216.664l-.528 3.084 2.769-1.456a.75.75 0 01.698 0l2.77 1.456-.53-3.084a.75.75 0 01.216-.664l2.24-2.183-3.096-.45a.75.75 0 01-.564-.41L8 2.694v.001z\"/>\n\t\t\t</svg>\n\t\t\t&nbsp; <span>{stars}</span>\n\t\t  </div>\n\t\t  <div  className={Styles.section}>\n\t\t\t<svg className={Styles.svg} aria-label=\"fork\" viewBox=\"0 0 16 16\" version=\"1.1\" width=\"16\" height=\"16\"\n\t\t\t\t role=\"img\">\n\t\t\t  <path fillRule=\"evenodd\" d=\"M5 3.25a.75.75 0 11-1.5 0 .75.75 0 011.5 0zm0 2.122a2.25 2.25 0 10-1.5 0v.878A2.25 2.25 0 005.75 8.5h1.5v2.128a2.251 2.251 0 101.5 0V8.5h1.5a2.25 2.25 0 002.25-2.25v-.878a2.25 2.25 0 10-1.5 0v.878a.75.75 0 01-.75.75h-4.5A.75.75 0 015 6.25v-.878zm3.75 7.378a.75.75 0 11-1.5 0 .75.75 0 011.5 0zm3-8.75a.75.75 0 100-1.5.75.75 0 000 1.5z\"/>\n\t\t\t</svg>\n\t\t\t&nbsp; <span>{forks}</span>\n\t\t  </div>\n\t\t</div>\n\t  </div>;\n}\nexport default Repo;"
  },
  {
    "path": "unfollow-ninja-ui/src/components/Repo.module.scss",
    "content": ".card {\n  font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji;\n  border: 1px solid #e1e4e8;\n  border-radius: 6px;\n  background: white;\n  padding: 16px;\n  font-size: 14px;\n  line-height: 1.5;\n  color: #24292e;\n  max-width: 300px;\n}\n\n\n.section {\n  display: flex;\n  align-items: center\n}\n\n.title {\n  text-decoration: none;\n  font-weight: 600;\n  color: #0366d6!important;\n}\n\n.description {\n  font-size: 12px;\n  margin-bottom: 16px;\n  margin-top: 8px;\n  color: #586069;\n}\n\n.footer {\n  font-size: 12px;\n  color: #586069;\n  display: flex;\n}\n.languageName {\n  width: 12px;\n  height: 12px;\n  border-radius: 100%;\n  background-color: #2b7489;\n  display: inline-block; top: 1px;\n  position: relative;\n}\n\n.svg {\n  fill: #586069;\n}"
  },
  {
    "path": "unfollow-ninja-ui/src/components/Section.js",
    "content": "import React from 'react';\nimport {Box} from \"grommet/es6\";\nimport Styles from './Section.module.scss';\n\nconst Section = (props) => (\n    <Box align='center' className={props.sloped ? Styles.sloped : ''} {...props}>\n      <Box width='xlarge'>\n        {props.children}\n      </Box>\n    </Box>\n);\nexport default Section;"
  },
  {
    "path": "unfollow-ninja-ui/src/components/Section.module.scss",
    "content": ".sloped {\n  padding-top:80px!important;\n  clip-path: polygon(\n                  0 0,\n                  100% 80px,\n                  100% 100%,\n                  0 100%\n  );\n}"
  },
  {
    "path": "unfollow-ninja-ui/src/components/index.js",
    "content": "export {default as Faq} from './Faq';\nexport {default as Link} from './Link';\nexport {default as MiniApp} from './MiniApp';\nexport {default as Navbar} from './Navbar';\nexport {default as Repo} from './Repo';\nexport {default as Section} from './Section';"
  },
  {
    "path": "unfollow-ninja-ui/src/images/index.js",
    "content": "import { useState, useEffect } from 'react';\nimport Alaska from './alaska.jpg';\nimport AlaskaWebp from './alaska.webp';\n\nexport {default as Dog} from './dog.svg'\nexport {default as Logo}from './logo.svg';\nexport {default as DmScreenshot} from './dmscreenshot.png'\nexport {default as Affinitweet} from './affinitweet.png'\nexport {default as Uzzy} from './uzzy.svg'\nexport {default as UnfollowMonkey} from './unfollowmonkey.svg'\n\nexport function useAlaska() { // react hook to get the right alaska image url\n    const [supportsWebP, setWebP] = useState(null); // true if supports, otherwise false\n    useEffect(() => {\n        const img = new window.Image();\n        img.onload = () => setWebP((img.width > 0) && (img.height > 0));\n        img.onerror = () => setWebP(false);\n        img.src = 'data:image/webp;base64,UklGRiIAAABXRUJQVlA4IBYAAAAwAQCdASoBAAEADsD+JaQAA3AAAAAA';\n    }, []);\n    if (supportsWebP === true) {\n        return AlaskaWebp;\n    } else if (supportsWebP === false) {\n        return Alaska;\n    } else {\n        return null;\n    }\n}"
  },
  {
    "path": "unfollow-ninja-ui/src/index.js",
    "content": "import React from 'react';\nimport { hydrate, render } from \"react-dom\";\nimport App from './App';\nimport * as Sentry from '@sentry/browser';\nimport * as serviceWorker from './serviceWorkerRegistration';\n\nconst DSN = process.env.REACT_APP_SENTRY_DSN;\nif (DSN) {\n  Sentry.init({dsn: DSN});\n}\n\nconst rootElement = document.getElementById(\"root\");\nif (rootElement.hasChildNodes()) {\n  hydrate(<App />, rootElement);\n} else {\n  render(<App />, rootElement);\n}\n\n// ReactDOM.render(<App />, document.getElementById('root'));\n\n// If you want your app to work offline and load faster, you can change\n// unregister() to register() below. Note this comes with some pitfalls.\n// Learn more about service workers: https://bit.ly/CRA-PWA\nserviceWorker.unregister();\n/*\nserviceWorker.register({\n  onUpdate: registration => {\n    // reload the page if there is an update\n    if (registration && registration.waiting) {\n      registration.waiting.postMessage({ type: 'SKIP_WAITING' });\n    }\n    window.location.reload();\n  }\n});\n*/\n"
  },
  {
    "path": "unfollow-ninja-ui/src/reportWebVitals.js",
    "content": "const reportWebVitals = (onPerfEntry) => {\n  if (onPerfEntry && onPerfEntry instanceof Function) {\n    import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {\n      getCLS(onPerfEntry);\n      getFID(onPerfEntry);\n      getFCP(onPerfEntry);\n      getLCP(onPerfEntry);\n      getTTFB(onPerfEntry);\n    });\n  }\n};\n\nexport default reportWebVitals;\n"
  },
  {
    "path": "unfollow-ninja-ui/src/service-worker.js",
    "content": "/* eslint-disable no-restricted-globals */\n\n// This service worker can be customized!\n// See https://developers.google.com/web/tools/workbox/modules\n// for the list of available Workbox modules, or add any other\n// code you'd like.\n// You can also remove this file if you'd prefer not to use a\n// service worker, and the Workbox build step will be skipped.\n\nimport { clientsClaim } from 'workbox-core';\nimport { ExpirationPlugin } from 'workbox-expiration';\nimport { precacheAndRoute, createHandlerBoundToURL } from 'workbox-precaching';\nimport { registerRoute } from 'workbox-routing';\nimport { StaleWhileRevalidate } from 'workbox-strategies';\n\nclientsClaim();\n\n// Precache all of the assets generated by your build process.\n// Their URLs are injected into the manifest variable below.\n// This variable must be present somewhere in your service worker file,\n// even if you decide not to use precaching. See https://cra.link/PWA\nprecacheAndRoute(self.__WB_MANIFEST);\n\n// Set up App Shell-style routing, so that all navigation requests\n// are fulfilled with your index.html shell. Learn more at\n// https://developers.google.com/web/fundamentals/architecture/app-shell\nconst fileExtensionRegexp = new RegExp('/[^/?]+\\\\.[^/]+$');\nregisterRoute(\n  // Return false to exempt requests from being fulfilled by index.html.\n  ({ request, url }) => {\n    // If this isn't a navigation, skip.\n    if (request.mode !== 'navigate') {\n      return false;\n    } // If this is a URL that starts with /_, skip.\n\n    if (url.pathname.startsWith('/_')) {\n      return false;\n    } // If this looks like a URL for a resource, because it contains // a file extension, skip.\n\n    if (url.pathname.match(fileExtensionRegexp)) {\n      return false;\n    } // Return true to signal that we want to use the handler.\n\n    return true;\n  },\n  createHandlerBoundToURL(process.env.PUBLIC_URL + '/index.html')\n);\n\n// An example runtime caching route for requests that aren't handled by the\n// precache, in this case same-origin .png requests like those from in public/\nregisterRoute(\n  // Add in any other file extensions or routing criteria as needed.\n    // Customize this strategy as needed, e.g., by changing to CacheFirst.\n  ({ url }) => url.origin === self.location.origin &&\n      (url.pathname.endsWith('.png') || url.pathname.endsWith('.jpg') || url.pathname.endsWith('.svg')),\n  new StaleWhileRevalidate({\n    cacheName: 'images',\n    plugins: [\n      // Ensure that once this runtime cache reaches a maximum size the\n      // least-recently used images are removed.\n      new ExpirationPlugin({ maxEntries: 50 }),\n    ],\n  })\n);\n\n// This allows the web app to trigger skipWaiting via\n// registration.waiting.postMessage({type: 'SKIP_WAITING'})\nself.addEventListener('message', (event) => {\n  if (event.data && event.data.type === 'SKIP_WAITING') {\n    self.skipWaiting();\n  }\n});\n\n// Any other custom service worker logic can go here.\n"
  },
  {
    "path": "unfollow-ninja-ui/src/serviceWorkerRegistration.js",
    "content": "// This optional code is used to register a service worker.\n// register() is not called by default.\n\n// This lets the app load faster on subsequent visits in production, and gives\n// it offline capabilities. However, it also means that developers (and users)\n// will only see deployed updates on subsequent visits to a page, after all the\n// existing tabs open on the page have been closed, since previously cached\n// resources are updated in the background.\n\n// To learn more about the benefits of this model and instructions on how to\n// opt-in, read https://cra.link/PWA\n\nconst isLocalhost = Boolean(\n  window.location.hostname === 'localhost' ||\n    // [::1] is the IPv6 localhost address.\n    window.location.hostname === '[::1]' ||\n    // 127.0.0.0/8 are considered localhost for IPv4.\n    window.location.hostname.match(/^127(?:\\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/)\n);\n\nexport function register(config) {\n  if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {\n    // The URL constructor is available in all browsers that support SW.\n    const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);\n    if (publicUrl.origin !== window.location.origin) {\n      // Our service worker won't work if PUBLIC_URL is on a different origin\n      // from what our page is served on. This might happen if a CDN is used to\n      // serve assets; see https://github.com/facebook/create-react-app/issues/2374\n      return;\n    }\n\n    window.addEventListener('load', () => {\n      const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;\n\n      if (isLocalhost) {\n        // This is running on localhost. Let's check if a service worker still exists or not.\n        checkValidServiceWorker(swUrl, config);\n\n        // Add some additional logging to localhost, pointing developers to the\n        // service worker/PWA documentation.\n        navigator.serviceWorker.ready.then(() => {\n          console.log(\n            'This web app is being served cache-first by a service ' +\n              'worker. To learn more, visit https://cra.link/PWA'\n          );\n        });\n      } else {\n        // Is not localhost. Just register service worker\n        registerValidSW(swUrl, config);\n      }\n    });\n  }\n}\n\nfunction registerValidSW(swUrl, config) {\n  navigator.serviceWorker\n    .register(swUrl)\n    .then((registration) => {\n      registration.onupdatefound = () => {\n        const installingWorker = registration.installing;\n        if (installingWorker == null) {\n          return;\n        }\n        installingWorker.onstatechange = () => {\n          if (installingWorker.state === 'installed') {\n            if (navigator.serviceWorker.controller) {\n              // At this point, the updated precached content has been fetched,\n              // but the previous service worker will still serve the older\n              // content until all client tabs are closed.\n              console.log(\n                'New content is available and will be used when all ' +\n                  'tabs for this page are closed. See https://cra.link/PWA.'\n              );\n\n              // Execute callback\n              if (config && config.onUpdate) {\n                config.onUpdate(registration);\n              }\n            } else {\n              // At this point, everything has been precached.\n              // It's the perfect time to display a\n              // \"Content is cached for offline use.\" message.\n              console.log('Content is cached for offline use.');\n\n              // Execute callback\n              if (config && config.onSuccess) {\n                config.onSuccess(registration);\n              }\n            }\n          }\n        };\n      };\n    })\n    .catch((error) => {\n      console.error('Error during service worker registration:', error);\n    });\n}\n\nfunction checkValidServiceWorker(swUrl, config) {\n  // Check if the service worker can be found. If it can't reload the page.\n  fetch(swUrl, {\n    headers: { 'Service-Worker': 'script' },\n  })\n    .then((response) => {\n      // Ensure service worker exists, and that we really are getting a JS file.\n      const contentType = response.headers.get('content-type');\n      if (\n        response.status === 404 ||\n        (contentType != null && contentType.indexOf('javascript') === -1)\n      ) {\n        // No service worker found. Probably a different app. Reload the page.\n        navigator.serviceWorker.ready.then((registration) => {\n          registration.unregister().then(() => {\n            window.location.reload();\n          });\n        });\n      } else {\n        // Service worker found. Proceed as normal.\n        registerValidSW(swUrl, config);\n      }\n    })\n    .catch(() => {\n      console.log('No internet connection found. App is running in offline mode.');\n    });\n}\n\nexport function unregister() {\n  if ('serviceWorker' in navigator) {\n    navigator.serviceWorker.ready\n      .then((registration) => {\n        registration.unregister();\n      })\n      .catch((error) => {\n        console.error(error.message);\n      });\n  }\n}\n"
  },
  {
    "path": "unfollow-ninja-ui/src/style.scss",
    "content": "body {\n  margin: 0;\n  color: #4c4e6e;\n}\nimg {\n  vertical-align: middle;\n}"
  },
  {
    "path": "unfollow-ninja-ui/src/twemojis/Emojis.js",
    "content": "import React from 'react';\nimport Styles from './Emojis.module.scss';\n\nimport ImgWavingHand from './1f44b.png';\nimport ImgSeeNoEvil from './1f648.png';\nimport ImgNoEntry from './26d4.png';\nimport ImgPoo from './1f4a9.png';\nimport ImgBrokenHeart from './1f494.png';\n\nconst EmoImg = ({alt, src}) => <img draggable=\"false\" className={Styles.emoji} alt={alt} src={src}/>;\nexport const WavingHand = () => <EmoImg alt=\"👋\" src={ImgWavingHand}/>;\nexport const SeeNoEvil = () => <EmoImg alt=\"🙈\" src={ImgSeeNoEvil}/>;\nexport const NoEntry = () => <EmoImg alt=\"⛔\" src={ImgNoEntry}/>;\nexport const Poo = () => <EmoImg alt=\"💩\" src={ImgPoo}/>;\nexport const BrokenHeart = () => <EmoImg alt=\"💔\" src={ImgBrokenHeart}/>;\n\nconst Emojis = { WavingHand, SeeNoEvil, NoEntry, Poo, BrokenHeart };\nexport default Emojis;"
  },
  {
    "path": "unfollow-ninja-ui/src/twemojis/Emojis.module.scss",
    "content": "img.emoji {\n  height: 1em;\n  width: 1em;\n  margin: 0 .05em 0 .1em;\n  vertical-align: -0.1em;\n}"
  }
]