Skip to content

Commit

Permalink
v1.4.0 - Farming (#8)
Browse files Browse the repository at this point in the history
* Add util tests

* Refactor to listener + more tests + ts tests

* Refactor all commands + some more tests

* Add all listener setup tests

* Further unit tests

* linkWallet tests

* getWallet tests

* All commands unit tested

* link wallet with XUMM show link

* got so bad with case rename

* readd files

* Update node.js.yml

* Exclude dist from tests

* lowercase util sleep

* Add XLS-20 NFT Support

* Add /richlist command

* Update Readme

* Add Farming & Richlist command (#7)

Added farming for token values
Added commands related to farming (Start farming & stop farming)
Added unit tests for farming commands
Added CRON web endpoint for farming progress tracker
Added /richlist command

* Fix import casing
  • Loading branch information
jacobpretorius authored May 8, 2023
1 parent e10c8bb commit 795a644
Show file tree
Hide file tree
Showing 41 changed files with 1,097 additions and 7 deletions.
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,6 @@

[![Build CI](https://github.com/jacobpretorius/XRPL-Discord-Bot/actions/workflows/node.js.yml/badge.svg?branch=main)](https://github.com/jacobpretorius/XRPL-Discord-Bot/actions/workflows/node.js.yml)

See [the wiki](https://github.com/jacobpretorius/XRPL-Discord-Bot/wiki) for more information and guides.
A customisable open source Discord bot that brings the power of the XRPL to Discord communities.

See [the wiki](https://github.com/jacobpretorius/XRPL-Discord-Bot/wiki) for more information and install guides.
7 changes: 4 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "XRPL-Discord-Bot",
"version": "1.3.0",
"version": "1.4.0",
"description": "A customisable open-source Discord bot that brings the power of the XRPL to Discord communities.",
"repository": {
"type": "git",
Expand Down
23 changes: 22 additions & 1 deletion src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import EventFactory from './events/EventFactory';
import { EventTypes } from './events/BotEvents';
import { scanLinkedWallets } from './business/scanLinkedWallets';
import { scanLinkedAccounts } from './business/scanLinkedAccounts';
import { scanFarmingWallets } from './business/scanFarmingWallets';
import xummWebhook from './integration/xumm/webhook';

// Discord Client
Expand Down Expand Up @@ -51,6 +52,10 @@ const commands = [
name: 'price',
description: 'Shows the last trading price on Sologenic 💲',
},
{
name: 'richlist',
description: 'Shows the top 10 community members 🙌',
},
];

const rest = new REST({ version: '9' }).setToken(SETTINGS.DISCORD.BOT_TOKEN);
Expand Down Expand Up @@ -101,19 +106,22 @@ discordClient.on('ready', async () => {

discordClient.login(SETTINGS.DISCORD.BOT_TOKEN);

// Webserver
// Webserver setup
const webServer = express();

// Basic web page to check its up locally
webServer.get('/', async (req, res) => {
res.send(
'The XRPL Discord Bot is running! See <a href="https://github.com/jacobpretorius/XRPL-Discord-Bot">here for updates</a>'
);
});

// /status endpoint (useful for automated uptime monitoring)
webServer.get('/status', async (req, res) => {
res.send('Ok');
});

// Endpoints used by CRON task runners
webServer.get('/updateWallets', async (req, res) => {
req.setTimeout(9999999);
const forceRefreshRoles =
Expand All @@ -134,6 +142,19 @@ webServer.get('/updateAccounts', async (req, res) => {
res.send(await scanLinkedAccounts(discordClient, LOGGER));
});

webServer.get('/updateFarmingWallets', async (req, res) => {
if (!SETTINGS.FARMING.ENABLED) {
return res.send('ERROR: Farming is currently disabled in settings.ts');
}

req.setTimeout(9999999);
const forceRefreshHours =
req.query.forceRefreshHours === 'true' ? true : false;

res.send(await scanFarmingWallets(discordClient, LOGGER, forceRefreshHours));
});

// Endpoints used by XUMM
webServer.use('/xummWebhook', bodyParser.json());

webServer.post('/xummWebhook', async (req, res) => {
Expand Down
118 changes: 118 additions & 0 deletions src/business/scanFarmingWallets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import SETTINGS from '../settings';
import sleep from '../utils/sleep';
import { getFarmingWallets } from '../data/getFarmingWallets';
import { getUserByDiscordId } from '../data/getUserByDiscordId';
import { updateWalletFarming } from '../data/updateWalletFarming';
import { deleteWalletFarming } from '../data/deleteWalletFarming';
import { setWalletEarned } from '../data/setWalletEarned';

import { Client, TextChannel } from 'discord.js';

const scanFarmingWallets = async (
client: Client,
LOGGER: any,
forceRefreshHours: boolean
) => {
// Get all wallets
const farmingWallets = await getFarmingWallets();

let changes = 0;
let paused = 0;
for (let i = 0; i < farmingWallets.length; i++) {
const farmingProgress = farmingWallets[i];
const farmingUser = await getUserByDiscordId(farmingProgress?.discordId);

if (!farmingProgress || !farmingUser) {
continue;
}

await sleep(100);

// Check holdings
if (farmingUser.totalPoints < farmingProgress.rewardPointsRequired) {
// They don't have enough anymore, pause their farming.
paused += 1;
await updateWalletFarming(
farmingProgress.discordId,
farmingProgress.rewardPointsRequired,
farmingProgress.rewardGoalAmount,
farmingProgress.rewardGoalHoursRequired,
farmingProgress.hoursFarmed,
false, // Pause this wallet farming
farmingProgress.dateStarted
);
continue;
}

// All good for this farmer
changes += 1;
let hoursFarmed = farmingProgress.hoursFarmed + 1;

// Check if we have to refresh wallets with enough points to continue farming
if (forceRefreshHours) {
// If so, credit them for all hours since date farming started
// this is for if there are network issues for a long time and we
// want to be nice to the community users.
const startedAt = new Date(farmingProgress.dateStarted).getTime();
const now = new Date().getTime();
hoursFarmed = Math.floor(Math.abs(now - startedAt) / 36e5);
}

// Check if they can claim now and save to other table
const completed = hoursFarmed >= farmingProgress.rewardGoalHoursRequired;

if (completed) {
// Completed farming, save to earned table
await setWalletEarned(
farmingProgress.discordId,
farmingUser.discordUsername,
farmingUser.discordDiscriminator,
farmingProgress.rewardGoalAmount,
hoursFarmed,
farmingProgress.dateStarted,
new Date().toISOString()
);

// Delete from farmed table
await deleteWalletFarming(farmingProgress.discordId);

// Post in payout channel
const channel = client.channels.cache.get(
SETTINGS.DISCORD.FARMING_DONE_CHANNEL_ID
) as TextChannel;
if (channel !== null) {
channel.send(
`🚨🚜 User finished farming: ${farmingUser.discordUsername}#${farmingUser.discordDiscriminator} with discord id ${farmingUser.discordId} has finished farming for ${farmingProgress.rewardGoalAmount} ${SETTINGS.FARMING.EARNINGS_NAME} with ${hoursFarmed} hours.`
);
}

// DM user
const walletUser = client.users.cache.get(farmingUser.discordId);

if (walletUser) {
await walletUser.send(
`Congratulations! You have finished farming for ${farmingProgress.rewardGoalAmount} ${SETTINGS.FARMING.EARNINGS_NAME} at ${hoursFarmed} hours!`
);
}
} else {
// Didn't complete yet, save progress so far.
await updateWalletFarming(
farmingProgress.discordId,
farmingProgress.rewardPointsRequired,
farmingProgress.rewardGoalAmount,
farmingProgress.rewardGoalHoursRequired,
hoursFarmed,
true,
farmingProgress.dateStarted
);
}
}

if (LOGGER !== null) {
LOGGER.trackMetric({ name: 'scanFarmers-changes', value: changes });
}

return `All done for ${farmingWallets.length} farmers with ${changes} changes and ${paused} paused`;
};

export { scanFarmingWallets };
1 change: 0 additions & 1 deletion src/commands/adminLinkWallet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import adminLinkWallet from './adminLinkWallet';
import isAdmin from '../utils/isAdmin';
import getWalletAddress from '../utils/getWalletAddress';
import { getWalletHoldings } from '../integration/xrpl/getWalletHoldings';
import { getUserAccountIdByUsername } from '../integration/discord/getUserAccountIdByUsername';
import { updateUserWallet } from '../data/updateUserWallet';
import { updateUserRoles } from '../integration/discord/updateUserRoles';
import getUserNameFromAdminLinkWalletCommand from '../utils/getUserNameFromAdminLinkWalletCommand';
Expand Down
23 changes: 23 additions & 0 deletions src/commands/help.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import help from './help';
import { Message } from 'discord.js';
import isAdmin from '../utils/isAdmin';
import isFarmingEnabled from '../utils/isFarmingEnabled';
import SETTINGS from '../settings';

jest.mock('../utils/isAdmin', () => jest.fn());
jest.mock('../utils/isFarmingEnabled', () => jest.fn());

describe('help command logic', () => {
let message: Message;
Expand All @@ -22,6 +25,9 @@ describe('help command logic', () => {
};

(isAdmin as jest.MockedFunction<typeof isAdmin>).mockReturnValue(false);
(
isFarmingEnabled as jest.MockedFunction<typeof isFarmingEnabled>
).mockReturnValue(false);
});

afterEach(() => {
Expand Down Expand Up @@ -76,4 +82,21 @@ describe('help command logic', () => {

expect(message.reply).toHaveBeenCalledWith(reply);
});

it('Responds to help message with farming details when farming enabled', async () => {
(
isFarmingEnabled as jest.MockedFunction<typeof isFarmingEnabled>
).mockReturnValue(true);

const reply = `You can
- Link a wallet to your account using: 'linkwallet WALLETADDRESSHERE'
- Check wallet points using: 'checkwallet WALLETADDRESSHERE'
- Start farming for ${SETTINGS.FARMING.EARNINGS_NAME}: 'start farming'
- Check farming progress: 'check farming'
- Delete farming progress: 'stop farming'`;

await help(payload);

expect(message.reply).toHaveBeenCalledWith(reply);
});
});
8 changes: 8 additions & 0 deletions src/commands/help.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
import isAdmin from '../utils/isAdmin';
import { Message } from 'discord.js';
import { EventPayload } from '../events/BotEvents';
import SETTINGS from '../settings';
import isFarmingEnabled from '../utils/isFarmingEnabled';

const processCommand = async (message: Message) => {
let reply = `You can
- Link a wallet to your account using: 'linkwallet WALLETADDRESSHERE'
- Check wallet points using: 'checkwallet WALLETADDRESSHERE'
`;

if (isFarmingEnabled()) {
reply += `- Start farming for ${SETTINGS.FARMING.EARNINGS_NAME}: 'start farming'
- Check farming progress: 'check farming'
- Delete farming progress: 'stop farming'`;
}

if (isAdmin(message.author.id)) {
reply += `\nAdmin commands
- Link a wallet to user account using: 'adminlinkwallet WALLETADDRESSHERE DISCORDUSER#NUMBER'
Expand Down
75 changes: 75 additions & 0 deletions src/commands/richlist.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { getTopHolders } from '../data/getTopHolders';
import richlist from './richlist';
import { CommandInteraction } from 'discord.js';
import { jest } from '@jest/globals';

jest.mock('../data/getTopHolders');

describe('richlist interaction logic', () => {
let interaction: CommandInteraction;
let payload: any;

beforeEach(() => {
interaction = {
author: { id: '123' },
commandName: 'richlist',
reply: jest.fn(),
} as unknown as CommandInteraction;

payload = {
handled: false,
interaction,
};
});

afterEach(() => {
jest.resetAllMocks();
});

it('calls interaction.reply when payload.handled is false', async () => {
payload.handled = false;

await richlist(payload);

expect(interaction.reply).toHaveBeenCalled();
});

it('does not call interaction.reply when payload.handled is true', async () => {
payload.handled = true;

await richlist(payload);

expect(interaction.reply).not.toHaveBeenCalled();
});

it('calls interaction.reply with top members if all ok', async () => {
const userName = 'User';
const discriminator = '123';
const points = 55;

(
getTopHolders as jest.MockedFunction<typeof getTopHolders>
).mockReturnValue(
Promise.resolve([
{
discordId: '123456',
discordUsername: userName,
discordDiscriminator: discriminator,
previousDiscordUsername: 'Prev',
previousDiscordDiscriminator: '333',
totalPoints: points,
wallets: [
{ address: 'wallet', points: 50, verified: false },
{ address: 'wallet2', points: 5, verified: true },
],
},
])
);

await richlist(payload);

expect(interaction.reply).toHaveBeenCalledWith({
content: `Top 10 community holders:\n ${points}\t\t->\t\t${userName}#${discriminator}`,
});
});
});
Loading

0 comments on commit 795a644

Please sign in to comment.