mirror of
https://github.com/roleypoly/roleypoly-v1.git
synced 2025-04-25 04:09:12 +00:00
absolutely massive typescript porting time
This commit is contained in:
parent
01f238f515
commit
30d08a630f
159 changed files with 2563 additions and 3861 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -10,3 +10,5 @@ yarn-error\.log
|
|||
\.next/
|
||||
coverage/
|
||||
packages/*/lib
|
||||
*/tsconfig.tsbuildinfo
|
||||
.pnp
|
|
@ -1,4 +1,4 @@
|
|||
FROM node:11.14 AS builder
|
||||
FROM node:12 AS builder
|
||||
WORKDIR /src
|
||||
COPY . /src
|
||||
# we double yarn here to strip off dev-only packages that are needed at build time.
|
||||
|
@ -6,7 +6,7 @@ RUN yarn --frozen-lockfile &&\
|
|||
yarn build &&\
|
||||
yarn --prod --frozen-lockfile
|
||||
|
||||
FROM node:11.14-alpine
|
||||
FROM node:12-alpine
|
||||
ENV NODE_ENV production
|
||||
WORKDIR /dist
|
||||
COPY --from=builder /src /dist
|
||||
|
|
27
package.json
27
package.json
|
@ -9,8 +9,6 @@
|
|||
"dev:backend": "babel-node packages/roleypoly-server/index.js",
|
||||
"dev:bot": "babel-node packages/roleypoly-bot/index.js",
|
||||
"build": "lerna run build",
|
||||
"flow:install": "flow-mono install-types",
|
||||
"flow": "flow",
|
||||
"remotedebug": "remotedebug_ios_webkit_adapter --port=9000 > /dev/null",
|
||||
"test": "jest --color --coverage",
|
||||
"lint": "lerna run lint-staged",
|
||||
|
@ -29,30 +27,21 @@
|
|||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.4.4",
|
||||
"@babel/core": "^7.4.4",
|
||||
"@babel/node": "^7.2.2",
|
||||
"@babel/plugin-proposal-class-properties": "^7.4.4",
|
||||
"@babel/plugin-proposal-optional-chaining": "^7.2.0",
|
||||
"@babel/plugin-syntax-dynamic-import": "^7.2.0",
|
||||
"@babel/core": "^7.4.5",
|
||||
"@babel/plugin-transform-runtime": "^7.4.4",
|
||||
"@babel/preset-env": "^7.4.4",
|
||||
"@babel/preset-flow": "^7.0.0",
|
||||
"@babel/preset-react": "^7.0.0",
|
||||
"@types/enzyme": "^3.9.2",
|
||||
"@types/sinon": "^7.0.11",
|
||||
"@types/enzyme": "^3.9.3",
|
||||
"@types/sinon": "^7.0.12",
|
||||
"@types/webpack-env": "^1.13.9",
|
||||
"await-outside": "^2.1.2",
|
||||
"babel-eslint": "^10.0.1",
|
||||
"babel-jest": "^24.8.0",
|
||||
"babel-loader": "^8.0.6",
|
||||
"babel-plugin-macros": "^2.5.1",
|
||||
"babel-plugin-styled-components": "^1.10.0",
|
||||
"chokidar": "^2.1.5",
|
||||
"chokidar": "^3.0.0",
|
||||
"enzyme": "^3.9.0",
|
||||
"enzyme-adapter-react-16": "^1.13.1",
|
||||
"enzyme-to-json": "^3.3.5",
|
||||
"husky": "^1.3.1",
|
||||
"husky": "^2.3.0",
|
||||
"jest": "^24.8.0",
|
||||
"jest-styled-components": "^6.3.1",
|
||||
"lerna": "^3.14.1",
|
||||
|
@ -70,9 +59,9 @@
|
|||
"stylelint-config-styled-components": "^0.1.1",
|
||||
"stylelint-processor-styled-components": "^1.7.0",
|
||||
"ts-jest": "^24.0.2",
|
||||
"tslint": "^5.16.0",
|
||||
"typescript": "^3.4.5",
|
||||
"typescript-tslint-plugin": "^0.3.1"
|
||||
"tslint": "^5.17.0",
|
||||
"typescript": "^3.5.1",
|
||||
"typescript-tslint-plugin": "^0.4.0"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{ts,tsx}": [
|
||||
|
|
14
packages/roleypoly-bot/.lintstagedrc.yml
Normal file
14
packages/roleypoly-bot/.lintstagedrc.yml
Normal file
|
@ -0,0 +1,14 @@
|
|||
linters:
|
||||
lib/*.{js,jsx}:
|
||||
- standard --fix
|
||||
- git add
|
||||
lib/*.d.ts:
|
||||
- tslint --fix
|
||||
- git add
|
||||
src/*.{ts,tsx}:
|
||||
- tslint --fix
|
||||
- stylelint --fix
|
||||
- jest --bail --findRelatedTests
|
||||
- git add
|
||||
|
||||
|
|
@ -3,15 +3,19 @@
|
|||
"name": "@roleypoly/bot",
|
||||
"version": "2.0.0",
|
||||
"scripts": {
|
||||
"build": "babel --delete-dir-on-start -d lib .",
|
||||
"build": "tsc",
|
||||
"precommit": "lint-staged",
|
||||
"dev": "yarn build --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"async-retry": "^1.2.3",
|
||||
"dotenv": "^7.0.0",
|
||||
"dotenv": "^8.0.0",
|
||||
"eris": "^0.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.4.3"
|
||||
"@types/async-retry": "^1.4.1",
|
||||
"lint-staged": "^8.1.7",
|
||||
"tslint": "^5.17.0",
|
||||
"typescript": "^3.5.1"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,23 +1,24 @@
|
|||
// @flow
|
||||
import Eris, { type Message, type TextChannel, type Guild } from 'eris'
|
||||
import RPCClient from '@roleypoly/rpc-client'
|
||||
// tslint:disable: no-floating-promises
|
||||
|
||||
import Eris, { Message, TextChannel, Guild } from 'eris'
|
||||
// import RPCClient from '@roleypoly/rpc-client'
|
||||
import randomEmoji from './random-emoji'
|
||||
import DMCommands from './commands/dm'
|
||||
import TextCommands from './commands/text'
|
||||
import RootCommands from './commands/root'
|
||||
import type { Command } from './commands/_types'
|
||||
import { Command } from './commands/_types'
|
||||
import retry from 'async-retry'
|
||||
|
||||
import logger from './logger'
|
||||
const log = logger(__filename)
|
||||
|
||||
export type BotConfig = $Shape<{
|
||||
export type BotConfig = {
|
||||
sharedSecret: string,
|
||||
botToken: string,
|
||||
rootUsers: Set<string>,
|
||||
appUrl: string,
|
||||
logChannel: string
|
||||
}>
|
||||
}
|
||||
|
||||
export default class Bot {
|
||||
config: BotConfig = {
|
||||
|
@ -28,23 +29,21 @@ export default class Bot {
|
|||
logChannel: process.env.LOG_CHANNEL || ''
|
||||
}
|
||||
|
||||
client: Eris
|
||||
client: Eris.Client
|
||||
|
||||
$RPC: RPCClient
|
||||
// $RPC: RPCClient
|
||||
|
||||
rpc: *
|
||||
// rpc: typeof RPCClient
|
||||
|
||||
commandCheck: RegExp
|
||||
commandCheck: RegExp = new RegExp(`^<@!?${process.env.DISCORD_CLIENT_ID}>`)
|
||||
|
||||
constructor (config: BotConfig = {}) {
|
||||
if (config != null) {
|
||||
constructor (config: Partial<BotConfig> = {}) {
|
||||
this.config = {
|
||||
...this.config,
|
||||
...config
|
||||
}
|
||||
}
|
||||
|
||||
this.client = new Eris(this.config.botToken, {
|
||||
this.client = new Eris.Client(this.config.botToken, {
|
||||
disableEveryone: true,
|
||||
maxShards: 'auto',
|
||||
messageLimit: 10,
|
||||
|
@ -64,8 +63,8 @@ export default class Bot {
|
|||
}
|
||||
})
|
||||
|
||||
this.$RPC = new RPCClient({ forceDev: false, retry: true })
|
||||
this.rpc = this.$RPC.withBotAuth(this.config.sharedSecret)
|
||||
// this.$RPC = new RPCClient({ forceDev: false, retry: true })
|
||||
// this.rpc = this.$RPC.withBotAuth(this.config.sharedSecret)
|
||||
this.botPing()
|
||||
|
||||
if (this.config.sharedSecret === '') {
|
||||
|
@ -85,17 +84,16 @@ export default class Bot {
|
|||
}
|
||||
|
||||
sendErrorLog (msg: Message, err: Error) {
|
||||
if (this.config.logChannel === '') return
|
||||
if (this.config.logChannel === '') {
|
||||
return
|
||||
}
|
||||
|
||||
this.client.createMessage(
|
||||
this.config.logChannel,
|
||||
`❌ **Command errored:**\nUser > <@${msg.author.id}> (${msg.author.username}#${msg.author.discriminator}) \nChannel > <#${msg.channel.id}> in ${(msg.channel: any)?.guild?.name || 'DM'}\nInput > \`${msg.content}\`\nTrace > \`\`\`${err.stack}\`\`\``
|
||||
)
|
||||
this.client.createMessage(this.config.logChannel, `command errored.`)
|
||||
}
|
||||
|
||||
botPing = async () => {
|
||||
try {
|
||||
await this.rpc.botPing()
|
||||
// await this.rpc.botPing()
|
||||
} catch (e) {
|
||||
log.fatal('bot failed to connect to RPC server.', e)
|
||||
}
|
||||
|
@ -116,15 +114,15 @@ export default class Bot {
|
|||
name: `${author.username}#${author.discriminator}`
|
||||
},
|
||||
channel: {
|
||||
id: channel.id,
|
||||
name: channel.name || ''
|
||||
id: channel.id
|
||||
// name: channel.name || ''
|
||||
},
|
||||
guild: {}
|
||||
}
|
||||
|
||||
try {
|
||||
if (type === 0) {
|
||||
const { id, name } = ((msg.channel: any): TextChannel).guild
|
||||
const { id, name } = (msg.channel as TextChannel).guild
|
||||
o.guild = {
|
||||
id,
|
||||
name
|
||||
|
@ -138,28 +136,30 @@ export default class Bot {
|
|||
}
|
||||
|
||||
// this is a very silly O(n) matcher. i bet we can do better later.
|
||||
findCmdIn (text: string, set: Set<Command>): ?{cmd: Command, matches: string[]} {
|
||||
for (const cmd of set) {
|
||||
findCmdIn (text: string, items: Set<Command>): { cmd: Command, matches: string[] } | undefined {
|
||||
for (const cmd of items) {
|
||||
const matches = text.match(cmd.regex)
|
||||
if (matches != null) {
|
||||
if (matches !== null) {
|
||||
return { cmd, matches }
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
async tryCommand (msg: Message, text: string, startTime: number, ...sets: Array<Set<Command> | null>): Promise<boolean> {
|
||||
for (const set of sets) {
|
||||
if (set == null) {
|
||||
if (set === null) {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const cmd of set) {
|
||||
const matches = text.match(cmd.regex)
|
||||
if (matches != null) {
|
||||
if (matches !== null) {
|
||||
// handle!
|
||||
try {
|
||||
const result = await cmd.handler(this, msg, matches.slice(1))
|
||||
if (result != null) {
|
||||
if (result !== undefined) {
|
||||
await msg.channel.createMessage(result)
|
||||
}
|
||||
} catch (e) {
|
||||
|
@ -182,7 +182,7 @@ export default class Bot {
|
|||
|
||||
// only react if there's a mention of myself.
|
||||
const check = msg.mentions.find(v => v.id === this.client.user.id)
|
||||
if (check == null) {
|
||||
if (check === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -206,7 +206,7 @@ export default class Bot {
|
|||
}
|
||||
}
|
||||
|
||||
const channel: TextChannel = (msg.channel: any)
|
||||
const channel: TextChannel = msg.channel as TextChannel
|
||||
channel.createMessage(`${randomEmoji()} Assign your roles here! ${this.config.appUrl}/s/${channel.guild.id}`)
|
||||
}
|
||||
|
||||
|
@ -227,10 +227,12 @@ export default class Bot {
|
|||
case 1: // dm channel
|
||||
return this.handleDMMessage(msg)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
async syncGuild (type: string, guild: Guild) {
|
||||
await this.rpc.syncGuild(type, guild.id)
|
||||
// await this.rpc.syncGuild(type, guild.id)
|
||||
}
|
||||
|
||||
async start () {
|
|
@ -1,6 +1,5 @@
|
|||
// @flow
|
||||
import type { Message } from 'eris'
|
||||
import type Bot from '../Bot'
|
||||
import { Message } from 'eris'
|
||||
import Bot from '../Bot'
|
||||
|
||||
export type Command = {
|
||||
regex: RegExp,
|
|
@ -3,4 +3,4 @@ import 'dotenv/config'
|
|||
import Bot from './Bot'
|
||||
|
||||
const B = new Bot()
|
||||
B.start()
|
||||
B.start().catch()
|
|
@ -1,4 +1,3 @@
|
|||
// @flow
|
||||
export const emojis = [
|
||||
':beginner:',
|
||||
':beginner:',
|
|
@ -1,8 +1,8 @@
|
|||
// @flow
|
||||
import type Bot from './Bot'
|
||||
import type { Message } from 'eris'
|
||||
import Bot from './Bot'
|
||||
import { Message } from 'eris'
|
||||
|
||||
export const withTyping = (fn: Function) => async (bot: Bot, msg: Message, ...args: any[]) => {
|
||||
msg.channel.sendTyping()
|
||||
await msg.channel.sendTyping()
|
||||
return fn(bot, msg, ...args)
|
||||
}
|
9
packages/roleypoly-bot/tsconfig.json
Normal file
9
packages/roleypoly-bot/tsconfig.json
Normal file
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./lib"
|
||||
},
|
||||
"include": [
|
||||
"./src"
|
||||
]
|
||||
}
|
3
packages/roleypoly-bot/tslint.json
Normal file
3
packages/roleypoly-bot/tslint.json
Normal file
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"extends": "tslint-config-standard"
|
||||
}
|
|
@ -1,11 +1,9 @@
|
|||
{
|
||||
"presets": [
|
||||
"next/babel",
|
||||
"@babel/preset-typescript",
|
||||
"@zeit/next-typescript/babel"
|
||||
],
|
||||
"plugins": [
|
||||
"@babel/plugin-syntax-dynamic-import",
|
||||
"@babel/plugin-proposal-class-properties",
|
||||
[
|
||||
"@babel/plugin-transform-runtime",
|
||||
{
|
||||
|
@ -18,8 +16,6 @@
|
|||
"ssr": true
|
||||
}
|
||||
],
|
||||
"@babel/plugin-proposal-optional-chaining",
|
||||
"macros"
|
||||
],
|
||||
"env": {
|
||||
"test": {
|
||||
|
@ -32,10 +28,6 @@
|
|||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"plugins": [
|
||||
"require-context-hook",
|
||||
"macros"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,8 +4,11 @@ linters:
|
|||
- git add
|
||||
lib/*.d.ts:
|
||||
- tslint --fix
|
||||
- git add
|
||||
src/*.{ts,tsx}:
|
||||
- tslint --fix
|
||||
- stylelint --fix
|
||||
- jest --bail --findRelatedTests
|
||||
- git add
|
||||
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
<script src="https://use.typekit.net/bck0pci.js"></script>
|
||||
<script>try { Typekit.load(); } catch (e) { }</script>
|
||||
<script src="https://use.typekit.net/bck0pci.js" async></script>
|
||||
<script async>try { Typekit.load({ async: true }); } catch (e) { }</script>
|
||||
<style>body{font-family: "source-han-sans-japanese", "Source Sans Pro", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol" !important;}</style>
|
|
@ -11,6 +11,7 @@
|
|||
"files": [
|
||||
"lib"
|
||||
],
|
||||
"types": "./lib/index.d.ts",
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.4.4",
|
||||
"@babel/preset-typescript": "^7.3.3",
|
||||
|
@ -25,7 +26,7 @@
|
|||
"@types/enzyme-adapter-react-16": "^1.0.5",
|
||||
"@types/jest": "^24.0.13",
|
||||
"@types/node": "^12.0.2",
|
||||
"@types/react": "^16.8.17",
|
||||
"@types/react": "^16.8.19",
|
||||
"@types/react-dom": "^16.8.4",
|
||||
"@types/react-test-renderer": "^16.8.1",
|
||||
"@types/storybook__addon-actions": "^3.4.2",
|
||||
|
@ -36,16 +37,14 @@
|
|||
"@types/styled-components": "4.1.8",
|
||||
"awesome-typescript-loader": "^5.2.1",
|
||||
"babel-loader": "^8.0.6",
|
||||
"babel-plugin-macros": "^2.5.1",
|
||||
"babel-plugin-require-context-hook": "^1.0.0",
|
||||
"lint-staged": "^8.1.7",
|
||||
"react-docgen-typescript-loader": "^3.1.0",
|
||||
"react-test-renderer": "^16.8.6",
|
||||
"require-context.macro": "^1.0.4",
|
||||
"sinon": "^7.3.2",
|
||||
"stylelint": "^10.0.1",
|
||||
"tslint": "^5.16.0",
|
||||
"tslint": "^5.17.0",
|
||||
"tslint-config-standard": "^8.0.1",
|
||||
"typescript": "^3.4.5"
|
||||
"typescript": "^3.5.1"
|
||||
},
|
||||
"scripts": {
|
||||
"storybook": "start-storybook -p 6006",
|
||||
|
|
|
@ -45,25 +45,155 @@ exports[`Storyshots Button Disabled 1`] = `
|
|||
`;
|
||||
|
||||
exports[`Storyshots Button Loading 1`] = `
|
||||
<styled.button
|
||||
<Button
|
||||
disabled={false}
|
||||
loadingPct={0}
|
||||
onClick={[Function]}
|
||||
primary={false}
|
||||
secondary={false}
|
||||
theme={
|
||||
loading={false}
|
||||
loadingPct={100}
|
||||
onButtonPress={[Function]}
|
||||
overrides={
|
||||
Object {
|
||||
"BaseButton": Object {
|
||||
"component": Object {
|
||||
"$$typeof": Symbol(react.forward_ref),
|
||||
"attrs": Array [],
|
||||
"componentStyle": ComponentStyle {
|
||||
"componentId": "sc-bxivhb",
|
||||
"isStatic": false,
|
||||
"rules": Array [
|
||||
"
|
||||
/* reset some styles */
|
||||
box-shadow: none;
|
||||
appearance: none;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
background-color: rgba(0,0,0,0.1);
|
||||
|
||||
/* real styles */
|
||||
position: relative;
|
||||
transition: all 0.05s ease-in-out;
|
||||
padding: 1em 1.4em;
|
||||
font-weight: bold;
|
||||
border: 1px solid rgba(0,0,0,0.1);
|
||||
border-radius: ",
|
||||
[Function],
|
||||
";
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&:not(:disabled) {
|
||||
&::after {
|
||||
content: \\"\\";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
|
||||
/* transparent by default */
|
||||
background-color: transparent;
|
||||
|
||||
transition: background-color 0.05s ease-in-out;
|
||||
|
||||
/* put the overlay behind the text */
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
/* on hover, raise, brighten and shadow */
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
|
||||
|
||||
&::after {
|
||||
background-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
/* on click, lower and darken */
|
||||
&:active {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
border-color: rgba(0,0,0,0.2);
|
||||
|
||||
transform: translateY(0);
|
||||
|
||||
&::after {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
",
|
||||
"
|
||||
--right: 0%;
|
||||
&::after {
|
||||
|
||||
content: \\"\\";
|
||||
/* z-index: ; */
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: var(--right);
|
||||
border-radius: ",
|
||||
[Function],
|
||||
";
|
||||
position: absolute;
|
||||
opacity: 0.5;
|
||||
/* background-color: red; */
|
||||
background-image: linear-gradient(to right, 0px rgba(0,0,0,1), 100% red);
|
||||
|
||||
animation: ",
|
||||
Keyframes {
|
||||
"id": "sc-keyframes-cwUnhJ",
|
||||
"inject": [Function],
|
||||
"name": "cwUnhJ",
|
||||
"rules": Array [
|
||||
"@-webkit-keyframes cwUnhJ{0%{background-image:linear-gradient(to right,0px transparent,1px red);}100%{background-image:linear-gradient(to right,0px transparent,100% red);}}",
|
||||
"@keyframes cwUnhJ{0%{background-image:linear-gradient(to right,0px transparent,1px red);}100%{background-image:linear-gradient(to right,0px transparent,100% red);}}",
|
||||
],
|
||||
"toString": [Function],
|
||||
},
|
||||
" forwards infinite 1s;
|
||||
}
|
||||
",
|
||||
],
|
||||
},
|
||||
"defaultProps": Object {
|
||||
"loadingPct": 100,
|
||||
"theme": Object {
|
||||
"actions": Object {
|
||||
"primary": "#46b646",
|
||||
},
|
||||
"button": Object {
|
||||
"borderRadius": "2px",
|
||||
},
|
||||
},
|
||||
},
|
||||
"displayName": "Styled(styled.button)",
|
||||
"foldedComponentIds": Array [
|
||||
"sc-bdVaJa",
|
||||
],
|
||||
"render": [Function],
|
||||
"styledComponentId": "sc-bxivhb",
|
||||
"target": "button",
|
||||
"toString": [Function],
|
||||
"warnTooManyClasses": [Function],
|
||||
"withComponent": [Function],
|
||||
},
|
||||
"props": Object {
|
||||
"disabled": true,
|
||||
"style": Object {
|
||||
"--right": "0%",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
primary={false}
|
||||
secondary={false}
|
||||
>
|
||||
Example
|
||||
</styled.button>
|
||||
</Button>
|
||||
`;
|
||||
|
||||
exports[`Storyshots Button Primary 1`] = `
|
||||
|
|
|
@ -1,10 +1,16 @@
|
|||
import * as React from 'react'
|
||||
import { storiesOf } from '@storybook/react'
|
||||
import { action } from '@storybook/addon-actions'
|
||||
import { number, withKnobs } from '@storybook/addon-knobs'
|
||||
|
||||
import Button, { PrimaryButton, SecondaryButton } from './Button'
|
||||
import Button, {
|
||||
PrimaryButton,
|
||||
SecondaryButton,
|
||||
LoadingButton
|
||||
} from './Button'
|
||||
|
||||
const s = storiesOf('Button', module)
|
||||
s.addDecorator(withKnobs)
|
||||
|
||||
const pressed = action('button pressed!')
|
||||
|
||||
|
@ -12,4 +18,4 @@ s.add('Default', () => <Button onButtonPress={pressed}>Example</Button>)
|
|||
s.add('Disabled', () => <Button disabled onButtonPress={pressed}>Example</Button>)
|
||||
s.add('Primary', () => <PrimaryButton onButtonPress={pressed}>Example</PrimaryButton>)
|
||||
s.add('Secondary', () => <SecondaryButton onButtonPress={pressed}>Example</SecondaryButton>)
|
||||
s.add('Loading', () => <Button loading onButtonPress={pressed}>Example</Button>)
|
||||
s.add('Loading', () => <LoadingButton onButtonPress={pressed} loadingPct={number('Loading percentage', 100, ({ max: 100, min: 0, step: 1 } as any))}>Example</LoadingButton>)
|
||||
|
|
|
@ -1,8 +1,11 @@
|
|||
import * as React from 'react'
|
||||
import { shallow } from 'enzyme'
|
||||
import * as sinon from 'sinon'
|
||||
import 'jest-styled-components'
|
||||
|
||||
import Button from './Button'
|
||||
// import { StyledLoadingButton } from './styled'
|
||||
// import { ButtonProps } from './types'
|
||||
|
||||
describe('<Button />', () => {
|
||||
it('calls onButtonPress when not disabled', () => {
|
||||
|
@ -21,3 +24,10 @@ describe('<Button />', () => {
|
|||
expect(spy.notCalled).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('<StyledLoadingButton />', () => {
|
||||
// it('clamps between 0-100%', () => {
|
||||
// const button = shallow<ButtonProps>(<StyledLoadingButton loadingPct={-100} />)
|
||||
|
||||
// })
|
||||
})
|
||||
|
|
|
@ -5,12 +5,15 @@ import {
|
|||
import {
|
||||
StyledButton,
|
||||
StyledPrimaryButton,
|
||||
StyledSecondaryButton
|
||||
StyledSecondaryButton,
|
||||
StyledLoadingButton
|
||||
} from './styled'
|
||||
import {
|
||||
ButtonProps
|
||||
} from './types'
|
||||
|
||||
const clampPct = (i: number): number => Math.max(0, Math.min(100, i))
|
||||
|
||||
export default class Button extends React.Component<ButtonProps> {
|
||||
static defaultProps: ButtonProps = {
|
||||
disabled: false,
|
||||
|
@ -64,3 +67,15 @@ export const SecondaryButton = (props: ButtonProps) => <Button {...props} overri
|
|||
component: StyledSecondaryButton
|
||||
}
|
||||
}} />
|
||||
|
||||
export const LoadingButton = (props: ButtonProps & { loadingPct?: number }) => <Button {...props} overrides={{
|
||||
BaseButton: {
|
||||
component: StyledLoadingButton,
|
||||
props: {
|
||||
disabled: true,
|
||||
style: {
|
||||
'--right': `${clampPct(100 - (props.loadingPct || 0))}%`
|
||||
}
|
||||
}
|
||||
}
|
||||
}} />
|
||||
|
|
|
@ -1,4 +1,10 @@
|
|||
export { default as default } from './Button'
|
||||
export {
|
||||
default as default,
|
||||
default as Button,
|
||||
PrimaryButton,
|
||||
SecondaryButton,
|
||||
LoadingButton
|
||||
} from './Button'
|
||||
|
||||
export {
|
||||
StyledButton
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
// import * as React from 'react'
|
||||
import styled from 'styled-components'
|
||||
import styled, { keyframes } from 'styled-components'
|
||||
|
||||
import * as colorHelpers from '../helpers/colors'
|
||||
|
||||
// import { ButtonProps } from './types'
|
||||
|
||||
export const StyledButton = styled.button`
|
||||
/* reset some styles */
|
||||
box-shadow: none;
|
||||
|
@ -95,3 +97,37 @@ export const StyledSecondaryButton = styled(StyledButton)`
|
|||
border-color: ${props => colorHelpers.stepDown(props.theme.actions.primary)};
|
||||
}
|
||||
`
|
||||
|
||||
const loadingAnim = keyframes`
|
||||
0% {
|
||||
background-image: linear-gradient(to right, 0px transparent, 1px red);
|
||||
}
|
||||
|
||||
100% {
|
||||
background-image: linear-gradient(to right, 0px transparent, 100% red);
|
||||
}
|
||||
`
|
||||
export const StyledLoadingButton = styled(StyledButton)<{loadingPct?: number}>`
|
||||
--right: 0%;
|
||||
&::after {
|
||||
|
||||
content: "";
|
||||
/* z-index: ; */
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: var(--right);
|
||||
border-radius: ${props => props.theme.button.borderRadius};
|
||||
position: absolute;
|
||||
opacity: 0.5;
|
||||
/* background-color: red; */
|
||||
background-image: linear-gradient(to right, 0px rgba(0,0,0,1), 100% red);
|
||||
|
||||
animation: ${loadingAnim} forwards infinite 1s;
|
||||
}
|
||||
`
|
||||
|
||||
StyledLoadingButton.defaultProps = {
|
||||
...StyledButton.defaultProps,
|
||||
loadingPct: 100
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import * as Color from 'color'
|
||||
|
||||
const color = (i: Color | string) => {
|
||||
export const color = (i: Color | string) => {
|
||||
return new Color(i)
|
||||
}
|
||||
|
||||
|
|
4
packages/roleypoly-design/src/index.ts
Normal file
4
packages/roleypoly-design/src/index.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
import * as Colors from './helpers/colors'
|
||||
export { Colors }
|
||||
|
||||
export * from './button'
|
|
@ -1,10 +0,0 @@
|
|||
{
|
||||
"presets": [ "@babel/preset-env", "@babel/preset-flow" ],
|
||||
"plugins": [
|
||||
"@babel/plugin-syntax-dynamic-import",
|
||||
"@babel/plugin-proposal-class-properties",
|
||||
"@babel/plugin-proposal-optional-chaining",
|
||||
["@babel/plugin-transform-runtime",
|
||||
{ "helpers": false }]
|
||||
]
|
||||
}
|
|
@ -1,20 +0,0 @@
|
|||
{
|
||||
"private": true,
|
||||
"name": "@roleypoly/rpc-client",
|
||||
"version": "2.0.0",
|
||||
"scripts": {
|
||||
"build": "babel -d ./lib ./src",
|
||||
"dev": "yarn build --watch",
|
||||
"postinstall": "test -d lib || npm run build"
|
||||
},
|
||||
"types": "src/index.js",
|
||||
"main": "lib/index.js",
|
||||
"files": ["lib"],
|
||||
"dependencies": {
|
||||
"async-retry": "^1.2.3",
|
||||
"superagent": "^5.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.4.3"
|
||||
}
|
||||
}
|
|
@ -1,21 +0,0 @@
|
|||
// @flow
|
||||
import type { RPCResponse } from './index'
|
||||
|
||||
class RPCError extends Error {
|
||||
code: ?number
|
||||
extra: any[]
|
||||
remoteStack: ?string
|
||||
constructor (msg: string, code?: number, ...extra: any[]) {
|
||||
super(msg)
|
||||
this.code = code
|
||||
this.extra = extra
|
||||
}
|
||||
|
||||
static fromResponse (body: RPCResponse, status: number) {
|
||||
const e = new RPCError(body.msg, status)
|
||||
e.remoteStack = body.trace
|
||||
return e
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = RPCError
|
|
@ -1,160 +0,0 @@
|
|||
// @flow
|
||||
import superagent from 'superagent'
|
||||
import RPCError from './error.js'
|
||||
import retry from 'async-retry'
|
||||
|
||||
export { RPCError }
|
||||
|
||||
export type RPCResponse = {
|
||||
response?: mixed,
|
||||
hash?: string,
|
||||
|
||||
// error stuff
|
||||
error?: boolean,
|
||||
msg: string,
|
||||
trace?: string
|
||||
}
|
||||
|
||||
export type RPCRequest = {
|
||||
fn: string,
|
||||
args: mixed[]
|
||||
}
|
||||
|
||||
export default class RPCClient {
|
||||
dev: boolean = false
|
||||
baseUrl: string
|
||||
firstKnownHash: string
|
||||
recentHash: string
|
||||
cookieHeader: string
|
||||
|
||||
headerMixins: {
|
||||
[x:string]: string
|
||||
} = {}
|
||||
|
||||
rpc: {
|
||||
[fn: string]: (...args: any[]) => Promise<any>
|
||||
} = {}
|
||||
|
||||
__rpcAvailable: Array<{
|
||||
name: string,
|
||||
args: number
|
||||
}> = []
|
||||
|
||||
retry: boolean
|
||||
|
||||
constructor ({ forceDev, baseUrl = '/api/_rpc', retry = false }: { forceDev?: boolean, baseUrl?: string, retry?: boolean } = {}) {
|
||||
this.baseUrl = (process.env.APP_URL || '') + baseUrl
|
||||
|
||||
if (forceDev != null) {
|
||||
this.dev = forceDev
|
||||
} else {
|
||||
this.dev = process.env.NODE_ENV === 'development'
|
||||
}
|
||||
|
||||
this.retry = retry
|
||||
|
||||
this.rpc = new Proxy({}, {
|
||||
get: this.__rpcCall,
|
||||
has: this.__checkCall,
|
||||
ownKeys: this.__listCalls,
|
||||
delete: () => {}
|
||||
})
|
||||
|
||||
if (this.dev) {
|
||||
this.updateCalls()
|
||||
}
|
||||
}
|
||||
|
||||
withCookies = (h: string) => {
|
||||
this.cookieHeader = h
|
||||
return this.rpc
|
||||
}
|
||||
|
||||
withBotAuth = (h: string) => {
|
||||
this.headerMixins['Authorization'] = `Bot ${h}`
|
||||
return this.rpc
|
||||
}
|
||||
|
||||
async updateCalls () {
|
||||
// this is for development only. doing in prod is probably dumb.
|
||||
const rsp = await superagent.get(this.baseUrl)
|
||||
if (rsp.status !== 200) {
|
||||
console.error(rsp)
|
||||
return
|
||||
}
|
||||
|
||||
const { hash, available } = rsp.body
|
||||
|
||||
this.__rpcAvailable = available
|
||||
if (this.firstKnownHash == null) {
|
||||
this.firstKnownHash = hash
|
||||
}
|
||||
|
||||
this.recentHash = hash
|
||||
|
||||
// just kinda prefill. none of these get called anyway.
|
||||
// and don't matter in prod either.
|
||||
for (let { name } of available) {
|
||||
this.rpc[name] = async () => {}
|
||||
}
|
||||
}
|
||||
|
||||
call (fn: string, ...args: any[]): mixed {
|
||||
// console.debug('rpc call:', { fn, args })
|
||||
if (this.retry) {
|
||||
return this.callWithRetry(fn, ...args)
|
||||
} else {
|
||||
return this.callAsNormal(fn, ...args)
|
||||
}
|
||||
}
|
||||
|
||||
async callWithRetry (fn: string, ...args: any[]): mixed {
|
||||
return retry<mixed>(async (bail) => {
|
||||
try {
|
||||
return await this.callAsNormal(fn, ...args)
|
||||
} catch (e) {
|
||||
if (e instanceof RPCError) {
|
||||
bail(e)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async callAsNormal (fn: string, ...args: any[]): mixed {
|
||||
const req: RPCRequest = { fn, args }
|
||||
const rq = superagent.post(this.baseUrl).set({ ...this.headerMixins })
|
||||
|
||||
if (this.cookieHeader != null && this.cookieHeader !== '') {
|
||||
rq.cookies = this.cookieHeader
|
||||
}
|
||||
|
||||
const rsp = await rq.send(req).ok(() => true)
|
||||
const body: RPCResponse = rsp.body
|
||||
|
||||
// console.log(body)
|
||||
if (body.error === true) {
|
||||
console.error(body)
|
||||
throw RPCError.fromResponse(body, rsp.status)
|
||||
}
|
||||
|
||||
if (body.hash != null) {
|
||||
if (this.firstKnownHash == null) {
|
||||
this.firstKnownHash = body.hash
|
||||
}
|
||||
|
||||
this.recentHash = body.hash
|
||||
|
||||
if (this.firstKnownHash !== this.recentHash) {
|
||||
this.updateCalls()
|
||||
}
|
||||
}
|
||||
|
||||
return body.response
|
||||
}
|
||||
|
||||
// PROXY HANDLERS
|
||||
// __rpcCall = (_: {}, fn: string) => this.call.bind(this, fn)
|
||||
__rpcCall = (_: {}, fn: string) => this.call.bind(this, fn)
|
||||
__checkCall = (_: {}, fn: string) => this.dev ? this.__listCalls(_).includes(fn) : true
|
||||
__listCalls = (_: {}): string[] => this.__rpcAvailable.map(x => x.name)
|
||||
}
|
|
@ -1,11 +0,0 @@
|
|||
{
|
||||
"presets": [ ["@babel/preset-env", { "targets": {"node": "current"} }], "@babel/preset-flow" ],
|
||||
"plugins": [
|
||||
"@babel/plugin-syntax-dynamic-import",
|
||||
"@babel/plugin-proposal-class-properties",
|
||||
"@babel/plugin-proposal-optional-chaining",
|
||||
"@babel/plugin-proposal-export-namespace-from",
|
||||
["@babel/plugin-transform-runtime",
|
||||
{ "helpers": false }]
|
||||
]
|
||||
}
|
|
@ -1,24 +0,0 @@
|
|||
{
|
||||
"private": true,
|
||||
"name": "@roleypoly/rpc-server",
|
||||
"version": "2.0.0",
|
||||
"scripts": {
|
||||
"build": "babel -d ./lib ./src",
|
||||
"dev": "yarn build --watch",
|
||||
"postinstall": "test -d lib || npm run build"
|
||||
},
|
||||
"types": "src/index.js",
|
||||
"main": "lib/index.js",
|
||||
"files": [
|
||||
"lib"
|
||||
],
|
||||
"dependencies": {
|
||||
"async-retry": "^1.2.3",
|
||||
"glob": "^7.1.3",
|
||||
"nats": "^1.2.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.4.3",
|
||||
"@babel/plugin-proposal-export-namespace-from": "^7.2.0"
|
||||
}
|
||||
}
|
|
@ -1,53 +0,0 @@
|
|||
// @flow
|
||||
import logger from './logger'
|
||||
import glob from 'glob'
|
||||
import path from 'path'
|
||||
|
||||
const log = logger(__filename)
|
||||
const PROD: boolean = process.env.NODE_ENV === 'production'
|
||||
|
||||
export default (globString: string, ctx: any, forceClear: ?boolean = false): {
|
||||
[rpc: string]: Function
|
||||
} => {
|
||||
let map = {}
|
||||
const apis = glob.sync(globString)
|
||||
log.debug('found rpcs', apis)
|
||||
|
||||
for (let a of apis) {
|
||||
const filename = path.basename(a)
|
||||
// const dirname = path.dirname(a)
|
||||
|
||||
const pathname = a
|
||||
delete require.cache[require.resolve(pathname)]
|
||||
|
||||
// internal stuff
|
||||
if (filename.startsWith('_')) {
|
||||
log.debug(`skipping ${a}`)
|
||||
continue
|
||||
}
|
||||
|
||||
// testing only
|
||||
if (filename.endsWith('_test.js') && PROD) {
|
||||
log.debug(`skipping ${a}`)
|
||||
continue
|
||||
}
|
||||
|
||||
log.debug(`mounting ${a}`)
|
||||
try {
|
||||
const r = require(pathname)
|
||||
let o = r
|
||||
if (o.default) {
|
||||
o = r.default
|
||||
}
|
||||
|
||||
map = {
|
||||
...map,
|
||||
...o(ctx)
|
||||
}
|
||||
} catch (e) {
|
||||
log.error(`couldn't mount ${a}`, e)
|
||||
}
|
||||
}
|
||||
|
||||
return map
|
||||
}
|
|
@ -1,143 +0,0 @@
|
|||
// @flow
|
||||
import fnv from 'fnv-plus'
|
||||
import autoloader from './autoloader'
|
||||
import { RPCError } from '@roleypoly/rpc-client'
|
||||
import type Roleypoly, { Router } from '@roleypoly/server/Roleypoly'
|
||||
import type { Context } from 'koa'
|
||||
export * as secureAs from './security'
|
||||
// import logger from '../logger'
|
||||
// const log = logger(__filename)
|
||||
|
||||
export type RPCIncoming = {
|
||||
fn: string,
|
||||
args: any[]
|
||||
}
|
||||
|
||||
export type RPCOutgoing = {
|
||||
hash: string,
|
||||
response: any
|
||||
}
|
||||
|
||||
export default class RPCServer {
|
||||
ctx: Roleypoly
|
||||
|
||||
rpcMap: {
|
||||
[rpc: string]: Function
|
||||
}
|
||||
|
||||
mapHash: string
|
||||
rpcCalls: { name: string, args: number }[]
|
||||
|
||||
constructor (ctx: Roleypoly) {
|
||||
this.ctx = ctx
|
||||
this.reload()
|
||||
ctx.addRouteHook(this.hookRoutes)
|
||||
}
|
||||
|
||||
reload () {
|
||||
// actual function map
|
||||
this.rpcMap = autoloader(this.ctx.ctx)
|
||||
|
||||
// hash of the map
|
||||
// used for known-reloads in the client.
|
||||
this.mapHash = fnv.hash(Object.keys(this.rpcMap)).str()
|
||||
|
||||
// call map for the client.
|
||||
this.rpcCalls = Object.keys(this.rpcMap).map(fn => ({ name: fn, args: 0 }))
|
||||
}
|
||||
|
||||
hookRoutes = (router: Router) => {
|
||||
// RPC call reporter.
|
||||
// this is NEVER called in prod.
|
||||
// it is used to generate errors if RPC calls don't exist or are malformed in dev.
|
||||
router.get('/api/_rpc', async (ctx: Context) => {
|
||||
ctx.body = ({
|
||||
hash: this.mapHash,
|
||||
available: this.rpcCalls
|
||||
}: any)
|
||||
ctx.status = 200
|
||||
return true
|
||||
})
|
||||
|
||||
router.post('/api/_rpc', this.handleRPC)
|
||||
}
|
||||
|
||||
handleRPC = async (ctx: Context) => {
|
||||
// handle an impossible situation
|
||||
if (!(ctx.request.body instanceof Object)) {
|
||||
return this.rpcError(ctx, null, new RPCError('RPC format was very incorrect.', 400))
|
||||
}
|
||||
|
||||
// check if RPC exists
|
||||
const { fn, args } = ((ctx.request.body: any): RPCIncoming)
|
||||
|
||||
if (!(fn in this.rpcMap)) {
|
||||
return this.rpcError(ctx, null, new RPCError(`RPC call ${fn}(...) not found.`, 404))
|
||||
}
|
||||
|
||||
const call = this.rpcMap[fn]
|
||||
|
||||
// if call.length (which is the solid args list)
|
||||
// is longer than args, we have too little to call the function.
|
||||
// if (args.length < call.length) {
|
||||
// return this.rpcError(ctx, null, new RPCError(`RPC call ${fn}() with ${args.length} arguments does not exist.`, 400))
|
||||
// }
|
||||
|
||||
try {
|
||||
const response = await call(ctx, ...args)
|
||||
|
||||
ctx.body = {
|
||||
hash: this.mapHash,
|
||||
response
|
||||
}
|
||||
|
||||
ctx.status = 200
|
||||
} catch (err) {
|
||||
return this.rpcError(ctx, 'RPC call errored', err, 500)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* For internally called stuff, such as from a bot shard.
|
||||
*/
|
||||
async call (fn: string, ...args: any[]) {
|
||||
if (!(fn in this.rpcMap)) {
|
||||
throw new RPCError(`RPC call ${fn}(...) not found.`, 404)
|
||||
}
|
||||
|
||||
const call = this.rpcMap[fn]
|
||||
return call(...args)
|
||||
}
|
||||
|
||||
rpcError (ctx: Context & {body: any}, msg: ?string, err: ?Error = null, code: ?number = null) {
|
||||
// log.error('rpc error', { msg, err })
|
||||
|
||||
ctx.body = {
|
||||
msg: err?.message || msg,
|
||||
error: true
|
||||
}
|
||||
|
||||
ctx.status = code || 500
|
||||
if (err != null) {
|
||||
if (err instanceof RPCError || err.constructor.name === 'RPCError') {
|
||||
// $FlowFixMe
|
||||
ctx.status = err.code
|
||||
}
|
||||
}
|
||||
|
||||
// if (err != null && err.constructor.name === 'RPCError') {
|
||||
// console.log({ status: err.code })
|
||||
// // this is one of our own errors, so we have a lot of data on this.
|
||||
// ctx.status = err.code // || code || 500
|
||||
// ctx.body.msg = `${err.message || msg || 'RPC Error'}`
|
||||
// } else {
|
||||
// if (msg == null && err != null) {
|
||||
// ctx.body.msg = err.message
|
||||
// }
|
||||
|
||||
// // if not, just play cloudflare, say something was really weird, and move on.
|
||||
// ctx.status = code || 520
|
||||
// ctx.message = 'Unknown Error'
|
||||
// }
|
||||
}
|
||||
}
|
|
@ -1,181 +0,0 @@
|
|||
// @flow
|
||||
import { type AppContext } from '@roleypoly/server/Roleypoly'
|
||||
import { type Context } from 'koa'
|
||||
import { RPCError } from '@roleypoly/rpc-client'
|
||||
|
||||
import logger from '../../roleypoly-server/logger'
|
||||
const log = logger(__filename)
|
||||
|
||||
const PermissionError = new RPCError('User does not have permission to call this RPC.', 403)
|
||||
|
||||
const logFacts = (
|
||||
ctx: Context,
|
||||
extra: { [x:string]: any } = {}
|
||||
) => ({
|
||||
fn: (ctx.request.body: any).fn,
|
||||
ip: ctx.ip,
|
||||
user: ctx.session.userId,
|
||||
...extra
|
||||
})
|
||||
|
||||
export const authed = (
|
||||
$: AppContext,
|
||||
fn: (ctx: Context, ...args: any[]) => any,
|
||||
silent: boolean = false
|
||||
) => async (
|
||||
ctx: Context,
|
||||
...args: any[]
|
||||
) => {
|
||||
if (await $.auth.isLoggedIn(ctx)) {
|
||||
return fn(ctx, ...args)
|
||||
}
|
||||
|
||||
if ($.config.dev) {
|
||||
log.debug('authed failed')
|
||||
throw new RPCError('User is not logged in', 403)
|
||||
}
|
||||
|
||||
if (!silent) {
|
||||
log.info('RPC call authed check fail', logFacts(ctx))
|
||||
}
|
||||
|
||||
throw PermissionError
|
||||
}
|
||||
|
||||
export const root = (
|
||||
$: AppContext,
|
||||
fn: (ctx: Context, ...args: any[]) => any,
|
||||
silent: boolean = false
|
||||
) => authed($, (
|
||||
ctx: Context,
|
||||
...args: any[]
|
||||
) => {
|
||||
if ($.discord.isRoot(ctx.session.userId)) {
|
||||
return fn(ctx, ...args)
|
||||
}
|
||||
|
||||
if ($.config.dev) {
|
||||
log.debug('root failed')
|
||||
throw new RPCError('User is not root', 403)
|
||||
}
|
||||
|
||||
if (!silent) {
|
||||
log.info('RPC call root check fail', logFacts(ctx))
|
||||
}
|
||||
|
||||
throw PermissionError
|
||||
})
|
||||
|
||||
export const manager = (
|
||||
$: AppContext,
|
||||
fn: (ctx: Context, server: string, ...args: any[]) => any,
|
||||
silent: boolean = false
|
||||
) => member($, (
|
||||
ctx: Context,
|
||||
server: string,
|
||||
...args: any[]
|
||||
) => {
|
||||
if ($.discord.canManageRoles(server, ctx.session.userId)) {
|
||||
return fn(ctx, server, ...args)
|
||||
}
|
||||
|
||||
if ($.config.dev) {
|
||||
log.debug('manager failed')
|
||||
throw new RPCError('User is not a role manager', 403)
|
||||
}
|
||||
|
||||
if (!silent) {
|
||||
log.info('RPC call manager check fail', logFacts(ctx, { server }))
|
||||
}
|
||||
|
||||
throw PermissionError
|
||||
})
|
||||
|
||||
export const member = (
|
||||
$: AppContext,
|
||||
fn: (ctx: Context, server: string, ...args: any[]) => any,
|
||||
silent: boolean = false
|
||||
) => authed($, async (
|
||||
ctx: Context,
|
||||
server: string,
|
||||
...args: any[]
|
||||
) => {
|
||||
if (await $.discord.isMember(server, ctx.session.userId)) {
|
||||
return fn(ctx, server, ...args)
|
||||
}
|
||||
|
||||
if ($.config.dev) {
|
||||
log.debug('member failed')
|
||||
throw new RPCError('User is not a member of this server', 403)
|
||||
}
|
||||
|
||||
if (!silent) {
|
||||
log.info('RPC call member check fail', logFacts(ctx, { server }))
|
||||
}
|
||||
|
||||
throw PermissionError
|
||||
})
|
||||
|
||||
export const any = (
|
||||
$: AppContext,
|
||||
fn: (ctx: Context, ...args: any[]) => any,
|
||||
silent: boolean = false
|
||||
) => (...args: any) => fn(...args)
|
||||
|
||||
export const bot = (
|
||||
$: AppContext,
|
||||
fn: (ctx: Context, ...args: any[]) => any,
|
||||
silent: boolean = false
|
||||
) => (
|
||||
ctx: Context,
|
||||
...args: any
|
||||
) => {
|
||||
const authToken: ?string = ctx.request.headers['authorization']
|
||||
|
||||
if (authToken != null && authToken.startsWith('Bot ')) {
|
||||
if (authToken === `Bot ${$.config.sharedSecret}`) {
|
||||
return fn(ctx, ...args)
|
||||
}
|
||||
}
|
||||
|
||||
if (!silent) {
|
||||
log.info('RPC bot check failed', logFacts(ctx))
|
||||
}
|
||||
|
||||
throw PermissionError
|
||||
}
|
||||
|
||||
type Handler = (ctx: Context, ...args: any[]) => any
|
||||
type Strategy = (
|
||||
$: AppContext,
|
||||
fn: Handler,
|
||||
silent?: boolean
|
||||
) => any
|
||||
type StrategyPair = [ Strategy, Handler ]
|
||||
|
||||
/**
|
||||
* Weird func but ok -- test that a strategy doesn't fail, and run the first that doesn't.
|
||||
*/
|
||||
export const decide = (
|
||||
$: AppContext,
|
||||
...strategies: StrategyPair[]
|
||||
) => async (...args: any) => {
|
||||
for (let [ strat, handler ] of strategies) {
|
||||
if (strat === null) {
|
||||
strat = any
|
||||
}
|
||||
|
||||
try {
|
||||
return await strat($, handler, true)(...args)
|
||||
} catch (e) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// if we reach the end, just throw
|
||||
if ($.config.dev) {
|
||||
log.info('decide failed for', strategies.map(v => v[0].name))
|
||||
}
|
||||
|
||||
throw PermissionError
|
||||
}
|
14
packages/roleypoly-rpc/.lintstagedrc.yml
Normal file
14
packages/roleypoly-rpc/.lintstagedrc.yml
Normal file
|
@ -0,0 +1,14 @@
|
|||
linters:
|
||||
lib/*.{js,jsx}:
|
||||
- standard --fix
|
||||
- git add
|
||||
lib/*.d.ts:
|
||||
- tslint --fix
|
||||
- git add
|
||||
src/*.{ts,tsx}:
|
||||
- tslint --fix
|
||||
- stylelint --fix
|
||||
- jest --bail --findRelatedTests
|
||||
- git add
|
||||
|
||||
|
18
packages/roleypoly-rpc/package.json
Normal file
18
packages/roleypoly-rpc/package.json
Normal file
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"name": "@roleypoly/rpc",
|
||||
"version": "2.0.0",
|
||||
"devDependencies": {
|
||||
"lint-staged": "^8.1.7",
|
||||
"tslint": "^5.17.0",
|
||||
"typescript": "^3.5.1"
|
||||
},
|
||||
"scripts": {
|
||||
"generate": "bentoc ./**/*.proto",
|
||||
"build": "tsc",
|
||||
"precommit": "lint-staged"
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@kayteh/bento": "^0.1.1"
|
||||
}
|
||||
}
|
11
packages/roleypoly-rpc/src/proto-lib/types.bento.ts
Normal file
11
packages/roleypoly-rpc/src/proto-lib/types.bento.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
/**
|
||||
* GENERATED FILE. This file was generated by @kayteh/bento. Editing it is a bad idea.
|
||||
* @generated
|
||||
*/
|
||||
import Bento, { IBentoTransport } from '@kayteh/bento'
|
||||
export type ServerSlug = {
|
||||
id?: string
|
||||
name?: string
|
||||
ownerID?: string
|
||||
icon?: string
|
||||
}
|
8
packages/roleypoly-rpc/src/proto-lib/types.proto
Normal file
8
packages/roleypoly-rpc/src/proto-lib/types.proto
Normal file
|
@ -0,0 +1,8 @@
|
|||
syntax = "proto3";
|
||||
|
||||
message ServerSlug {
|
||||
string id = 1;
|
||||
string name = 2;
|
||||
string ownerID = 3;
|
||||
string icon = 4;
|
||||
}
|
41
packages/roleypoly-rpc/src/services/servers.bento.ts
Normal file
41
packages/roleypoly-rpc/src/services/servers.bento.ts
Normal file
|
@ -0,0 +1,41 @@
|
|||
/**
|
||||
* GENERATED FILE. This file was generated by @kayteh/bento. Editing it is a bad idea.
|
||||
* @generated
|
||||
*/
|
||||
import Bento, { IBentoTransport } from '@kayteh/bento'
|
||||
export type ServerQuery = {
|
||||
id: string
|
||||
}
|
||||
|
||||
export type RootServerItem = {
|
||||
url?: string
|
||||
name?: string
|
||||
members?: number
|
||||
roles?: number
|
||||
}
|
||||
|
||||
export type RootServerOverview = {
|
||||
servers?: RootServerItem[]
|
||||
}
|
||||
|
||||
export type ServerSlug = {
|
||||
id?: string
|
||||
name?: string
|
||||
ownerID?: string
|
||||
icon?: string
|
||||
}
|
||||
|
||||
export interface IServersService {
|
||||
getServerSlug (ctx: any, request: ServerQuery): Promise<ServerSlug> | ServerSlug
|
||||
rootGetAllServers (ctx: any, request: void): Promise<RootServerOverview> | RootServerOverview
|
||||
}
|
||||
export class ServersClient {
|
||||
static __SERVICE__: string = 'Servers'
|
||||
constructor (private bento: Bento, private transport?: IBentoTransport) {}
|
||||
async getServerSlug (request: ServerQuery): Promise<ServerSlug> {
|
||||
return this.bento.makeRequest(this.transport || undefined, 'Servers', 'GetServerSlug', request)
|
||||
}
|
||||
async rootGetAllServers (request: void): Promise<RootServerOverview> {
|
||||
return this.bento.makeRequest(this.transport || undefined, 'Servers', 'RootGetAllServers', request)
|
||||
}
|
||||
}
|
31
packages/roleypoly-rpc/src/services/servers.proto
Normal file
31
packages/roleypoly-rpc/src/services/servers.proto
Normal file
|
@ -0,0 +1,31 @@
|
|||
syntax = "proto3";
|
||||
import "../proto-lib/types.proto";
|
||||
|
||||
message void {
|
||||
option render.exclude = true;
|
||||
};
|
||||
|
||||
service Servers {
|
||||
|
||||
rpc GetServerSlug (ServerQuery) returns (ServerSlug) {};
|
||||
|
||||
// for root only, get all servers for whatever reason
|
||||
rpc RootGetAllServers (void) returns (RootServerOverview) {
|
||||
option secureAs.root = true;
|
||||
};
|
||||
};
|
||||
|
||||
message ServerQuery {
|
||||
required string id = 1;
|
||||
}
|
||||
|
||||
message RootServerItem {
|
||||
string url = 1;
|
||||
string name = 2;
|
||||
int32 members = 3;
|
||||
int32 roles = 4;
|
||||
}
|
||||
|
||||
message RootServerOverview {
|
||||
repeated RootServerItem servers = 1;
|
||||
};
|
9
packages/roleypoly-rpc/tsconfig.json
Normal file
9
packages/roleypoly-rpc/tsconfig.json
Normal file
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./lib"
|
||||
},
|
||||
"include": [
|
||||
"./src"
|
||||
]
|
||||
}
|
3
packages/roleypoly-rpc/tslint.json
Normal file
3
packages/roleypoly-rpc/tslint.json
Normal file
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"extends": "tslint-config-standard"
|
||||
}
|
|
@ -1,15 +0,0 @@
|
|||
{
|
||||
"presets": [ ["@babel/preset-env", {
|
||||
"targets": {
|
||||
"node": true
|
||||
}
|
||||
}], "@babel/preset-flow" ],
|
||||
"plugins": [
|
||||
"@babel/plugin-syntax-dynamic-import",
|
||||
"@babel/plugin-proposal-class-properties",
|
||||
"@babel/plugin-proposal-optional-chaining",
|
||||
["@babel/plugin-transform-runtime",
|
||||
{ "helpers": false }]
|
||||
],
|
||||
"ignore": ["ui/**/*"]
|
||||
}
|
14
packages/roleypoly-server/.lintstagedrc.yml
Normal file
14
packages/roleypoly-server/.lintstagedrc.yml
Normal file
|
@ -0,0 +1,14 @@
|
|||
linters:
|
||||
lib/*.{js,jsx}:
|
||||
- standard --fix
|
||||
- git add
|
||||
lib/*.d.ts:
|
||||
- tslint --fix
|
||||
- git add
|
||||
src/*.{ts,tsx}:
|
||||
- tslint --fix
|
||||
- stylelint --fix
|
||||
- jest --bail --findRelatedTests
|
||||
- git add
|
||||
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
// @flow
|
||||
import chalk from 'chalk'
|
||||
|
||||
export class Logger {
|
||||
debugOn: boolean
|
||||
name: string
|
||||
quietSql: boolean
|
||||
|
||||
constructor (name: string, debugOverride: boolean = false) {
|
||||
this.name = name
|
||||
this.debugOn = (process.env.DEBUG === 'true' || process.env.DEBUG === '*') || debugOverride
|
||||
this.quietSql = (process.env.DEBUG_SQL !== 'true')
|
||||
}
|
||||
|
||||
fatal (text: string, ...data: any) {
|
||||
this.error(text, data)
|
||||
|
||||
if (typeof data[data.length - 1] === 'number') {
|
||||
process.exit(data[data.length - 1])
|
||||
} else {
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
error (text: string, ...data: any) {
|
||||
console.error(chalk.red.bold(`ERR ${this.name}:`) + `\n ${text}`, data)
|
||||
}
|
||||
|
||||
warn (text: string, ...data: any) {
|
||||
console.warn(chalk.yellow.bold(`WARN ${this.name}:`) + `\n ${text}`, data)
|
||||
}
|
||||
|
||||
notice (text: string, ...data: any) {
|
||||
console.log(chalk.cyan.bold(`NOTICE ${this.name}:`) + `\n ${text}`, data)
|
||||
}
|
||||
|
||||
info (text: string, ...data: any) {
|
||||
console.info(chalk.blue.bold(`INFO ${this.name}:`) + `\n ${text}`, data)
|
||||
}
|
||||
|
||||
deprecated (text: string, ...data: any) {
|
||||
console.warn(chalk.yellowBright(`DEPRECATED ${this.name}:`) + `\n ${text}`, data)
|
||||
console.trace()
|
||||
}
|
||||
|
||||
request (text: string, ...data: any) {
|
||||
console.info(chalk.green.bold(`HTTP ${this.name}:`) + `\n ${text}`)
|
||||
}
|
||||
|
||||
debug (text: string, ...data: any) {
|
||||
if (this.debugOn) {
|
||||
console.log(chalk.gray.bold(`DEBUG ${this.name}:`) + `\n ${text}`, data)
|
||||
}
|
||||
}
|
||||
|
||||
rpc (call: string, ...data: any) {
|
||||
console.log(chalk.redBright.bold`RPC` + chalk.redBright(` ${call}():`), data)
|
||||
}
|
||||
|
||||
sql (logger: Logger, ...data: any) {
|
||||
if (logger.debugOn && !logger.quietSql) {
|
||||
console.log(chalk.bold('DEBUG SQL:\n '), data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default (pathname: string) => {
|
||||
const name = pathname.replace(__dirname, '').replace('.js', '')
|
||||
return new Logger(name)
|
||||
}
|
|
@ -3,57 +3,43 @@
|
|||
"private": true,
|
||||
"version": "2.0.0",
|
||||
"scripts": {
|
||||
"start": "NODE_ENV=production node dist/index.js",
|
||||
"pretest": "standard --fix",
|
||||
"build": "NODE_ENV=production babel --delete-dir-on-start -d dist ."
|
||||
"start": "node lib/index.js",
|
||||
"precommit": "lint-staged",
|
||||
"build": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@discordjs/uws": "^11.149.1",
|
||||
"@roleypoly/types": "^2.0.0",
|
||||
"@roleypoly/rpc-server": "^2.0.0",
|
||||
"@roleypoly/ui": "^2.0.0",
|
||||
"async-retry": "^1.2.3",
|
||||
"bufferutil": "^4.0.1",
|
||||
"chalk": "^2.4.2",
|
||||
"dotenv": "^7.0.0",
|
||||
"eris": "^0.9.0",
|
||||
"erlpack": "discordapp/erlpack",
|
||||
"eventemitter3": "^3.1.0",
|
||||
"dotenv": "^8.0.0",
|
||||
"eventemitter3": "^3.1.2",
|
||||
"fnv-plus": "^1.2.12",
|
||||
"glob": "^7.1.3",
|
||||
"glob": "^7.1.4",
|
||||
"immutable": "^4.0.0-rc.12",
|
||||
"keygrip": "^1.0.3",
|
||||
"keygrip": "^1.1.0",
|
||||
"koa": "^2.7.0",
|
||||
"koa-better-router": "^2.1.1",
|
||||
"koa-bodyparser": "^4.2.1",
|
||||
"koa-session": "^5.10.1",
|
||||
"koa-session": "^5.12.0",
|
||||
"kompression": "^1.0.0",
|
||||
"ksuid": "^1.2.0",
|
||||
"lru-cache": "^5.1.1",
|
||||
"moment": "^2.24.0",
|
||||
"moniker": "^0.1.2",
|
||||
"nanoid": "^2.0.1",
|
||||
"pg": "^7.9.0",
|
||||
"nanoid": "^2.0.3",
|
||||
"pg": "^7.11.0",
|
||||
"pg-hstore": "^2.3.2",
|
||||
"sequelize": "^5.3.5",
|
||||
"superagent": "^5.0.2",
|
||||
"zlib-sync": "^0.1.4"
|
||||
"sequelize": "^5.8.6",
|
||||
"superagent": "^5.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.4.3",
|
||||
"@babel/core": "^7.4.3",
|
||||
"@babel/node": "^7.2.2",
|
||||
"@babel/plugin-proposal-class-properties": "^7.4.0",
|
||||
"@babel/plugin-proposal-optional-chaining": "^7.2.0",
|
||||
"@babel/plugin-syntax-dynamic-import": "^7.2.0",
|
||||
"@babel/plugin-transform-runtime": "^7.4.3",
|
||||
"@babel/preset-env": "^7.4.3",
|
||||
"@babel/preset-flow": "^7.0.0",
|
||||
"@roleypoly/types": "^2.0.0",
|
||||
"@types/koa": "^2.0.48",
|
||||
"@types/lru-cache": "^5.1.0",
|
||||
"@types/nanoid": "^2.0.0",
|
||||
"await-outside": "^2.1.2",
|
||||
"babel-eslint": "^10.0.1",
|
||||
"babel-plugin-transform-flow-strip-types": "^6.22.0",
|
||||
"chokidar": "^2.1.5",
|
||||
"eslint-plugin-flowtype": "^3.6.1",
|
||||
"standard": "12.0.1"
|
||||
"chokidar": "^3.0.0",
|
||||
"lint-staged": "^8.1.7",
|
||||
"standard": "12.0.1",
|
||||
"tslint": "^5.17.0",
|
||||
"typescript": "^3.5.1"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,12 +0,0 @@
|
|||
// @flow
|
||||
import { Logger } from '../logger'
|
||||
import { type AppContext } from '../Roleypoly'
|
||||
|
||||
export default class Service {
|
||||
ctx: AppContext
|
||||
log: Logger
|
||||
constructor (ctx: AppContext) {
|
||||
this.ctx = ctx
|
||||
this.log = new Logger(this.constructor.name)
|
||||
}
|
||||
}
|
|
@ -1,346 +0,0 @@
|
|||
/*
|
||||
import Service from './Service'
|
||||
import superagent from 'superagent'
|
||||
// import invariant from 'invariant'
|
||||
import { type AppContext } from '../Roleypoly'
|
||||
import Eris from 'eris'
|
||||
import Bot from '../bot'
|
||||
import type { AuthTokens } from './auth'
|
||||
export type UserPartial = {
|
||||
id: string,
|
||||
username: string,
|
||||
discriminator: string,
|
||||
avatar: string
|
||||
}
|
||||
|
||||
export type Permissions = {
|
||||
isAdmin: boolean,
|
||||
canManageRoles: boolean
|
||||
}
|
||||
|
||||
// type ChatCommandHandler = (message: Message, matches: string[], reply: (text: string) => Promise<Message>) => Promise<void>|void
|
||||
// type ChatCommand = {
|
||||
// regex: RegExp,
|
||||
// handler: ChatCommandHandler
|
||||
// }
|
||||
|
||||
class DiscordService extends Service {
|
||||
botToken: string = process.env.DISCORD_BOT_TOKEN || ''
|
||||
clientId: string = process.env.DISCORD_CLIENT_ID || ''
|
||||
clientSecret: string = process.env.DISCORD_CLIENT_SECRET || ''
|
||||
oauthCallback: string = process.env.OAUTH_AUTH_CALLBACK || ''
|
||||
isBot: boolean = process.env.IS_BOT === 'true'
|
||||
|
||||
appUrl: string
|
||||
botCallback: string
|
||||
rootUsers: Set<string>
|
||||
client: Eris
|
||||
cmds: ChatCommand[]
|
||||
bot: Bot
|
||||
constructor (ctx: AppContext) {
|
||||
super(ctx)
|
||||
this.appUrl = ctx.config.appUrl
|
||||
|
||||
this.oauthCallback = `${this.appUrl}/api/oauth/callback`
|
||||
this.botCallback = `${this.appUrl}/api/oauth/bot/callback`
|
||||
this.rootUsers = new Set((process.env.ROOT_USERS || '').split(','))
|
||||
this.bot = new Bot(this)
|
||||
}
|
||||
|
||||
ownGm (server: string) {
|
||||
return this.gm(server, this.client.user.id)
|
||||
}
|
||||
|
||||
fakeGm ({ id = '0', nickname = '[none]', displayHexColor = '#ffffff' }: $Shape<{ id: string, nickname: string, displayHexColor: string }>) { // eslint-disable-line no-undef
|
||||
return { id, nickname, displayHexColor, __faked: true, roles: { has () { return false } } }
|
||||
}
|
||||
|
||||
isRoot (id: string): boolean {
|
||||
return this.rootUsers.has(id)
|
||||
}
|
||||
|
||||
getRelevantServers (userId: string): Collection<string, Guild> {
|
||||
return this.client.guilds.filter((g) => g.members.has(userId))
|
||||
}
|
||||
|
||||
gm (serverId: string, userId: string): ?GuildMember {
|
||||
const s = this.client.guilds.get(serverId)
|
||||
if (s == null) {
|
||||
return null
|
||||
}
|
||||
return s.members.get(userId)
|
||||
}
|
||||
|
||||
getRoles (server: string) {
|
||||
const s = this.client.guilds.get(server)
|
||||
if (s == null) {
|
||||
return null
|
||||
}
|
||||
return s.roles
|
||||
}
|
||||
|
||||
getPermissions (gm: GuildMember): Permissions {
|
||||
if (this.isRoot(gm.id)) {
|
||||
return {
|
||||
isAdmin: true,
|
||||
canManageRoles: true
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isAdmin: gm.permissions.has('ADMINISTRATOR'),
|
||||
canManageRoles: gm.permissions.has('MANAGE_ROLES', true)
|
||||
}
|
||||
}
|
||||
|
||||
safeRole (server: string, role: string) {
|
||||
const rl = this.getRoles(server)
|
||||
if (rl == null) {
|
||||
throw new Error(`safeRole can't see ${server}`)
|
||||
}
|
||||
const r: ?Role = rl.get(role)
|
||||
if (r == null) {
|
||||
throw new Error(`safeRole can't find ${role} in ${server}`)
|
||||
}
|
||||
|
||||
return r.editable && !r.hasPermission('MANAGE_ROLES', false, true)
|
||||
}
|
||||
|
||||
// oauth step 2 flow, grab the auth token via code
|
||||
async getAuthToken (code: string): Promise<AuthTokens> {
|
||||
const url = 'https://discordapp.com/api/oauth2/token'
|
||||
try {
|
||||
const rsp =
|
||||
await superagent
|
||||
.post(url)
|
||||
.set('Content-Type', 'application/x-www-form-urlencoded')
|
||||
.send({
|
||||
client_id: this.clientId,
|
||||
client_secret: this.clientSecret,
|
||||
grant_type: 'authorization_code',
|
||||
code: code,
|
||||
redirect_uri: this.oauthCallback
|
||||
})
|
||||
|
||||
return rsp.body
|
||||
} catch (e) {
|
||||
this.log.error('getAuthToken failed', e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
async getUserFromToken (authToken?: string): Promise<UserPartial> {
|
||||
const url = 'https://discordapp.com/api/v6/users/@me'
|
||||
try {
|
||||
if (authToken == null || authToken === '') {
|
||||
throw new Error('not logged in')
|
||||
}
|
||||
|
||||
const rsp =
|
||||
await superagent
|
||||
.get(url)
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
return rsp.body
|
||||
} catch (e) {
|
||||
this.log.error('getUser error', e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
getUserPartial (userId: string): ?UserPartial {
|
||||
const U = this.client.users.get(userId)
|
||||
if (U == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
username: U.username,
|
||||
discriminator: U.discriminator,
|
||||
avatar: U.displayAvatarURL,
|
||||
id: U.id
|
||||
}
|
||||
}
|
||||
|
||||
async refreshOAuth ({ refreshToken }: { refreshToken: string }): Promise<AuthTokens> {
|
||||
const url = 'https://discordapp.com/api/oauth2/token'
|
||||
try {
|
||||
const rsp =
|
||||
await superagent
|
||||
.post(url)
|
||||
.set('Content-Type', 'application/x-www-form-urlencoded')
|
||||
.send({
|
||||
client_id: this.clientId,
|
||||
client_secret: this.clientSecret,
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken,
|
||||
redirect_uri: this.oauthCallback
|
||||
})
|
||||
|
||||
return rsp.body
|
||||
} catch (e) {
|
||||
this.log.error('refreshOAuth failed', e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
async revokeOAuth ({ accessToken }: { accessToken: string }) {
|
||||
const url = 'https://discordapp.com/api/oauth2/token/revoke'
|
||||
try {
|
||||
const rsp =
|
||||
await superagent
|
||||
.post(url)
|
||||
.set('Content-Type', 'application/x-www-form-urlencoded')
|
||||
.send({
|
||||
client_id: this.clientId,
|
||||
client_secret: this.clientSecret,
|
||||
grant_type: 'access_token',
|
||||
token: accessToken,
|
||||
redirect_uri: this.oauthCallback
|
||||
})
|
||||
|
||||
return rsp.body
|
||||
} catch (e) {
|
||||
this.log.error('revokeOAuth failed', e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
// returns oauth authorize url with IDENTIFY permission
|
||||
// we only need IDENTIFY because we only use it for matching IDs from the bot
|
||||
getAuthUrl (state: string): string {
|
||||
return `https://discordapp.com/oauth2/authorize?client_id=${this.clientId}&redirect_uri=${this.oauthCallback}&response_type=code&scope=identify&state=${state}`
|
||||
}
|
||||
|
||||
// returns the bot join url with MANAGE_ROLES permission
|
||||
// MANAGE_ROLES is the only permission we really need.
|
||||
getBotJoinUrl (): string {
|
||||
return `https://discordapp.com/oauth2/authorize?client_id=${this.clientId}&scope=bot&permissions=268435456`
|
||||
}
|
||||
|
||||
mentionResponse (message: Message) {
|
||||
message.channel.send(`🔰 Assign your roles here! ${this.appUrl}/s/${message.guild.id}`, { disableEveryone: true })
|
||||
}
|
||||
|
||||
_cmds () {
|
||||
const cmds = [
|
||||
{
|
||||
regex: /say (.*)/,
|
||||
handler (message, matches, r) {
|
||||
r(matches[0])
|
||||
}
|
||||
},
|
||||
{
|
||||
regex: /set username (.*)/,
|
||||
async handler (message, matches) {
|
||||
const { username } = this.client.user
|
||||
await this.client.user.setUsername(matches[0])
|
||||
message.channel.send(`Username changed from ${username} to ${matches[0]}`)
|
||||
}
|
||||
},
|
||||
{
|
||||
regex: /stats/,
|
||||
async handler (message, matches) {
|
||||
const t = [
|
||||
`**Stats** 📈`,
|
||||
'',
|
||||
`👩❤️👩 **Users Served:** ${this.client.guilds.reduce((acc, g) => acc + g.memberCount, 0)}`,
|
||||
`🔰 **Servers:** ${this.client.guilds.size}`,
|
||||
`💮 **Roles Seen:** ${this.client.guilds.reduce((acc, g) => acc + g.roles.size, 0)}`
|
||||
]
|
||||
message.channel.send(t.join('\n'))
|
||||
}
|
||||
}
|
||||
]
|
||||
// prefix regex with ^ for ease of code
|
||||
.map(({ regex, ...rest }) => ({ regex: new RegExp(`^${regex.source}`, regex.flags), ...rest }))
|
||||
|
||||
this.cmds = cmds
|
||||
return cmds
|
||||
}
|
||||
|
||||
async handleCommand (message: Message) {
|
||||
const cmd = message.content.replace(`<@${this.client.user.id}> `, '')
|
||||
this.log.debug(`got command from ${message.author.username}`, cmd)
|
||||
for (let { regex, handler } of this.cmds) {
|
||||
const match = regex.exec(cmd)
|
||||
if (match !== null) {
|
||||
this.log.debug('command accepted', { cmd, match })
|
||||
try {
|
||||
await handler.call(this, message, match.slice(1))
|
||||
return
|
||||
} catch (e) {
|
||||
this.log.error('command errored', { e, cmd, message })
|
||||
message.channel.send(`❌ **An error occured.** ${e}`)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// nothing matched?
|
||||
this.mentionResponse(message)
|
||||
}
|
||||
|
||||
async issueChallenge (author: string) {
|
||||
// Create a challenge
|
||||
const chall = await this.ctx.auth.createDMChallenge(author)
|
||||
|
||||
const randomLines = [
|
||||
'🐄 A yellow cow is only as bright as it lets itself be. ✨',
|
||||
'‼ **Did you know?** On this day, at least one second ago, you were right here!',
|
||||
'<:AkkoC8:437428070849314816> *Reticulating splines...*',
|
||||
'Also, you look great today <:YumekoWink:439519270376964107>',
|
||||
'btw, ur bright like a <:diamond:544665968631087125>',
|
||||
`🌈 psst! pssssst! I'm an expensive bot, would you please spare some change? <https://ko-fi.com/roleypoly>`,
|
||||
'📣 have suggestions? wanna help out? join my discord! <https://discord.gg/PWQUVsd>\n*(we\'re nice people, i swear!)*',
|
||||
`🤖 this bot is at least ${Math.random() * 100}% LIT 🔥`,
|
||||
'💖 wanna contribute to these witty lines? <https://discord.gg/PWQUVsd> suggest them on our discord!',
|
||||
'🛠 I am completely open source, check me out!~ <https://github.com/kayteh/roleypoly>'
|
||||
]
|
||||
|
||||
return ([
|
||||
'**Hey there!** <a:StockKyaa:435353158462603266>',
|
||||
'',
|
||||
`Use this secret code: **${chall.human}**`,
|
||||
`Or, click here: <${this.ctx.config.appUrl}/magic/${chall.magic}>`,
|
||||
'',
|
||||
'This code will self-destruct in 1 hour.',
|
||||
'---',
|
||||
randomLines[Math.floor(Math.random() * randomLines.length)]
|
||||
].join('\n'))
|
||||
}
|
||||
|
||||
handleDM (message: Message) {
|
||||
switch (message.content.toLowerCase()) {
|
||||
case 'login':
|
||||
case 'auth':
|
||||
case 'log in':
|
||||
this.issueChallenge(message)
|
||||
}
|
||||
}
|
||||
|
||||
handleMessage (message: Message) {
|
||||
if (message.author.bot) { // drop bot messages
|
||||
return
|
||||
}
|
||||
|
||||
if (message.channel.type === 'dm') {
|
||||
// handle dm
|
||||
return this.handleDM(message)
|
||||
}
|
||||
|
||||
if (message.mentions.users.has(this.client.user.id)) {
|
||||
if (this.rootUsers.has(message.author.id)) {
|
||||
this.handleCommand(message)
|
||||
} else {
|
||||
this.mentionResponse(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async handleJoin (guild: Guild) {
|
||||
await this.ctx.server.ensure(guild)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = DiscordService
|
||||
*/
|
|
@ -1,16 +0,0 @@
|
|||
// @flow
|
||||
import type {
|
||||
User,
|
||||
Member,
|
||||
Guild
|
||||
} from 'eris'
|
||||
|
||||
export interface IFetcher {
|
||||
getUser: (id: string) => Promise<?User>;
|
||||
|
||||
getGuild: (id: string) => Promise<?Guild>;
|
||||
|
||||
getMember: (server: string, user: string) => Promise<?Member>;
|
||||
|
||||
getGuilds: () => Promise<Guild[]>;
|
||||
}
|
|
@ -1,22 +1,19 @@
|
|||
// @flow
|
||||
import Sequelize from 'sequelize'
|
||||
import connector from '@roleypoly/ui/connector'
|
||||
import type Next from 'next'
|
||||
import Next from 'next'
|
||||
import betterRouter from 'koa-better-router'
|
||||
import type EventEmitter from 'events'
|
||||
import EventEmitter from 'events'
|
||||
import logger from './logger'
|
||||
import ServerService from './services/server'
|
||||
import DiscordService from './services/discord'
|
||||
import SessionService from './services/sessions'
|
||||
import AuthService from './services/auth'
|
||||
import PresentationService from './services/presentation'
|
||||
import RPCServer from '@roleypoly/rpc-server'
|
||||
import fetchModels, { type Models } from './models'
|
||||
import fetchModels, { Models } from './models'
|
||||
import fetchApis from './api'
|
||||
import retry from 'async-retry'
|
||||
|
||||
import type SocketIO from 'socket.io'
|
||||
import type KoaApp, { Context } from 'koa'
|
||||
import KoaApp, { Context } from 'koa'
|
||||
const log = logger(__filename)
|
||||
|
||||
type HTTPHandler = (path: string, handler: (ctx: Context, next: () => void) => any) => void
|
||||
|
@ -31,6 +28,15 @@ export type Router = {
|
|||
|
||||
export type RouteHook = (router: Router) => void
|
||||
|
||||
export type KoaContextExt = KoaApp.Context & {
|
||||
request: {
|
||||
body: any
|
||||
},
|
||||
session: {
|
||||
[x: string]: any
|
||||
}
|
||||
}
|
||||
|
||||
export type AppContext = {
|
||||
config: {
|
||||
appUrl: string,
|
||||
|
@ -40,46 +46,42 @@ export type AppContext = {
|
|||
},
|
||||
ui: Next,
|
||||
uiHandler: Next.Handler,
|
||||
io: SocketIO,
|
||||
// io: SocketIO,
|
||||
|
||||
server: ServerService,
|
||||
discord: DiscordService,
|
||||
sessions: SessionService,
|
||||
P: PresentationService,
|
||||
RPC: RPCServer,
|
||||
// RPC: RPCServer,
|
||||
M: Models,
|
||||
sql: Sequelize,
|
||||
sql: Sequelize.Sequelize,
|
||||
auth: AuthService
|
||||
}
|
||||
|
||||
class Roleypoly {
|
||||
ctx: AppContext|any
|
||||
io: SocketIO
|
||||
export default class Roleypoly {
|
||||
ctx: AppContext | any
|
||||
router: Router
|
||||
|
||||
M: Models
|
||||
|
||||
__app: KoaApp
|
||||
__initialized: Promise<void>
|
||||
__apiWatcher: EventEmitter
|
||||
__rpcWatcher: EventEmitter
|
||||
// private app: KoaApp
|
||||
private initialized: Promise<void>
|
||||
private apiWatcher: EventEmitter
|
||||
private rpcWatcher: EventEmitter
|
||||
|
||||
__routeHooks: Set<RouteHook> = new Set()
|
||||
constructor (io: SocketIO, app: KoaApp) {
|
||||
this.io = io
|
||||
this.__app = app
|
||||
private routeHooks: Set<RouteHook> = new Set()
|
||||
constructor (
|
||||
io: undefined,
|
||||
private app: KoaApp
|
||||
) {
|
||||
// this.io = io
|
||||
|
||||
if (log.debugOn) log.warn('debug mode is on')
|
||||
|
||||
const dev = process.env.NODE_ENV !== 'production'
|
||||
|
||||
// simple check if we're in a compiled situation or not.
|
||||
|
||||
const ui = connector({ dev })
|
||||
const uiHandler = ui.getRequestHandler()
|
||||
|
||||
const appUrl = process.env.APP_URL
|
||||
if (appUrl == null) {
|
||||
if (appUrl === undefined) {
|
||||
throw new Error('APP_URL was unset.')
|
||||
}
|
||||
|
||||
|
@ -90,27 +92,25 @@ class Roleypoly {
|
|||
hotReload: process.env.NO_HOT_RELOAD !== '1',
|
||||
sharedSecret: process.env.SHARED_SECRET
|
||||
},
|
||||
io,
|
||||
ui,
|
||||
uiHandler
|
||||
io
|
||||
}
|
||||
|
||||
this.__initialized = this._mountServices()
|
||||
this.initialized = this._mountServices()
|
||||
}
|
||||
|
||||
async awaitServices () {
|
||||
await this.__initialized
|
||||
await this.initialized
|
||||
}
|
||||
|
||||
async _mountServices () {
|
||||
const dbUrl: ?string = process.env.DB_URL
|
||||
if (dbUrl == null) {
|
||||
const dbUrl: string = process.env.DB_URL || ''
|
||||
if (dbUrl === '') {
|
||||
throw log.fatal('DB_URL not set.')
|
||||
}
|
||||
|
||||
await this.ctx.ui.prepare()
|
||||
|
||||
const sequelize = new Sequelize(dbUrl, { logging: log.sql.bind(log, log) })
|
||||
const sequelize = new Sequelize.Sequelize(dbUrl, { logging: log.sql.bind(log, log) })
|
||||
this.ctx.sql = sequelize
|
||||
this.M = fetchModels(sequelize)
|
||||
this.ctx.M = this.M
|
||||
|
@ -133,15 +133,15 @@ class Roleypoly {
|
|||
this.ctx.sessions = new SessionService(this.ctx)
|
||||
this.ctx.auth = new AuthService(this.ctx)
|
||||
this.ctx.P = new PresentationService(this.ctx)
|
||||
this.ctx.RPC = new RPCServer(this)
|
||||
// this.ctx.RPC = new RPCServer(this)
|
||||
}
|
||||
|
||||
addRouteHook (hook: RouteHook) {
|
||||
this.__routeHooks.add(hook)
|
||||
this.routeHooks.add(hook)
|
||||
}
|
||||
|
||||
hookServiceRoutes (router: Router) {
|
||||
for (let h of this.__routeHooks) {
|
||||
for (let h of this.routeHooks) {
|
||||
h(router)
|
||||
}
|
||||
}
|
||||
|
@ -172,14 +172,14 @@ class Roleypoly {
|
|||
const path = require('path')
|
||||
let hotMiddleware = mw
|
||||
|
||||
this.__apiWatcher = chokidar.watch(path.join(__dirname, 'api/**'))
|
||||
this.__apiWatcher.on('all', async (path) => {
|
||||
this.apiWatcher = chokidar.watch(path.join(__dirname, 'api/**'))
|
||||
this.apiWatcher.on('all', async (path) => {
|
||||
log.info('reloading APIs...', path)
|
||||
hotMiddleware = await this.loadRoutes(true)
|
||||
})
|
||||
|
||||
this.__rpcWatcher = chokidar.watch(path.join(__dirname, 'rpc/**'))
|
||||
this.__rpcWatcher.on('all', (path) => {
|
||||
this.rpcWatcher = chokidar.watch(path.join(__dirname, 'rpc/**'))
|
||||
this.rpcWatcher.on('all', (path) => {
|
||||
log.info('reloading RPCs...', path)
|
||||
this.ctx.RPC.reload()
|
||||
})
|
||||
|
@ -190,8 +190,6 @@ class Roleypoly {
|
|||
}
|
||||
}
|
||||
|
||||
this.__app.use(mw)
|
||||
this.app.use(mw)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Roleypoly
|
|
@ -1,16 +1,15 @@
|
|||
// @flow
|
||||
import { type Context } from 'koa'
|
||||
import { type AppContext, type Router } from '../Roleypoly'
|
||||
// import { Context } from 'koa'
|
||||
import { AppContext, Router, KoaContextExt } from '../Roleypoly'
|
||||
import ksuid from 'ksuid'
|
||||
import logger from '../logger'
|
||||
import renderError from '../util/error'
|
||||
const log = logger(__filename)
|
||||
|
||||
export default (R: Router, $: AppContext) => {
|
||||
R.post('/api/auth/token', async (ctx: Context) => {
|
||||
const { token } = ((ctx.request.body: any): { token: string })
|
||||
R.post('/api/auth/token', async (ctx: KoaContextExt) => {
|
||||
const { token } = ctx.request.body as { token: string | undefined }
|
||||
|
||||
if (token == null || token === '') {
|
||||
if (token === undefined || token === '') {
|
||||
ctx.body = { err: 'token_missing' }
|
||||
ctx.status = 400
|
||||
return
|
||||
|
@ -37,8 +36,8 @@ export default (R: Router, $: AppContext) => {
|
|||
}
|
||||
})
|
||||
|
||||
R.get('/api/auth/user', async (ctx: Context) => {
|
||||
const { accessToken } = ((ctx.session: any): { accessToken?: string })
|
||||
R.get('/api/auth/user', async (ctx: KoaContextExt) => {
|
||||
const { accessToken } = ctx.session as { accessToken?: string }
|
||||
if (accessToken === undefined) {
|
||||
ctx.body = { err: 'not_logged_in' }
|
||||
ctx.status = 401
|
||||
|
@ -57,7 +56,7 @@ export default (R: Router, $: AppContext) => {
|
|||
}
|
||||
})
|
||||
|
||||
R.get('/api/auth/redirect', async (ctx: Context) => {
|
||||
R.get('/api/auth/redirect', async (ctx: KoaContextExt) => {
|
||||
const { r } = ctx.query
|
||||
// check if already authed
|
||||
if (await $.auth.isLoggedIn(ctx, { refresh: true })) {
|
||||
|
@ -75,7 +74,7 @@ export default (R: Router, $: AppContext) => {
|
|||
ctx.redirect(url)
|
||||
})
|
||||
|
||||
R.get('/api/oauth/callback', async (ctx: Context, next: *) => {
|
||||
R.get('/api/oauth/callback', async (ctx: KoaContextExt, next: *) => {
|
||||
const { code, state } = ctx.query
|
||||
const { oauthRedirect: r } = ctx.session
|
||||
delete ctx.session.oauthRedirect
|
||||
|
@ -93,8 +92,8 @@ export default (R: Router, $: AppContext) => {
|
|||
if (state != null) {
|
||||
try {
|
||||
const ksState = ksuid.parse(state)
|
||||
const fiveMinAgo = new Date() - 1000 * 60 * 5
|
||||
if (ksState.date < fiveMinAgo) {
|
||||
const fiveMinAgo = Date.now() - 1000 * 60 * 5
|
||||
if (+ksState.date < fiveMinAgo) {
|
||||
ctx.status = 419
|
||||
await renderError($, ctx)
|
||||
return
|
||||
|
@ -119,22 +118,22 @@ export default (R: Router, $: AppContext) => {
|
|||
}
|
||||
})
|
||||
|
||||
R.post('/api/auth/logout', async (ctx: Context) => {
|
||||
ctx.session = null
|
||||
R.post('/api/auth/logout', async (ctx: KoaContextExt) => {
|
||||
ctx.session = {}
|
||||
})
|
||||
|
||||
R.get('/api/auth/logout', async (ctx: Context) => {
|
||||
R.get('/api/auth/logout', async (ctx: KoaContextExt) => {
|
||||
if (await $.auth.isLoggedIn(ctx)) {
|
||||
if (ctx.session.authType === 'oauth') {
|
||||
await $.discord.revokeOAuth(ctx.session)
|
||||
await $.discord.revokeOAuth(ctx.session )
|
||||
}
|
||||
}
|
||||
|
||||
ctx.session = null
|
||||
ctx.session = {}
|
||||
return ctx.redirect('/')
|
||||
})
|
||||
|
||||
R.get('/api/oauth/bot', async (ctx: Context) => {
|
||||
R.get('/api/oauth/bot', async (ctx: KoaContextExt) => {
|
||||
const url = $.discord.getBotJoinUrl()
|
||||
if (ctx.query.url === '✔️') {
|
||||
ctx.body = { url }
|
||||
|
@ -144,11 +143,11 @@ export default (R: Router, $: AppContext) => {
|
|||
ctx.redirect(url)
|
||||
})
|
||||
|
||||
R.get('/api/oauth/bot/callback', async (ctx: Context) => {
|
||||
R.get('/api/oauth/bot/callback', async (ctx: KoaContextExt) => {
|
||||
// console.log(ctx.request)
|
||||
})
|
||||
|
||||
R.get('/magic/:challenge', async (ctx: Context) => {
|
||||
R.get('/magic/:challenge', async (ctx: KoaContextExt) => {
|
||||
if (ctx.request.headers['user-agent'].includes('Discordbot')) {
|
||||
return $.ui.render(ctx.req, ctx.res, '/_internal/_discordbot/_magic', {})
|
||||
}
|
|
@ -1,12 +1,11 @@
|
|||
// @flow
|
||||
import glob from 'glob'
|
||||
import type { Router, AppContext } from '../Roleypoly'
|
||||
import { Router, AppContext } from '../Roleypoly'
|
||||
import logger from '../logger'
|
||||
const log = logger(__filename)
|
||||
|
||||
const PROD = process.env.NODE_ENV === 'production'
|
||||
|
||||
export default async (router: Router, ctx: AppContext, { forceClear = false }: { forceClear: boolean } = {}) => {
|
||||
export default async (router: Router, ctx: AppContext, { forceClear }: { forceClear: boolean } = { forceClear: false }) => {
|
||||
const apis = glob.sync(`${__dirname}/**/!(index).js`).map(v => v.replace(__dirname, '.'))
|
||||
log.debug('found apis', apis)
|
||||
|
||||
|
@ -20,7 +19,6 @@ export default async (router: Router, ctx: AppContext, { forceClear = false }: {
|
|||
if (forceClear) {
|
||||
delete require.cache[require.resolve(a)]
|
||||
}
|
||||
// $FlowFixMe this isn't an important error. potentially dangerous, but irrelevant.
|
||||
require(a).default(router, ctx)
|
||||
} catch (e) {
|
||||
log.error(`couldn't mount ${a}`, e)
|
|
@ -1,7 +1,8 @@
|
|||
// @flow
|
||||
import { type Context } from 'koa'
|
||||
import { type AppContext, type Router } from '../Roleypoly'
|
||||
import { type ServerModel } from '../models/Server'
|
||||
import { Context } from 'koa'
|
||||
import { AppContext, Router, KoaContextExt } from '../Roleypoly'
|
||||
import { ServerModel } from '../models/Server'
|
||||
import { MemberExt } from '../services/discord'
|
||||
import { Member } from 'eris'
|
||||
|
||||
export default (R: Router, $: AppContext) => {
|
||||
R.get('/api/servers', async (ctx: Context) => {
|
||||
|
@ -22,28 +23,27 @@ export default (R: Router, $: AppContext) => {
|
|||
|
||||
const srv = $.discord.client.guilds.get(id)
|
||||
|
||||
if (srv == null) {
|
||||
if (srv === undefined) {
|
||||
ctx.body = { err: 'not found' }
|
||||
ctx.status = 404
|
||||
return
|
||||
}
|
||||
|
||||
let gm
|
||||
let gm: Partial<MemberExt> | undefined
|
||||
if (srv.members.has(userId)) {
|
||||
gm = $.discord.gm(id, userId)
|
||||
gm = await $.discord.gm(id, userId)
|
||||
} else if ($.discord.isRoot(userId)) {
|
||||
gm = $.discord.fakeGm({ id: userId })
|
||||
}
|
||||
|
||||
if (gm == null) {
|
||||
if (gm === undefined) {
|
||||
ctx.body = { err: 'not_a_member' }
|
||||
ctx.status = 400
|
||||
return
|
||||
}
|
||||
|
||||
const server = await $.P.presentableServer(srv, gm)
|
||||
const server = await $.P.presentableServer(srv, gm as Member)
|
||||
|
||||
// $FlowFixMe bad koa type
|
||||
ctx.body = server
|
||||
})
|
||||
|
||||
|
@ -55,22 +55,22 @@ export default (R: Router, $: AppContext) => {
|
|||
|
||||
console.log(srv)
|
||||
|
||||
if (srv == null) {
|
||||
if (srv === undefined) {
|
||||
ctx.body = { err: 'not found' }
|
||||
ctx.status = 404
|
||||
return
|
||||
}
|
||||
|
||||
// $FlowFixMe bad koa type
|
||||
ctx.body = await $.P.serverSlug(srv)
|
||||
ctx.body = $.P.serverSlug(srv)
|
||||
})
|
||||
|
||||
R.patch('/api/server/:id', async (ctx: Context) => {
|
||||
const { userId } = (ctx.session: { userId: string })
|
||||
const { id } = (ctx.params: { id: string })
|
||||
R.patch('/api/server/:id', async (ctx: KoaContextExt) => {
|
||||
const { userId } = (ctx.session as { userId: string })
|
||||
const { id } = (ctx.params as { id: string })
|
||||
|
||||
let gm = $.discord.gm(id, userId)
|
||||
if (gm == null) {
|
||||
let gm = await $.discord.gm(id, userId)
|
||||
|
||||
if (gm === undefined) {
|
||||
if ($.discord.isRoot(userId)) {
|
||||
gm = $.discord.fakeGm({ id: userId })
|
||||
} else {
|
||||
|
@ -83,29 +83,30 @@ export default (R: Router, $: AppContext) => {
|
|||
}
|
||||
|
||||
// check perms
|
||||
if (!$.discord.getPermissions(gm).canManageRoles) {
|
||||
if (!$.discord.getPermissions(gm as Member).canManageRoles) {
|
||||
ctx.status = 403
|
||||
ctx.body = { err: 'cannot_manage_roles' }
|
||||
return
|
||||
}
|
||||
|
||||
const { message, categories } = ((ctx.request.body: any): $Shape<ServerModel>)
|
||||
const { message, categories } = ctx.request.body as Partial<ServerModel>
|
||||
|
||||
// todo make less nasty
|
||||
await $.server.update(id, {
|
||||
...((message != null) ? { message } : {}),
|
||||
...((categories != null) ? { categories } : {})
|
||||
...((message !== undefined) ? { message } : {}),
|
||||
...((categories !== undefined) ? { categories } : {})
|
||||
})
|
||||
|
||||
ctx.body = { ok: true }
|
||||
})
|
||||
|
||||
R.patch('/api/servers/:server/roles', async (ctx: Context) => {
|
||||
const { userId } = (ctx.session: { userId: string })
|
||||
const { server } = (ctx.params: { server: string })
|
||||
R.patch('/api/servers/:server/roles', async (ctx: KoaContextExt) => {
|
||||
const { userId } = (ctx.session as { userId: string })
|
||||
const { server } = (ctx.params as { server: string })
|
||||
|
||||
let gm = $.discord.gm(server, userId)
|
||||
if (gm == null) {
|
||||
let gm = await $.discord.gm(server, userId)
|
||||
|
||||
if (gm === undefined) {
|
||||
if ($.discord.isRoot(userId)) {
|
||||
gm = $.discord.fakeGm({ id: userId })
|
||||
} else {
|
||||
|
@ -117,8 +118,8 @@ export default (R: Router, $: AppContext) => {
|
|||
}
|
||||
}
|
||||
|
||||
const originalRoles = gm.roles
|
||||
let { added, removed } = ((ctx.request.body: any): { added: string[], removed: string[] })
|
||||
const originalRoles = gm.roles || []
|
||||
let { added, removed } = ctx.request.body as { added: string[], removed: string[] }
|
||||
|
||||
const allowedRoles: string[] = await $.server.getAllowedRoles(server)
|
||||
|
||||
|
@ -128,7 +129,9 @@ export default (R: Router, $: AppContext) => {
|
|||
removed = removed.filter(isSafe)
|
||||
|
||||
const newRoles = originalRoles.concat(added).filter(x => !removed.includes(x))
|
||||
gm.edit({
|
||||
|
||||
// force cast this because we know this will be correct.
|
||||
await (gm as Member).edit({
|
||||
roles: newRoles
|
||||
})
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
// @flow
|
||||
import { type Context } from 'koa'
|
||||
import { type AppContext, type Router } from '../Roleypoly'
|
||||
import { Context } from 'koa'
|
||||
import { AppContext, Router } from '../Roleypoly'
|
||||
|
||||
export default (R: Router, $: AppContext) => {
|
||||
R.get('/api/~/relevant-servers/:user', async (ctx: Context, next: () => void) => {
|
||||
// ctx.body = 'ok'
|
|
@ -1,6 +1,5 @@
|
|||
// @flow
|
||||
import { type Context } from 'koa'
|
||||
import { type AppContext, type Router } from '../Roleypoly'
|
||||
import { Context } from 'koa'
|
||||
import { AppContext, Router } from '../Roleypoly'
|
||||
export default (R: Router, $: AppContext) => {
|
||||
// note, this file only contains stuff for complicated routes.
|
||||
// next.js will handle anything beyond this.
|
|
@ -18,8 +18,8 @@ const server = http.createServer(app.callback())
|
|||
|
||||
const M = new Roleypoly(null, app) // eslint-disable-line no-unused-vars
|
||||
|
||||
const appKey = process.env.APP_KEY
|
||||
if (appKey == null || appKey === '') {
|
||||
const appKey = process.env.APP_KEY || ''
|
||||
if (appKey === '') {
|
||||
throw new Error('APP_KEY not set')
|
||||
}
|
||||
app.keys = new Keygrip([ appKey ])
|
||||
|
@ -37,7 +37,7 @@ async function start () {
|
|||
|
||||
// Request logger
|
||||
app.use(async (ctx, next) => {
|
||||
let timeStart = new Date()
|
||||
let timeStart = Date.now()
|
||||
try {
|
||||
await next()
|
||||
} catch (e) {
|
||||
|
@ -51,7 +51,7 @@ async function start () {
|
|||
}
|
||||
}
|
||||
}
|
||||
let timeElapsed = new Date() - timeStart
|
||||
let timeElapsed = Date.now() - timeStart
|
||||
|
||||
log.request(`${ctx.status} ${ctx.method} ${ctx.url} - ${ctx.ip} - took ${timeElapsed}ms`)
|
||||
// return null
|
|
@ -1,7 +1,6 @@
|
|||
// @flow
|
||||
import type Sequelize, { DataTypes as DT } from 'sequelize'
|
||||
import Sequelize from 'sequelize'
|
||||
|
||||
export default (sql: Sequelize, DataTypes: DT) => {
|
||||
export default (sql: Sequelize.Sequelize, DataTypes: typeof Sequelize.DataTypes) => {
|
||||
return sql.define('auth_challenge', {
|
||||
userId: DataTypes.TEXT,
|
||||
issuedAt: DataTypes.DATE,
|
|
@ -1,5 +1,5 @@
|
|||
// @flow
|
||||
import type Sequelize, { DataTypes as DT } from 'sequelize'
|
||||
import Sequelize from 'sequelize'
|
||||
|
||||
export type Category = {
|
||||
hidden: boolean,
|
||||
|
@ -17,7 +17,7 @@ export type ServerModel = {
|
|||
message: string
|
||||
}
|
||||
|
||||
export default (sql: Sequelize, DataTypes: DT) => {
|
||||
export default (sql: Sequelize.Sequelize, DataTypes: typeof Sequelize.DataTypes) => {
|
||||
return sql.define('server', {
|
||||
id: { // discord snowflake
|
||||
type: DataTypes.TEXT,
|
|
@ -1,4 +1,6 @@
|
|||
module.exports = (sequelize, DataTypes) => {
|
||||
import Sequelize from 'sequelize'
|
||||
|
||||
export default (sequelize: Sequelize.Sequelize, DataTypes: typeof Sequelize.DataTypes) => {
|
||||
return sequelize.define('session', {
|
||||
id: { type: DataTypes.TEXT, primaryKey: true },
|
||||
maxAge: DataTypes.BIGINT,
|
|
@ -1,16 +1,15 @@
|
|||
// @flow
|
||||
import logger from '../logger'
|
||||
import glob from 'glob'
|
||||
import path from 'path'
|
||||
import type Sequelize, { Model } from 'sequelize'
|
||||
import { Sequelize, Model } from 'sequelize'
|
||||
|
||||
const log = logger(__filename)
|
||||
|
||||
export type Models = {
|
||||
[modelName: string]: Model<any> & {
|
||||
__associations: (models: Models) => void,
|
||||
__instanceMethods: (model: Model<any>) => void,
|
||||
}
|
||||
[modelName: string]: typeof Model & Partial<{
|
||||
__associations: (models: Models) => void
|
||||
__instanceMethods: (model: typeof Model) => void
|
||||
}>
|
||||
}
|
||||
|
||||
export default (sql: Sequelize): Models => {
|
||||
|
@ -18,12 +17,13 @@ export default (sql: Sequelize): Models => {
|
|||
const modelFiles = glob.sync(`${__dirname}/**/!(index).js`).map(v => v.replace(__dirname, '.'))
|
||||
log.debug('found models', modelFiles)
|
||||
|
||||
modelFiles.forEach((v) => {
|
||||
for (const v of modelFiles) {
|
||||
let name = path.basename(v).replace('.js', '')
|
||||
if (v === `./index.js`) {
|
||||
log.debug('index.js hit, skipped')
|
||||
return
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
log.debug('importing..', v)
|
||||
let model = sql.import(v)
|
||||
|
@ -32,16 +32,17 @@ export default (sql: Sequelize): Models => {
|
|||
log.fatal('error importing model ' + v, err)
|
||||
process.exit(-1)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Object.keys(models).forEach((v) => {
|
||||
if (models[v].hasOwnProperty('__associations')) {
|
||||
models[v].__associations(models)
|
||||
for (const model of Object.values(models)) {
|
||||
if (model.__associations !== undefined) {
|
||||
model.__associations(models)
|
||||
}
|
||||
|
||||
if (model.__instanceMethods !== undefined) {
|
||||
model.__instanceMethods(model)
|
||||
}
|
||||
if (models[v].hasOwnProperty('__instanceMethods')) {
|
||||
models[v].__instanceMethods(models[v])
|
||||
}
|
||||
})
|
||||
|
||||
return models
|
||||
}
|
|
@ -1,6 +1,5 @@
|
|||
// @flow
|
||||
import { type AppContext } from '../Roleypoly'
|
||||
import { type Context } from 'koa'
|
||||
import { AppContext } from '../Roleypoly'
|
||||
import { Context } from 'koa'
|
||||
import { bot } from './_security'
|
||||
|
||||
export default ($: AppContext) => ({
|
9
packages/roleypoly-server/src/services/Service.ts
Normal file
9
packages/roleypoly-server/src/services/Service.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
// @flow
|
||||
import { Logger } from '../logger'
|
||||
import { AppContext } from '../Roleypoly'
|
||||
|
||||
export default class Service {
|
||||
// @ts-ignore log is used in sub-classes
|
||||
protected log: Logger = new Logger(this.constructor.name)
|
||||
constructor (public ctx: AppContext) {}
|
||||
}
|
|
@ -1,9 +1,9 @@
|
|||
// @flow
|
||||
import Service from './Service'
|
||||
import nanoid from 'nanoid'
|
||||
import moniker from 'moniker'
|
||||
import type { AppContext } from '../Roleypoly'
|
||||
import type { Context } from 'koa'
|
||||
import { AppContext } from '../Roleypoly'
|
||||
import { Context } from 'koa'
|
||||
import Sequelize from 'sequelize'
|
||||
// import type { UserPartial } from './discord'
|
||||
// import type { Models } from '../models'
|
||||
|
||||
|
@ -11,7 +11,7 @@ export type DMChallenge = {
|
|||
userId: string, // snowflake of the user
|
||||
human: string, // humanized string for input elsewhere, adjective adjective noun
|
||||
magic: string, // magic URL, a nanoid
|
||||
issuedAt: Date
|
||||
issuedAt: number // Date.now()
|
||||
}
|
||||
|
||||
export type AuthTokens = {
|
||||
|
@ -20,15 +20,17 @@ export type AuthTokens = {
|
|||
expires_in: string
|
||||
}
|
||||
|
||||
type AuthModels = { AuthChallenge: Sequelize.ModelCtor<any>, Session: Sequelize.ModelCtor<any> }
|
||||
|
||||
export default class AuthService extends Service {
|
||||
M: { AuthChallenge: any, Session: any }
|
||||
M: AuthModels
|
||||
monikerGen = moniker.generator([ moniker.adjective, moniker.adjective, moniker.noun ], { glue: ' ' })
|
||||
constructor (ctx: AppContext) {
|
||||
super(ctx)
|
||||
this.M = ctx.M
|
||||
this.M = ctx.M as AuthModels
|
||||
}
|
||||
|
||||
async isLoggedIn (ctx: Context, { refresh = false }: { refresh: boolean } = {}) {
|
||||
async isLoggedIn (ctx: Context, { refresh }: { refresh: boolean } = { refresh: false }) {
|
||||
const { userId, expiresAt, authType } = ctx.session
|
||||
this.log.debug('isLoggedIn session', ctx.session)
|
||||
if (userId == null) {
|
||||
|
@ -54,8 +56,8 @@ export default class AuthService extends Service {
|
|||
return true
|
||||
}
|
||||
|
||||
async createDMChallenge (userId: string): Promise<DMChallenge> {
|
||||
if (userId == null || userId === '') {
|
||||
async createDMChallenge (userId: string | undefined): Promise<DMChallenge> {
|
||||
if (userId === undefined || userId === '') {
|
||||
throw new Error('userId was not set')
|
||||
}
|
||||
|
||||
|
@ -67,7 +69,7 @@ export default class AuthService extends Service {
|
|||
userId,
|
||||
human: this.monikerGen.choose(),
|
||||
magic: nanoid(10),
|
||||
issuedAt: new Date()
|
||||
issuedAt: Date.now()
|
||||
}
|
||||
|
||||
await this.M.AuthChallenge.build({ ...out, type: 'dm' }).save()
|
||||
|
@ -75,15 +77,15 @@ export default class AuthService extends Service {
|
|||
return out
|
||||
}
|
||||
|
||||
async fetchDMChallenge (input: { human: string } | { magic: string }): Promise<?DMChallenge> {
|
||||
const challenge: ?DMChallenge = this.M.AuthChallenge.findOne({ where: input })
|
||||
if (challenge == null) {
|
||||
async fetchDMChallenge (input: { human: string } | { magic: string }): Promise<DMChallenge | null> {
|
||||
const challenge: DMChallenge | undefined = this.M.AuthChallenge.findOne({ where: input })
|
||||
if (challenge === undefined) {
|
||||
this.log.debug('challenge not found', challenge)
|
||||
return null
|
||||
}
|
||||
|
||||
// if issued more than 1 hour ago, it doesn't matter.
|
||||
if (+challenge.issuedAt + 3.6e5 < new Date()) {
|
||||
if (+challenge.issuedAt + 3.6e5 < Date.now()) {
|
||||
this.log.debug('challenge expired', challenge)
|
||||
return null
|
||||
}
|
||||
|
@ -109,6 +111,7 @@ export default class AuthService extends Service {
|
|||
injectSessionFromOAuth (ctx: Context, tokens: AuthTokens, userId: string) {
|
||||
const { expires_in: expiresIn, access_token: accessToken, refresh_token: refreshToken } = tokens
|
||||
ctx.session = {
|
||||
...ctx.session,
|
||||
userId,
|
||||
authType: 'oauth',
|
||||
expiresAt: Date.now() + expiresIn,
|
|
@ -1,14 +1,14 @@
|
|||
// @flow
|
||||
import Service from './Service'
|
||||
import type { AppContext } from '../Roleypoly'
|
||||
import Eris, { type Member, Role, type Guild, type Permission as ErisPermission } from 'eris'
|
||||
import { AppContext } from '../Roleypoly'
|
||||
import Eris, { Member, Role, Guild, Permission as ErisPermission } from 'eris'
|
||||
import LRU from 'lru-cache'
|
||||
// $FlowFixMe
|
||||
import { OrderedSet, OrderedMap } from 'immutable'
|
||||
import superagent from 'superagent'
|
||||
import type { AuthTokens } from './auth'
|
||||
import type { IFetcher } from './discord/types'
|
||||
import { AuthTokens } from './auth'
|
||||
import { IFetcher } from './discord/types'
|
||||
import RestFetcher from './discord/restFetcher'
|
||||
import { oc } from 'ts-optchain'
|
||||
|
||||
type DiscordServiceConfig = {
|
||||
token: string,
|
||||
|
@ -57,7 +57,7 @@ export default class DiscordService extends Service {
|
|||
static _guildExpiration = +(process.env.GUILD_INVALIDATION_TIME || 36e5)
|
||||
|
||||
ctx: AppContext
|
||||
client: Eris
|
||||
client: Eris.Client
|
||||
|
||||
cfg: DiscordServiceConfig
|
||||
|
||||
|
@ -69,7 +69,8 @@ export default class DiscordService extends Service {
|
|||
|
||||
fetcher: IFetcher
|
||||
|
||||
guilds: OrderedMap<Guild> = OrderedMap<Guild>()
|
||||
guilds: OrderedMap<string, Guild> = OrderedMap<string, Guild>()
|
||||
|
||||
_lastGuildFetch: number
|
||||
constructor (ctx: AppContext) {
|
||||
super(ctx)
|
||||
|
@ -87,30 +88,30 @@ export default class DiscordService extends Service {
|
|||
this.ownRoleCache = new LRU()
|
||||
this.topRoleCache = new LRU()
|
||||
|
||||
this.client = new Eris(`Bot ${this.cfg.token}`, {
|
||||
this.client = new Eris.Client(`Bot ${this.cfg.token}`, {
|
||||
restMode: true
|
||||
})
|
||||
|
||||
this.fetcher = new RestFetcher(this)
|
||||
this.fetchGuilds(true)
|
||||
this.fetchGuilds(true).then(() => null).catch(e => this.log.error)
|
||||
}
|
||||
|
||||
isRoot (id: string): boolean {
|
||||
return this.cfg.rootUsers.has(id)
|
||||
}
|
||||
|
||||
getRelevantServers (user: string): OrderedSet<Guild> {
|
||||
getRelevantServers (user: string): OrderedMap<string, Guild> {
|
||||
return this.guilds.filter(guild => guild.members.has(user))
|
||||
}
|
||||
|
||||
async gm (serverId: string, userId: string, { canFake = false }: { canFake: boolean } = {}): Promise<?MemberExt> {
|
||||
const gm: ?Member = await this.fetcher.getMember(serverId, userId)
|
||||
if (gm == null && this.isRoot(userId)) {
|
||||
async gm (serverId: string, userId: string, { canFake }: { canFake: boolean } = { canFake: false }): Promise<Partial<MemberExt> | MemberExt | undefined> {
|
||||
const gm: Member | undefined = await this.fetcher.getMember(serverId, userId)
|
||||
if (gm === undefined && this.isRoot(userId)) {
|
||||
return this.fakeGm({ id: userId })
|
||||
}
|
||||
|
||||
if (gm == null) {
|
||||
return null
|
||||
if (gm === undefined) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const out: MemberExt = gm
|
||||
|
@ -122,24 +123,35 @@ export default class DiscordService extends Service {
|
|||
return this.gm(serverId, this.client.user.id)
|
||||
}
|
||||
|
||||
fakeGm ({ id = '0', nick = '[none]', color = 0 }: $Shape<MemberExt>): $Shape<MemberExt> {
|
||||
fakeGm ({ id = '0', nick = '[none]', color = 0 }: Partial<MemberExt>): Partial<MemberExt> {
|
||||
return { id, nick, color, __faked: true, roles: [] }
|
||||
}
|
||||
|
||||
getRoles (server: string) {
|
||||
return this.client.guilds.get(server)?.roles
|
||||
const s = this.client.guilds.get(server)
|
||||
if (s === undefined) {
|
||||
return new Map<string, Eris.Role>()
|
||||
}
|
||||
|
||||
async getOwnPermHeight (server: Guild): Promise<number> {
|
||||
if (this.ownRoleCache.has(server)) {
|
||||
const r = this.ownRoleCache.get(server)
|
||||
return new Map(s.roles)
|
||||
}
|
||||
|
||||
async getOwnPermHeight (server: Eris.Guild): Promise<number> {
|
||||
if (this.ownRoleCache.has(server.id)) {
|
||||
const r = this.ownRoleCache.get(server.id) as CachedRole // we know this exists.
|
||||
return r.position
|
||||
}
|
||||
|
||||
const gm = await this.ownGm(server.id)
|
||||
const g = gm?.guild
|
||||
const r: Role = OrderedSet(gm?.roles).map(id => g?.roles.get(id)).minBy(r => r.position)
|
||||
this.ownRoleCache.set(server, {
|
||||
const gm = oc(await this.ownGm(server.id))
|
||||
const g = gm.guild()
|
||||
if (g === undefined) {
|
||||
throw new Error('guild undefined.')
|
||||
}
|
||||
|
||||
const rs = OrderedSet(gm.roles([])).map(id => g.roles.get(id) as Eris.Role)
|
||||
const r = rs.minBy(role => role.position) as Eris.Role
|
||||
|
||||
this.ownRoleCache.set(server.id, {
|
||||
id: r.id,
|
||||
position: r.position
|
||||
})
|
||||
|
@ -149,15 +161,16 @@ export default class DiscordService extends Service {
|
|||
|
||||
getHighestRole (gm: MemberExt): Role {
|
||||
const trk = `${gm.guild.id}:${gm.id}`
|
||||
|
||||
if (this.topRoleCache.has(trk)) {
|
||||
const r = gm.guild.roles.get(this.topRoleCache.get(trk).id)
|
||||
if (r != null) {
|
||||
const r = gm.guild.roles.get((this.topRoleCache.get(trk) as CachedRole).id)
|
||||
if (r !== undefined) {
|
||||
return r
|
||||
}
|
||||
}
|
||||
|
||||
const g = gm.guild
|
||||
const top = OrderedSet(gm.roles).map(id => g.roles.get(id)).minBy(r => r.position)
|
||||
const top = OrderedSet(gm.roles).map(id => g.roles.get(id) as Eris.Role).minBy(r => r.position) as Eris.Role
|
||||
this.topRoleCache.set(trk, {
|
||||
id: top.id,
|
||||
position: top.position,
|
||||
|
@ -191,8 +204,8 @@ export default class DiscordService extends Service {
|
|||
}
|
||||
|
||||
async safeRole (server: string, role: string) {
|
||||
const r = this.getRoles(server)?.get(role)
|
||||
if (r == null) {
|
||||
const r = this.getRoles(server).get(role)
|
||||
if (r === undefined) {
|
||||
throw new Error(`safeRole can't find ${role} in ${server}`)
|
||||
}
|
||||
|
||||
|
@ -205,7 +218,7 @@ export default class DiscordService extends Service {
|
|||
return ownPh > role.position
|
||||
}
|
||||
|
||||
async oauthRequest (path: string, data: OAuthRequestData) {
|
||||
async oauthRequest (path: string, data: OAuthRequestData): Promise<AuthTokens> {
|
||||
const url = `https://discordapp.com/api/oauth2/${path}`
|
||||
try {
|
||||
const rsp = await superagent.post(url)
|
||||
|
@ -218,7 +231,7 @@ export default class DiscordService extends Service {
|
|||
...data
|
||||
})
|
||||
|
||||
return (rsp.body: AuthTokens)
|
||||
return rsp.body
|
||||
} catch (e) {
|
||||
this.log.error('oauthRequest failed', { e, path })
|
||||
throw e
|
||||
|
@ -246,11 +259,11 @@ export default class DiscordService extends Service {
|
|||
})
|
||||
}
|
||||
|
||||
async getUserPartial (userId: string): Promise<?UserPartial> {
|
||||
async getUserPartial (userId: string): Promise<UserPartial> {
|
||||
const u = await this.fetcher.getUser(userId)
|
||||
if (u == null) {
|
||||
if (u === undefined) {
|
||||
this.log.debug('userPartial got a null user', userId, u)
|
||||
return null
|
||||
throw new Error('userPartial got a null user')
|
||||
}
|
||||
|
||||
return {
|
||||
|
@ -296,19 +309,19 @@ export default class DiscordService extends Service {
|
|||
}
|
||||
}
|
||||
|
||||
async guild (id: string, invalidate: boolean = false): Promise<?Guild> {
|
||||
async guild (id: string, invalidate: boolean = false): Promise<Guild | undefined> {
|
||||
// fetch if needed
|
||||
await this.fetchGuilds()
|
||||
|
||||
// do we know about it?
|
||||
// (also don't get this if we're invalidating)
|
||||
if (invalidate === false && this.guilds.has(id)) {
|
||||
return this.guilds.get(id)
|
||||
return this.guilds.get(id) as Eris.Guild
|
||||
}
|
||||
|
||||
// else let's fetch and cache.
|
||||
const g = await this.fetcher.getGuild(id)
|
||||
if (g != null) {
|
||||
if (g !== undefined) {
|
||||
this.guilds = this.guilds.set(g.id, g)
|
||||
}
|
||||
|
||||
|
@ -345,21 +358,21 @@ export default class DiscordService extends Service {
|
|||
}
|
||||
|
||||
async canManageRoles (server: string, user: string): Promise<boolean> {
|
||||
const gm = await this.gm(server, user)
|
||||
if (gm == null) {
|
||||
const gm = await this.gm(server, user) as Member | undefined
|
||||
if (gm === undefined) {
|
||||
return false
|
||||
}
|
||||
|
||||
return this.getPermissions(gm).canManageRoles
|
||||
}
|
||||
|
||||
isMember (server: string, user: string): boolean {
|
||||
return this.gm(server, user) != null
|
||||
async isMember (server: string, user: string): Promise<boolean> {
|
||||
return await this.gm(server, user) !== undefined
|
||||
}
|
||||
|
||||
async isValidUser (user: string): Promise<boolean> {
|
||||
const u = await this.fetcher.getUser(user)
|
||||
if (u != null) {
|
||||
if (u !== undefined) {
|
||||
return true
|
||||
}
|
||||
|
|
@ -1,16 +1,14 @@
|
|||
// @flow
|
||||
import type { IFetcher } from './types'
|
||||
import type DiscordSvc from '../discord'
|
||||
import type ErisClient, { User, Member, Guild } from 'eris'
|
||||
import { IFetcher } from './types'
|
||||
import DiscordSvc from '../discord'
|
||||
import { Client, User, Member, Guild } from 'eris'
|
||||
import LRU from 'lru-cache'
|
||||
import logger from '../../logger'
|
||||
// $FlowFixMe
|
||||
import { OrderedSet } from 'immutable'
|
||||
const log = logger(__filename)
|
||||
|
||||
export default class BotFetcher implements IFetcher {
|
||||
ctx: DiscordSvc
|
||||
client: ErisClient
|
||||
client: Client
|
||||
cache: LRU<string, Guild | Member | User>
|
||||
|
||||
constructor (ctx: DiscordSvc) {
|
||||
|
@ -22,10 +20,10 @@ export default class BotFetcher implements IFetcher {
|
|||
})
|
||||
}
|
||||
|
||||
getUser = async (id: string): Promise<?User> => {
|
||||
getUser = async (id: string): Promise<User | undefined> => {
|
||||
if (this.cache.has(`U:${id}`)) {
|
||||
log.debug('user cache hit')
|
||||
return this.cache.get(`U:${id}`)
|
||||
return this.cache.get(`U:${id}`) as User
|
||||
}
|
||||
|
||||
log.debug('user cache miss')
|
||||
|
@ -35,32 +33,32 @@ export default class BotFetcher implements IFetcher {
|
|||
this.cache.set(`U:${id}`, u)
|
||||
return u
|
||||
} catch (e) {
|
||||
return null
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
getMember = async (server: string, user: string): Promise<?Member> => {
|
||||
getMember = async (server: string, user: string): Promise<Member | undefined> => {
|
||||
if (this.cache.has(`M:${server}:${user}`)) {
|
||||
log.debug('member cache hit')
|
||||
return this.cache.get(`M:${server}:${user}`)
|
||||
// log.debug('member cache hit')
|
||||
return this.cache.get(`M:${server}:${user}`) as Member
|
||||
}
|
||||
|
||||
log.debug('member cache miss')
|
||||
// log.debug('member cache miss')
|
||||
|
||||
try {
|
||||
const m = await this.client.getRESTGuildMember(server, user)
|
||||
this.cache.set(`M:${server}:${user}`, m)
|
||||
// $FlowFixMe
|
||||
m.guild = await this.getGuild(server) // we have to prefill this for whatever reason
|
||||
m.guild = await this.getGuild(server) as Guild // we have to prefill this for whatever reason
|
||||
return m
|
||||
} catch (e) {
|
||||
return null
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
getGuild = async (server: string): Promise<?Guild> => {
|
||||
getGuild = async (server: string): Promise<Guild | undefined> => {
|
||||
if (this.cache.has(`G:${server}`)) {
|
||||
log.debug('guild cache hit')
|
||||
return this.cache.get(`G:${server}`)
|
||||
return this.cache.get(`G:${server}`) as Guild
|
||||
}
|
||||
|
||||
log.debug('guild cache miss')
|
||||
|
@ -70,18 +68,17 @@ export default class BotFetcher implements IFetcher {
|
|||
this.cache.set(`G:${server}`, g)
|
||||
return g
|
||||
} catch (e) {
|
||||
return null
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
getGuilds = async (): Promise<Guild[]> => {
|
||||
const last: ?string = undefined
|
||||
const last: string | undefined = undefined
|
||||
const limit: number = 100
|
||||
let out = OrderedSet<Guild>()
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
// $FlowFixMe -- last is optional, but typedef isn't.
|
||||
const gl = await this.client.getRESTGuilds(limit, last)
|
||||
|
||||
out = out.union(gl)
|
15
packages/roleypoly-server/src/services/discord/types.ts
Normal file
15
packages/roleypoly-server/src/services/discord/types.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
import {
|
||||
User,
|
||||
Member,
|
||||
Guild
|
||||
} from 'eris'
|
||||
|
||||
export interface IFetcher {
|
||||
getUser: (id: string) => Promise<User | undefined>
|
||||
|
||||
getGuild: (id: string) => Promise<Guild | undefined>
|
||||
|
||||
getMember: (server: string, user: string) => Promise<Member | undefined>
|
||||
|
||||
getGuilds: () => Promise<Guild[]>
|
||||
}
|
|
@ -1,24 +1,25 @@
|
|||
// @flow
|
||||
import Service from './Service'
|
||||
import LRU from 'lru-cache'
|
||||
import { type AppContext } from '../Roleypoly'
|
||||
import { type Models } from '../models'
|
||||
import { type ServerModel } from '../models/Server'
|
||||
import type DiscordService from './discord'
|
||||
import { AppContext } from '../Roleypoly'
|
||||
import { Models } from '../models'
|
||||
import { ServerModel } from '../models/Server'
|
||||
import DiscordService from './discord'
|
||||
// $FlowFixMe
|
||||
import type { Sequence } from 'immutable'
|
||||
import { Sequence } from 'immutable'
|
||||
import {
|
||||
type Guild,
|
||||
type Collection
|
||||
Guild,
|
||||
Collection
|
||||
} from 'eris'
|
||||
import type {
|
||||
import {
|
||||
Member,
|
||||
PresentableServer,
|
||||
ServerSlug,
|
||||
PresentableRole
|
||||
} from '@roleypoly/types'
|
||||
import areduce from '../util/areduce'
|
||||
class PresentationService extends Service {
|
||||
|
||||
export default class PresentationService extends Service {
|
||||
cache: LRU
|
||||
M: Models
|
||||
discord: DiscordService
|
||||
|
@ -41,7 +42,7 @@ class PresentationService extends Service {
|
|||
}
|
||||
|
||||
presentableServers (collection: Collection<Guild> | Sequence<Guild>, userId: string): Promise<PresentableServer[]> {
|
||||
return areduce(Array.from(collection.values()), async (acc, server) => {
|
||||
return areduce<Guild, PresentableServer>(Array.from(collection.values()), async (acc: PresentableServer[], server: Guild) => {
|
||||
const gm = server.members.get(userId)
|
||||
if (gm == null) {
|
||||
throw new Error(`somehow this guildmember ${userId} of ${server.id} didn't exist.`)
|
||||
|
@ -49,17 +50,17 @@ class PresentationService extends Service {
|
|||
|
||||
acc.push(await this.presentableServer(server, gm, { incRoles: false }))
|
||||
return acc
|
||||
}, [])
|
||||
})
|
||||
}
|
||||
|
||||
async presentableServer (server: Guild, gm: Member, { incRoles = true }: { incRoles: boolean } = {}): Promise<PresentableServer> {
|
||||
async presentableServer (server: Guild, gm: Member, { incRoles = true }: { incRoles?: boolean } = {}): Promise<PresentableServer> {
|
||||
const sd = await this.ctx.server.get(server.id)
|
||||
|
||||
return {
|
||||
id: server.id,
|
||||
gm: {
|
||||
nickname: gm.nick || gm.user.username,
|
||||
color: gm?.color,
|
||||
color: gm.color || null,
|
||||
roles: gm.roles
|
||||
},
|
||||
server: this.serverSlug(server),
|
||||
|
@ -82,5 +83,3 @@ class PresentationService extends Service {
|
|||
}))
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PresentationService
|
|
@ -1,14 +1,12 @@
|
|||
// @flow
|
||||
import Service from './Service'
|
||||
import { type AppContext } from '../Roleypoly'
|
||||
import type PresentationService from './presentation'
|
||||
import {
|
||||
type Guild
|
||||
} from 'eris'
|
||||
import { type ServerModel, type Category } from '@roleypoly/types'
|
||||
import { AppContext } from '../Roleypoly'
|
||||
import PresentationService from './presentation'
|
||||
import { Guild } from 'eris'
|
||||
import { ServerModel, Category } from '@roleypoly/types'
|
||||
|
||||
export default class ServerService extends Service {
|
||||
Server: * // Model<ServerModel> but flowtype is bugged
|
||||
Server: any // Model<ServerModel> but flowtype is bugged
|
||||
P: PresentationService
|
||||
constructor (ctx: AppContext) {
|
||||
super(ctx)
|
||||
|
@ -17,14 +15,14 @@ export default class ServerService extends Service {
|
|||
}
|
||||
|
||||
async ensure (server: Guild) {
|
||||
let srv
|
||||
let srv: ServerModel | undefined = undefined
|
||||
try {
|
||||
srv = await this.get(server.id)
|
||||
} catch (e) {
|
||||
|
||||
this.log.info('creating server', server.id)
|
||||
}
|
||||
|
||||
if (srv == null) {
|
||||
if (srv === undefined) {
|
||||
return this.create({
|
||||
id: server.id,
|
||||
message: '',
|
||||
|
@ -39,7 +37,7 @@ export default class ServerService extends Service {
|
|||
return srv.save()
|
||||
}
|
||||
|
||||
async update (id: string, newData: $Shape<ServerModel>) { // eslint-disable-line no-undef
|
||||
async update (id: string, newData: Partial<ServerModel>) { // eslint-disable-line no-undef
|
||||
const srv = await this.get(id, false)
|
||||
|
||||
return srv.update(newData)
|
||||
|
@ -61,7 +59,7 @@ export default class ServerService extends Service {
|
|||
|
||||
async getAllowedRoles (id: string) {
|
||||
const server: ServerModel = await this.get(id)
|
||||
const categories: Category[] = (Object.values(server.categories): any)
|
||||
const categories: Category[] = Object.values(server.categories)
|
||||
const allowed: string[] = []
|
||||
|
||||
for (const c of categories) {
|
|
@ -1,6 +1,6 @@
|
|||
// @flow
|
||||
import Service from './Service'
|
||||
import { type AppContext } from '../Roleypoly'
|
||||
import { AppContext } from '../Roleypoly'
|
||||
import Sequelize from 'sequelize'
|
||||
|
||||
type SessionData = {
|
||||
rolling: boolean,
|
||||
|
@ -9,7 +9,7 @@ type SessionData = {
|
|||
}
|
||||
|
||||
export default class SessionsService extends Service {
|
||||
Session: any
|
||||
Session: Sequelize.ModelCtor<any>
|
||||
constructor (ctx: AppContext) {
|
||||
super(ctx)
|
||||
this.Session = ctx.M.Session
|
||||
|
@ -25,7 +25,7 @@ export default class SessionsService extends Service {
|
|||
return user.data
|
||||
}
|
||||
|
||||
async set (id: string, data: any, { maxAge, rolling, changed }: SessionData) {
|
||||
async set<T> (id: string, data: any, { maxAge, rolling, changed }: SessionData) {
|
||||
let session = await this.Session.findOne({ where: { id } })
|
||||
if (session === null) {
|
||||
session = this.Session.build({ id })
|
||||
|
@ -42,7 +42,7 @@ export default class SessionsService extends Service {
|
|||
async destroy (id: string) {
|
||||
const sess = await this.Session.findOne({ where: { id } })
|
||||
|
||||
if (sess != null) {
|
||||
if (sess !== null) {
|
||||
return sess.destroy()
|
||||
}
|
||||
}
|
13
packages/roleypoly-server/src/util/areduce.ts
Normal file
13
packages/roleypoly-server/src/util/areduce.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
const areduce = async <T, V> (
|
||||
array: T[],
|
||||
predicate: (acc: V[], val: T) => Promise<V[]>,
|
||||
acc: undefined | V[] = []
|
||||
): Promise<Array<V>> => {
|
||||
for (let i of array) {
|
||||
acc = await predicate(acc, i)
|
||||
}
|
||||
|
||||
return acc
|
||||
}
|
||||
|
||||
export default areduce
|
|
@ -4,7 +4,7 @@ import os from 'os'
|
|||
import { addAwaitOutsideToReplServer } from 'await-outside'
|
||||
import Roleypoly from '../Roleypoly'
|
||||
import chokidar from 'chokidar'
|
||||
import logger from '../logger'
|
||||
import logger from '../../logger'
|
||||
// process.env.DEBUG = false
|
||||
process.env.IS_BOT = false
|
||||
|
12
packages/roleypoly-server/tsconfig.json
Normal file
12
packages/roleypoly-server/tsconfig.json
Normal file
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./lib",
|
||||
"plugins": [
|
||||
{ "transform": "ts-optchain/transform" }
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"./src"
|
||||
]
|
||||
}
|
3
packages/roleypoly-server/tslint.json
Normal file
3
packages/roleypoly-server/tslint.json
Normal file
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"extends": "tslint-config-standard"
|
||||
}
|
|
@ -1,8 +0,0 @@
|
|||
// @flow
|
||||
export default async function<T, V> (array: Array<T>, predicate: (Array<V>, T) => Promise<Array<V>>, acc: Array<V>): Promise<Array<V>> {
|
||||
for (let i of array) {
|
||||
acc = await predicate(acc, i)
|
||||
}
|
||||
|
||||
return acc
|
||||
}
|
14
packages/roleypoly-types/.lintstagedrc.yml
Normal file
14
packages/roleypoly-types/.lintstagedrc.yml
Normal file
|
@ -0,0 +1,14 @@
|
|||
linters:
|
||||
lib/*.{js,jsx}:
|
||||
- standard --fix
|
||||
- git add
|
||||
lib/*.d.ts:
|
||||
- tslint --fix
|
||||
- git add
|
||||
src/*.{ts,tsx}:
|
||||
- tslint --fix
|
||||
- stylelint --fix
|
||||
- jest --bail --findRelatedTests
|
||||
- git add
|
||||
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
// @flow
|
||||
export type Category = {
|
||||
export declare type Category = {
|
||||
hidden: boolean,
|
||||
name: string,
|
||||
roles: string[],
|
|
@ -1,21 +1,21 @@
|
|||
// @flow
|
||||
export type {
|
||||
declare module "@roleypoly/types"
|
||||
export {
|
||||
PresentableRole,
|
||||
CachedRole,
|
||||
Permissions
|
||||
} from './role'
|
||||
|
||||
export type {
|
||||
export {
|
||||
PresentableServer,
|
||||
ServerModel,
|
||||
ServerSlug
|
||||
} from './server'
|
||||
|
||||
export type {
|
||||
export {
|
||||
Category
|
||||
} from './category'
|
||||
|
||||
export type {
|
||||
export {
|
||||
UserPartial,
|
||||
Member
|
||||
} from './user'
|
|
@ -1,5 +1,13 @@
|
|||
{
|
||||
"name": "@roleypoly/types",
|
||||
"version": "2.0.0",
|
||||
"private": true
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"precommit": "lint-staged"
|
||||
},
|
||||
"devDependencies": {
|
||||
"lint-staged": "^8.1.7",
|
||||
"tslint": "^5.17.0",
|
||||
"typescript": "^3.5.1"
|
||||
}
|
||||
}
|
||||
|
|
2
packages/roleypoly-types/packages/moniker.d.ts
vendored
Normal file
2
packages/roleypoly-types/packages/moniker.d.ts
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
export declare module 'moniker';
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
// @flow
|
||||
|
||||
export type PresentableRole = {
|
||||
export declare type PresentableRole = {
|
||||
id: string,
|
||||
color: number,
|
||||
name: string,
|
||||
|
@ -8,14 +7,14 @@ export type PresentableRole = {
|
|||
safe: boolean
|
||||
}
|
||||
|
||||
export type Permissions = {
|
||||
export declare type Permissions = {
|
||||
canManageRoles: boolean,
|
||||
isAdmin: boolean,
|
||||
faked?: boolean,
|
||||
__faked?: Permissions
|
||||
}
|
||||
|
||||
export type CachedRole = {
|
||||
export declare type CachedRole = {
|
||||
id: string,
|
||||
position: number,
|
||||
color?: number
|
|
@ -1,15 +1,14 @@
|
|||
// @flow
|
||||
import type { Category } from './category'
|
||||
import type { PresentableRole, Permissions } from './role'
|
||||
import { Category } from './category'
|
||||
import { PresentableRole, Permissions } from './role'
|
||||
|
||||
export type ServerSlug = {
|
||||
export declare type ServerSlug = {
|
||||
id: string,
|
||||
name: string,
|
||||
ownerID: string,
|
||||
icon: string
|
||||
}
|
||||
|
||||
export type ServerModel = {
|
||||
export declare type ServerModel = {
|
||||
id: string,
|
||||
categories: {
|
||||
[uuid: string]: Category
|
||||
|
@ -17,7 +16,7 @@ export type ServerModel = {
|
|||
message: string
|
||||
}
|
||||
|
||||
export type PresentableServer = ServerModel & {
|
||||
export declare type PresentableServer = ServerModel & {
|
||||
id: string,
|
||||
gm?: {
|
||||
color?: number | string,
|
||||
|
@ -25,6 +24,6 @@ export type PresentableServer = ServerModel & {
|
|||
roles: string[]
|
||||
},
|
||||
server: ServerSlug,
|
||||
roles: ?PresentableRole[],
|
||||
roles?: PresentableRole[],
|
||||
perms: Permissions
|
||||
}
|
21
packages/roleypoly-types/sessions.d.ts
vendored
Normal file
21
packages/roleypoly-types/sessions.d.ts
vendored
Normal file
|
@ -0,0 +1,21 @@
|
|||
|
||||
export declare type BaseSession = {
|
||||
userId: string,
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
export declare type OAuthSession = BaseSession & {
|
||||
authType: 'oauth',
|
||||
accessToken: string,
|
||||
refreshToken: string
|
||||
}
|
||||
|
||||
export declare type DMSession = BaseSession & {
|
||||
authType: 'dm'
|
||||
}
|
||||
|
||||
export declare type AuthSession = OAuthSession | DMSession
|
||||
|
||||
export declare type Session = AuthSession & Partial<{
|
||||
|
||||
}>
|
3
packages/roleypoly-types/tslint.json
Normal file
3
packages/roleypoly-types/tslint.json
Normal file
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"extends": "tslint-config-standard"
|
||||
}
|
13
packages/roleypoly-types/user.d.ts
vendored
Normal file
13
packages/roleypoly-types/user.d.ts
vendored
Normal file
|
@ -0,0 +1,13 @@
|
|||
import { Member as ErisMember } from 'eris'
|
||||
|
||||
export declare type UserPartial = {
|
||||
id: string,
|
||||
username: string,
|
||||
discriminator: string,
|
||||
avatar: string
|
||||
}
|
||||
|
||||
export declare type Member = ErisMember & {
|
||||
color?: number,
|
||||
__faked?: true
|
||||
}
|
|
@ -1,14 +0,0 @@
|
|||
// @flow
|
||||
import type { Member as ErisMember } from 'eris'
|
||||
|
||||
export type UserPartial = {
|
||||
id: string,
|
||||
username: string,
|
||||
discriminator: string,
|
||||
avatar: string
|
||||
}
|
||||
|
||||
export type Member = ErisMember & {
|
||||
color?: number,
|
||||
__faked?: true
|
||||
}
|
|
@ -1,14 +1,12 @@
|
|||
{
|
||||
"presets": [
|
||||
"next/babel", "@babel/preset-flow"
|
||||
"next/babel",
|
||||
"@zeit/next-typescript/babel"
|
||||
],
|
||||
"plugins": [
|
||||
"@babel/plugin-syntax-dynamic-import",
|
||||
"@babel/plugin-proposal-class-properties",
|
||||
["@babel/plugin-transform-runtime",
|
||||
{ "helpers": false }],
|
||||
[ "styled-components", { "ssr": true } ],
|
||||
"@babel/plugin-proposal-optional-chaining"
|
||||
],
|
||||
"env": {
|
||||
"test": {
|
||||
|
|
14
packages/roleypoly-ui/.lintstagedrc.yml
Normal file
14
packages/roleypoly-ui/.lintstagedrc.yml
Normal file
|
@ -0,0 +1,14 @@
|
|||
linters:
|
||||
lib/*.{js,jsx}:
|
||||
- standard --fix
|
||||
- git add
|
||||
lib/*.d.ts:
|
||||
- tslint --fix
|
||||
- git add
|
||||
src/*.{ts,tsx}:
|
||||
- tslint --fix
|
||||
- stylelint --fix
|
||||
- jest --bail --findRelatedTests
|
||||
- git add
|
||||
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`<DiscordButton /> renders correctly 1`] = `
|
||||
<discord-button__Button>
|
||||
<discord-button__ButtonIcon
|
||||
<styled.a>
|
||||
<styled.img
|
||||
src="/static/discord-logo.svg"
|
||||
/>
|
||||
|
||||
Hello!
|
||||
</discord-button__Button>
|
||||
</styled.a>
|
||||
`;
|
|
@ -22,7 +22,7 @@ exports[`<DiscordGuildPic /> falls-back to a default when things go bad. 1`] = `
|
|||
id="0000"
|
||||
name="Mock"
|
||||
>
|
||||
<discord-guild-pic__Fallback
|
||||
<styled.div
|
||||
serverName="Mock"
|
||||
style={
|
||||
Object {
|
||||
|
@ -36,17 +36,22 @@ exports[`<DiscordGuildPic /> falls-back to a default when things go bad. 1`] = `
|
|||
"$$typeof": Symbol(react.forward_ref),
|
||||
"attrs": Array [],
|
||||
"componentStyle": ComponentStyle {
|
||||
"componentId": "discord-guild-pic__Fallback-sc-93euug-0",
|
||||
"componentId": "sc-bdVaJa",
|
||||
"isStatic": true,
|
||||
"lastClassName": "c0",
|
||||
"rules": Array [
|
||||
"display:flex;justify-content:center;align-items:center;background-color:var(--fallback-color);",
|
||||
"
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: var(--fallback-color);
|
||||
",
|
||||
],
|
||||
},
|
||||
"displayName": "discord-guild-pic__Fallback",
|
||||
"displayName": "styled.div",
|
||||
"foldedComponentIds": Array [],
|
||||
"render": [Function],
|
||||
"styledComponentId": "discord-guild-pic__Fallback-sc-93euug-0",
|
||||
"styledComponentId": "sc-bdVaJa",
|
||||
"target": "div",
|
||||
"toString": [Function],
|
||||
"warnTooManyClasses": [Function],
|
||||
|
@ -72,7 +77,7 @@ exports[`<DiscordGuildPic /> falls-back to a default when things go bad. 1`] = `
|
|||
M
|
||||
</div>
|
||||
</StyledComponent>
|
||||
</discord-guild-pic__Fallback>
|
||||
</styled.div>
|
||||
</DiscordGuildPic>
|
||||
`;
|
||||
|
|
@ -1,3 +1,6 @@
|
|||
/**
|
||||
* @jest-environment jsdom
|
||||
*/
|
||||
/* eslint-env jest */
|
||||
import * as React from 'react'
|
||||
// import renderer from 'react-test-renderer'
|
|
@ -1,3 +1,6 @@
|
|||
/**
|
||||
* @jest-environment jsdom
|
||||
*/
|
||||
/* eslint-env jest */
|
||||
import * as React from 'react'
|
||||
// import renderer from 'react-test-renderer'
|
||||
|
@ -24,8 +27,10 @@ describe('<DiscordGuildPic />', () => {
|
|||
})
|
||||
|
||||
it('falls-back to a default when things go bad.', () => {
|
||||
const pic = mount(<DiscordGuildPic {...mockServer} />)
|
||||
pic.find('img').simulate('error')
|
||||
const el = <DiscordGuildPic { ...mockServer } />
|
||||
const pic = mount<typeof el>(el)
|
||||
pic.setState({ ok: false })
|
||||
|
||||
expect(pic).toMatchSnapshot()
|
||||
|
||||
expect(pic.text()).toEqual(mockServer.name[0])
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue