Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

have the bot use the GraphQL API #1

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# DATABASE_URL=postgres://postgres@localhost:5432/hybrid_bot?connection_limit=1
# TEST_DATABASE_URL=postgres://postgres@localhost:5432/hybrid_bot_test?connection_limit=1
# BOT_TOKEN=DISCORD_BOT_TOKEN
# API_KEY=RANDOM_API_KEY
# API_BASE=http://localhost:8910/.netlify/functions
3 changes: 2 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@
},
"[prisma]": {
"editor.formatOnSave": true
}
},
"github.remoteName": "gitlab"
}
57 changes: 51 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,54 @@ pgbouncer which provides a serverless application access to the database.

# Running

First, copy `.env.example` to `.env`, and set `DATABASE_URL` and
`TEST_DATABASE_URL`.
## Server

Then, create a bot on Discord with the send messages permission, and copy the
There needs to be an API server instance running. It needs the following
environment variables:

- `DATABASE_URL`
- `BOT_TOKEN`
- `API_KEY`

The `DATABASE_URL` environment variable needs to be set to a postgres database.
Here is a local example:

```
DATABASE_URL=postgres://postgres@localhost:5432/hybrid_bot?connection_limit=1
```

Here is a DigitalOcean example. This is for a pgbouncer. Be sure to set it up
in the DigitalOcean dashboard.

```
postgres://myusername:mypassword@my-project-db-do-user-7575757-0.a.db.ondigitalocean.com:25252/mydbname?sslmode=require&connection_limit=1
```

reate a bot on Discord with the send messages permission, and copy the
bot token, and place it in `.env`.

To run the bot, run `yarn rw build api && yarn workspace api start-bot`.
Finally, set `API_KEY` to a random string that will also be used by the bot.

## Bot

The bot only needs the `API_BASE` and `API_KEY` environment variables. It uses
those to get its configuration, which includes the Discord bot token.

To build the bot, run:

```bash
yarn rw build api
```

Where the bot will be running, set the `API_KEY` and `API_BASE` environment
variables. The `API_KEY` needs to be the same as the API server's `API_KEY`
environment variable.

To run the bot:

```bash
yarn workspace api start-bot
```

# How it works

Expand Down Expand Up @@ -55,8 +96,12 @@ This is added to scripts in `api/package.json`:
# Demo

There is a bot called `@hybrid-bot` running in the
[Resources.co Discord community][discord-community]. It will reply when you
mention it.
[Resources.co Discord community][discord-community]. It understands these
commands:

- `@hybrid-bot snack` - will print out a random snack
- `@hybrid-bot enter <name>` - will enter a name in a drawing (not yet implemented)
- `@hybrid-bot draw` - will draw a random name (not yet implemented)

[discord-community]: https://discord.gg/BSjufZhFsM
[redwood]: https://redwoodjs.com/
4 changes: 3 additions & 1 deletion api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
"private": true,
"dependencies": {
"@redwoodjs/api": "^0.23.0",
"discord.js": "^12.5.1"
"discord.js": "^12.5.1",
"graphql": "^15.4.0",
"graphql-request": "^3.4.0"
},
"scripts": {
"start-bot": "node dist/lib/bot"
Expand Down
3 changes: 3 additions & 0 deletions api/src/functions/graphql.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,12 @@ import {

import schemas from 'src/graphql/**/*.{js,ts}'
import services from 'src/services/**/*.{js,ts}'

import { getCurrentUser } from 'src/lib/auth'
import { db } from 'src/lib/db'

export const handler = createGraphQLHandler({
getCurrentUser,
schema: makeMergedSchema({
schemas,
services: makeServices({ services }),
Expand Down
9 changes: 9 additions & 0 deletions api/src/graphql/config.sdl.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export const schema = gql`
type Config {
botToken: String
}

type Query {
config(apiKey: String!): Config
}
`
144 changes: 144 additions & 0 deletions api/src/lib/auth.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
// Define what you want `currentUser` to return throughout your app. For example,
// to return a real user from your database, you could do something like:
//
// export const getCurrentUser = async ({ email }) => {
// return await db.user.findUnique({ where: { email } })
// }
//
// If you want to enforce role-based access ...
//
// You'll need to set the currentUser's roles attributes to the
// collection of roles as defined by your app.
//
// This allows requireAuth() on the api side and hasRole() in the useAuth() hook on the web side
// to check if the user is assigned a given role or not.
//
// How you set the currentUser's roles depends on your auth provider and its implementation.
//
// For example, your decoded JWT may store `roles` in it namespaced `app_metadata`:
//
// {
// 'https://example.com/app_metadata': { authorization: { roles: ['admin'] } },
// 'https://example.com/user_metadata': {},
// iss: 'https://app.us.auth0.com/',
// sub: 'email|1234',
// aud: [
// 'https://example.com',
// 'https://app.us.auth0.com/userinfo'
// ],
// iat: 1596481520,
// exp: 1596567920,
// azp: '1l0w6JXXXXL880T',
// scope: 'openid profile email'
// }
//
// The parseJWT utility will extract the roles from decoded token.
//
// The app_medata claim may or may not be namespaced based on the auth provider.
// Note: Auth0 requires namespacing custom JWT claims
//
// Some providers, such as with Auth0, will set roles an authorization
// attribute in app_metadata (namespaced or not):
//
// 'app_metadata': { authorization: { roles: ['publisher'] } }
// 'https://example.com/app_metadata': { authorization: { roles: ['publisher'] } }
//
// Other providers may include roles simply within app_metadata:
//
// 'app_metadata': { roles: ['author'] }
// 'https://example.com/app_metadata': { roles: ['author'] }
//
// And yet other may define roles as a custom claim at the root of the decoded token:
//
// roles: ['admin']
//
// The function `getCurrentUser` should return the user information
// together with a collection of roles to check for role assignment:

import { AuthenticationError, ForbiddenError, parseJWT } from '@redwoodjs/api'

/**
* Use requireAuth in your services to check that a user is logged in,
* whether or not they are assigned a role, and optionally raise an
* error if they're not.
*
* @param {string=, string[]=} role - An optional role
*
* @example - No role-based access control.
*
* export const getCurrentUser = async (decoded) => {
* return await db.user.findUnique({ where: { decoded.email } })
* }
*
* @example - User info is contained in the decoded token and roles extracted
*
* export const getCurrentUser = async (decoded, { _token, _type }) => {
* return { ...decoded, roles: parseJWT({ decoded }).roles }
* }
*
* @example - User record query by email with namespaced app_metadata roles
*
* export const getCurrentUser = async (decoded) => {
* const currentUser = await db.user.findUnique({ where: { email: decoded.email } })
*
* return {
* ...currentUser,
* roles: parseJWT({ decoded: decoded, namespace: NAMESPACE }).roles,
* }
* }
*
* @example - User record query by an identity with app_metadata roles
*
* const getCurrentUser = async (decoded) => {
* const currentUser = await db.user.findUnique({ where: { userIdentity: decoded.sub } })
* return {
* ...currentUser,
* roles: parseJWT({ decoded: decoded }).roles,
* }
* }
*/
export const getCurrentUser = async (decoded, { _token, _type }) => {
return { ...decoded, roles: parseJWT({ decoded }).roles }
}

/**
* Use requireAuth in your services to check that a user is logged in,
* whether or not they are assigned a role, and optionally raise an
* error if they're not.
*
* @param {string=} roles - An optional role or list of roles
* @param {string[]=} roles - An optional list of roles

* @example
*
* // checks if currentUser is authenticated
* requireAuth()
*
* @example
*
* // checks if currentUser is authenticated and assigned one of the given roles
* requireAuth({ role: 'admin' })
* requireAuth({ role: ['editor', 'author'] })
* requireAuth({ role: ['publisher'] })
*/
export const requireAuth = ({ role } = {}) => {
if (!context.currentUser) {
throw new AuthenticationError("You don't have permission to do that.")
}

if (
typeof role !== 'undefined' &&
typeof role === 'string' &&
!context.currentUser.roles?.includes(role)
) {
throw new ForbiddenError("You don't have access to do that.")
}

if (
typeof role !== 'undefined' &&
Array.isArray(role) &&
!context.currentUser.roles?.some((r) => role.includes(r))
) {
throw new ForbiddenError("You don't have access to do that.")
}
}
26 changes: 26 additions & 0 deletions api/src/lib/bot/contest.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* @typedef {import('discord.js').Message} Message
*/

const enterRegex = /\benter (\S+)/
const drawRegex = /\bdraw\b/

export default {
/**
* @param {Message} message
*/
match(message) {
return enterRegex.test(message.content) || drawRegex.test(message.content)
},
/**
* @param {Message} message
*/
run(message) {
if (enterRegex.test(message.content)) {
const name = enterRegex.exec(message.content)[1]
message.channel.send(`Entered ${name} (not really)`)
} else if (drawRegex.test(message.content)) {
message.channel.send('Draw not yet implemented')
}
},
}
38 changes: 33 additions & 5 deletions api/src/lib/bot/index.js
Original file line number Diff line number Diff line change
@@ -1,19 +1,47 @@
import '@redwoodjs/api'
import { gql, request } from 'graphql-request'
import Discord from 'discord.js'
import snack from './snack'
import contest from './contest'

const configQuery = gql`
query getConfig($apiKey: String!) {
config(apiKey: $apiKey) {
botToken
}
}
`

class Bot {
constructor() {
this.client = new Discord.Client()
this.client.on('message', (message) => {
if (!message.author.bot && message.mentions.has(this.client.user)) {
message.channel.send('Cheetos!')
this.client.on('message', this.handleMessage)
this.plugins = [contest, snack]
}

handleMessage = (message) => {
if (!message.author.bot && message.mentions.has(this.client.user)) {
for (const plugin of this.plugins) {
if (plugin.match(message)) {
plugin.run(message)
return
}
}
}
}

async loadConfig() {
const apiBase = process.env.API_BASE
const apiKey = process.env.API_KEY
const data = await request(`${apiBase}/graphql`, configQuery, {
apiKey,
})
this.botToken = data.config.botToken
}

async start() {
try {
await this.client.login(process.env.BOT_TOKEN)
await this.loadConfig()
await this.client.login(this.botToken)
} catch (err) {
console.error('Error running bot', err)
}
Expand Down
20 changes: 20 additions & 0 deletions api/src/lib/bot/snack.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* @typedef {import('discord.js').Message} Message
*/

const snacks = ['Cheetos!', 'Almonds', 'Apple', 'Orange']

export default {
/**
* @param {Message} message
*/
match(message) {
return /\bsnack\b/.test(message.content)
},
/**
* @param {Message} message
*/
run(message) {
message.channel.send(snacks[Math.floor(Math.random() * snacks.length)])
},
}
9 changes: 9 additions & 0 deletions api/src/services/config/config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export const config = ({ apiKey }) => {
if (apiKey === process.env.API_KEY) {
return {
botToken: process.env.BOT_TOKEN,
}
} else {
throw new Error('config: Invalid API Key')
}
}
2 changes: 1 addition & 1 deletion redwood.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,4 @@
port = 8911
schemaPath = "./api/db/schema.prisma"
[browser]
open = true
open = false
2 changes: 2 additions & 0 deletions web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@
]
},
"dependencies": {
"@redwoodjs/auth": "^0.23.0",
"@redwoodjs/forms": "^0.23.0",
"@redwoodjs/router": "^0.23.0",
"@redwoodjs/web": "^0.23.0",
"netlify-identity-widget": "^1.9.1",
"prop-types": "^15.7.2",
"react": "^16.13.1",
"react-dom": "^16.13.1"
Expand Down
Loading