From 98ce48f1596545490773e3e5baebc376573496c1 Mon Sep 17 00:00:00 2001 From: patrick Date: Thu, 25 Mar 2021 03:34:05 -0400 Subject: [PATCH 01/23] update toml --- redwood.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/redwood.toml b/redwood.toml index 7f523b3..ece1e4e 100644 --- a/redwood.toml +++ b/redwood.toml @@ -5,9 +5,9 @@ # For the full list of options, see the "App Configuration: redwood.toml" doc: # https://redwoodjs.com/docs/app-configuration-redwood-toml -# [web] -# port = 8910 -# apiProxyPath = "/api" +[web] + port = 8910 + apiProxyPath = "/api" [api] port = 8911 schemaPath = "./api/db/schema.prisma" From de5b16eadc2f88cbaec0f117c381d0cce2404f58 Mon Sep 17 00:00:00 2001 From: patrick Date: Thu, 25 Mar 2021 03:57:51 -0400 Subject: [PATCH 02/23] add notifyMemberUpdate --- bot/src/bot.js | 1 + bot/src/commands/invoke.js | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/src/bot.js b/bot/src/bot.js index 350dcb4..ebbd867 100644 --- a/bot/src/bot.js +++ b/bot/src/bot.js @@ -3,6 +3,7 @@ const Discord = require('discord.js') const { handleInvoke } = require('./commands/invoke') const { handleAdminUpdate } = require('./commands/admin') +const { notifyMemberUpdate } = require('./commands/notifications') const { DISCORD_NO_DM_INVOCATION } = require('./textContent') const discordClient = new Discord.Client() diff --git a/bot/src/commands/invoke.js b/bot/src/commands/invoke.js index aca8f29..439eaf6 100644 --- a/bot/src/commands/invoke.js +++ b/bot/src/commands/invoke.js @@ -114,7 +114,7 @@ const doSwordyAuth = async ({ message, guildMember, guild }) => { return ['✅'].includes(reaction.emoji.name) } prompt - .awaitReactions(filter, { max: 1, time: 60000, errors: ['time'] }) + .awaitReactions(filter, { max: 1, time: 120000, errors: ['time'] }) .then(() => { checkNftAndAssignRoles({ message, From fe9b4716c37f700403dcdacc1dadcc7301478415 Mon Sep 17 00:00:00 2001 From: patrick Date: Sun, 28 Mar 2021 12:12:36 -0400 Subject: [PATCH 03/23] allow spaces in role name --- bot/src/commands/admin.js | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/bot/src/commands/admin.js b/bot/src/commands/admin.js index 8f26559..25cacc6 100644 --- a/bot/src/commands/admin.js +++ b/bot/src/commands/admin.js @@ -14,10 +14,13 @@ const INVALID_NUMBER_OF_PARAMETERS = 'Invalid number of parameters' const { verifyInstall, getRoleFromName } = require('./common') const parseParams = ({ text }) => { - // !eg. "add-lock 0xabc1 100 role-name url-for-buying" - const params = text.split(' ') - if (params.length < 5) throw Error('#' + INVALID_NUMBER_OF_PARAMETERS) - const [_, chainId, contractAddress, balance, roleName, purchaseUrl] = params + // !eg. add-lock 0xabc1 100 "role name" url-for-buying" + const initialParams = text.split(' ').slice(0, 2) + const role = text.split('"')[1] + if (initialParams.length < 4 || !role) + throw Error('#' + INVALID_NUMBER_OF_PARAMETERS) + const purchaseUrl = text.split('"')[2] + const [_, chainId, contractAddress, balance] = initialParams if (!chainId.match(/^[0-9]+$/)) throw Error('#' + 'Invalid chain ID') if (!contractAddress.match(/^0x[0-9a-fA-F]{40}$/)) throw Error('#' + INVALID_ETHEREUM_ADDRESS) From 480d64abba2870ec2be0ac98833acf2faf85b3b1 Mon Sep 17 00:00:00 2001 From: patrick Date: Sun, 28 Mar 2021 17:03:50 -0400 Subject: [PATCH 04/23] community blog --- how-to-build-token-centric-community.md | 122 ++++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 how-to-build-token-centric-community.md diff --git a/how-to-build-token-centric-community.md b/how-to-build-token-centric-community.md new file mode 100644 index 0000000..8840ebc --- /dev/null +++ b/how-to-build-token-centric-community.md @@ -0,0 +1,122 @@ +# How to build a token-centric community + +Good reasons for building a token-centric community: + +- DAOs for giving grants +- DAOs for doing work +- Tokens for Defi protocols +- An altruistic cause you think others also +- Artist or Content-Creator with a following + +Bad reasons for building a token-centric community: + +- Making money or influencing token prices +- A poorly researched idea. +- Harm or put-down others + +## Purpose + +Do not be extractive. No one will want to participate if they feel they are being taken advantage of. In order to success, your community must have a clear purpose that people can get behind. + +## People + +Identify a small Power Squad so you can + +## Incentives + +# Practical Steps + +# From 0-10 Members + +## Platform + +Discord is the best place to build, since you need separate channels which are topic-focused. To get you started, I created a template you can use to get started: + +The key elements are: + +1. Keep things simple and concise. Rambling conversations on 100 different channels is not helpful. +2. Organize topics with clear names. Rather than name a channel "inner-chambers", instead use something like "DAO-members". This prevents newcomers from being overwhelmed with jargon. +3. Have outlets for being social and hanging out. All work and no play makes Jack a dull boy. Consider making a Category just for gaming, random topics, memes, and listening to music with [groovy bot](https://groovy.bot/). + +![]('./hangout-channels.png') + +## Core Contributors + +## Social bonds + +The third element in the above list is very important. Communities find strength when they discover mutually shared values, beyond the prescribed goals. If a small cohort from your community discover they all enjoy listening to Taylor Swift, a social bond will form. These bonds cannot be created artificially, so you must provide space for them to occur organically. + +> Your community is only as strong the bonds between its members. + +Scheduling work-free low-effort hangouts is a great way to help bootstrap the process of shared-interest discovery. For example, host a weekly non-mandatory **happy hour**, casual multiplayer gaming, or chess tournament. + +![]('./happy-hour.png') + +You'll begin to see common interests develop, and your members will start to ask about adding topic-specific channels for them to "geek-out". If achieve this goal, your community will become stronger than ever. + +## Doin' Work + +Time to get our hands dirty. Make couple + +![]('./work-channels.png') + +# From 10-100 Members + +## Opening the Doors to the Public + +Awesome! You're ready to open your community up to outsiders. You've already laid a strong foundation, and new members joining will be able to tell. + +Before you start advertising yourself, you should already have identified your "core-contributors". AND you should already have a handful of work-specific channels created. + +Before you open your doors to the public, here are a few things you need to complete: + +- [Enable Your Community Server](https://support.discord.com/hc/en-us/articles/360047132851-Enabling-Your-Community-Server)(_mandatory_) in your server's settings. This is the easy-button for ensuring you have the correct settings fro explicit content-filters, security/safety, and notifications. It also enables a couple other features which will come in handy. +- Create a welcome screen + +### Set up a Code of Conduct + +If you want to attract a diverse membership (and you do), then you must do your part to fight discrimination. One of the core requirements for doing so is to adopt and abide by a Code of Conduct. + +> This is your signal to the world that you're not a gaggle of immature fuckboys. + +The open-source software community already has a great Code of Conduct. You can read, understand, and use ours from [contributor-covenant.org](http://contributor-covenant.org/). + +p.s. If you skip this step, probably no one will tell you, because they think your community is close-minded. Don't foster a close-minded community. I'm telling you now, so you have no excuse. + +### Define the Rules + +These don't have to be complicated. Get the easy stuff out of the way towards the top: "No spamming or shilling, unless its directly related to the community". Keep it simple enough that your members actually read it. + +Your goal here is "self-policing". The better your members understand the rules, the less work moderating you have to do. + +![]('./hey-admin-this-guy-sucks.png') + +# From 100 Members and beyond + +## Permissions + +### Setting up roles + +Now that you've attracted some members + +### Using bots + +I already mentioned how groovy-bot can help connect members. Another bot + +- Haus Bot +- CollabLand +- Swordy Bot + +## Go Global + +Its time to open up a few channels in other languages. Even if you can't understand whats happening, or help moderate, your community members will be very excited to chat with fellow native speakers. And since everyone knows the rules by now, you can have more confidence that the channel will be self-moderated. + +Here's a good list to start with: + +- français +- deutsch +- 日本語 +- pусский +- español + +![]('./global-channels.png') From 360898da27a72ad671152de72f516711877e3238 Mon Sep 17 00:00:00 2001 From: patrick Date: Sun, 28 Mar 2021 20:18:32 -0400 Subject: [PATCH 05/23] tpc baby --- how-to-build-token-centric-community.md | 114 ++++++++++++++++++------ 1 file changed, 88 insertions(+), 26 deletions(-) diff --git a/how-to-build-token-centric-community.md b/how-to-build-token-centric-community.md index 8840ebc..7b5b6fc 100644 --- a/how-to-build-token-centric-community.md +++ b/how-to-build-token-centric-community.md @@ -1,19 +1,53 @@ -# How to build a token-centric community +# Practical Guide for Building a Token Powered Community -Good reasons for building a token-centric community: +There are plenty of good reasons for building a community around a token: -- DAOs for giving grants -- DAOs for doing work -- Tokens for Defi protocols -- An altruistic cause you think others also -- Artist or Content-Creator with a following +- DAOs for giving grants or doing work +- Open-source technology development and governance +- Altruistic causes and public goods +- Content creators and artists with followers -Bad reasons for building a token-centric community: +And plenty of bad reasons as well: - Making money or influencing token prices - A poorly researched idea. - Harm or put-down others +My goal here is to walk through what setting up a community should look like. These are my experiences both as an active participant in several crypto communities, and an observer of dozens more. + +# What is a Token Powered Community + +A **Token Powered Community** (TPC) is a broad term to describe any group of people using a cryptocurrency token to accomplish their goals. The primary purpose of the token is usually to compensate community members for their efforts in achieving the goals. + +The more valuable the token is, the more "buying-power" it affords, and this means the TPC can accomplish more. Thus a secondary goal always exists to increasing the value of the token, and ensure the sustainability of the TPC. + +> Tokens align incentives among different people to create a sustainable organization focused on a common set of goals. + +# How Does a TPC Work? + +A TPC can maintain a central treasury of tokens, and/or benefit from a virtual treasury. A **central treasury** is like tokens in a DAO's piggy bank, which can be paid out directly to contributors for doing work. + +But a **virtual treasury** doesn't actually exist anywhere. Confused? If TPC members individually own the token, and the value of the token increases as a result of the TPC's efforts, then all members benefit just like if they were paid from a central treasury. This is a "rising tide lifts all ships" scenario. + +The concept of the virtual treasury means TPCs, just by existing, create incentives for members to contribute. It is the source of the never-ending "buzz" of a community, and keeps things moving even when there might not be a price tag for a particular task. The virtual treasury injects a healthy dose of positive moral into every conversation about the TPC. + +# Examples of TPCs + +The Decentralized Autonomous Organization (DAO) is the best example of a TPC. Here are some examples: + +- [MetaCartel](https://www.metacartel.org/) has distributed hundreds of thousands of dollars in the form of grants to support community projects. As new members join, the DAO's treasury grows from membership fees. +- [Raid Guild](https://raidguild.org/) has completed hundreds of projects as a tech development studio. As projects are completed, a portion of earnings are put back into the treasury. + +A TPC can also form around projects with governance or utility tokens. These are usually curated by the project team's themselves, but this isn't always the case. Here are some examples: + +- [$UNI](https://www.coingecko.com/en/coins/uni) tokens for Uniswap protocol are used to pay for making improvements to the product. +- [$RAC](https://www.coingecko.com/en/coins/rac) tokens are used by electronic artist Rac to curate his Discord server. +- [$UDT](https://unlock-protocol.com/blog/unlock-tokens-launched) token is used by Unlock Protocol to allow holders to claim discounts on their purchases, and provides financial rewards for stakeholders. + +# Prerequisites + +Before you begin your journey to create a TPC, you must determine where you'd like to start from. + ## Purpose Do not be extractive. No one will want to participate if they feel they are being taken advantage of. In order to success, your community must have a clear purpose that people can get behind. @@ -24,9 +58,16 @@ Identify a small Power Squad so you can ## Incentives -# Practical Steps +This is where the token comes in! Here are your options: + +- Use tokens from existing projects +- Create a DAO using [DaoHaus](https://daohaus.club/). The shares can serve as your token. +- Hire the [Raid Guild](https://raidguild.org/) to design and launch a custom token. +- Create one yourself. There are some great resources for learning Ethereum development at [EthHole.com](https://ethhole.com/learning) -# From 0-10 Members +# From 0 to 10 Members - Establish your Foundation + +Let's get started! ## Platform @@ -36,15 +77,27 @@ The key elements are: 1. Keep things simple and concise. Rambling conversations on 100 different channels is not helpful. 2. Organize topics with clear names. Rather than name a channel "inner-chambers", instead use something like "DAO-members". This prevents newcomers from being overwhelmed with jargon. -3. Have outlets for being social and hanging out. All work and no play makes Jack a dull boy. Consider making a Category just for gaming, random topics, memes, and listening to music with [groovy bot](https://groovy.bot/). +3. Create outlets for being social and hanging out. All work and no play makes Jack a dull boy. Consider making a Category just for gaming, random topics, memes, and listening to music with [groovy bot](https://groovy.bot/). ![]('./hangout-channels.png') ## Core Contributors +While its tempting to immediately open your community up to everyone, this will not be healthy or sustainable until you've established a strong foundation. + +**Don't announce your community anywhere. Instead, recruit core contributors who already identify with your purpose.** + +This is a delicate time, so don't be too harsh if you find the initial members you invited don't see eye-to-eye with your intentions. When in doubt, broaden your community's purpose and values to establish a good foundation. You can always narrow focus and be specific later, after you've found a solution that fits everyone's needs/goals. + +And some people just might not be a good fit, which is perfectly fine. Allow them hang out anyways. Who knows, they might jump back in one day. + +> The "@contributors" role is a great way to signal who has been showing up, and doing work. Hand these out as much as possible, they'll be very useful later! + +Starting now, until you've reached 100 users, every channel should be publicly viewable. All discussions should "in the open", and you should encourage lots of brainstorming. + ## Social bonds -The third element in the above list is very important. Communities find strength when they discover mutually shared values, beyond the prescribed goals. If a small cohort from your community discover they all enjoy listening to Taylor Swift, a social bond will form. These bonds cannot be created artificially, so you must provide space for them to occur organically. +Communities find strength when they discover mutually shared values, beyond the community-prescribed goals. If a small cohort discovers they share a love for Taylor Swift, a social bond will form. These bonds cannot be created artificially, so you must provide space for them to occur organically. > Your community is only as strong the bonds between its members. @@ -56,24 +109,31 @@ You'll begin to see common interests develop, and your members will start to ask ## Doin' Work -Time to get our hands dirty. Make couple +At this point, you have a rough idea of your community's purpose. Your members also may have identified some common interests outside of the community, and shared some laughs along the way. + +Now its time to cash-in on the social-credit you've generated. + +To do this, you'll need to recruit someone to the role of **Facilitator**. This person is responsible for making sure deadlines are set, and decisions get made by those deadlines. The most basic example of this is to schedule a meeting to finalize the community's Mission Statement. + +Facilitators are successful when they gather casual conversations during the week, and turn them into action items at the weekly meeting. ![]('./work-channels.png') -# From 10-100 Members +All your conversation so far can take place in the general chat and voice channels. + +# From 10 to 100 Members - Open the Door -## Opening the Doors to the Public +Boom, you've determined your community's focus, and are ready to start opening things up. New members will join with a clear understanding of what you want to accomplish. -Awesome! You're ready to open your community up to outsiders. You've already laid a strong foundation, and new members joining will be able to tell. +Before you open the door completely, here are a few things you need to complete: -Before you start advertising yourself, you should already have identified your "core-contributors". AND you should already have a handful of work-specific channels created. +## Enable "Community" Mode -Before you open your doors to the public, here are a few things you need to complete: +[Enable Your Community Server](https://support.discord.com/hc/en-us/articles/360047132851-Enabling-Your-Community-Server) in your server's settings. This mandatory step is the "easy-button" for ensuring you have the correct settings for explicit content-filters, security/safety, and notifications. -- [Enable Your Community Server](https://support.discord.com/hc/en-us/articles/360047132851-Enabling-Your-Community-Server)(_mandatory_) in your server's settings. This is the easy-button for ensuring you have the correct settings fro explicit content-filters, security/safety, and notifications. It also enables a couple other features which will come in handy. -- Create a welcome screen +During this process, you'll be prompted to create a **Custom Welcome Screen**. This is where you can briefly explain the community's purpose/values, and where to find the rules (explained in the next sections). -### Set up a Code of Conduct +## Set up a Code of Conduct If you want to attract a diverse membership (and you do), then you must do your part to fight discrimination. One of the core requirements for doing so is to adopt and abide by a Code of Conduct. @@ -81,17 +141,19 @@ If you want to attract a diverse membership (and you do), then you must do your The open-source software community already has a great Code of Conduct. You can read, understand, and use ours from [contributor-covenant.org](http://contributor-covenant.org/). -p.s. If you skip this step, probably no one will tell you, because they think your community is close-minded. Don't foster a close-minded community. I'm telling you now, so you have no excuse. +p.s. If you skip this step, no one will probably tell you, because they think your community is close-minded. Don't foster a close-minded community. I'm telling you now, so you have no excuse. ### Define the Rules -These don't have to be complicated. Get the easy stuff out of the way towards the top: "No spamming or shilling, unless its directly related to the community". Keep it simple enough that your members actually read it. +These don't have to be complicated. Get the easy stuff out of the way at the top - "No spamming or shilling, unless its directly related to the community". Keep it simple enough that your members actually read it. -Your goal here is "self-policing". The better your members understand the rules, the less work moderating you have to do. +Your goal here is to encourage "self-moderation". The better your members understand the rules, the less work moderating you have to do. ![]('./hey-admin-this-guy-sucks.png') -# From 100 Members and beyond +# 100 Members and Beyond - Management and Structure + +Superb work making it this far. If you laid the foundation right, getting from 10 to 100 members should have gone pretty quickly. Now is time to start locking things down a bit. We don't want everyone to have access to everything. This is where you can start using roles to prevent access to certain elements. ## Permissions @@ -111,7 +173,7 @@ I already mentioned how groovy-bot can help connect members. Another bot Its time to open up a few channels in other languages. Even if you can't understand whats happening, or help moderate, your community members will be very excited to chat with fellow native speakers. And since everyone knows the rules by now, you can have more confidence that the channel will be self-moderated. -Here's a good list to start with: +Here's a good list to start with. Each should be typed in it's native language. - français - deutsch From 1220f8165f125e7652b41c9cb97bf406c91a1cff Mon Sep 17 00:00:00 2001 From: patrick Date: Sun, 28 Mar 2021 20:39:40 -0400 Subject: [PATCH 06/23] tpc --- how-to-build-token-centric-community.md | 38 +++++++++++-------------- 1 file changed, 16 insertions(+), 22 deletions(-) diff --git a/how-to-build-token-centric-community.md b/how-to-build-token-centric-community.md index 7b5b6fc..91f88f8 100644 --- a/how-to-build-token-centric-community.md +++ b/how-to-build-token-centric-community.md @@ -31,7 +31,7 @@ But a **virtual treasury** doesn't actually exist anywhere. Confused? If TPC mem The concept of the virtual treasury means TPCs, just by existing, create incentives for members to contribute. It is the source of the never-ending "buzz" of a community, and keeps things moving even when there might not be a price tag for a particular task. The virtual treasury injects a healthy dose of positive moral into every conversation about the TPC. -# Examples of TPCs +# Examples of Existing TPCs The Decentralized Autonomous Organization (DAO) is the best example of a TPC. Here are some examples: @@ -44,31 +44,21 @@ A TPC can also form around projects with governance or utility tokens. These are - [$RAC](https://www.coingecko.com/en/coins/rac) tokens are used by electronic artist Rac to curate his Discord server. - [$UDT](https://unlock-protocol.com/blog/unlock-tokens-launched) token is used by Unlock Protocol to allow holders to claim discounts on their purchases, and provides financial rewards for stakeholders. -# Prerequisites - -Before you begin your journey to create a TPC, you must determine where you'd like to start from. - -## Purpose - -Do not be extractive. No one will want to participate if they feel they are being taken advantage of. In order to success, your community must have a clear purpose that people can get behind. +# From 0 to 10 Members - Establish your Foundation -## People +Let's get started! -Identify a small Power Squad so you can +## Token -## Incentives +I'm putting this section first, since the timing depends on what type of community you're creating, not because its the first thing you need to do. In fact, you really shouldn't launch a token until you have at least a dozen or so core contributors, a healthy mission, and high confidence and community support. -This is where the token comes in! Here are your options: +Here are your options for your community tokens: -- Use tokens from existing projects +- Use tokens from existing projects. Mix-n-match to your liking! - Create a DAO using [DaoHaus](https://daohaus.club/). The shares can serve as your token. - Hire the [Raid Guild](https://raidguild.org/) to design and launch a custom token. - Create one yourself. There are some great resources for learning Ethereum development at [EthHole.com](https://ethhole.com/learning) -# From 0 to 10 Members - Establish your Foundation - -Let's get started! - ## Platform Discord is the best place to build, since you need separate channels which are topic-focused. To get you started, I created a template you can use to get started: @@ -81,19 +71,23 @@ The key elements are: ![]('./hangout-channels.png') +## Purpose Statement + +Before you can invite people, we need a concise statement that explains what we want to accomplish. Set some goals for now (hopefully to do good in the world), and write this in the form of a Purpose Statement. This will be your community's starting point, which will develop over time, and eventually become the Mission Statement. + ## Core Contributors -While its tempting to immediately open your community up to everyone, this will not be healthy or sustainable until you've established a strong foundation. +Good, now you have a starting point, its time to invite people in. While its tempting to immediately open your community up to everyone, this will not be healthy or sustainable until you've established a strong foundation. -**Don't announce your community anywhere. Instead, recruit core contributors who already identify with your purpose.** +**Don't announce your community just yet. Instead, recruit core contributors who are already aligned with your Purpose Statement.** -This is a delicate time, so don't be too harsh if you find the initial members you invited don't see eye-to-eye with your intentions. When in doubt, broaden your community's purpose and values to establish a good foundation. You can always narrow focus and be specific later, after you've found a solution that fits everyone's needs/goals. +This is a delicate time, so don't be too harsh if the initial members you invited don't see eye-to-eye with your intentions. When in doubt, broaden your community's purpose to establish a good foundation. You can always narrow focus and be specific later. -And some people just might not be a good fit, which is perfectly fine. Allow them hang out anyways. Who knows, they might jump back in one day. +Some of those you invited might not be a good fit, which is perfectly fine. Allow them hang out anyways. Who knows, they might jump back in one day. > The "@contributors" role is a great way to signal who has been showing up, and doing work. Hand these out as much as possible, they'll be very useful later! -Starting now, until you've reached 100 users, every channel should be publicly viewable. All discussions should "in the open", and you should encourage lots of brainstorming. +From this point, until you've reached 100 users, every channel should be open for all participants. All discussions should "in the open", and you should encourage lots of brainstorming. ## Social bonds From f98b181c840c909885442c01a6823d3fd674f547 Mon Sep 17 00:00:00 2001 From: patrick Date: Sun, 28 Mar 2021 21:07:26 -0400 Subject: [PATCH 07/23] fix add-lock --- bot/src/commands/admin.js | 6 +++--- how-to-build-token-centric-community.md | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/bot/src/commands/admin.js b/bot/src/commands/admin.js index 25cacc6..1f477e3 100644 --- a/bot/src/commands/admin.js +++ b/bot/src/commands/admin.js @@ -15,9 +15,9 @@ const { verifyInstall, getRoleFromName } = require('./common') const parseParams = ({ text }) => { // !eg. add-lock 0xabc1 100 "role name" url-for-buying" - const initialParams = text.split(' ').slice(0, 2) - const role = text.split('"')[1] - if (initialParams.length < 4 || !role) + const initialParams = text.split(' ').slice(0, 4) + const roleName = text.split('"')[1] + if (initialParams.length < 4 || !roleName) throw Error('#' + INVALID_NUMBER_OF_PARAMETERS) const purchaseUrl = text.split('"')[2] const [_, chainId, contractAddress, balance] = initialParams diff --git a/how-to-build-token-centric-community.md b/how-to-build-token-centric-community.md index 91f88f8..e6696be 100644 --- a/how-to-build-token-centric-community.md +++ b/how-to-build-token-centric-community.md @@ -37,6 +37,7 @@ The Decentralized Autonomous Organization (DAO) is the best example of a TPC. He - [MetaCartel](https://www.metacartel.org/) has distributed hundreds of thousands of dollars in the form of grants to support community projects. As new members join, the DAO's treasury grows from membership fees. - [Raid Guild](https://raidguild.org/) has completed hundreds of projects as a tech development studio. As projects are completed, a portion of earnings are put back into the treasury. +- Chess DAO likes to play chess. They're still forming if you're interested. A TPC can also form around projects with governance or utility tokens. These are usually curated by the project team's themselves, but this isn't always the case. Here are some examples: @@ -69,7 +70,7 @@ The key elements are: 2. Organize topics with clear names. Rather than name a channel "inner-chambers", instead use something like "DAO-members". This prevents newcomers from being overwhelmed with jargon. 3. Create outlets for being social and hanging out. All work and no play makes Jack a dull boy. Consider making a Category just for gaming, random topics, memes, and listening to music with [groovy bot](https://groovy.bot/). -![]('./hangout-channels.png') +![]('./starting-place.png') ## Purpose Statement From 998469a15762eff332bbad4c2125eef827fc768e Mon Sep 17 00:00:00 2001 From: patrick Date: Sun, 28 Mar 2021 21:15:54 -0400 Subject: [PATCH 08/23] fix chain id --- api/src/lib/token/token.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/lib/token/token.js b/api/src/lib/token/token.js index ad60c73..91bae8c 100644 --- a/api/src/lib/token/token.js +++ b/api/src/lib/token/token.js @@ -6,7 +6,7 @@ import erc721Abi from './erc721Abi' const getProviderByChainId = (chainId) => { if (chainId === '100') return new JsonRpcProvider(process.env.XDAI_RPC_URL) - return new InfuraProvider(networkName, process.env.INFURA_ENDPOINT_KEY) + return new InfuraProvider(chainId, process.env.INFURA_ENDPOINT_KEY) } export const checkWorthiness = async ({ token, balance, userAddress }) => { From 6a7c09fbd39d0641078bfbc403f78a727e96422e Mon Sep 17 00:00:00 2001 From: patrick Date: Sun, 28 Mar 2021 21:20:05 -0400 Subject: [PATCH 09/23] chainId to Number --- api/src/lib/token/token.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/lib/token/token.js b/api/src/lib/token/token.js index 91bae8c..247d9d2 100644 --- a/api/src/lib/token/token.js +++ b/api/src/lib/token/token.js @@ -6,7 +6,7 @@ import erc721Abi from './erc721Abi' const getProviderByChainId = (chainId) => { if (chainId === '100') return new JsonRpcProvider(process.env.XDAI_RPC_URL) - return new InfuraProvider(chainId, process.env.INFURA_ENDPOINT_KEY) + return new InfuraProvider(Number(chainId), process.env.INFURA_ENDPOINT_KEY) } export const checkWorthiness = async ({ token, balance, userAddress }) => { From 03e5d3af1b2a759153ed61087746666fdbd35ca4 Mon Sep 17 00:00:00 2001 From: patrick Date: Sun, 28 Mar 2021 21:31:35 -0400 Subject: [PATCH 10/23] add erc20Abi --- api/src/lib/token/token.js | 1 + 1 file changed, 1 insertion(+) diff --git a/api/src/lib/token/token.js b/api/src/lib/token/token.js index 247d9d2..37e8c09 100644 --- a/api/src/lib/token/token.js +++ b/api/src/lib/token/token.js @@ -3,6 +3,7 @@ import { Contract } from '@ethersproject/contracts' import { Bignumber } from '@ethersproject/units' import erc721Abi from './erc721Abi' +import erc20Abi from './erc20Abi' const getProviderByChainId = (chainId) => { if (chainId === '100') return new JsonRpcProvider(process.env.XDAI_RPC_URL) From a08c1ba065b5b59cc9e3b5d3d81517aa692af4d8 Mon Sep 17 00:00:00 2001 From: patrick Date: Wed, 31 Mar 2021 16:36:29 -0400 Subject: [PATCH 11/23] change bot to admin permissions --- web/src/pages/HomePage/HomePage.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/pages/HomePage/HomePage.js b/web/src/pages/HomePage/HomePage.js index 490b55e..60f4f63 100644 --- a/web/src/pages/HomePage/HomePage.js +++ b/web/src/pages/HomePage/HomePage.js @@ -2,7 +2,7 @@ import { Link, routes } from '@redwoodjs/router' import logo from './logo.png' const ADD_BOT_LINK = - 'https://discord.com/oauth2/authorize?client_id=816782676438417429&scope=bot&permissions=268435456' + 'https://discord.com/oauth2/authorize?client_id=816782676438417429&scope=bot&permissions=8' const HomePage = () => { return ( From 7cf7a8eb7f8ea9a2d26866137b47d52a2b078eb0 Mon Sep 17 00:00:00 2001 From: patrick Date: Fri, 21 May 2021 03:52:04 -0400 Subject: [PATCH 12/23] update env --- api/src/lib/guild.js | 10 ++++++++++ bot/.template.env | 3 ++- redwood.toml | 2 ++ 3 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 api/src/lib/guild.js diff --git a/api/src/lib/guild.js b/api/src/lib/guild.js new file mode 100644 index 0000000..ca9988a --- /dev/null +++ b/api/src/lib/guild.js @@ -0,0 +1,10 @@ +import { db } from './db' + +const fetchGuild = async (data) => { + const { platformId } = data + let guild = await db.guild.findFirst({ where: platformId }) + if (!guild) { + guild = await db.guild.create(data) + } + return guild +} diff --git a/bot/.template.env b/bot/.template.env index 63f91e0..5a3074d 100644 --- a/bot/.template.env +++ b/bot/.template.env @@ -1,3 +1,4 @@ DISCORD_TOKEN= INVOCATION_STRING=!kneel,!unlock -API_URL=http://localhost:8911/graphql +LOGIN_URL=http://0.0.0.0:8910/login +API_URL=http://0.0.0.0:8911/graphql diff --git a/redwood.toml b/redwood.toml index ece1e4e..9072e5d 100644 --- a/redwood.toml +++ b/redwood.toml @@ -7,9 +7,11 @@ [web] port = 8910 + host = "0.0.0.0" apiProxyPath = "/api" [api] port = 8911 + host = "0.0.0.0" schemaPath = "./api/db/schema.prisma" includeEnvironmentVariables=['INFURA_ENDPOINT_KEY','XDAI_RPC_URL', 'COLLAB_LAND_APP_ID', 'COLLAB_LAND_APP_SECRET'] [browser] From c26e0e20acc136b22174aba54a7a44c2ecda6a2a Mon Sep 17 00:00:00 2001 From: patrick Date: Fri, 21 May 2021 04:56:59 -0400 Subject: [PATCH 13/23] fix lock issue --- api/src/lib/helpers.js | 6 +++ api/src/lib/roles.js | 21 +++++--- api/src/lib/token/token.js | 18 ++++--- api/src/lib/unlockProtocol/index.js | 2 +- api/src/lib/unlockProtocol/unlockProtocol.js | 50 ++++++-------------- 5 files changed, 45 insertions(+), 52 deletions(-) create mode 100644 api/src/lib/helpers.js diff --git a/api/src/lib/helpers.js b/api/src/lib/helpers.js new file mode 100644 index 0000000..d698abb --- /dev/null +++ b/api/src/lib/helpers.js @@ -0,0 +1,6 @@ +import { JsonRpcProvider, InfuraProvider } from '@ethersproject/providers' + +export const getProviderByChainId = (chainId) => { + if (chainId === '100') return new JsonRpcProvider(process.env.XDAI_RPC_URL) + return new InfuraProvider(Number(chainId), process.env.INFURA_ENDPOINT_KEY) +} diff --git a/api/src/lib/roles.js b/api/src/lib/roles.js index 19c2846..18cc6ed 100644 --- a/api/src/lib/roles.js +++ b/api/src/lib/roles.js @@ -2,6 +2,7 @@ import { db } from './db' import { checkWorthiness } from './token' export const updateRoles = async ({ platformId, guildId }) => { + console.log('updateRoles() ...') const user = await db.user.findFirst({ where: { platformId } }) const { address: userAddress } = user @@ -10,6 +11,7 @@ export const updateRoles = async ({ platformId, guildId }) => { .roles() await Promise.all( roles.map(async (role, index) => { + console.log('Checking worthiness for role: ', role.name) const token = await db.token.findFirst({ where: { id: role.tokenId } }) const isWorthy = await checkWorthiness({ token, @@ -17,6 +19,7 @@ export const updateRoles = async ({ platformId, guildId }) => { userAddress, }) if (isWorthy) { + console.log('User is worthy') await db.user.update({ where: { platformId }, data: { @@ -24,12 +27,18 @@ export const updateRoles = async ({ platformId, guildId }) => { }, }) } else { - await db.user.update({ - where: { platformId }, - data: { - roles: { disconnect: { id: role.id } }, - }, - }) + console.log('User is not worthy') + try { + await db.user.update({ + where: { platformId }, + data: { + roles: { disconnect: { id: role.id } }, + }, + }) + } catch (e) { + // TODO: remove need for try-catch here. + // console.log(e) + } } }) ) diff --git a/api/src/lib/token/token.js b/api/src/lib/token/token.js index 37e8c09..f9d9f4d 100644 --- a/api/src/lib/token/token.js +++ b/api/src/lib/token/token.js @@ -1,18 +1,16 @@ import { JsonRpcProvider, InfuraProvider } from '@ethersproject/providers' import { Contract } from '@ethersproject/contracts' -import { Bignumber } from '@ethersproject/units' +import { parseUnits } from '@ethersproject/units' + +import { getProviderByChainId } from 'src/lib/helpers' +import { isLockValid } from 'src/lib/unlockProtocol' import erc721Abi from './erc721Abi' import erc20Abi from './erc20Abi' -const getProviderByChainId = (chainId) => { - if (chainId === '100') return new JsonRpcProvider(process.env.XDAI_RPC_URL) - return new InfuraProvider(Number(chainId), process.env.INFURA_ENDPOINT_KEY) -} - export const checkWorthiness = async ({ token, balance, userAddress }) => { const { contractAddress, chainId, tokenId, type } = token - let userBalance + let userBalance = parseUnits('0', 18) const rpcProvider = getProviderByChainId(chainId) if (type === 'erc20') userBalance = await getErc20Balance({ @@ -27,9 +25,9 @@ export const checkWorthiness = async ({ token, balance, userAddress }) => { tokenId, userAddress, }) - return true - // TODO: uncomment - // return userBalance.gte(Bignumber.from(balance)) + if (type === 'unlock') + return await isLockValid({ contractAddress, chainId, userAddress }) + return userBalance.gte(parseUnits(balance.toString(), 18)) } const getErc721Balance = async ({ diff --git a/api/src/lib/unlockProtocol/index.js b/api/src/lib/unlockProtocol/index.js index e88455c..3e85808 100644 --- a/api/src/lib/unlockProtocol/index.js +++ b/api/src/lib/unlockProtocol/index.js @@ -1 +1 @@ -export { fetchUnlockProtocolNfts } from './unlockProtocol' +export { isLockValid } from './unlockProtocol' diff --git a/api/src/lib/unlockProtocol/unlockProtocol.js b/api/src/lib/unlockProtocol/unlockProtocol.js index 0039d52..9603cb4 100644 --- a/api/src/lib/unlockProtocol/unlockProtocol.js +++ b/api/src/lib/unlockProtocol/unlockProtocol.js @@ -1,42 +1,22 @@ import fetch from 'node-fetch' import { Contract } from '@ethersproject/contracts' import CONTRACTS from './contracts' -import { JsonRpcProvider, InfuraProvider } from '@ethersproject/providers' +import { getProviderByChainId } from 'src/lib/helpers' -const URL_COLLAB_LAND_AUTH = - 'https://api-qa.collab.land/client-applications/login-as-user?ttl=500' - -const getProviderByNetworkName = (networkName) => { - if (networkName === 'xdai') - return new JsonRpcProvider(process.env.XDAI_RPC_URL) - return new InfuraProvider(networkName, process.env.INFURA_ENDPOINT_KEY) +const getLockContract = ({ contractAddress, chainId }) => { + return new Contract( + contractAddress, + CONTRACTS.lock.abi, + getProviderByChainId(chainId) + ) } -export const fetchUnlockProtocolNfts = async (userAddress) => { - let nfts = [] - await Promise.all( - Object.keys(CONTRACTS.lock.network).map(async (networkName) => { - const contractAddress = CONTRACTS.lock.network[networkName].address - const lockContract = new Contract( - contractAddress, - CONTRACTS.lock.abi, - getProviderByNetworkName(networkName) - ) - - const hasValidKey = await lockContract.getHasValidKey(userAddress) - if (hasValidKey) { - const tokenId = await lockContract.getTokenIdFor(userAddress) - console.log(`Valid NFT found! Network: ${networkName}, Token ID: ${tokenId.toNumber()}`); - nfts.push({ - website: '', - contractAddress, - tokenId: tokenId.toNumber(), - uri: '', - chainId: CONTRACTS.lock.network[networkName].chainId, - iconUrl: '', - }) - } - }) - ) - return nfts +export const isLockValid = async ({ + contractAddress, + chainId, + userAddress, +}) => { + const lockContract = getLockContract({ contractAddress, chainId }) + const hasValidKey = await lockContract.getHasValidKey(userAddress) + return hasValidKey } From a08608eed4c8e9186b470372d2c920a8d740c6e3 Mon Sep 17 00:00:00 2001 From: patrick Date: Fri, 21 May 2021 05:16:13 -0400 Subject: [PATCH 14/23] update text --- bot/src/textContent.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/src/textContent.js b/bot/src/textContent.js index 86671e8..cc0b6c4 100644 --- a/bot/src/textContent.js +++ b/bot/src/textContent.js @@ -5,7 +5,7 @@ const DISCORD_SUCCESS_START = "Yup, you've got the right stuff!" const DISCORD_SUCCESS_ACTION = '\n✨🗡️ I knight thee with ' const DISCORD_SUCCESS_FINISH = 'Now arise and head back to your guild' -const DISCORD_INSUFFICIENT_BALANCE = `🏳️ Looks like you don't have the right tokens in your wallet.\nYou can try getting them using this link:\n` +const DISCORD_INSUFFICIENT_BALANCE = `🏳️ Looks like you don't have the right tokens in your wallet.\n` const DISCORD_REPLY = `You think you're worthy? Check your DMs` const DISCORD_INITIAL_PROMPT = `⚔️ Ready to be knighted? \n\nTo continue, I need the following permissions: \n-\`Fetch your wallet address from your CollabLand account\` From d89842ec1d208e73b6c956e76232f10a38c29450 Mon Sep 17 00:00:00 2001 From: patrick Date: Mon, 24 May 2021 18:06:12 -0400 Subject: [PATCH 15/23] default new tokens to unlock type --- api/src/services/roles/roles.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/services/roles/roles.js b/api/src/services/roles/roles.js index 81123bb..cadc426 100644 --- a/api/src/services/roles/roles.js +++ b/api/src/services/roles/roles.js @@ -63,7 +63,7 @@ export const updateRoleByBot = async ({ chainId, contractAddress, // TODO: Check contract for token type - type: 'erc721', + type: 'unlock', }, }) } From 0617e198061212763a29d4c9c7f00bd2f7a9f0c2 Mon Sep 17 00:00:00 2001 From: patrick Date: Tue, 31 Aug 2021 22:02:12 -0400 Subject: [PATCH 16/23] convert everything to postMessage() and do some cleanup --- .gitignore | 1 + api/src/graphql/users.sdl.js | 10 +-- bot/src/apiMgr.js | 23 ++++--- bot/src/bot.js | 25 ++++++- bot/src/commands/admin.js | 86 ------------------------- bot/src/commands/common.js | 5 -- bot/src/commands/invoke.js | 54 +++------------- bot/src/commands/notifications.js | 1 + bot/src/constants.js | 5 ++ bot/src/graphql-operations/mutations.js | 17 ++++- bot/src/graphql-operations/queries.js | 49 ++++++-------- bot/src/textContent.js | 3 +- web/src/Routes.js | 9 --- 13 files changed, 94 insertions(+), 194 deletions(-) delete mode 100644 bot/src/commands/admin.js create mode 100644 bot/src/constants.js diff --git a/.gitignore b/.gitignore index e7b00eb..672ca89 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ dist-babel node_modules yarn-error.log web/public/mockServiceWorker.js +notes.md diff --git a/api/src/graphql/users.sdl.js b/api/src/graphql/users.sdl.js index 8f90bf8..1c80aa5 100644 --- a/api/src/graphql/users.sdl.js +++ b/api/src/graphql/users.sdl.js @@ -22,11 +22,6 @@ export const schema = gql` users: [User!]! user(id: String!): User haveUserAddress(platformId: String!): HaveUserAddress - userByPlatformId( - platformId: String! - platform: String! - guildId: String - ): LimitedScopeUser userByDiscordId(discordId: String!): User } @@ -48,5 +43,10 @@ export const schema = gql` updateUser(id: String!, input: UpdateUserInput!): User! deleteUser(id: String!): User! mergeWithUser(id: String!): LimitedScopeUser! + getOrCreateUser( + platformId: String! + platform: String! + guildId: String + ): LimitedScopeUser } ` diff --git a/bot/src/apiMgr.js b/bot/src/apiMgr.js index be6db05..0492584 100644 --- a/bot/src/apiMgr.js +++ b/bot/src/apiMgr.js @@ -5,7 +5,8 @@ const fetch = require('cross-fetch') const { rolesByUserAndGuild, haveUserAddress, - userByPlatformId, + GET_OR_CREATE_USER_MUTATION, + POST_MESSAGE_QUERY, } = require('./graphql-operations/queries') const { updateRole } = require('./graphql-operations/mutations') @@ -31,23 +32,29 @@ class ApiMgr { }) } - async haveUserAddress({ platformId }) { + async postMessage({ message }) { try { const res = await this.client.query({ - query: haveUserAddress, - variables: { platformId }, + query: POST_MESSAGE_QUERY, + variables: { + content: message.content, + platformUserId: message.member.id, + platform: 'discord', + guildId: message.guild.id, + }, }) - return res.data.haveUserAddress.haveUserAddress + return res.data.response } catch (e) { console.log(e) throw new Error(e) } } - + //////////////////////////////////////////// + // TODO: Move to API async userByPlatformId({ platformId, platform, guildId }) { try { - const res = await this.client.query({ - query: userByPlatformId, + const res = await this.client.mutate({ + query: GET_OR_CREATE_USER_MUTATION, variables: { platformId, platform, guildId }, }) return res.data.userByPlatformId diff --git a/bot/src/bot.js b/bot/src/bot.js index ebbd867..c1c6297 100644 --- a/bot/src/bot.js +++ b/bot/src/bot.js @@ -1,25 +1,44 @@ require('dotenv').config() const Discord = require('discord.js') -const { handleInvoke } = require('./commands/invoke') const { handleAdminUpdate } = require('./commands/admin') const { notifyMemberUpdate } = require('./commands/notifications') const { DISCORD_NO_DM_INVOCATION } = require('./textContent') +const ApiMgr = require('../apiMgr') + +const apiMgr = new ApiMgr() const discordClient = new Discord.Client() +const handleInvoke = async (message) => { + console.log(`New invocation from ${message.author.id}`) + try { + // Verify the bot still has role-granting priveledges + if (!message.guild.me.hasPermission(['MANAGE_ROLES'])) + return message.channel.send(DISCORD_INVALID_PERMISSIONS) + + const response = await postMessage({ message }) + if (response) message.reply(response) + } catch (e) { + console.log(e) + message.reply(DISCORD_SERVER_ERROR) + } +} + discordClient.once('ready', async () => { console.log('Ready!') }) discordClient.on('message', async (message) => { if (process.env.INVOCATION_STRING.split(',').includes(message.content)) { - if (message.channel.type == 'dm') + if (message.channel.type == 'dm') { + // Direct messaging the bot won't work - we must know the Guild ID return message.reply(DISCORD_NO_DM_INVOCATION) + } handleInvoke(message) } - if (message.content.startsWith('!add-lock')) handleAdminUpdate(message) }) +// TODO: Remove if unused discordClient.on('guildMemberUpdate', (oldMember, newMember) => { notifyMemberUpdate({ oldMember, newMember }) }) diff --git a/bot/src/commands/admin.js b/bot/src/commands/admin.js deleted file mode 100644 index 1f477e3..0000000 --- a/bot/src/commands/admin.js +++ /dev/null @@ -1,86 +0,0 @@ -const fetch = require('node-fetch') -const ApiMgr = require('../apiMgr') -const apiMgr = new ApiMgr() - -const { - DISCORD_SERVER_ERROR, - DISCORD_INVALID_PERMISSIONS, - INVALID_ETHEREUM_ADDRESS, - ADMIN_PROPER_SYNTAX, -} = require('../textContent') - -const INVALID_NUMBER_OF_PARAMETERS = 'Invalid number of parameters' - -const { verifyInstall, getRoleFromName } = require('./common') - -const parseParams = ({ text }) => { - // !eg. add-lock 0xabc1 100 "role name" url-for-buying" - const initialParams = text.split(' ').slice(0, 4) - const roleName = text.split('"')[1] - if (initialParams.length < 4 || !roleName) - throw Error('#' + INVALID_NUMBER_OF_PARAMETERS) - const purchaseUrl = text.split('"')[2] - const [_, chainId, contractAddress, balance] = initialParams - if (!chainId.match(/^[0-9]+$/)) throw Error('#' + 'Invalid chain ID') - if (!contractAddress.match(/^0x[0-9a-fA-F]{40}$/)) - throw Error('#' + INVALID_ETHEREUM_ADDRESS) - if (!balance.match(/^[0-9\.]+$/)) throw Error('#' + 'Invalid balance') - return { chainId, contractAddress, balance, roleName, purchaseUrl } -} - -const handleAdminUpdate = async (message) => { - try { - const platform = 'discord' - const guild = message.guild - const guildPlatformId = guild.id - const guildName = guild.name - const guildDescription = guild.description - const guildIconUrl = guild.iconURL() - - // Validate parameters - const { - contractAddress, - chainId, - roleName, - balance, - purchaseUrl, - } = parseParams({ - text: message.content, - }) - - // Check bot has proper permissions - verifyInstall(guild) - - // Check if role exists on server - const role = getRoleFromName({ name: roleName, guild }) - - // Update the backend - await apiMgr.updateRole({ - platform, - guildPlatformId, - guildName, - guildDescription, - guildIconUrl, - roleName: roleName, - rolePlatformId: role.id, - roleDescription: '', // maybe unnecessary - balance, - chainId, - contractAddress, - purchaseUrl, - }) - - // Respond with success message - message.react('👌') - } catch (e) { - console.log(e) - if (e.message.startsWith('#')) { - // User error, so reply with message - message.reply(e.message.slice(1)) - return message.channel.send(ADMIN_PROPER_SYNTAX) - } - message.reply(DISCORD_SERVER_ERROR) - } -} - -module.exports = { handleAdminUpdate } diff --git a/bot/src/commands/common.js b/bot/src/commands/common.js index f2cba8b..6e3b5ee 100644 --- a/bot/src/commands/common.js +++ b/bot/src/commands/common.js @@ -3,11 +3,6 @@ const { DISCORD_GUILD_DOESNT_HAVE_ROLE, } = require('../textContent') -const verifyInstall = (guild) => { - if (!guild.me.hasPermission(['MANAGE_ROLES'])) - throw Error('#' + DISCORD_INVALID_PERMISSIONS) -} - const getRoleFromName = ({ name, guild }) => { const role = guild.roles.cache.find((role) => role.name === name) if (!role) diff --git a/bot/src/commands/invoke.js b/bot/src/commands/invoke.js index 439eaf6..4b307ad 100644 --- a/bot/src/commands/invoke.js +++ b/bot/src/commands/invoke.js @@ -1,5 +1,3 @@ -const fetch = require('node-fetch') - const ApiMgr = require('../apiMgr') const apiMgr = new ApiMgr() @@ -21,10 +19,12 @@ const { DISCORD_CHECKING_ACCOUNT, DISCORD_CONTINUE_AUTH, } = require('../textContent') +const { + UNLOCKED_ROLE_BASE, + CHIEV_ROLE_BASE, + LOGIN_UR, +} = require('../constants') -const UNLOCKED_ROLE_BASE = 'Unlocked-Holder' -const CHIEV_ROLE_BASE = 'one-snoo-club' -const LOGIN_URL = `${process.env.LOGIN_URL}?id=` const checkNftAndAssignRoles = async ({ message, guildMember, guild }) => { try { await message.reply(DISCORD_GETTING_ROLES) @@ -101,13 +101,14 @@ const doCollabAuth = async ({ message, lastBotMessage }) => { } const doSwordyAuth = async ({ message, guildMember, guild }) => { - const user = await apiMgr.userByPlatformId({ + // Create the user in the database + const { id: userId } = await apiMgr.userByPlatformId({ platformId: guildMember.id, platform: 'discord', guildId: guild.id, }) const prompt = await message.reply( - DISCORD_INITIAL_AUTH + LOGIN_URL + user.id + DISCORD_CONTINUE_AUTH + DISCORD_INITIAL_AUTH + LOGIN_URL + userId + DISCORD_CONTINUE_AUTH ) await prompt.react('✅') const filter = (reaction, user) => { @@ -129,43 +130,4 @@ const doSwordyAuth = async ({ message, guildMember, guild }) => { }) } -const handleInvoke = async (message) => { - console.log(`New message from ${message.author.id}`) - try { - const guild = message.guild - const guildMember = message.member - - // Check bot is set up properly - if (!message.guild.me.hasPermission(['MANAGE_ROLES'])) - return message.channel.send(DISCORD_INVALID_PERMISSIONS) - - // Tell user to check DMs - message.reply(DISCORD_REPLY) - // Start DM with user - let lastBotMessage = await message.author.send(DISCORD_CHECKING_ACCOUNT) - - const haveUserAddress = await apiMgr.haveUserAddress({ - platformId: message.author.id, - }) - if (haveUserAddress) - return checkNftAndAssignRoles({ - message: lastBotMessage, - guildMember, - guild, - }) - - // Otherwise do auth flow - // Callback from auth flow will trigger the next step - // return doCollabAuth({message, lastBotMessage}) - return doSwordyAuth({ - message: lastBotMessage, - guildMember, - guild, - }) - } catch (e) { - console.log(e) - message.reply(DISCORD_SERVER_ERROR) - } -} - module.exports = { handleInvoke } diff --git a/bot/src/commands/notifications.js b/bot/src/commands/notifications.js index 5b27d0b..39165ce 100644 --- a/bot/src/commands/notifications.js +++ b/bot/src/commands/notifications.js @@ -1,3 +1,4 @@ +// TODO: Remove if unused const notifyMemberUpdate = async ({ oldMember, newMember }) => { // If the role(s) are present on the old member object but no longer on the new one (i.e role(s) were removed) diff --git a/bot/src/constants.js b/bot/src/constants.js new file mode 100644 index 0000000..53eb20f --- /dev/null +++ b/bot/src/constants.js @@ -0,0 +1,5 @@ +const UNLOCKED_ROLE_BASE = 'Unlocked-Holder' +const CHIEV_ROLE_BASE = 'one-snoo-club' +const LOGIN_URL = `${process.env.LOGIN_URL}?id=` + +module.exports = { UNLOCKED_ROLE_BASE, CHIEV_ROLE_BASE, LOGIN_URL } diff --git a/bot/src/graphql-operations/mutations.js b/bot/src/graphql-operations/mutations.js index c83a6fb..0b47754 100644 --- a/bot/src/graphql-operations/mutations.js +++ b/bot/src/graphql-operations/mutations.js @@ -1,5 +1,20 @@ const gql = require('graphql-tag') +///////////////////////////////// +// TODO: Move to API or delete + +const GET_OR_CREATE_USER_MUTATION = gql` + query USER($platformId: String!, $platform: String!, $guildId: String) { + getOrCreateUser( + platformId: $platformId + platform: $platform + guildId: $guildId + ) { + id + } + } +` + const updateRole = gql` mutation UPDATE_ROLE_BY_BOT( $platform: String! @@ -33,4 +48,4 @@ const updateRole = gql` } } ` -module.exports = { updateRole } +module.exports = { updateRole, GET_OR_CREATE_USER_MUTATION } diff --git a/bot/src/graphql-operations/queries.js b/bot/src/graphql-operations/queries.js index 2715685..e3c67d4 100644 --- a/bot/src/graphql-operations/queries.js +++ b/bot/src/graphql-operations/queries.js @@ -1,22 +1,24 @@ const gql = require('graphql-tag') -// Old way - deleteme -// const userByDiscordId = gql` -// query USER_BY_DISCORD_ID($discordId: String!) { -// userByDiscordId(discordId: $discordId) { -// id -// nfts { -// website -// contractAddress -// tokenId -// uri -// chainId -// iconUrl -// } -// } -// } -// ` - +const POST_MESSAGE_QUERY = gql` + query POST_MESSAGE_QUERY( + $content: String! + $platformUserId: String! + $platform: String! + $guildId: String! + ) { + haveUserAddress( + content: $content + platformUserId: $platformUserId + platform: $platform + guildId: $guildId + ) { + response + } + } +` +///////////////////////////////// +// TODO: Move to API or delete const haveUserAddress = gql` query HAVE_USER_ADDRESS($platformId: String!) { haveUserAddress(platformId: $platformId) { @@ -24,17 +26,6 @@ const haveUserAddress = gql` } } ` -const userByPlatformId = gql` - query USER($platformId: String!, $platform: String!, $guildId: String) { - userByPlatformId( - platformId: $platformId - platform: $platform - guildId: $guildId - ) { - id - } - } -` const rolesByUserAndGuild = gql` query ROLES_BY_USER_AND_GUILD($guildId: String!, $platformId: String!) { @@ -56,4 +47,4 @@ const rolesByUserAndGuild = gql` } } ` -module.exports = { rolesByUserAndGuild, haveUserAddress, userByPlatformId } +module.exports = { rolesByUserAndGuild, haveUserAddress, POST_MESSAGE_QUERY } diff --git a/bot/src/textContent.js b/bot/src/textContent.js index cc0b6c4..557cd7f 100644 --- a/bot/src/textContent.js +++ b/bot/src/textContent.js @@ -6,7 +6,6 @@ const DISCORD_SUCCESS_ACTION = '\n✨🗡️ I knight thee with ' const DISCORD_SUCCESS_FINISH = 'Now arise and head back to your guild' const DISCORD_INSUFFICIENT_BALANCE = `🏳️ Looks like you don't have the right tokens in your wallet.\n` -const DISCORD_REPLY = `You think you're worthy? Check your DMs` const DISCORD_INITIAL_PROMPT = `⚔️ Ready to be knighted? \n\nTo continue, I need the following permissions: \n-\`Fetch your wallet address from your CollabLand account\` \n If you agree, click the ✅` @@ -24,10 +23,10 @@ const DISCORD_GUILD_DOESNT_HAVE_ROLE = `This role does not exist. Please create const INVALID_ETHEREUM_ADDRESS = 'The ethereum address you provided is invalid.' const ADMIN_PROPER_SYNTAX = 'The proper syntax is: \n` `\neg. `100 0x30D7b586F4fbd52ce164C1c204DD33BE49F53c7B 1 unlock-holder https://unlock-protocol.com/myNft`' -const DISCORD_CHECKING_ACCOUNT = 'Looking up your account, one moment...' const DISCORD_NO_DM_INVOCATION = 'Sorry, you can only do that from a server' const DISCORD_CONTINUE_AUTH = "\n\nAfter you're done, click the ✅ emoji to continue." + module.exports = { DISCORD_SERVER_ERROR, DISCORD_SUCCESS_START, diff --git a/web/src/Routes.js b/web/src/Routes.js index 5895585..831d535 100644 --- a/web/src/Routes.js +++ b/web/src/Routes.js @@ -1,12 +1,3 @@ -// In this file, all Page components from 'src/pages` are auto-imported. Nested -// directories are supported, and should be uppercase. Each subdirectory will be -// prepended onto the component name. -// -// Examples: -// -// 'src/pages/HomePage/HomePage.js' -> HomePage -// 'src/pages/Admin/BooksPage/BooksPage.js' -> AdminBooksPage - import { Router, Route, Private } from '@redwoodjs/router' const Routes = () => { From 239417edc750868461eaa0baae3af9ad6a0e1490 Mon Sep 17 00:00:00 2001 From: patrick Date: Tue, 31 Aug 2021 22:21:12 -0400 Subject: [PATCH 17/23] add handleMessage to /api --- .env.template | 2 + api/src/graphql/bot.js | 13 +++ api/src/graphql/users.sdl.js | 5 - api/src/lib/bot/bot.js | 29 +++++ api/src/lib/bot/constants.js | 7 ++ api/src/lib/bot/oldFiles/apiMgr.js | 109 ++++++++++++++++++ .../src/lib/bot/oldFiles}/common.js | 0 .../src/lib/bot/oldFiles}/invoke.js | 0 .../src/lib/bot/oldFiles}/mutations.js | 0 .../src/lib/bot/oldFiles}/queries.js | 0 .../src/lib/bot/oldFiles}/textContent.js | 2 - api/src/services/bot/bot.js | 6 + api/src/services/users/users.js | 18 --- bot/src/apiMgr.js | 63 +--------- bot/src/commands/notifications.js | 26 ----- bot/src/constants.js | 5 - bot/src/queries.js | 20 ++++ 17 files changed, 187 insertions(+), 118 deletions(-) create mode 100644 api/src/graphql/bot.js create mode 100644 api/src/lib/bot/bot.js create mode 100644 api/src/lib/bot/constants.js create mode 100644 api/src/lib/bot/oldFiles/apiMgr.js rename {bot/src/commands => api/src/lib/bot/oldFiles}/common.js (100%) rename {bot/src/commands => api/src/lib/bot/oldFiles}/invoke.js (100%) rename {bot/src/graphql-operations => api/src/lib/bot/oldFiles}/mutations.js (100%) rename {bot/src/graphql-operations => api/src/lib/bot/oldFiles}/queries.js (100%) rename {bot/src => api/src/lib/bot/oldFiles}/textContent.js (96%) create mode 100644 api/src/services/bot/bot.js delete mode 100644 bot/src/commands/notifications.js delete mode 100644 bot/src/constants.js create mode 100644 bot/src/queries.js diff --git a/.env.template b/.env.template index 80c5edb..f856593 100644 --- a/.env.template +++ b/.env.template @@ -4,4 +4,6 @@ ETHEREUM_JWT_SECRET= XDAI_RPC_URL= INFURA_ENDPOINT_KEY= +LOGIN_URL=http://0.0.0.0:8910/login + #DATABASE_URL=file:./dev.db diff --git a/api/src/graphql/bot.js b/api/src/graphql/bot.js new file mode 100644 index 0000000..1126637 --- /dev/null +++ b/api/src/graphql/bot.js @@ -0,0 +1,13 @@ +export const schema = gql` + type Response { + response: String! + } + type Query { + postMessage( + content: String! + platformUserId: String! + platform: String! + guildId: String! + ): Response! + } +` diff --git a/api/src/graphql/users.sdl.js b/api/src/graphql/users.sdl.js index 1c80aa5..5610635 100644 --- a/api/src/graphql/users.sdl.js +++ b/api/src/graphql/users.sdl.js @@ -43,10 +43,5 @@ export const schema = gql` updateUser(id: String!, input: UpdateUserInput!): User! deleteUser(id: String!): User! mergeWithUser(id: String!): LimitedScopeUser! - getOrCreateUser( - platformId: String! - platform: String! - guildId: String - ): LimitedScopeUser } ` diff --git a/api/src/lib/bot/bot.js b/api/src/lib/bot/bot.js new file mode 100644 index 0000000..0c64a17 --- /dev/null +++ b/api/src/lib/bot/bot.js @@ -0,0 +1,29 @@ +import { db } from 'src/lib/db' + +import { LOGIN_URL, DISCORD_INITIAL_AUTH } from 'src/lib/bot/constants' + +export const handleMessage = async ({ + content, + platformUserId, + platform, + guildId, +}) => { + // Create the user in the database + const user = await db.user.upsert({ + where: { platformId: platformUserId }, + create: { + platformId: platformUserId, + platform, + currentSessionGuild: { + connect: { platformId: guildId }, + }, + }, + update: { + currentSessionGuild: { + connect: { platformId: guildId }, + }, + }, + }) + // Return the unique URL for the response + return DISCORD_INITIAL_AUTH + LOGIN_URL + user.id +} diff --git a/api/src/lib/bot/constants.js b/api/src/lib/bot/constants.js new file mode 100644 index 0000000..cb1502f --- /dev/null +++ b/api/src/lib/bot/constants.js @@ -0,0 +1,7 @@ +export const UNLOCKED_ROLE_BASE = 'Unlocked-Holder' +export const CHIEV_ROLE_BASE = 'one-snoo-club' +export const LOGIN_URL = `${process.env.LOGIN_URL}?id=` + +export const DISCORD_INITIAL_AUTH = `⚔️ Ready to be knighted? \n\nJust one more thing I need from you: +\n-\`Ethereum wallet address\` +\n Click the link to login using your wallet:\n` diff --git a/api/src/lib/bot/oldFiles/apiMgr.js b/api/src/lib/bot/oldFiles/apiMgr.js new file mode 100644 index 0000000..0492584 --- /dev/null +++ b/api/src/lib/bot/oldFiles/apiMgr.js @@ -0,0 +1,109 @@ +const { ApolloClient } = require('apollo-client') +const { InMemoryCache } = require('apollo-cache-inmemory') +const { HttpLink } = require('apollo-link-http') +const fetch = require('cross-fetch') +const { + rolesByUserAndGuild, + haveUserAddress, + GET_OR_CREATE_USER_MUTATION, + POST_MESSAGE_QUERY, +} = require('./graphql-operations/queries') +const { updateRole } = require('./graphql-operations/mutations') + +class ApiMgr { + constructor() { + const debug = true + const cache = new InMemoryCache() + const link = new HttpLink({ + uri: process.env.API_URL, + fetch, + }) + this.client = new ApolloClient({ + link, + cache, + onError: (e) => { + debug && console.log(e) + }, + defaultOptions: { + query: { + fetchPolicy: 'network-only', + }, + }, + }) + } + + async postMessage({ message }) { + try { + const res = await this.client.query({ + query: POST_MESSAGE_QUERY, + variables: { + content: message.content, + platformUserId: message.member.id, + platform: 'discord', + guildId: message.guild.id, + }, + }) + return res.data.response + } catch (e) { + console.log(e) + throw new Error(e) + } + } + //////////////////////////////////////////// + // TODO: Move to API + async userByPlatformId({ platformId, platform, guildId }) { + try { + const res = await this.client.mutate({ + query: GET_OR_CREATE_USER_MUTATION, + variables: { platformId, platform, guildId }, + }) + return res.data.userByPlatformId + } catch (e) { + console.log(e) + throw new Error(e) + } + } + + async getRolesByUserAndGuild({ platformId, guildId }) { + if (!guildId || !platformId) throw new Error('no platformId or guildId') + try { + // TODO: send all guild roles, so we can remove them from db if they were deleted + const res = await this.client.query({ + query: rolesByUserAndGuild, + variables: { platformId, guildId }, + }) + return { roles: res.data.rolesByUserAndGuild } + } catch (e) { + console.log(e) + throw new Error(e) + } + } + + async getNfts(discordId) { + if (!discordId) throw new Error('no discordId') + try { + const user = await this.client.query({ + query: userByDiscordId, + variables: { discordId }, + }) + return { nfts: user.data.userByDiscordId.nfts } + } catch (e) { + console.log(e) + throw new Error(e) + } + } + + async updateRole(input) { + try { + await this.client.mutate({ + mutation: updateRole, + variables: { ...input }, + }) + } catch (e) { + console.log(e) + throw new Error(e) + } + } +} + +module.exports = ApiMgr diff --git a/bot/src/commands/common.js b/api/src/lib/bot/oldFiles/common.js similarity index 100% rename from bot/src/commands/common.js rename to api/src/lib/bot/oldFiles/common.js diff --git a/bot/src/commands/invoke.js b/api/src/lib/bot/oldFiles/invoke.js similarity index 100% rename from bot/src/commands/invoke.js rename to api/src/lib/bot/oldFiles/invoke.js diff --git a/bot/src/graphql-operations/mutations.js b/api/src/lib/bot/oldFiles/mutations.js similarity index 100% rename from bot/src/graphql-operations/mutations.js rename to api/src/lib/bot/oldFiles/mutations.js diff --git a/bot/src/graphql-operations/queries.js b/api/src/lib/bot/oldFiles/queries.js similarity index 100% rename from bot/src/graphql-operations/queries.js rename to api/src/lib/bot/oldFiles/queries.js diff --git a/bot/src/textContent.js b/api/src/lib/bot/oldFiles/textContent.js similarity index 96% rename from bot/src/textContent.js rename to api/src/lib/bot/oldFiles/textContent.js index 557cd7f..b29b020 100644 --- a/bot/src/textContent.js +++ b/api/src/lib/bot/oldFiles/textContent.js @@ -24,8 +24,6 @@ const INVALID_ETHEREUM_ADDRESS = 'The ethereum address you provided is invalid.' const ADMIN_PROPER_SYNTAX = 'The proper syntax is: \n` `\neg. `100 0x30D7b586F4fbd52ce164C1c204DD33BE49F53c7B 1 unlock-holder https://unlock-protocol.com/myNft`' const DISCORD_NO_DM_INVOCATION = 'Sorry, you can only do that from a server' -const DISCORD_CONTINUE_AUTH = - "\n\nAfter you're done, click the ✅ emoji to continue." module.exports = { DISCORD_SERVER_ERROR, diff --git a/api/src/services/bot/bot.js b/api/src/services/bot/bot.js new file mode 100644 index 0000000..7649500 --- /dev/null +++ b/api/src/services/bot/bot.js @@ -0,0 +1,6 @@ +import { handleMessage } from 'src/lib/bot/bot' + +export const postMessage = async ({ input }) => { + const response = await handleMessage(input) + return response +} diff --git a/api/src/services/users/users.js b/api/src/services/users/users.js index 7094920..576b38c 100644 --- a/api/src/services/users/users.js +++ b/api/src/services/users/users.js @@ -30,24 +30,6 @@ export const mergeWithUser = async ({ id }) => { return { id: user.id } } -export const userByPlatformId = async ({ platformId, platform, guildId }) => { - return db.user.upsert({ - where: { platformId }, - create: { - platformId, - platform, - currentSessionGuild: { - connect: { platformId: guildId }, - }, - }, - update: { - currentSessionGuild: { - connect: { platformId: guildId }, - }, - }, - }) -} - export const haveUserAddress = async ({ platformId }) => { let haveUserAddress = false const user = await db.user.findOne({ where: { platformId } }) diff --git a/bot/src/apiMgr.js b/bot/src/apiMgr.js index 0492584..bb65e32 100644 --- a/bot/src/apiMgr.js +++ b/bot/src/apiMgr.js @@ -2,13 +2,7 @@ const { ApolloClient } = require('apollo-client') const { InMemoryCache } = require('apollo-cache-inmemory') const { HttpLink } = require('apollo-link-http') const fetch = require('cross-fetch') -const { - rolesByUserAndGuild, - haveUserAddress, - GET_OR_CREATE_USER_MUTATION, - POST_MESSAGE_QUERY, -} = require('./graphql-operations/queries') -const { updateRole } = require('./graphql-operations/mutations') +const { POST_MESSAGE_QUERY } = require('./queries') class ApiMgr { constructor() { @@ -49,61 +43,6 @@ class ApiMgr { throw new Error(e) } } - //////////////////////////////////////////// - // TODO: Move to API - async userByPlatformId({ platformId, platform, guildId }) { - try { - const res = await this.client.mutate({ - query: GET_OR_CREATE_USER_MUTATION, - variables: { platformId, platform, guildId }, - }) - return res.data.userByPlatformId - } catch (e) { - console.log(e) - throw new Error(e) - } - } - - async getRolesByUserAndGuild({ platformId, guildId }) { - if (!guildId || !platformId) throw new Error('no platformId or guildId') - try { - // TODO: send all guild roles, so we can remove them from db if they were deleted - const res = await this.client.query({ - query: rolesByUserAndGuild, - variables: { platformId, guildId }, - }) - return { roles: res.data.rolesByUserAndGuild } - } catch (e) { - console.log(e) - throw new Error(e) - } - } - - async getNfts(discordId) { - if (!discordId) throw new Error('no discordId') - try { - const user = await this.client.query({ - query: userByDiscordId, - variables: { discordId }, - }) - return { nfts: user.data.userByDiscordId.nfts } - } catch (e) { - console.log(e) - throw new Error(e) - } - } - - async updateRole(input) { - try { - await this.client.mutate({ - mutation: updateRole, - variables: { ...input }, - }) - } catch (e) { - console.log(e) - throw new Error(e) - } - } } module.exports = ApiMgr diff --git a/bot/src/commands/notifications.js b/bot/src/commands/notifications.js deleted file mode 100644 index 39165ce..0000000 --- a/bot/src/commands/notifications.js +++ /dev/null @@ -1,26 +0,0 @@ -// TODO: Remove if unused -const notifyMemberUpdate = async ({ oldMember, newMember }) => { - // If the role(s) are present on the old member object but no longer on the new one (i.e role(s) were removed) - - const removedRoles = oldMember.roles.cache.filter( - (role) => !newMember.roles.cache.has(role.id) - ) - if (removedRoles.size > 0) - console.log( - `The roles ${removedRoles.map((r) => r.name)} were removed from ${ - oldMember.displayName - }.` - ) - // If the role(s) are present on the new member object but are not on the old one (i.e role(s) were added) - const addedRoles = newMember.roles.cache.filter( - (role) => !oldMember.roles.cache.has(role.id) - ) - if (addedRoles.size > 0) - console.log( - `The roles ${addedRoles.map((r) => r.name)} were added to ${ - oldMember.displayName - }.` - ) -} - -module.exports = { notifyMemberUpdate } diff --git a/bot/src/constants.js b/bot/src/constants.js deleted file mode 100644 index 53eb20f..0000000 --- a/bot/src/constants.js +++ /dev/null @@ -1,5 +0,0 @@ -const UNLOCKED_ROLE_BASE = 'Unlocked-Holder' -const CHIEV_ROLE_BASE = 'one-snoo-club' -const LOGIN_URL = `${process.env.LOGIN_URL}?id=` - -module.exports = { UNLOCKED_ROLE_BASE, CHIEV_ROLE_BASE, LOGIN_URL } diff --git a/bot/src/queries.js b/bot/src/queries.js new file mode 100644 index 0000000..5f71277 --- /dev/null +++ b/bot/src/queries.js @@ -0,0 +1,20 @@ +const gql = require('graphql-tag') + +const POST_MESSAGE_QUERY = gql` + query POST_MESSAGE_QUERY( + $content: String! + $platformUserId: String! + $platform: String! + $guildId: String! + ) { + haveUserAddress( + content: $content + platformUserId: $platformUserId + platform: $platform + guildId: $guildId + ) { + response + } + } +` +module.exports = { POST_MESSAGE_QUERY } From 757d4ce9b8de289befff6bd8de7d425a5d2eae88 Mon Sep 17 00:00:00 2001 From: patrick Date: Wed, 1 Sep 2021 13:53:17 -0400 Subject: [PATCH 18/23] ephemeralId --- api/db/schema.prisma | 1 + api/src/lib/bot/bot.js | 8 +++- api/src/lib/bot/constants.js | 2 +- api/src/services/users/users.js | 14 +++++-- web/src/Routes.js | 2 + web/src/pages/LoginPage/LoginPage.js | 46 +++++++++++----------- web/src/pages/UserPage/UserPage.js | 18 +++++++++ web/src/pages/UserPage/UserPage.stories.js | 7 ++++ web/src/pages/UserPage/UserPage.test.js | 11 ++++++ 9 files changed, 80 insertions(+), 29 deletions(-) create mode 100644 web/src/pages/UserPage/UserPage.js create mode 100644 web/src/pages/UserPage/UserPage.stories.js create mode 100644 web/src/pages/UserPage/UserPage.test.js diff --git a/api/db/schema.prisma b/api/db/schema.prisma index f614ab7..84afe57 100644 --- a/api/db/schema.prisma +++ b/api/db/schema.prisma @@ -23,6 +23,7 @@ model User { guilds Guild[] @relation(name: "userGuilds") roles Role[] @relation(name: "userRoles") currentSessionGuild Guild? + ephemeralId String? } model Guild { diff --git a/api/src/lib/bot/bot.js b/api/src/lib/bot/bot.js index 0c64a17..2526f6d 100644 --- a/api/src/lib/bot/bot.js +++ b/api/src/lib/bot/bot.js @@ -1,5 +1,5 @@ import { db } from 'src/lib/db' - +import { v4 as uuidv4 } from 'uuid' import { LOGIN_URL, DISCORD_INITIAL_AUTH } from 'src/lib/bot/constants' export const handleMessage = async ({ @@ -8,7 +8,9 @@ export const handleMessage = async ({ platform, guildId, }) => { + // TODO: is this always an invocation? // Create the user in the database + const ephemeralId = uuidv4() const user = await db.user.upsert({ where: { platformId: platformUserId }, create: { @@ -17,13 +19,15 @@ export const handleMessage = async ({ currentSessionGuild: { connect: { platformId: guildId }, }, + ephemeralId, }, update: { currentSessionGuild: { connect: { platformId: guildId }, }, + ephemeralId, }, }) // Return the unique URL for the response - return DISCORD_INITIAL_AUTH + LOGIN_URL + user.id + return DISCORD_INITIAL_AUTH + LOGIN_URL + ephemeralId } diff --git a/api/src/lib/bot/constants.js b/api/src/lib/bot/constants.js index cb1502f..a9398d0 100644 --- a/api/src/lib/bot/constants.js +++ b/api/src/lib/bot/constants.js @@ -1,6 +1,6 @@ export const UNLOCKED_ROLE_BASE = 'Unlocked-Holder' export const CHIEV_ROLE_BASE = 'one-snoo-club' -export const LOGIN_URL = `${process.env.LOGIN_URL}?id=` +export const LOGIN_URL = `${process.env.LOGIN_URL}?ephemeralId=` export const DISCORD_INITIAL_AUTH = `⚔️ Ready to be knighted? \n\nJust one more thing I need from you: \n-\`Ethereum wallet address\` diff --git a/api/src/services/users/users.js b/api/src/services/users/users.js index 576b38c..53594f2 100644 --- a/api/src/services/users/users.js +++ b/api/src/services/users/users.js @@ -15,10 +15,16 @@ export const user = ({ id }) => { }) } -export const mergeWithUser = async ({ id }) => { - const temporaryUser = await db.user.findOne({ where: { id } }) - if (!temporaryUser) throw Error('User with that ID was not found') - await db.user.delete({ where: { id } }) +export const loginSuccess = async ({ ephemeralId }) => { + // TODO: Get user ephemeralId from JWT auth + + // Remove the ephemeralId from the user + const user = await db.user.update({ + where: { address }, + data: { + ephemeralId: null, + }, + }) const { platformId, platform } = temporaryUser // Merge the temporary user with the current one diff --git a/web/src/Routes.js b/web/src/Routes.js index 831d535..c103e06 100644 --- a/web/src/Routes.js +++ b/web/src/Routes.js @@ -3,9 +3,11 @@ import { Router, Route, Private } from '@redwoodjs/router' const Routes = () => { return ( + + ) diff --git a/web/src/pages/LoginPage/LoginPage.js b/web/src/pages/LoginPage/LoginPage.js index e92d639..fe34d6f 100644 --- a/web/src/pages/LoginPage/LoginPage.js +++ b/web/src/pages/LoginPage/LoginPage.js @@ -1,5 +1,5 @@ import { Link, routes, navigate } from '@redwoodjs/router' -import { useMutation } from '@redwoodjs/web' +import { useQuery } from '@redwoodjs/web' import { useAuth } from '@redwoodjs/auth' import { useParams } from '@redwoodjs/router' @@ -8,27 +8,28 @@ const LOADING = 'loading' const COMPLETE = 'complete' const ERROR = 'error' -const MERGE_WITH_USER = gql` - mutation mergeWithUser($id: String!) { - mergeWithUser(id: $id) { - id +const LOGIN_SUCCESS_QUERY = gql` + query loginSuccess($address: String!) { + loginSuccess(address: $address) { + address } } ` const LoginPage = () => { const [status, setStatus] = React.useState(READY) - const { logIn, logOut, isAuthenticated, loading } = useAuth() - const { id } = useParams() + const { logIn, logOut, isAuthenticated, loading, currentUser } = useAuth() + const { ephemeralId } = useParams() - const [mergeWithUser, { _, error: mergeError }] = useMutation( - MERGE_WITH_USER, - { - onCompleted: () => { - setStatus(COMPLETE) - }, - } - ) + const [loginSuccess, { _, error: queryError }] = useQuery({ + LOGIN_SUCCESS_QUERY, + onCompleted: () => { + setStatus(COMPLETE) + setTimeout(function () { + navigate(routes.user({ address: currentUser?.address })) + }, 5000) + }, + }) const onLogIn = async (type) => { setStatus(LOADING) @@ -36,7 +37,7 @@ const LoginPage = () => { await logIn(type) if (id) { // TODO: if it makes sense, move this to backend - await mergeWithUser({ variables: { id } }) + await loginSuccess({ variables: { address: currentUser.address } }) } // send mutation with id } catch (e) { @@ -49,16 +50,17 @@ const LoginPage = () => { } const renderCallToAction = () => { - if (mergeError) + if (queryError) return (

- We had a problem! Please contact us if this keeps happening. + We had a problem! Please let us know in our Discord server if this + keeps happening.

) - if (!id) + if (!ephemeralId) return (

- Uh oh! Looks like your url is missing some things. Please start over. + Uh oh! Looks like your url is missing an ID value. Please start over.

) // Happy case @@ -84,7 +86,7 @@ const LoginPage = () => { ) : (

- 🎉 Done! Close this page and check your DMs. + 🎉 Login complete! Redirecting you to your profile now...

)} @@ -95,7 +97,7 @@ const LoginPage = () => { <>

- 👋 Welcome + 👋 Hello traveler! Let's check if you're worthy 🔮

Sign-in with your wallet

{renderCallToAction()} diff --git a/web/src/pages/UserPage/UserPage.js b/web/src/pages/UserPage/UserPage.js new file mode 100644 index 0000000..fbb6578 --- /dev/null +++ b/web/src/pages/UserPage/UserPage.js @@ -0,0 +1,18 @@ +import { Link, routes } from '@redwoodjs/router' + +const UserPage = () => { + return ( + <> +

UserPage

+

+ Find me in ./web/src/pages/UserPage/UserPage.js +

+

+ My default route is named user, link to me with ` + User` +

+ + ) +} + +export default UserPage diff --git a/web/src/pages/UserPage/UserPage.stories.js b/web/src/pages/UserPage/UserPage.stories.js new file mode 100644 index 0000000..67d8729 --- /dev/null +++ b/web/src/pages/UserPage/UserPage.stories.js @@ -0,0 +1,7 @@ +import UserPage from './UserPage' + +export const generated = () => { + return +} + +export default { title: 'Pages/UserPage' } diff --git a/web/src/pages/UserPage/UserPage.test.js b/web/src/pages/UserPage/UserPage.test.js new file mode 100644 index 0000000..fa21776 --- /dev/null +++ b/web/src/pages/UserPage/UserPage.test.js @@ -0,0 +1,11 @@ +import { render } from '@redwoodjs/testing' + +import UserPage from './UserPage' + +describe('UserPage', () => { + it('renders successfully', () => { + expect(() => { + render() + }).not.toThrow() + }) +}) From b48b36d8a447e8c819769bf93588cce1832712cd Mon Sep 17 00:00:00 2001 From: patrick Date: Wed, 1 Sep 2021 14:03:35 -0400 Subject: [PATCH 19/23] add response types --- api/src/graphql/bot.js | 4 +- api/src/lib/bot/bot.js | 6 +- api/src/services/bot/bot.js | 5 +- api/src/services/users/users.js | 2 +- bot/src/apiMgr.js | 2 +- bot/src/bot.js | 6 +- .../components/EditGameCell/EditGameCell.js | 63 ------ .../components/EditUserCell/EditUserCell.js | 47 ---- web/src/components/Game/Game.js | 211 ------------------ web/src/components/GameCard/GameCard.js | 101 --------- .../components/GameCard/GameCard.stories.js | 21 -- web/src/components/GameCell/GameCell.js | 31 --- web/src/components/GameForm/GameForm.js | 50 ----- web/src/components/GamesCell/GamesCell.js | 65 ------ web/src/components/NewGame/NewGame.js | 40 ---- web/src/components/NewUser/NewUser.js | 40 ---- web/src/components/Pagination/Pagination.js | 30 --- .../Pagination/Pagination.stories.js | 7 - .../components/Pagination/Pagination.test.js | 11 - web/src/components/UserForm/UserForm.js | 67 ------ 20 files changed, 15 insertions(+), 794 deletions(-) delete mode 100644 web/src/components/EditGameCell/EditGameCell.js delete mode 100644 web/src/components/EditUserCell/EditUserCell.js delete mode 100644 web/src/components/Game/Game.js delete mode 100644 web/src/components/GameCard/GameCard.js delete mode 100644 web/src/components/GameCard/GameCard.stories.js delete mode 100644 web/src/components/GameCell/GameCell.js delete mode 100644 web/src/components/GameForm/GameForm.js delete mode 100644 web/src/components/GamesCell/GamesCell.js delete mode 100644 web/src/components/NewGame/NewGame.js delete mode 100644 web/src/components/NewUser/NewUser.js delete mode 100644 web/src/components/Pagination/Pagination.js delete mode 100644 web/src/components/Pagination/Pagination.stories.js delete mode 100644 web/src/components/Pagination/Pagination.test.js delete mode 100644 web/src/components/UserForm/UserForm.js diff --git a/api/src/graphql/bot.js b/api/src/graphql/bot.js index 1126637..0799b22 100644 --- a/api/src/graphql/bot.js +++ b/api/src/graphql/bot.js @@ -1,6 +1,8 @@ export const schema = gql` type Response { - response: String! + type: String! + text: String + url: String } type Query { postMessage( diff --git a/api/src/lib/bot/bot.js b/api/src/lib/bot/bot.js index 2526f6d..84676fc 100644 --- a/api/src/lib/bot/bot.js +++ b/api/src/lib/bot/bot.js @@ -29,5 +29,9 @@ export const handleMessage = async ({ }, }) // Return the unique URL for the response - return DISCORD_INITIAL_AUTH + LOGIN_URL + ephemeralId + return { + text: DISCORD_INITIAL_AUTH + LOGIN_URL + ephemeralId, + type: 'reply', + url: null, + } } diff --git a/api/src/services/bot/bot.js b/api/src/services/bot/bot.js index 7649500..a8a1f6b 100644 --- a/api/src/services/bot/bot.js +++ b/api/src/services/bot/bot.js @@ -1,6 +1,3 @@ import { handleMessage } from 'src/lib/bot/bot' -export const postMessage = async ({ input }) => { - const response = await handleMessage(input) - return response -} +export const postMessage = ({ input }) => handleMessage(input) diff --git a/api/src/services/users/users.js b/api/src/services/users/users.js index 53594f2..82ec791 100644 --- a/api/src/services/users/users.js +++ b/api/src/services/users/users.js @@ -20,7 +20,7 @@ export const loginSuccess = async ({ ephemeralId }) => { // Remove the ephemeralId from the user const user = await db.user.update({ - where: { address }, + where: { ephemeralId }, data: { ephemeralId: null, }, diff --git a/bot/src/apiMgr.js b/bot/src/apiMgr.js index bb65e32..ea53a83 100644 --- a/bot/src/apiMgr.js +++ b/bot/src/apiMgr.js @@ -37,7 +37,7 @@ class ApiMgr { guildId: message.guild.id, }, }) - return res.data.response + return res.data } catch (e) { console.log(e) throw new Error(e) diff --git a/bot/src/bot.js b/bot/src/bot.js index c1c6297..219c057 100644 --- a/bot/src/bot.js +++ b/bot/src/bot.js @@ -16,8 +16,10 @@ const handleInvoke = async (message) => { if (!message.guild.me.hasPermission(['MANAGE_ROLES'])) return message.channel.send(DISCORD_INVALID_PERMISSIONS) - const response = await postMessage({ message }) - if (response) message.reply(response) + const { text, url, type } = await postMessage({ message }) + if (responseType === 'reply') message.reply(text) + // TODO: Add embed response type + // TODO: Add DM message response type } catch (e) { console.log(e) message.reply(DISCORD_SERVER_ERROR) diff --git a/web/src/components/EditGameCell/EditGameCell.js b/web/src/components/EditGameCell/EditGameCell.js deleted file mode 100644 index 261937b..0000000 --- a/web/src/components/EditGameCell/EditGameCell.js +++ /dev/null @@ -1,63 +0,0 @@ -import { useMutation, useFlash } from '@redwoodjs/web' -import { navigate, routes } from '@redwoodjs/router' -import GameForm from 'src/components/GameForm' - -export const QUERY = gql` - query FIND_GAME_BY_ID($id: String!) { - game: game(id: $id) { - id - createdAt - updatedAt - playedAt - mintedAt - moves - movesHash - black - white - userAddress - } - } -` -const UPDATE_GAME_MUTATION = gql` - mutation UpdateGameMutation($id: String!, $input: UpdateGameInput!) { - updateGame(id: $id, input: $input) { - id - createdAt - updatedAt - playedAt - mintedAt - moves - movesHash - black - white - userAddress - } - } -` - -export const Loading = () =>
Loading...
- -export const Success = ({ game }) => { - const { addMessage } = useFlash() - const [updateGame, { loading, error }] = useMutation(UPDATE_GAME_MUTATION, { - onCompleted: () => { - navigate(routes.games()) - addMessage('Game updated.', { classes: 'rw-flash-success' }) - }, - }) - - const onSave = (input, id) => { - updateGame({ variables: { id, input } }) - } - - return ( -
-
-

Edit Game {game.id}

-
-
- -
-
- ) -} diff --git a/web/src/components/EditUserCell/EditUserCell.js b/web/src/components/EditUserCell/EditUserCell.js deleted file mode 100644 index a56b3b5..0000000 --- a/web/src/components/EditUserCell/EditUserCell.js +++ /dev/null @@ -1,47 +0,0 @@ -import { useMutation, useFlash } from '@redwoodjs/web' -import { navigate, routes } from '@redwoodjs/router' -import UserForm from 'src/components/UserForm' - -export const QUERY = gql` - query FIND_USER_BY_ID($id: String!) { - user: user(id: $id) { - address - authDetailId - } - } -` -const UPDATE_USER_MUTATION = gql` - mutation UpdateUserMutation($id: String!, $input: UpdateUserInput!) { - updateUser(id: $id, input: $input) { - address - authDetailId - } - } -` - -export const Loading = () =>
Loading...
- -export const Success = ({ user }) => { - const { addMessage } = useFlash() - const [updateUser, { loading, error }] = useMutation(UPDATE_USER_MUTATION, { - onCompleted: () => { - navigate(routes.users()) - addMessage('User updated.', { classes: 'rw-flash-success' }) - }, - }) - - const onSave = (input, id) => { - updateUser({ variables: { id, input } }) - } - - return ( -
-
-

Edit User {user.id}

-
-
- -
-
- ) -} diff --git a/web/src/components/Game/Game.js b/web/src/components/Game/Game.js deleted file mode 100644 index 2e209a9..0000000 --- a/web/src/components/Game/Game.js +++ /dev/null @@ -1,211 +0,0 @@ -import { useMutation, useFlash } from '@redwoodjs/web' -import { Link, routes, navigate } from '@redwoodjs/router' -import { useAuth } from '@redwoodjs/auth' -import { Web3Provider } from '@ethersproject/providers' -import { Contract } from '@ethersproject/contracts' - -import toast from 'react-hot-toast' - -import { mint } from 'src/utils/niftyChess' -import CONTRACTS from 'src/utils/contracts' - -import { QUERY } from 'src/components/GamesCell' -import { truncate } from 'src/utils/general' -import { BLOCKSCOUT_URL } from 'src/utils/constants' - -import { Gif } from 'src/utils/gfychess/gif' - -const LOADING_GIF_SRC = '/skeleton.png' - -const MINT_GAME_MUTATION = gql` - mutation MintGameMutation($id: String!) { - mintGame(id: $id) { - transactionHash - id - } - } -` - -const timeTag = (datetime) => { - return ( - - ) -} - -const checkboxInputTag = (checked) => { - return -} - -const Game = ({ game }) => { - const { addMessage } = useFlash() - - const [error, setError] = React.useState(null) - const [loading, setLoading] = React.useState(false) - const [url, setUrl] = React.useState('') - - const [ - mintNFT, - { loading: loadingMutation, error: errorMutation }, - ] = useMutation(MINT_GAME_MUTATION, { - onCompleted: () => { - setLoading(false) - - // navigate(routes.game({game.id})) - addMessage('Minting complete!', { classes: 'rw-flash-success' }) - }, - }) - - const onMintClick = async (id) => { - setLoading(true) - const { tx, error } = await mint({ id }) - if (error) { - console.log(error.message) - toast.error(error.message) - setLoading(false) - return - } - toast.promise(tx.wait(), { - loading: 'Minting...', - success: Minted!, - error: (err) => { - setLoading(false) - console.log(err) - return Something went wrong. {err?.message} - }, - }) - } - - const download = () => { - const link = document.createElement('a') - link.download = `niftychess-${game.id}.gif` - link.href = url - - // https://stackoverflow.com/a/48367757 - link.dispatchEvent( - new MouseEvent(`click`, { bubbles: true, cancelable: true, view: window }) - ) - } - - return ( -
-
-
-
- -
-
-

-
- - - - {game.black}{' '} -
-
- - - - {game.white} -
-

-
-

- {timeTag(game.playedAt)} -

-
-
- Winner - {game.winner} -
- Location - {game.location} - {game.moveCount && ( - <> -
- Moves - {game.moveCount} - - )} - {game.event && ( - <> -
- Event - {game.event} - - )} - {game.externalUrl && ( - <> -
{' '} - - { - game.externalUrl - .replace(/(http:\/\/|https:\/\/)/, '') - .replace(/(www.)/, '') - .match(/.+(.com|.org)/)[0] - } - - - )} -
- -
- {game.mintedAt && ( - <> -
- Minted: {timeTag(game.mintedAt)} - - )} - {game.tokenId > 0 && ( - <> -
- Token ID: {game.tokenId} - - )} - {game.ownerAddress && ( - <> -
- Owner:{' '} - - {truncate(game.ownerAddress, 7)} - - - )} - {!game.ownerAddress && ( -
- -
- )} -
-
-
-
-
- ) -} - -export default Game diff --git a/web/src/components/GameCard/GameCard.js b/web/src/components/GameCard/GameCard.js deleted file mode 100644 index 3aa8318..0000000 --- a/web/src/components/GameCard/GameCard.js +++ /dev/null @@ -1,101 +0,0 @@ -import { useMutation, useFlash } from '@redwoodjs/web' -import { Link, routes } from '@redwoodjs/router' -import { Gif } from 'src/utils/gfychess/gif' - -import ChessImageGenerator from 'src/utils/chess-image-generator' -const LOADING_GIF_SRC = '/skeleton.png' - -const timeTag = (datetime) => { - return ( - - ) -} - -const GameCard = ({ game }) => { - const { addMessage } = useFlash() - const [src, setSrc] = React.useState(LOADING_GIF_SRC) - - // const [url, setUrl] = React.useState('') - // - // const fetchThumbnail = async () => { - // // const res = await fetch(`/api/thumbnail?moves=${game.moves}`) - // const res = await fetch( - // `api/thumbnail?moves=1. e4 e5 2. Nf3 Nc6 3. Bb5 a6 ` - // ) - // // const res = await fetch(`api/thumbnail?moves=1. e4 e5 2. Nf3 Nc6 3. Bb5 a6`) - // console.log(res) - // const blob = await res.data() - // var bytes = new Uint8Array(blob.length / 2); - // // const json = await res.text() - // // console.log(blob) - // console.log(blob) - // setSrc(blob) - // // console.log(URL.createObjectURL(blob)) - // setSrc(URL.createObjectURL(blob)) - // } - - const loadThumbnail = async () => { - const imageGenerator = new ChessImageGenerator() - try { - const image = imageGenerator.loadPGN(game.moves.replace('pgn', '')) - const dataURL = await imageGenerator.generateDataURL() - // setSrc(`'data:image/jpeg;base64,${buf.toString('base64')}`) - setSrc(dataURL) - } catch (e) { - console.log(e) - } - } - - React.useEffect(() => { - loadThumbnail() - // fetchThumbnail() - }, []) - - // - return ( -
- -
- -
-
-

-
- - - - {game.black}{' '} -
-
- - - - {game.white} -
-

-

- {timeTag(game.playedAt)} -

-
- -
- ) -} - -export default GameCard diff --git a/web/src/components/GameCard/GameCard.stories.js b/web/src/components/GameCard/GameCard.stories.js deleted file mode 100644 index 67171b7..0000000 --- a/web/src/components/GameCard/GameCard.stories.js +++ /dev/null @@ -1,21 +0,0 @@ -import GameCard from './GameCard' - -const game = { - __typename: 'Game', - id: '0xb0f6c8899a2fea606bcb0d2b4c23947671d96f80c4ea33798e3d085cf1e3d0f4', - createdAt: '2021-02-11T20:43:39.391Z', - updatedAt: '2021-02-11T20:43:39.391Z', - playedAt: '1899-12-31T05:00:00.000Z', - mintedAt: null, - moves: - 'pgn1.Nf3 Nf6 2.c4 e6 3.g3 d5 4.Bg2 Be7 5.O-O O-O 6.d4 dxc4 7.Qc2 a6\n8.a4 Nc6 9.Qxc4 Qd5 10.Nbd2 Rd8 11.e3 Qh5 12.e4 Bd7 13.b3 b5\n14.Qc3 bxa4 15.bxa4 Bb4 16.Qc2 Rac8 17.Nc4 Be8 18.h3 Rxd4 19.g4 Qc5\n20.Nxd4 Nxd4 21.Qd3 Rd8 22.Bb2 e5 23.Rfc1 Qe7 24.Bxd4 Rxd4 25.Qg3 Qe6\n26.Qb3 a5 27.Qc2 c5 28.Ne3 Bc6 29.Rd1 g6 30.f3 c4 31.Qe2 Bc5 32.Kh1 c3\n33.Nc2 Rxa4 34.Qd3 Bd4 35.f4 Rxa1 36.Rxa1 Bb6 37.Rb1 Bc5 38.f5 Qd7\n39.Qxc3 Nxe4 40.Qxe5 Bd6 41.Qxa5 Bc7 42.Qb4 Qd3 0-1\n\n[Event "cm2"]\n[Site "Moscow"]\n[Date "1968.??.??"]\n[Round "07"]\n[White "Tal M"]\n[Black "Korchnoi V"]\n[Result "1/2-1/2"]\n', - black: 'name', - white: 'name', - externalUrl: null, -} - -export const generated = () => { - return -} - -export default { title: 'Components/Pagination' } diff --git a/web/src/components/GameCell/GameCell.js b/web/src/components/GameCell/GameCell.js deleted file mode 100644 index 1c5636d..0000000 --- a/web/src/components/GameCell/GameCell.js +++ /dev/null @@ -1,31 +0,0 @@ -import Game from 'src/components/Game' - -export const QUERY = gql` - query FIND_GAME_BY_ID($id: String!) { - game: game(id: $id) { - id - createdAt - updatedAt - playedAt - mintedAt - minterAddress - ownerAddress - tokenId - externalUrl - location - event - moves - black - white - winner - } - } -` - -export const Loading = () =>
Loading...
- -export const Empty = () =>
Game not found
- -export const Success = ({ game }) => { - return -} diff --git a/web/src/components/GameForm/GameForm.js b/web/src/components/GameForm/GameForm.js deleted file mode 100644 index 4e3eff9..0000000 --- a/web/src/components/GameForm/GameForm.js +++ /dev/null @@ -1,50 +0,0 @@ -import { - Form, - FormError, - FieldError, - Label, - TextField, - Submit, -} from '@redwoodjs/forms' - -const GameForm = (props) => { - const onSubmit = (data) => { - props.onSave(data, props?.game?.id) - } - - return ( -
-
- - - - - -
- - Save - -
- -
- ) -} - -export default GameForm diff --git a/web/src/components/GamesCell/GamesCell.js b/web/src/components/GamesCell/GamesCell.js deleted file mode 100644 index 3dd1662..0000000 --- a/web/src/components/GamesCell/GamesCell.js +++ /dev/null @@ -1,65 +0,0 @@ -import { Link, routes } from '@redwoodjs/router' - -import Pagination from 'src/components/Pagination' -import GameCard from 'src/components/GameCard' - -const GAMES_PER_PAGE = 9 - -export const QUERY = gql` - query GAME_PAGE($page: Int) { - gamePage(page: $page) { - games { - id - createdAt - updatedAt - playedAt - mintedAt - moves - black - white - externalUrl - } - count - } - } -` - -export const beforeQuery = ({ page }) => { - page = page ? parseInt(page, 10) : 1 - return { variables: { page } } -} - -export const Loading = () =>
Loading...
- -export const Empty = () => { - return ( -
- {'No games yet. '} - - {'Create one?'} - -
- ) -} - -export const Success = ({ gamePage, page, perPage }) => { - page = page ? parseInt(page, 10) : 1 - return ( -
-
- {gamePage.games.map((game) => ( -
- -
- ))} -
-
- -
-
- ) -} diff --git a/web/src/components/NewGame/NewGame.js b/web/src/components/NewGame/NewGame.js deleted file mode 100644 index 108e9f5..0000000 --- a/web/src/components/NewGame/NewGame.js +++ /dev/null @@ -1,40 +0,0 @@ -import { useMutation } from '@redwoodjs/web' -import { navigate, routes } from '@redwoodjs/router' -import GameForm from 'src/components/GameForm' - -import toast from 'react-hot-toast' -import { QUERY } from 'src/components/GamesCell' - -const CREATE_GAME_MUTATION = gql` - mutation CreateGameMutation($input: CreateGameInput!) { - createGame(input: $input) { - id - } - } -` - -const NewGame = () => { - const [createGame, { loading, error }] = useMutation(CREATE_GAME_MUTATION, { - onCompleted: ({ createGame: { id } }) => { - navigate(routes.game({ id })) - toast.success('NFT generated') - }, - }) - - const onSave = (input) => { - createGame({ variables: { input } }) - } - - return ( -
-
-

Create a GIF

-
-
- -
-
- ) -} - -export default NewGame diff --git a/web/src/components/NewUser/NewUser.js b/web/src/components/NewUser/NewUser.js deleted file mode 100644 index 35753bd..0000000 --- a/web/src/components/NewUser/NewUser.js +++ /dev/null @@ -1,40 +0,0 @@ -import { useMutation, useFlash } from '@redwoodjs/web' -import { navigate, routes } from '@redwoodjs/router' -import UserForm from 'src/components/UserForm' - -import { QUERY } from 'src/components/UsersCell' - -const CREATE_USER_MUTATION = gql` - mutation CreateUserMutation($input: CreateUserInput!) { - createUser(input: $input) { - id - } - } -` - -const NewUser = () => { - const { addMessage } = useFlash() - const [createUser, { loading, error }] = useMutation(CREATE_USER_MUTATION, { - onCompleted: () => { - navigate(routes.users()) - addMessage('User created.', { classes: 'rw-flash-success' }) - }, - }) - - const onSave = (input) => { - createUser({ variables: { input } }) - } - - return ( -
-
-

New User

-
-
- -
-
- ) -} - -export default NewUser diff --git a/web/src/components/Pagination/Pagination.js b/web/src/components/Pagination/Pagination.js deleted file mode 100644 index 41ec5ef..0000000 --- a/web/src/components/Pagination/Pagination.js +++ /dev/null @@ -1,30 +0,0 @@ -import { Link, routes } from '@redwoodjs/router' - -const Pagination = ({ count, page, perPage }) => { - const items = [] - const addButton = ({ text, page }) => - items.push( - - {text} - - ) - - const totalPages = Math.ceil(count / perPage) - if (page > 10) addButton({ text: '<<', page: page - 10 }) - if (page > 1) addButton({ text: '<', page: page - 1 }) - items.push( -
- {page} -
- ) - if (page < totalPages) addButton({ text: '>', page: page + 1 }) - if (page < totalPages - 10) addButton({ text: '>>', page: page + 10 }) - - return
{items}
-} - -export default Pagination diff --git a/web/src/components/Pagination/Pagination.stories.js b/web/src/components/Pagination/Pagination.stories.js deleted file mode 100644 index 8c241b1..0000000 --- a/web/src/components/Pagination/Pagination.stories.js +++ /dev/null @@ -1,7 +0,0 @@ -import Pagination from './Pagination' - -export const generated = () => { - return -} - -export default { title: 'Components/Pagination' } diff --git a/web/src/components/Pagination/Pagination.test.js b/web/src/components/Pagination/Pagination.test.js deleted file mode 100644 index d005ca9..0000000 --- a/web/src/components/Pagination/Pagination.test.js +++ /dev/null @@ -1,11 +0,0 @@ -import { render } from '@redwoodjs/testing' - -import Pagination from './Pagination' - -describe('Pagination', () => { - it('renders successfully', () => { - expect(() => { - render() - }).not.toThrow() - }) -}) diff --git a/web/src/components/UserForm/UserForm.js b/web/src/components/UserForm/UserForm.js deleted file mode 100644 index 8e866b9..0000000 --- a/web/src/components/UserForm/UserForm.js +++ /dev/null @@ -1,67 +0,0 @@ -import { - Form, - FormError, - FieldError, - Label, - TextField, - Submit, -} from '@redwoodjs/forms' - -const UserForm = (props) => { - const onSubmit = (data) => { - props.onSave(data, props?.user?.id) - } - - return ( -
-
- - - - - - - - - - -
- - Save - -
- -
- ) -} - -export default UserForm From 6c988bedfa3b7b44ef908a21cf4262821218a55b Mon Sep 17 00:00:00 2001 From: patrick Date: Wed, 1 Sep 2021 14:07:36 -0400 Subject: [PATCH 20/23] cleanup --- bot/src/bot.js | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/bot/src/bot.js b/bot/src/bot.js index 219c057..b531a80 100644 --- a/bot/src/bot.js +++ b/bot/src/bot.js @@ -1,14 +1,15 @@ require('dotenv').config() const Discord = require('discord.js') -const { handleAdminUpdate } = require('./commands/admin') -const { notifyMemberUpdate } = require('./commands/notifications') -const { DISCORD_NO_DM_INVOCATION } = require('./textContent') -const ApiMgr = require('../apiMgr') +const ApiMgr = require('./apiMgr') const apiMgr = new ApiMgr() const discordClient = new Discord.Client() +const DISCORD_NO_DM_INVOCATION = 'Sorry, you can only do that from a server' +const DISCORD_INVALID_PERMISSIONS = `⛈️ Sorry, I'm powerless. Someone must have revoked my permission to manage roles.` +const DISCORD_SERVER_ERROR = `⛈️ Sorry, something went terribly wrong.` + const handleInvoke = async (message) => { console.log(`New invocation from ${message.author.id}`) try { @@ -40,9 +41,4 @@ discordClient.on('message', async (message) => { } }) -// TODO: Remove if unused -discordClient.on('guildMemberUpdate', (oldMember, newMember) => { - notifyMemberUpdate({ oldMember, newMember }) -}) - discordClient.login(process.env.DISCORD_TOKEN) From 71637f31743662d8f8f321b29fd3bb0ed71a12f5 Mon Sep 17 00:00:00 2001 From: patrick Date: Wed, 1 Sep 2021 14:33:03 -0400 Subject: [PATCH 21/23] update readme --- .env.example | 12 ++++++++--- .env.template | 9 -------- LICENSE | 2 +- README.md | 59 ++++++++++++++++++++++++++++++++++++++------------- 4 files changed, 54 insertions(+), 28 deletions(-) delete mode 100644 .env.template diff --git a/.env.example b/.env.example index 17df5cc..f856593 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,9 @@ -# DATABASE_URL=file:./dev.db -# TEST_DATABASE_URL=file:./.redwood/test.db -# PRISMA_HIDE_UPDATE_MESSAGE=true +COLLAB_LAND_APP_ID= +COLLAB_LAND_APP_SECRET= +ETHEREUM_JWT_SECRET= +XDAI_RPC_URL= +INFURA_ENDPOINT_KEY= + +LOGIN_URL=http://0.0.0.0:8910/login + +#DATABASE_URL=file:./dev.db diff --git a/.env.template b/.env.template deleted file mode 100644 index f856593..0000000 --- a/.env.template +++ /dev/null @@ -1,9 +0,0 @@ -COLLAB_LAND_APP_ID= -COLLAB_LAND_APP_SECRET= -ETHEREUM_JWT_SECRET= -XDAI_RPC_URL= -INFURA_ENDPOINT_KEY= - -LOGIN_URL=http://0.0.0.0:8910/login - -#DATABASE_URL=file:./dev.db diff --git a/LICENSE b/LICENSE index f943bbb..e737f1f 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2020 Redwood +Copyright (c) 2020 Patrick Gallagher Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 076bfc5..cda2441 100644 --- a/README.md +++ b/README.md @@ -1,45 +1,67 @@ -

Welcome to unlock-protocol-discord-bot 👋

+

Welcome swordy-bot 👋

License: MIT

-> A Discord bot which prompts the user to enter their DID, and creates a new challenge for them. +> An Ethereum wallet verification service for role-gated Discord servers. ## Whats included -- **Discord bot** w/ docker container in `/bot` -- **API server** RedwoodJS API in `/api` -- **(incomplete) Frontend** RedwoodJS web app in `/web` +- **🤖 Discord bot `/bot`** +- **🛰️ Redwood api server `/api`** +- **🖥️ Redwood web app `/web`** -## Local Development +## Development -Setup the backend + frontend +Install both the app and bot ```bash +# Redwood yarn -# Initialize SQL-lite and perform initial migrations -yarn rw db save -yarn rw db up - -# Start the API service and Frontend -yarn rw dev +# Discord Bot +cd bot && yarn ``` Start the bot ```bash -cd bot && yarn +# In /bot, update the variables here +cp .env.template .env + yarn start ``` +Set up the Redwood App + +```bash +# In root of the repo, update the variables here +cp .env.template .env + + +# Create a new database and perform migrations +yarn rw prisma migrate dev +``` + +Start Prisma Studio (database admin tool) + +```bash +yarn rw prisma studio +``` + +Start both the Redwood frontend and api + +```bash +yarn rw dev +``` + ## Going to Production The easiest way to deploy is using Vercel for the frontend and backend, and Heroku for the bot and postgres database. Hosting this way is free, and can be setup in about 10 minutes. -In order to collect all the required environment variables, you'll need to deploy all three parts (bot, FE+BE, discord app), and go back and update as necessary. +In order to collect all the required environment variables, you'll need to deploy all three parts (bot, FE+BE, discord app), then go back and update the env variables as necessary. ### Vercel - Backend + Frontend @@ -70,8 +92,11 @@ Copy the `TOKEN` and add it to your bot app. Now add the bot to your server. In the Discord Application, in "General Information", copy the `CLIENT ID`. Insert it in this URL, and have the server administrator open it. ``` + # Add the bot with role management permissions + https://discord.com/oauth2/authorize?client_id=&scope=bot&permissions=268435456 + ``` ## Docker option @@ -115,6 +140,10 @@ docker-compose up -d Give a ⭐️ if this project helped you! +## Sponsors + +Big thanks to the [Unlock Protocol](unlock-protocol.com) developer grants program for providing support for this project! + --- _This README was generated with ❤️ by [readme-md-generator](https://github.com/kefranabg/readme-md-generator)_ From 91f10bfa8da0ef834139e5ed5e15da869e42b007 Mon Sep 17 00:00:00 2001 From: patrick Date: Wed, 1 Sep 2021 14:41:17 -0400 Subject: [PATCH 22/23] readme --- README.md | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index cda2441..3dc8849 100644 --- a/README.md +++ b/README.md @@ -65,19 +65,19 @@ In order to collect all the required environment variables, you'll need to deplo ### Vercel - Backend + Frontend -Point vercel to your repo, and deploy. Redwood build commands should be auto-configured. +Create a new Vercel app using your forked repo. The build commands are automatically detected for RedwoodJS projects. -Update the environment variables for what you see in the root `.env` file. The only new one is `DATABASE_URL` which you need from your Heroku database in the next step. +Update the environment variables for what you see in the root `.env` file. You will get `DATABASE_URL` from your Heroku database in the next step. ### Heroku - Bot Heroku doesn't like apps that aren't in the root folder. To get around this, I added a `heroku-prebuild` script which installs the bot dependencies. The Procfile runs the `bot.js` -Once deployed, head to the "Resources" tab, turn off the `web` Dyno, and turn on the `worker` Dyno. This Dyno is defined in the repo root `Procfile`. +Once deployed, head to the "Resources" tab for this Heroku app, turn off the `web` Dyno, and turn on the `worker` Dyno. This Dyno is defined in the repo root `Procfile`. Update the environment variables in "Settings" tab to reflect what you see in the `.env` here. -Add a new postgres add-on and add the url to the Vercel environment. +Create a new **postgres add-on** in this Heroku app, and add the url to the Vercel environment as `DATABASE_URL`. ### Discord @@ -101,7 +101,11 @@ https://discord.com/oauth2/authorize?client_id=&scope=bot&permissions= ## Docker option -If you want to avoid Heroku for the bot, you can use docker. If you've made changes to the bot, you'll need to generate a new Docker image. +If you want to avoid Heroku for the bot, you can use docker. + +> Note: avoiding Heroku means you'll also need to bring your own postgres database. See the docs for [self-hosting redwood](https://redwoodjs.com/cookbook/self-hosting-redwood.html#self-hosting-redwood). + +If you've made changes to the bot, generate your own Docker image. ```bash # Build @@ -123,10 +127,10 @@ source .env docker-compose up -d ``` -## Notes +## Troubleshooting + Tips - If you have permissions errors, try giving the bot a higher role. Bots can only give roles to members in a _lower position_ than their own highest role. See https://discord.com/developers/docs/topics/permissions#permission-hierarchy -- Helpful discord docs for making a "emoji-reaction" menu https://discordjs.guide/popular-topics/reactions.html#awaiting-reactions +- Make an "emoji-reaction" menu https://discordjs.guide/popular-topics/reactions.html#awaiting-reactions ## Author From 9a134b16fb59a78dfa59cc7a34fe3ad03cb59728 Mon Sep 17 00:00:00 2001 From: patrick Date: Wed, 1 Sep 2021 14:55:11 -0400 Subject: [PATCH 23/23] cleanup --- api/src/graphql/users.sdl.js | 1 + api/src/services/users/users.js | 44 ---------------------------- web/src/pages/LoginPage/LoginPage.js | 18 +++++++----- 3 files changed, 11 insertions(+), 52 deletions(-) diff --git a/api/src/graphql/users.sdl.js b/api/src/graphql/users.sdl.js index 5610635..e35c404 100644 --- a/api/src/graphql/users.sdl.js +++ b/api/src/graphql/users.sdl.js @@ -21,6 +21,7 @@ export const schema = gql` type Query { users: [User!]! user(id: String!): User + loginSuccess(address: String!): User haveUserAddress(platformId: String!): HaveUserAddress userByDiscordId(discordId: String!): User } diff --git a/api/src/services/users/users.js b/api/src/services/users/users.js index 82ec791..24cf7e0 100644 --- a/api/src/services/users/users.js +++ b/api/src/services/users/users.js @@ -26,53 +26,9 @@ export const loginSuccess = async ({ ephemeralId }) => { }, }) - const { platformId, platform } = temporaryUser - // Merge the temporary user with the current one - let user = await db.user.update({ - where: { id: context.currentUser.id }, - data: { platform, platformId }, - }) - return { id: user.id } } -export const haveUserAddress = async ({ platformId }) => { - let haveUserAddress = false - const user = await db.user.findOne({ where: { platformId } }) - const userAddress = user?.address - if (userAddress) haveUserAddress = true - return { haveUserAddress } -} - -export const userByDiscordId = async ({ discordId }) => { - // DELETE ME only for testing purposes - // await db.user.delete({ - // where: { discordId }, - // }) - let user = await db.user.findOne({ - where: { discordId }, - }) - if (user) console.log(`User "${discordId}" found.`) - if (!user) { - console.log(`User "${discordId}" not found. Asking CollabLand...`) - const wallets = await fetchCollabLandUserWallets(discordId) - // TODO: throw error if no wallets? - if (!wallets.length) throw Error('User is not signed up with Collab Land') - - // TODO: check multiple wallets - const userAddress = wallets[0].address - - user = await db.user.create({ - data: { discordId, address: userAddress }, - }) - } - - // NOTE: NFT ownership data is ephemeral, so we should not store it in the database - const nfts = await fetchUnlockProtocolNfts(user.address) - const chievs = await fetchChievNfts(user.address) - return { ...user, nfts: [...nfts, ...chievs] } -} - export const createUser = ({ input }) => { throw new Error('Unauthorized') return db.user.create({ diff --git a/web/src/pages/LoginPage/LoginPage.js b/web/src/pages/LoginPage/LoginPage.js index fe34d6f..5678127 100644 --- a/web/src/pages/LoginPage/LoginPage.js +++ b/web/src/pages/LoginPage/LoginPage.js @@ -21,15 +21,17 @@ const LoginPage = () => { const { logIn, logOut, isAuthenticated, loading, currentUser } = useAuth() const { ephemeralId } = useParams() - const [loginSuccess, { _, error: queryError }] = useQuery({ + const [loginSuccess, { _, error: queryError }] = useQuery( LOGIN_SUCCESS_QUERY, - onCompleted: () => { - setStatus(COMPLETE) - setTimeout(function () { - navigate(routes.user({ address: currentUser?.address })) - }, 5000) - }, - }) + { + onCompleted: () => { + setStatus(COMPLETE) + setTimeout(function () { + navigate(routes.user({ address: currentUser?.address })) + }, 5000) + }, + } + ) const onLogIn = async (type) => { setStatus(LOADING)