diff --git a/.github/workflows/pr_action.yml b/.github/workflows/pr_action.yml index d8adfb1..2d5bbf5 100644 --- a/.github/workflows/pr_action.yml +++ b/.github/workflows/pr_action.yml @@ -19,6 +19,23 @@ jobs: yarn yarn test working-directory: ./ + run_style_check: + name: Code Style Check + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [16.x] + steps: + - uses: actions/checkout@v2 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v2 + with: + node-version: ${{ matrix.node-version }} + - name: check_style + run: | + yarn + yarn lint:check + working-directory: ./ build_code: name: Build Check runs-on: ubuntu-latest diff --git a/.prettierrc.js b/.prettierrc.js index 9277095..c8c6a54 100644 --- a/.prettierrc.js +++ b/.prettierrc.js @@ -1,7 +1,7 @@ module.exports = { - semi: true, - trailingComma: "all", - singleQuote: true, - printWidth: 120, - tabWidth: 4, + semi: true, + trailingComma: 'all', + singleQuote: true, + printWidth: 120, + tabWidth: 4, }; diff --git a/README.md b/README.md index d702d7b..cbcf4af 100644 --- a/README.md +++ b/README.md @@ -10,14 +10,22 @@ You can do this by creating a `.env` file with the following variables. ```env # Bot user app token DISCORD_APP_TOKEN= + # Bot user client ID DISCORD_APP_CLIENT_ID= + # Server ID for the bot to be installed DISCORD_GUILD_ID= + # The channel ID for the bot to listen to DISCORD_FAUCET_CHANNEL_ID= + # Secret phrase (mnemonic) for the faucet account FAUCET_SECRET_PHRASE= + +# Redis URL in URL format +# In Heroku, this is automatically set when you add the Redis add-on to your app. +REDIS_URL= ``` ### Scripts diff --git a/package.json b/package.json index 91032d7..8f3f2d7 100644 --- a/package.json +++ b/package.json @@ -27,8 +27,8 @@ "serve": "node build/index.js", "dev": "ts-node-dev -r dotenv/config src/index.ts", "build": "tsc --project tsconfig.json", - "lint": "eslint '*/**/*.{js,ts}' --quiet --fix", - "lint:check": "eslint '*/**/*.{js,ts}'", + "lint": "eslint '*/**/*.{js,ts}' --quiet --fix && prettier -w .", + "lint:check": "eslint '*/**/*.{js,ts}' && prettier -c .", "test": "NODE_ENV=test echo \"Test not implemented\"!" }, "engines": { @@ -37,6 +37,7 @@ "license": "MIT", "devDependencies": { "@types/express": "^4.17.13", + "@types/ioredis": "^4.28.1", "@types/jest": "^27.0.1", "@types/node": "^16.9.3", "@types/node-fetch": "2.5.10", @@ -63,6 +64,7 @@ "@polkadot/util-crypto": "^7.4.1", "discord-api-types": "^0.23.1", "discord.js": "^13.1.0", - "express": "^4.17.1" + "express": "^4.17.1", + "ioredis": "^4.28.0" } } diff --git a/src/app.ts b/src/app.ts index d207139..3dcb142 100644 --- a/src/app.ts +++ b/src/app.ts @@ -6,6 +6,8 @@ import { NetworkName, ASTAR_TOKEN_DECIMALS, } from './clients'; +import { canRequestFaucet, logRequest } from './middlewares'; + import { DISCORD_APP_TOKEN, DISCORD_APP_CLIENT_ID, DISCORD_GUILD_ID, DISCORD_FAUCET_CHANNEL_ID } from './config'; import { Client, Intents, Interaction } from 'discord.js'; import BN from 'bn.js'; @@ -73,9 +75,15 @@ const discordFaucetApp = async (appCred: DiscordCredentials) => { throw new Error('No address was given!'); } - // todo: check if the user has already requested tokens or not + // Send 'Waiting' message to the user await interaction.deferReply(); + // Check if the user has already requested tokens or not + const requesterId = interaction.user.id; + const now = Date.now(); + await canRequestFaucet(requesterId, now); + + // Send token to the requester console.log(`Sending ${astarApi.formatBalance(dripAmount)} to ${address}`); await astarApi.sendTokenTo(address); @@ -87,6 +95,9 @@ const discordFaucetApp = async (appCred: DiscordCredentials) => { astarApi.faucetAccount.address }\``, ); + + // Log the faucet request. + await logRequest(requesterId, now); } catch (err) { console.warn(err); await interaction.editReply({ content: `${err}` }); diff --git a/src/config/appConfig.json b/src/config/appConfig.json index 8edd733..d56b815 100644 --- a/src/config/appConfig.json +++ b/src/config/appConfig.json @@ -2,28 +2,33 @@ "discord": { "scope": ["bot", "applications.commands"], "permissions": 2147551232, - "slashCommands": [{ - "name": "drip", - "type": 1, - "description": "Receive a testnet token from the faucet", - "options": [{ - "name": "network", - "description": "The name of the testnet", - "type": 3, - "required": true, - "choices": [{ - "name": "Shibuya", - "value": "shibuya" - }] - }, - { - "name": "address", - "description": "Your SS58 (Substrate) or H160 (EVM) public address for receiving the token", - "type": 3, - "required": true - } - ] - }] + "slashCommands": [ + { + "name": "drip", + "type": 1, + "description": "Receive a testnet token from the faucet", + "options": [ + { + "name": "network", + "description": "The name of the testnet", + "type": 3, + "required": true, + "choices": [ + { + "name": "Shibuya", + "value": "shibuya" + } + ] + }, + { + "name": "address", + "description": "Your SS58 (Substrate) or H160 (EVM) public address for receiving the token", + "type": 3, + "required": true + } + ] + } + ] }, "network": { @@ -137,8 +142,7 @@ "rewards": "Balance", "staked": "Balance" } - } } } -} \ No newline at end of file +} diff --git a/src/middlewares/index.ts b/src/middlewares/index.ts new file mode 100644 index 0000000..7a44874 --- /dev/null +++ b/src/middlewares/index.ts @@ -0,0 +1 @@ +export * from './requestFilter'; diff --git a/src/middlewares/requestFilter.ts b/src/middlewares/requestFilter.ts new file mode 100644 index 0000000..01415ba --- /dev/null +++ b/src/middlewares/requestFilter.ts @@ -0,0 +1,28 @@ +import Redis from 'ioredis'; + +const redis = new Redis(process.env.REDIS_URL); + +// Cooldown time in millisecond. +// The requester must wait for Cooldown time to request next faucet. +const cooldownTimeMillisecond = 60 * 60 * 1000; + +// Check whether the requester can request Faucet or not based on the last request time. +export const canRequestFaucet = async (requesterId: string, now: number): Promise => { + const lastRequestAt = Number(await redis.get(requesterId)); + const elapsedTimeFromLastRequest = now - lastRequestAt; + + // If lastReuqest was made within the cooldown time, the requester cannot request. + if (cooldownTimeMillisecond > elapsedTimeFromLastRequest) { + const untilNextRequestMillisec = cooldownTimeMillisecond - elapsedTimeFromLastRequest; + const untilNextRequestMin = Math.floor(untilNextRequestMillisec / 1000 / 60); + const untilNextRequestSec = Math.floor(untilNextRequestMillisec / 1000 - untilNextRequestMin * 60); + + const replyMessage = `You already requested the Faucet. Try again in ${untilNextRequestMin} mins ${untilNextRequestSec} secs.`; + throw new Error(replyMessage); + } +}; + +// Log the Faucet request on Redis +export const logRequest = async (requesterId: string, now: number): Promise => { + await redis.set(requesterId, now, 'PX', cooldownTimeMillisecond); +}; diff --git a/yarn.lock b/yarn.lock index 5ba559a..3b9b4c4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -954,6 +954,13 @@ dependencies: "@types/node" "*" +"@types/ioredis@^4.28.1": + version "4.28.1" + resolved "https://registry.yarnpkg.com/@types/ioredis/-/ioredis-4.28.1.tgz#27d66f4c0540145826d984b6d0a5b54bbb88c32a" + integrity sha512-raYHPqRWrfnEoym94BY28mG1+tcZqh3dsp2q7x5IyMAAEvIdu+H0X8diASMpncIm+oHyH9dalOeOnGOL/YnuOA== + dependencies: + "@types/node" "*" + "@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1": version "2.0.3" resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.3.tgz#4ba8ddb720221f432e443bd5f9117fd22cfd4762" @@ -1571,6 +1578,11 @@ cliui@^7.0.2: strip-ansi "^6.0.0" wrap-ansi "^7.0.0" +cluster-key-slot@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/cluster-key-slot/-/cluster-key-slot-1.1.0.tgz#30474b2a981fb12172695833052bc0d01336d10d" + integrity sha512-2Nii8p3RwAPiFwsnZvukotvow2rIHM+yQ6ZcBXGHdniadkYGZYiGmkHJIbZPIV9nfv7m/U1IPMVVcAhoWFeklw== + co@^4.6.0: version "4.6.0" resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" @@ -1754,6 +1766,11 @@ delayed-stream@~1.0.0: resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk= +denque@^1.1.0: + version "1.5.1" + resolved "https://registry.yarnpkg.com/denque/-/denque-1.5.1.tgz#07f670e29c9a78f8faecb2566a1e2c11929c5cbf" + integrity sha512-XwE+iZ4D6ZUB7mfYRMb5wByE8L74HCn30FBN7sWnXksWc1LO1bPDl67pBR9o/kC4z/xSNAwkMYcGgqDV3BE3Hw== + depd@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" @@ -2550,6 +2567,23 @@ inherits@2.0.3: resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4= +ioredis@^4.28.0: + version "4.28.0" + resolved "https://registry.yarnpkg.com/ioredis/-/ioredis-4.28.0.tgz#5a2be3f37ff2075e2332f280eaeb02ab4d9ff0d3" + integrity sha512-I+zkeeWp3XFgPT2CtJKxvaF5FjGBGt4yGYljRjQecdQKteThuAsKqffeF1lgHVlYnuNeozRbPOCDNZ7tDWPeig== + dependencies: + cluster-key-slot "^1.1.0" + debug "^4.3.1" + denque "^1.1.0" + lodash.defaults "^4.2.0" + lodash.flatten "^4.4.0" + lodash.isarguments "^3.1.0" + p-map "^2.1.0" + redis-commands "1.7.0" + redis-errors "^1.2.0" + redis-parser "^3.0.0" + standard-as-callback "^2.1.0" + ip-regex@^4.3.0: version "4.3.0" resolved "https://registry.yarnpkg.com/ip-regex/-/ip-regex-4.3.0.tgz#687275ab0f57fa76978ff8f4dddc8a23d5990db5" @@ -3218,6 +3252,21 @@ lodash.clonedeep@^4.5.0: resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef" integrity sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8= +lodash.defaults@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz#d09178716ffea4dde9e5fb7b37f6f0802274580c" + integrity sha1-0JF4cW/+pN3p5ft7N/bwgCJ0WAw= + +lodash.flatten@^4.4.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/lodash.flatten/-/lodash.flatten-4.4.0.tgz#f31c22225a9632d2bbf8e4addbef240aa765a61f" + integrity sha1-8xwiIlqWMtK7+OSt2+8kCqdlph8= + +lodash.isarguments@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz#2f573d85c6a24289ff00663b491c1d338ff3458a" + integrity sha1-L1c9hcaiQon/AGY7SRwdM4/zRYo= + lodash.isequal@^4.5.0: version "4.5.0" resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0" @@ -3525,6 +3574,11 @@ p-locate@^4.1.0: dependencies: p-limit "^2.2.0" +p-map@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/p-map/-/p-map-2.1.0.tgz#310928feef9c9ecc65b68b17693018a665cea175" + integrity sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw== + p-try@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" @@ -3710,6 +3764,23 @@ readdirp@~3.6.0: dependencies: picomatch "^2.2.1" +redis-commands@1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/redis-commands/-/redis-commands-1.7.0.tgz#15a6fea2d58281e27b1cd1acfb4b293e278c3a89" + integrity sha512-nJWqw3bTFy21hX/CPKHth6sfhZbdiHP6bTawSgQBlKOVRG7EZkfHbbHwQJnrE4vsQf0CMNE+3gJ4Fmm16vdVlQ== + +redis-errors@^1.0.0, redis-errors@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/redis-errors/-/redis-errors-1.2.0.tgz#eb62d2adb15e4eaf4610c04afe1529384250abad" + integrity sha1-62LSrbFeTq9GEMBK/hUpOEJQq60= + +redis-parser@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/redis-parser/-/redis-parser-3.0.0.tgz#b66d828cdcafe6b4b8a428a7def4c6bcac31c8b4" + integrity sha1-tm2CjNyv5rS4pCin3vTGvKwxyLQ= + dependencies: + redis-errors "^1.0.0" + regenerator-runtime@^0.13.4: version "0.13.9" resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz#8925742a98ffd90814988d7566ad30ca3b263b52" @@ -3948,6 +4019,11 @@ stack-utils@^2.0.3: dependencies: escape-string-regexp "^2.0.0" +standard-as-callback@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/standard-as-callback/-/standard-as-callback-2.1.0.tgz#8953fc05359868a77b5b9739a665c5977bb7df45" + integrity sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A== + "statuses@>= 1.5.0 < 2", statuses@~1.5.0: version "1.5.0" resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c"