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 80c5edb..0000000 --- a/.env.template +++ /dev/null @@ -1,7 +0,0 @@ -COLLAB_LAND_APP_ID= -COLLAB_LAND_APP_SECRET= -ETHEREUM_JWT_SECRET= -XDAI_RPC_URL= -INFURA_ENDPOINT_KEY= - -#DATABASE_URL=file:./dev.db 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/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..3dc8849 100644 --- a/README.md +++ b/README.md @@ -1,61 +1,83 @@ -

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 -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 @@ -70,13 +92,20 @@ 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 -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 @@ -98,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 @@ -115,6 +144,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)_ 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/graphql/bot.js b/api/src/graphql/bot.js new file mode 100644 index 0000000..0799b22 --- /dev/null +++ b/api/src/graphql/bot.js @@ -0,0 +1,15 @@ +export const schema = gql` + type Response { + type: String! + text: String + url: 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 8f90bf8..e35c404 100644 --- a/api/src/graphql/users.sdl.js +++ b/api/src/graphql/users.sdl.js @@ -21,12 +21,8 @@ export const schema = gql` type Query { users: [User!]! user(id: String!): User + loginSuccess(address: String!): User haveUserAddress(platformId: String!): HaveUserAddress - userByPlatformId( - platformId: String! - platform: String! - guildId: String - ): LimitedScopeUser userByDiscordId(discordId: String!): User } diff --git a/api/src/lib/bot/bot.js b/api/src/lib/bot/bot.js new file mode 100644 index 0000000..84676fc --- /dev/null +++ b/api/src/lib/bot/bot.js @@ -0,0 +1,37 @@ +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 ({ + content, + platformUserId, + 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: { + platformId: platformUserId, + platform, + currentSessionGuild: { + connect: { platformId: guildId }, + }, + ephemeralId, + }, + update: { + currentSessionGuild: { + connect: { platformId: guildId }, + }, + ephemeralId, + }, + }) + // Return the unique URL for the response + return { + text: DISCORD_INITIAL_AUTH + LOGIN_URL + ephemeralId, + type: 'reply', + url: null, + } +} diff --git a/api/src/lib/bot/constants.js b/api/src/lib/bot/constants.js new file mode 100644 index 0000000..a9398d0 --- /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}?ephemeralId=` + +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 73% rename from bot/src/commands/common.js rename to api/src/lib/bot/oldFiles/common.js index f2cba8b..6e3b5ee 100644 --- a/bot/src/commands/common.js +++ b/api/src/lib/bot/oldFiles/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/api/src/lib/bot/oldFiles/invoke.js similarity index 71% rename from bot/src/commands/invoke.js rename to api/src/lib/bot/oldFiles/invoke.js index aca8f29..4b307ad 100644 --- a/bot/src/commands/invoke.js +++ b/api/src/lib/bot/oldFiles/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,20 +101,21 @@ 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) => { 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, @@ -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/graphql-operations/mutations.js b/api/src/lib/bot/oldFiles/mutations.js similarity index 69% rename from bot/src/graphql-operations/mutations.js rename to api/src/lib/bot/oldFiles/mutations.js index c83a6fb..0b47754 100644 --- a/bot/src/graphql-operations/mutations.js +++ b/api/src/lib/bot/oldFiles/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/api/src/lib/bot/oldFiles/queries.js similarity index 54% rename from bot/src/graphql-operations/queries.js rename to api/src/lib/bot/oldFiles/queries.js index 2715685..e3c67d4 100644 --- a/bot/src/graphql-operations/queries.js +++ b/api/src/lib/bot/oldFiles/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/api/src/lib/bot/oldFiles/textContent.js similarity index 88% rename from bot/src/textContent.js rename to api/src/lib/bot/oldFiles/textContent.js index 86671e8..b29b020 100644 --- a/bot/src/textContent.js +++ b/api/src/lib/bot/oldFiles/textContent.js @@ -5,8 +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_REPLY = `You think you're worthy? Check your DMs` +const DISCORD_INSUFFICIENT_BALANCE = `🏳️ Looks like you don't have the right tokens in your wallet.\n` 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,8 @@ 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/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/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 ad60c73..f9d9f4d 100644 --- a/api/src/lib/token/token.js +++ b/api/src/lib/token/token.js @@ -1,17 +1,16 @@ import { JsonRpcProvider, InfuraProvider } from '@ethersproject/providers' import { Contract } from '@ethersproject/contracts' -import { Bignumber } from '@ethersproject/units' +import { parseUnits } from '@ethersproject/units' -import erc721Abi from './erc721Abi' +import { getProviderByChainId } from 'src/lib/helpers' +import { isLockValid } from 'src/lib/unlockProtocol' -const getProviderByChainId = (chainId) => { - if (chainId === '100') return new JsonRpcProvider(process.env.XDAI_RPC_URL) - return new InfuraProvider(networkName, process.env.INFURA_ENDPOINT_KEY) -} +import erc721Abi from './erc721Abi' +import erc20Abi from './erc20Abi' 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({ @@ -26,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 } diff --git a/api/src/services/bot/bot.js b/api/src/services/bot/bot.js new file mode 100644 index 0000000..a8a1f6b --- /dev/null +++ b/api/src/services/bot/bot.js @@ -0,0 +1,3 @@ +import { handleMessage } from 'src/lib/bot/bot' + +export const postMessage = ({ input }) => handleMessage(input) 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', }, }) } diff --git a/api/src/services/users/users.js b/api/src/services/users/users.js index 7094920..24cf7e0 100644 --- a/api/src/services/users/users.js +++ b/api/src/services/users/users.js @@ -15,74 +15,18 @@ 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 } }) - - 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 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 loginSuccess = async ({ ephemeralId }) => { + // TODO: Get user ephemeralId from JWT auth + + // Remove the ephemeralId from the user + const user = await db.user.update({ + where: { ephemeralId }, + data: { + ephemeralId: null, }, }) -} -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] } + return { id: user.id } } export const createUser = ({ input }) => { 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/bot/src/apiMgr.js b/bot/src/apiMgr.js index be6db05..ea53a83 100644 --- a/bot/src/apiMgr.js +++ b/bot/src/apiMgr.js @@ -2,12 +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, - userByPlatformId, -} = require('./graphql-operations/queries') -const { updateRole } = require('./graphql-operations/mutations') +const { POST_MESSAGE_QUERY } = require('./queries') class ApiMgr { constructor() { @@ -31,67 +26,18 @@ class ApiMgr { }) } - async haveUserAddress({ platformId }) { + async postMessage({ message }) { try { const res = await this.client.query({ - query: haveUserAddress, - variables: { platformId }, - }) - return res.data.haveUserAddress.haveUserAddress - } catch (e) { - console.log(e) - throw new Error(e) - } - } - - async userByPlatformId({ platformId, platform, guildId }) { - try { - const res = await this.client.query({ - query: userByPlatformId, - 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 }, + query: POST_MESSAGE_QUERY, + variables: { + content: message.content, + platformUserId: message.member.id, + platform: 'discord', + guildId: message.guild.id, + }, }) + 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 350dcb4..b531a80 100644 --- a/bot/src/bot.js +++ b/bot/src/bot.js @@ -1,26 +1,44 @@ require('dotenv').config() const Discord = require('discord.js') -const { handleInvoke } = require('./commands/invoke') -const { handleAdminUpdate } = require('./commands/admin') -const { DISCORD_NO_DM_INVOCATION } = require('./textContent') +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 { + // Verify the bot still has role-granting priveledges + if (!message.guild.me.hasPermission(['MANAGE_ROLES'])) + return message.channel.send(DISCORD_INVALID_PERMISSIONS) + + 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) + } +} + 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) -}) - -discordClient.on('guildMemberUpdate', (oldMember, newMember) => { - notifyMemberUpdate({ oldMember, newMember }) }) discordClient.login(process.env.DISCORD_TOKEN) diff --git a/bot/src/commands/admin.js b/bot/src/commands/admin.js deleted file mode 100644 index 8f26559..0000000 --- a/bot/src/commands/admin.js +++ /dev/null @@ -1,83 +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 params = text.split(' ') - if (params.length < 5) throw Error('#' + INVALID_NUMBER_OF_PARAMETERS) - const [_, chainId, contractAddress, balance, roleName, purchaseUrl] = params - 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/notifications.js b/bot/src/commands/notifications.js deleted file mode 100644 index 5b27d0b..0000000 --- a/bot/src/commands/notifications.js +++ /dev/null @@ -1,25 +0,0 @@ -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/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 } diff --git a/how-to-build-token-centric-community.md b/how-to-build-token-centric-community.md new file mode 100644 index 0000000..e6696be --- /dev/null +++ b/how-to-build-token-centric-community.md @@ -0,0 +1,179 @@ +# Practical Guide for Building a Token Powered Community + +There are plenty of good reasons for building a community around a token: + +- DAOs for giving grants or doing work +- Open-source technology development and governance +- Altruistic causes and public goods +- Content creators and artists with followers + +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 Existing 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. +- 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: + +- [$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. + +# From 0 to 10 Members - Establish your Foundation + +Let's get started! + +## Token + +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. + +Here are your options for your community tokens: + +- 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) + +## 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. 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/). + +![]('./starting-place.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 + +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 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 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. + +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! + +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 + +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. + +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 + +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') + +All your conversation so far can take place in the general chat and voice channels. + +# From 10 to 100 Members - Open the Door + +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. + +Before you open the door completely, here are a few things you need to complete: + +## Enable "Community" Mode + +[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. + +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 + +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, 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 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 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') + +# 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 + +### 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. Each should be typed in it's native language. + +- français +- deutsch +- 日本語 +- pусский +- español + +![]('./global-channels.png') diff --git a/redwood.toml b/redwood.toml index 7f523b3..9072e5d 100644 --- a/redwood.toml +++ b/redwood.toml @@ -5,11 +5,13 @@ # 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 + 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] diff --git a/web/src/Routes.js b/web/src/Routes.js index 5895585..c103e06 100644 --- a/web/src/Routes.js +++ b/web/src/Routes.js @@ -1,20 +1,13 @@ -// 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 = () => { return ( + + ) 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/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 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 ( diff --git a/web/src/pages/LoginPage/LoginPage.js b/web/src/pages/LoginPage/LoginPage.js index e92d639..5678127 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,24 +8,27 @@ 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, + const [loginSuccess, { _, error: queryError }] = useQuery( + LOGIN_SUCCESS_QUERY, { onCompleted: () => { setStatus(COMPLETE) + setTimeout(function () { + navigate(routes.user({ address: currentUser?.address })) + }, 5000) }, } ) @@ -36,7 +39,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 +52,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 +88,7 @@ const LoginPage = () => { ) : (

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

)} @@ -95,7 +99,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/components/Pagination/Pagination.test.js b/web/src/pages/UserPage/UserPage.test.js similarity index 57% rename from web/src/components/Pagination/Pagination.test.js rename to web/src/pages/UserPage/UserPage.test.js index d005ca9..fa21776 100644 --- a/web/src/components/Pagination/Pagination.test.js +++ b/web/src/pages/UserPage/UserPage.test.js @@ -1,11 +1,11 @@ import { render } from '@redwoodjs/testing' -import Pagination from './Pagination' +import UserPage from './UserPage' -describe('Pagination', () => { +describe('UserPage', () => { it('renders successfully', () => { expect(() => { - render() + render() }).not.toThrow() }) })