diff --git a/README.md b/README.md index 7c52023..ba0d47d 100644 --- a/README.md +++ b/README.md @@ -29,3 +29,15 @@ A Discord bot powered by discord.js and TypeScript. ``` For development with auto-restart: + +```bash +npm dev +``` + +## Local development + +Run cloudflared in a terminal and add he tunnel url to Url mapping in discord developer console + +```bash +docker run cloudflare/cloudflared:latest tunnel --url http://localhost:3000 +``` diff --git a/src/commands/burn-or-revoke-role.ts b/src/commands/burn-or-revoke-role.ts new file mode 100644 index 0000000..610d04e --- /dev/null +++ b/src/commands/burn-or-revoke-role.ts @@ -0,0 +1,191 @@ +import { + BundlerService, + getAccountAddress, + getAccountBalance, + getCardAddress, +} from "@citizenwallet/sdk"; +import { + APIRole, + ChatInputCommandInteraction, + Client, + GuildMember, + Role, +} from "discord.js"; +import { keccak256, toUtf8Bytes, Wallet } from "ethers"; +import { getCommunity } from "../cw"; +import { ContentResponse, generateContent } from "../utils/content"; +import { createProgressSteps } from "../utils/progress"; + +export const handleBurnOrRevokeRoleCommand = async ( + client: Client, + interaction: ChatInputCommandInteraction +) => { + await interaction.reply({ + content: createProgressSteps(0), + ephemeral: true, + }); + + const alias = interaction.options.getString("token"); + if (!alias) { + await interaction.editReply("You need to specify a token!"); + return; + } + + const amount = interaction.options.getNumber("amount"); + if (!amount) { + await interaction.editReply("You need to specify an amount!"); + return; + } + + const role = interaction.options.getRole("role"); + if (!role) { + await interaction.editReply("You need to specify a role!"); + return; + } + + const message = interaction.options.getString("message"); + + const community = getCommunity(alias); + const token = community.primaryToken; + const guild = await client.guilds.fetch(interaction.guildId); + + const privateKey = process.env.BOT_PRIVATE_KEY; + if (!privateKey) { + await interaction.editReply({ + content: "Private key is not set", + }); + return; + } + + const signer = new Wallet(privateKey); + + const signerAccountAddress = await getAccountAddress( + community, + signer.address + ); + if (!signerAccountAddress) { + await interaction.editReply({ + content: "Could not find an account for you!", + }); + return; + } + + // signer setup done + + const content: ContentResponse = { + header: createProgressSteps(1), + content: [], + }; + await interaction.editReply({ + content: generateContent(content), + }); + + const users = guild.members.cache.filter((member) => { + // check whether member is not a bot and has the given role + return !member.user.bot && member.roles.cache.has(role.id); + }); + + for (const [userId, user] of users) { + const hashedUserId = keccak256(toUtf8Bytes(userId)); + + const cardAddress = await getCardAddress(community, hashedUserId); + if (!cardAddress) { + content.content.push( + `Could not find an account to send to for user ${user.user.displayName}!` + ); + await interaction.editReply({ + content: generateContent(content), + }); + continue; + } + + content.header = createProgressSteps(2); + await interaction.editReply({ + content: generateContent(content), + }); + + // check user status + const burnStatus = { status: "new", remainingBurns: amount }; //await getBurnStatus(user, role); + + if (burnStatus.status === "burnt") { + content.content.push(`${user} has already burned`); + await interaction.editReply({ + content: generateContent(content), + }); + } else { + const balance = await getAccountBalance(community, cardAddress); + + content.content.push( + `Handling user: ${user}, balance: ${balance} ${token.symbol}` + ); + await interaction.editReply({ + content: generateContent(content), + }); + + if (burnStatus.remainingBurns > balance) { + await guild.members.removeRole({ + user: user, + role: role.id, + }); + content.content.push( + `${user} has not enough ${token.symbol}, removed role ${role.name}.` + ); + await interaction.editReply({ + content: generateContent(content), + }); + } else { + const bundler = new BundlerService(community); + + try { + const hash = await bundler.burnFromERC20Token( + signer, + token.address, + signerAccountAddress, + cardAddress, + burnStatus.remainingBurns.toString(), + message + ); + content.content.push( + `Burnt ${burnStatus.remainingBurns.toString()} ${ + token.symbol + } for ${user}: ${hash}` + ); + await interaction.editReply({ + content: generateContent(content), + }); + } catch (e) { + content.content.push( + `Failed to burnt ${burnStatus.remainingBurns.toString()} ${ + token.symbol + } for ${user} (${e.message})` + ); + await interaction.editReply({ + content: generateContent(content), + }); + } + } + } + + content.header = createProgressSteps(3); + + await interaction.editReply({ + content: generateContent(content), + }); + } + + content.header = "✅ Done"; + + await interaction.editReply({ + content: generateContent(content), + }); +}; + +const getBurnStatus = async ( + user: GuildMember, + role: NonNullable +) => { + return { + status: "partial", + remainingBurns: 2, + }; +}; diff --git a/src/index.ts b/src/index.ts index d185339..0835192 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,6 +17,7 @@ import { handleShareAddressCommand } from "./commands/shareAddress"; import { handleBurnManyCommand } from "./commands/burn-many"; import { handleDoCommand } from "./commands/do"; import { startLiveUpdates } from "./live"; +import { handleBurnOrRevokeRoleCommand } from "./commands/burn-or-revoke-role"; // Create a new client instance const client = new Client({ @@ -109,6 +110,9 @@ client.on(Events.InteractionCreate, async (interaction) => { case "burn-many": await handleBurnManyCommand(client, interaction); break; + case "burn-or-revoke-role": + await handleBurnOrRevokeRoleCommand(client, interaction); + break; case "add-owner": await handleAddOwnerCommand(client, interaction); break; diff --git a/src/openai/index.ts b/src/openai/index.ts index 134f961..8f6b26a 100644 --- a/src/openai/index.ts +++ b/src/openai/index.ts @@ -16,7 +16,7 @@ import { } from "../commands/do/tasks"; const client = new OpenAI({ - apiKey: process.env["OPENAI_API_KEY"], + apiKey: process.env["OPENAI_API_KEY"] || "invalid key", }); const constructSystemPrompt = ( diff --git a/src/register-commands.ts b/src/register-commands.ts index bff2ae6..975e421 100644 --- a/src/register-commands.ts +++ b/src/register-commands.ts @@ -195,6 +195,32 @@ const getCommands = () => }, ], }, + { + name: "burn-or-revoke-role", + description: "Burn tokens or revoke role if not enough!", + default_member_permissions: "32", + options: [ + { + name: "token", + description: "The token of debts", + type: 3, // STRING type + required: true, + autocomplete: true, + }, + { + name: "amount", + description: "The amount to burn", + type: 10, // NUMBER type + required: true, + }, + { + name: "role", + description: "The role to revoke if user has not burnt enough tokens", + type: 8, // ROLE type + required: true, + }, + ], + }, { name: "add-owner", description: "Add an owner to your Safe! 🔑",