Skip to content

Commit

Permalink
Astar faucet instance (#2)
Browse files Browse the repository at this point in the history
* add faucet command and astar api inst

* balance format

* refactor api instance

* add fund transfers

* change token decimal

* added callback interaction

* fix message block style

* add channel id

* add description
  • Loading branch information
hoonsubin authored Sep 27, 2021
1 parent 5564aed commit 00f5908
Show file tree
Hide file tree
Showing 12 changed files with 971 additions and 110 deletions.
17 changes: 5 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,5 @@
# Discord Bot Starter Project

## Introduction

This is a Discord bot starter project made with [Discord.js](https://discord.js.org/) and TypeScript.
This template project comes with a simple ping-pong slash command for a predefined guild (server).

## Usage

### Creating a Discord Application
Expand All @@ -25,17 +20,15 @@ You can do this by creating a `.env` file with the following variables.
# Bot user app token
DISCORD_APP_TOKEN=<bot token>
# Bot user client ID
DISCORD_APP_CLIENT_ID=<bot client>
DISCORD_APP_CLIENT_ID=<app id>
# Server ID for the bot to be installed
DISCORD_GUILD_ID=<guild id>
# The channel ID for the bot to listen to
DISCORD_FAUCET_CHANNEL_ID=<channel id>
# Secret phrase (nmonic) for the faucet account
FAUCET_SECRET_PHRASE=<secret phrase>
```

The `DISCORD_GUILD_ID` refers to the Discord server ID (or guild ID) that the bot will listen to.
You can configure the OAuth2 redirect URL to read and store the guild ID from a remote database when the user add the application to their server for public distribution.

`src/config/appConfig.json` contains the bot permission, scope, and slash commands.
The values must reflect the ones in the Discord Developer Portal app settings.

### Scripts

```bash
Expand Down
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
"build": "tsc --project tsconfig.json",
"lint": "eslint '*/**/*.{js,ts}' --quiet --fix",
"lint:check": "eslint '*/**/*.{js,ts}'",
"test": "NODE_ENV=test jest --verbose --coverage"
"test": "NODE_ENV=test echo \"Test not implemented\"!"
},
"engines": {
"node": ">=16.6.x"
Expand Down Expand Up @@ -61,6 +61,10 @@
},
"dependencies": {
"@discordjs/rest": "^0.1.0-canary.0",
"@polkadot/api": "^6.0.5",
"@polkadot/keyring": "^7.4.1",
"@polkadot/util": "^7.4.1",
"@polkadot/util-crypto": "^7.4.1",
"discord-api-types": "^0.23.1",
"discord.js": "^13.1.0",
"express": "^4.17.1"
Expand Down
113 changes: 110 additions & 3 deletions src/app.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import { discordApp, expressApp } from './clients';
import { DISCORD_APP_TOKEN, DISCORD_APP_CLIENT_ID } from './config';
import {
AstarFaucetApi,
expressApp,
DiscordCredentials,
refreshSlashCommands,
NetworkName,
ASTAR_TOKEN_DECIMALS,
} from './clients';
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';

/**
* the main entry function for running the discord application
Expand All @@ -8,6 +17,104 @@ export default async function app() {
if (!DISCORD_APP_TOKEN || !DISCORD_APP_CLIENT_ID) {
throw new Error('No app tokens or ID were given!');
}
await discordApp({ token: DISCORD_APP_TOKEN, clientId: DISCORD_APP_CLIENT_ID });

await discordFaucetApp({ token: DISCORD_APP_TOKEN, clientId: DISCORD_APP_CLIENT_ID });
await expressApp();
}

/**
* The main controller for Discord API requests. Everything that is done from Discord should be written here
*/
const discordFaucetApp = async (appCred: DiscordCredentials) => {
// todo: refactor this to handle multiple guilds
if (!DISCORD_GUILD_ID || !DISCORD_FAUCET_CHANNEL_ID) {
throw new Error(
'No server information was given, please set the environment variable DISCORD_GUILD_ID and DISCORD_FAUCET_CHANNEL_ID',
);
}

if (!process.env.FAUCET_SECRET_PHRASE) {
throw new Error('No seed phrase was provided for the faucet account');
}

await refreshSlashCommands(appCred.token, appCred.clientId, DISCORD_GUILD_ID);

const clientApp = new Client({ intents: [Intents.FLAGS.GUILDS] });

// send 30 testnet tokens per call
const oneToken = new BN(10).pow(new BN(ASTAR_TOKEN_DECIMALS));
const dripAmount = new BN(15).mul(oneToken);

const astarApi = new AstarFaucetApi({ faucetAccountSeed: process.env.FAUCET_SECRET_PHRASE, dripAmount });

// todo: find a way to connect to both Dusty and Shibuya
await astarApi.connectTo('shibuya');

clientApp.on('ready', async () => {
if (clientApp.user) {
console.log(`${clientApp.user.tag} is ready!`);
} else {
console.log(`Failed to login to Discord`);
}
});

// handle faucet token request
clientApp.on('interactionCreate', async (interaction: Interaction) => {
if (!interaction.isCommand() || interaction.channelId !== DISCORD_FAUCET_CHANNEL_ID) return;

const { commandName } = interaction;

if (commandName === 'drip') {
// note: the values are based on `src/config/appConfig.json`
const networkName = interaction.options.data[0]?.value as NetworkName;
const address = interaction.options.data[1]?.value;
try {
if (!address || typeof address !== 'string' || !networkName) {
throw new Error('No address was given!');
}

// todo: check if the user has already requested tokens or not
await interaction.deferReply();

const unsub = await astarApi.sendTokenTo(address, async (result) => {
console.log(`Sending ${astarApi.formatBalance(dripAmount)} to ${address}`);

await interaction.editReply(
`Sending ${astarApi.formatBalance(
dripAmount,
)} to \`${address}\`. Please wait until the transaction has been finalized.`,
);

if (result.status.isInBlock) {
console.log(`Transaction included at block hash ${result.status.asInBlock}`);

await interaction.editReply(
`Sending ${astarApi.formatBalance(
dripAmount,
)} to \`${address}\`. Transaction included at block hash \`${result.status.asInBlock}\``,
);
} else if (result.status.isFinalized) {
console.log(`Transaction finalized at block hash ${result.status.asFinalized}`);

const remainingFunds = await astarApi.getFaucetBalance();
await interaction.editReply(
`Sent ${astarApi.formatBalance(
dripAmount,
)} to \`${address}\`. Transaction finalized at blockHash \`${
result.status.asFinalized
}\`.\nRemaining funds: \`${remainingFunds}\`\nPlease send unused tokens back to the faucet \`${
astarApi.faucetAccount.address
}\``,
);
unsub();
}
});
} catch (err) {
console.warn(err);
await interaction.editReply({ content: `${err}` });
}
}
});

await clientApp.login(DISCORD_APP_TOKEN);
};
113 changes: 113 additions & 0 deletions src/clients/astar.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { ApiPromise, WsProvider, Keyring } from '@polkadot/api';
import type { ISubmittableResult } from '@polkadot/types/types';
import { appConfig } from '../config';
import { formatBalance } from '@polkadot/util';
import { evmToAddress } from '@polkadot/util-crypto';
import type { KeyringPair } from '@polkadot/keyring/types';
import BN from 'bn.js';
import { checkAddressType } from '../helpers';

export type NetworkName = 'dusty' | 'shibuya';
export interface FaucetOption {
faucetAccountSeed: string;
dripAmount: BN;
}

// ss58 address prefix
export const ASTAR_SS58_FORMAT = 5;

export const ASTAR_TOKEN_DECIMALS = 18;

export class AstarFaucetApi {
private _keyring: Keyring;
private _faucetAccount: KeyringPair;
private _api: ApiPromise;
// token amount to send from the faucet per request
private _dripAmount: BN;

public get faucetAccount() {
return this._faucetAccount;
}

public get api() {
return this._api;
}

constructor(options: FaucetOption) {
this._keyring = new Keyring({ type: 'sr25519', ss58Format: ASTAR_SS58_FORMAT });
this._faucetAccount = this._keyring.addFromUri(options.faucetAccountSeed, { name: 'Astar Faucet' });
this._dripAmount = options.dripAmount;
//this._api = new ApiPromise();
}

public async connectTo(networkName: NetworkName) {
// get chain endpoint and types from the config file
const endpoint = appConfig.network[networkName].endpoint;
const chainMetaTypes = appConfig.network[networkName].types;

// establish node connection with the endpoint
const provider = new WsProvider(endpoint);
const api = new ApiPromise({
provider,
types: chainMetaTypes,
});

const apiInst = await api.isReady;

// get chain metadata
const { tokenSymbol } = await apiInst.rpc.system.properties();
const unit = tokenSymbol.unwrap()[0].toString();

// set token display format
formatBalance.setDefaults({
unit,
decimals: ASTAR_TOKEN_DECIMALS, // we can get this directly from the chain too
});

// subscribe to account balance changes
// await apiInst.query.system.account(this._faucetAccount.address, ({ data }) => {
// const faucetReserve = formatBalance(data.free.toBn(), {
// withSi: true,
// withUnit: true,
// });
// console.log(`Faucet has ${faucetReserve}`);
// });

this._api = apiInst;

return apiInst;
}

public async getFaucetBalance() {
const addr = this._faucetAccount.address;

const { data } = await this._api.query.system.account(addr);

const faucetReserve = this.formatBalance(data.free.toBn());

return faucetReserve;
}

public formatBalance(input: string | number | BN) {
return formatBalance(input, {
withSi: true,
withUnit: true,
});
}

public async sendTokenTo(to: string, statusCb: (result: ISubmittableResult) => Promise<void>) {
// send 30 testnet tokens per call
//const faucetAmount = new BN(30).mul(new BN(10).pow(new BN(18)));

let destinationAccount = to;
const addrType = checkAddressType(to);

// convert the h160 (evm) account to ss58 before sending the tokens
if (addrType === 'H160') {
destinationAccount = evmToAddress(to, ASTAR_SS58_FORMAT);
}
return await this._api.tx.balances
.transfer(destinationAccount, this._dripAmount)
.signAndSend(this._faucetAccount, statusCb);
}
}
54 changes: 5 additions & 49 deletions src/clients/discord.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { Client, Intents } from 'discord.js';
import { REST } from '@discordjs/rest';
import { Routes } from 'discord-api-types/v9';
import { DISCORD_APP_TOKEN, DISCORD_APP_CLIENT_ID, DISCORD_GUILD_ID, appConfig } from '../config';
import { DISCORD_APP_CLIENT_ID, appConfig } from '../config';

export interface DiscordCredentials {
token: string;
Expand All @@ -18,66 +17,23 @@ export const appOauthInstallUrl = () => {

// used to add the bot to a server (https://discordjs.guide/preparations/adding-your-bot-to-servers.html#bot-invite-links)
return `https://discord.com/api/oauth2/authorize?client_id=${DISCORD_APP_CLIENT_ID}&permissions=${
appConfig.permissions
}&scope=${concatBotScope(appConfig.scope)}`;
appConfig.discord.permissions
}&scope=${concatBotScope(appConfig.discord.scope)}`;
};

const refreshSlashCommands = async (appToken: string, appClientId: string, guildId: string) => {
export const refreshSlashCommands = async (appToken: string, appClientId: string, guildId: string) => {
// generally, you only need to run this function when the slash command changes
const rest = new REST({ version: '9' }).setToken(appToken);
try {
console.log('Started refreshing application (/) commands.');

// note: the `DISCORD_GUILD_ID` is hard-coded in this project, but this can be changed to read it from a remote database
await rest.put(Routes.applicationGuildCommands(appClientId, guildId), {
body: appConfig.slashCommands,
body: appConfig.discord.slashCommands,
});

console.log('Successfully reloaded application (/) commands.');
} catch (error) {
console.error(error);
}
};

/**
* The main controller for Discord API requests. Everything that is done from Discord should be written here
*/
export const discordApp = async (appCred: DiscordCredentials) => {
// todo: refactor this to handle multiple guilds
if (!DISCORD_GUILD_ID) {
throw new Error(
'No Discord bot token was provided, please set the environment variable DISCORD_APP_TOKEN and DISCORD_APP_CLIENT_ID',
);
}

await refreshSlashCommands(appCred.token, appCred.clientId, DISCORD_GUILD_ID);

const clientApp = new Client({ intents: [Intents.FLAGS.GUILDS] });

clientApp.on('ready', async () => {
if (clientApp.user) {
console.log(`${clientApp.user.tag} is ready!`);
} else {
console.log(`Failed to login as a user!`);
}
});

// a ping-pong test
clientApp.on('interactionCreate', async (interaction) => {
if (!interaction.isCommand()) return;

const { commandName } = interaction;

if (commandName === 'ping') {
await interaction.reply('Pong!');
} else if (commandName === 'greet') {
await interaction.reply('Hello ' + interaction.user.tag);
} else if (commandName === 'blep') {
await interaction.reply(`You chose ${JSON.stringify(interaction.options.data)}`);
}
});

await clientApp.login(DISCORD_APP_TOKEN);

return clientApp;
};
1 change: 1 addition & 0 deletions src/clients/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './discord';
export * from './express';
export * from './astar';
Loading

0 comments on commit 00f5908

Please sign in to comment.