mirror of
https://github.com/roleypoly/roleypoly-v1.git
synced 2025-06-17 02:29:10 +00:00
lerna: split up bulk of packages
This commit is contained in:
parent
cb0b1d2410
commit
47a2e5694e
90 changed files with 0 additions and 0 deletions
12
packages/roleypoly-server/services/Service.js
Normal file
12
packages/roleypoly-server/services/Service.js
Normal file
|
@ -0,0 +1,12 @@
|
|||
// @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)
|
||||
}
|
||||
}
|
102
packages/roleypoly-server/services/auth.js
Normal file
102
packages/roleypoly-server/services/auth.js
Normal file
|
@ -0,0 +1,102 @@
|
|||
// @flow
|
||||
import Service from './Service'
|
||||
import nanoid from 'nanoid'
|
||||
import moniker from 'moniker'
|
||||
import type { AppContext } from '../Roleypoly'
|
||||
import type { Context } from 'koa'
|
||||
// import type { UserPartial } from './discord'
|
||||
// import type { Models } from '../models'
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export type AuthTokens = {
|
||||
access_token: string,
|
||||
refresh_token: string,
|
||||
expires_in: string
|
||||
}
|
||||
|
||||
export default class AuthService extends Service {
|
||||
M: { AuthChallenge: any }
|
||||
monikerGen = moniker.generator([ moniker.adjective, moniker.adjective, moniker.noun ], { glue: ' ' })
|
||||
constructor (ctx: AppContext) {
|
||||
super(ctx)
|
||||
this.M = ctx.M
|
||||
}
|
||||
|
||||
async isLoggedIn (ctx: Context, { refresh = false }: { refresh: boolean } = {}) {
|
||||
const { userId, expiresAt, authType } = ctx.session
|
||||
if (userId == null) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (expiresAt < Date.now()) {
|
||||
if (refresh && authType === 'oauth') {
|
||||
const tokens = await this.ctx.discord.refreshOAuth(ctx.session)
|
||||
this.injectSessionFromOAuth(ctx, tokens, userId)
|
||||
return true
|
||||
}
|
||||
|
||||
ctx.session = null // reset session as well
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
async createDMChallenge (userId: string): Promise<DMChallenge> {
|
||||
const out: DMChallenge = {
|
||||
userId,
|
||||
human: this.monikerGen.choose(),
|
||||
magic: nanoid(10),
|
||||
issuedAt: new Date()
|
||||
}
|
||||
|
||||
await this.M.AuthChallenge.build({ ...out, type: 'dm' }).save()
|
||||
this.log.debug('created DM auth challenge', out)
|
||||
return out
|
||||
}
|
||||
|
||||
async fetchDMChallenge (input: { human: string } | { magic: string }): Promise<?DMChallenge> {
|
||||
const challenge: ?DMChallenge = this.M.AuthChallenge.findOne({ where: input })
|
||||
if (challenge == null) {
|
||||
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()) {
|
||||
this.log.debug('challenge expired', challenge)
|
||||
return null
|
||||
}
|
||||
|
||||
return challenge
|
||||
}
|
||||
|
||||
deleteDMChallenge (input: DMChallenge) {
|
||||
return this.M.AuthChallenge.destroy({ where: { magic: input.magic } })
|
||||
}
|
||||
|
||||
injectSessionFromChallenge (ctx: Context, chall: DMChallenge) {
|
||||
ctx.session = {
|
||||
userId: chall.userId,
|
||||
authType: 'dm',
|
||||
expiresAt: Date.now() + 1000 * 60 * 60 * 24
|
||||
}
|
||||
}
|
||||
|
||||
injectSessionFromOAuth (ctx: Context, tokens: AuthTokens, userId: string) {
|
||||
const { expires_in: expiresIn, access_token: accessToken, refresh_token: refreshToken } = tokens
|
||||
ctx.session = {
|
||||
userId,
|
||||
authType: 'oauth',
|
||||
expiresAt: Date.now() + expiresIn,
|
||||
accessToken,
|
||||
refreshToken
|
||||
}
|
||||
}
|
||||
}
|
351
packages/roleypoly-server/services/discord.js
Normal file
351
packages/roleypoly-server/services/discord.js
Normal file
|
@ -0,0 +1,351 @@
|
|||
// @flow
|
||||
import Service from './Service'
|
||||
import type { AppContext } from '../Roleypoly'
|
||||
import Bot from '../bot'
|
||||
import Eris, { type Member, Role, type Guild, type Permission as ErisPermission } from 'eris'
|
||||
import LRU from 'lru-cache'
|
||||
// $FlowFixMe
|
||||
import { OrderedSet } from 'immutable'
|
||||
import superagent from 'superagent'
|
||||
import type { AuthTokens } from './auth'
|
||||
import type { IFetcher } from './discord/types'
|
||||
|
||||
type DiscordServiceConfig = {
|
||||
token: string,
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
rootUsers: Set<string>,
|
||||
isBot: boolean
|
||||
}
|
||||
|
||||
export type Permissions = {
|
||||
canManageRoles: boolean,
|
||||
isAdmin: boolean,
|
||||
faked?: boolean,
|
||||
__faked?: Permissions
|
||||
}
|
||||
|
||||
type CachedRole = {
|
||||
id: string,
|
||||
position: number,
|
||||
color?: number
|
||||
}
|
||||
|
||||
type OAuthRequestData = {
|
||||
grant_type: 'authorization_code',
|
||||
code: string
|
||||
} | {
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: string
|
||||
} | {
|
||||
grant_type: 'access_token',
|
||||
token: string
|
||||
}
|
||||
|
||||
export type UserPartial = {
|
||||
id: string,
|
||||
username: string,
|
||||
discriminator: string,
|
||||
avatar: string
|
||||
}
|
||||
|
||||
export type MemberExt = Member & {
|
||||
color?: number,
|
||||
__faked?: true
|
||||
}
|
||||
|
||||
export default class DiscordService extends Service {
|
||||
ctx: AppContext
|
||||
bot: Bot
|
||||
client: Eris
|
||||
|
||||
cfg: DiscordServiceConfig
|
||||
|
||||
// a small cache of role data for checking viability
|
||||
ownRoleCache: LRU<string, CachedRole>
|
||||
topRoleCache: LRU<string, CachedRole>
|
||||
|
||||
oauthCallback: string
|
||||
|
||||
fetcher: IFetcher
|
||||
|
||||
constructor (ctx: AppContext) {
|
||||
super(ctx)
|
||||
this.ctx = ctx
|
||||
|
||||
this.cfg = {
|
||||
rootUsers: new Set((process.env.ROOT_USERS || '').split(',')),
|
||||
token: process.env.DISCORD_BOT_TOKEN || '',
|
||||
clientId: process.env.DISCORD_CLIENT_ID || '',
|
||||
clientSecret: process.env.DISCORD_CLIENT_SECRET || '',
|
||||
isBot: process.env.IS_BOT === 'true'
|
||||
}
|
||||
|
||||
this.oauthCallback = `${ctx.config.appUrl}/api/oauth/callback`
|
||||
|
||||
this.ownRoleCache = new LRU()
|
||||
this.topRoleCache = new LRU()
|
||||
|
||||
if (this.cfg.isBot) {
|
||||
this.client = new Eris(this.cfg.token, {
|
||||
disableEveryone: true,
|
||||
maxShards: 'auto',
|
||||
messageLimit: 10,
|
||||
disableEvents: {
|
||||
CHANNEL_PINS_UPDATE: true,
|
||||
USER_SETTINGS_UPDATE: true,
|
||||
USER_NOTE_UPDATE: true,
|
||||
RELATIONSHIP_ADD: true,
|
||||
RELATIONSHIP_REMOVE: true,
|
||||
GUILD_BAN_ADD: true,
|
||||
GUILD_BAN_REMOVE: true,
|
||||
TYPING_START: true,
|
||||
MESSAGE_UPDATE: true,
|
||||
MESSAGE_DELETE: true,
|
||||
MESSAGE_DELETE_BULK: true,
|
||||
VOICE_STATE_UPDATE: true
|
||||
}
|
||||
})
|
||||
this.bot = new Bot(this)
|
||||
const BotFetcher = require('./discord/botFetcher').default
|
||||
this.fetcher = new BotFetcher(this)
|
||||
} else {
|
||||
this.client = new Eris(`Bot ${this.cfg.token}`, {
|
||||
restMode: true
|
||||
})
|
||||
const RestFetcher = require('./discord/restFetcher').default
|
||||
this.fetcher = new RestFetcher(this)
|
||||
}
|
||||
}
|
||||
|
||||
isRoot (id: string): boolean {
|
||||
return this.cfg.rootUsers.has(id)
|
||||
}
|
||||
|
||||
getRelevantServers (user: string) {
|
||||
return this.client.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)) {
|
||||
return this.fakeGm({ id: userId })
|
||||
}
|
||||
|
||||
if (gm == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
const out: MemberExt = gm
|
||||
out.color = this.getHighestRole(gm).color
|
||||
return out
|
||||
}
|
||||
|
||||
ownGm (serverId: string) {
|
||||
return this.gm(serverId, this.client.user.id)
|
||||
}
|
||||
|
||||
fakeGm ({ id = '0', nick = '[none]', color = 0 }: $Shape<MemberExt>): $Shape<MemberExt> {
|
||||
return { id, nick, color, __faked: true, roles: [] }
|
||||
}
|
||||
|
||||
getRoles (server: string) {
|
||||
return this.client.guilds.get(server)?.roles
|
||||
}
|
||||
|
||||
async getOwnPermHeight (server: Guild): Promise<number> {
|
||||
if (this.ownRoleCache.has(server)) {
|
||||
const r = this.ownRoleCache.get(server)
|
||||
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, {
|
||||
id: r.id,
|
||||
position: r.position
|
||||
})
|
||||
|
||||
return r.position
|
||||
}
|
||||
|
||||
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) {
|
||||
return r
|
||||
}
|
||||
}
|
||||
|
||||
const g = gm.guild
|
||||
const top = OrderedSet(gm.roles).map(id => g.roles.get(id)).minBy(r => r.position)
|
||||
this.topRoleCache.set(trk, {
|
||||
id: top.id,
|
||||
position: top.position,
|
||||
color: top.color
|
||||
})
|
||||
|
||||
return top
|
||||
}
|
||||
|
||||
calcPerms (permable: Role | Member): Permissions {
|
||||
const p: ErisPermission = (permable instanceof Role) ? permable.permissions : permable.permission
|
||||
return {
|
||||
isAdmin: p.has('administrator'),
|
||||
canManageRoles: p.has('manageRoles') || p.has('administrator')
|
||||
}
|
||||
}
|
||||
|
||||
getPermissions (gm: Member): Permissions {
|
||||
const real = this.calcPerms(gm)
|
||||
|
||||
if (this.isRoot(gm.id)) {
|
||||
return {
|
||||
isAdmin: true,
|
||||
canManageRoles: true,
|
||||
faked: true,
|
||||
__faked: real
|
||||
}
|
||||
}
|
||||
|
||||
return real
|
||||
}
|
||||
|
||||
async safeRole (server: string, role: string) {
|
||||
const r = this.getRoles(server)?.get(role)
|
||||
if (r == null) {
|
||||
throw new Error(`safeRole can't find ${role} in ${server}`)
|
||||
}
|
||||
|
||||
return (await this.roleIsEditable(r)) && !this.calcPerms(r).canManageRoles
|
||||
}
|
||||
|
||||
async roleIsEditable (role: Role): Promise<boolean> {
|
||||
// role will be LOWER than my own
|
||||
const ownPh = await this.getOwnPermHeight(role.guild)
|
||||
return ownPh > role.position
|
||||
}
|
||||
|
||||
async oauthRequest (path: string, data: OAuthRequestData) {
|
||||
const url = `https://discordapp.com/api/oauth2/${path}`
|
||||
try {
|
||||
const rsp = await superagent.post(url)
|
||||
.set('Content-Type', 'application/x-www-form-urlencoded')
|
||||
.set('User-Agent', 'DiscordBot (https://roleypoly.com, 2.x.x) OAuthHandler/1.0')
|
||||
.send({
|
||||
client_id: this.cfg.clientId,
|
||||
client_secret: this.cfg.clientSecret,
|
||||
redirect_uri: this.oauthCallback,
|
||||
...data
|
||||
})
|
||||
|
||||
return (rsp.body: AuthTokens)
|
||||
} catch (e) {
|
||||
this.log.error('oauthRequest failed', { e, path })
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
initializeOAuth (code: string) {
|
||||
return this.oauthRequest('token', {
|
||||
grant_type: 'authorization_code',
|
||||
code
|
||||
})
|
||||
}
|
||||
|
||||
refreshOAuth ({ refreshToken }: { refreshToken: string }) {
|
||||
return this.oauthRequest('token', {
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken
|
||||
})
|
||||
}
|
||||
|
||||
revokeOAuth ({ accessToken }: { accessToken: string }) {
|
||||
return this.oauthRequest('token/revoke', {
|
||||
grant_type: 'access_token',
|
||||
token: accessToken
|
||||
})
|
||||
}
|
||||
|
||||
async getUserPartial (userId: string): Promise<?UserPartial> {
|
||||
const u = await this.fetcher.getUser(userId)
|
||||
if (u == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
username: u.username,
|
||||
discriminator: u.discriminator,
|
||||
avatar: u.avatarURL,
|
||||
id: u.id
|
||||
}
|
||||
}
|
||||
|
||||
async getUserFromToken (authToken: string): Promise<UserPartial> {
|
||||
const url = 'https://discordapp.com/api/v6/users/@me'
|
||||
try {
|
||||
const rsp = await superagent.get(url)
|
||||
.set('User-Agent', 'DiscordBot (https://roleypoly.com, 2.x.x) OAuthHandler/1.0')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
|
||||
return rsp.body
|
||||
} catch (e) {
|
||||
this.log.error('getUser error', e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
getAuthUrl (state: string): string {
|
||||
return `https://discordapp.com/oauth2/authorize?client_id=${this.cfg.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.cfg.clientId}&scope=bot&permissions=268435456`
|
||||
}
|
||||
|
||||
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'))
|
||||
}
|
||||
|
||||
async canManageRoles (server: string, user: string): Promise<boolean> {
|
||||
const gm = await this.gm(server, user)
|
||||
if (gm == null) {
|
||||
return false
|
||||
}
|
||||
|
||||
return this.getPermissions(gm).canManageRoles
|
||||
}
|
||||
|
||||
isMember (server: string, user: string): boolean {
|
||||
return this.gm(server, user) != null
|
||||
}
|
||||
}
|
346
packages/roleypoly-server/services/discord.old.js
Normal file
346
packages/roleypoly-server/services/discord.old.js
Normal file
|
@ -0,0 +1,346 @@
|
|||
/*
|
||||
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
|
||||
*/
|
22
packages/roleypoly-server/services/discord/botFetcher.js
Normal file
22
packages/roleypoly-server/services/discord/botFetcher.js
Normal file
|
@ -0,0 +1,22 @@
|
|||
// @flow
|
||||
import type { IFetcher } from './types'
|
||||
import type DiscordSvc from '../discord'
|
||||
import type ErisClient, { User, Member, Guild } from 'eris'
|
||||
|
||||
export default class BotFetcher implements IFetcher {
|
||||
ctx: DiscordSvc
|
||||
client: ErisClient
|
||||
constructor (ctx: DiscordSvc) {
|
||||
this.ctx = ctx
|
||||
this.client = ctx.client
|
||||
}
|
||||
|
||||
getUser = async (id: string): Promise<?User> =>
|
||||
this.client.users.get(id)
|
||||
|
||||
getMember = async (server: string, user: string): Promise<?Member> =>
|
||||
this.client.guilds.get(server)?.members.get(user)
|
||||
|
||||
getGuild = async (server: string): Promise<?Guild> =>
|
||||
this.client.guilds.get(server)
|
||||
}
|
36
packages/roleypoly-server/services/discord/restFetcher.js
Normal file
36
packages/roleypoly-server/services/discord/restFetcher.js
Normal file
|
@ -0,0 +1,36 @@
|
|||
// @flow
|
||||
import type { IFetcher } from './types'
|
||||
import type DiscordSvc from '../discord'
|
||||
import type ErisClient, { User, Member, Guild } from 'eris'
|
||||
|
||||
export default class BotFetcher implements IFetcher {
|
||||
ctx: DiscordSvc
|
||||
client: ErisClient
|
||||
constructor (ctx: DiscordSvc) {
|
||||
this.ctx = ctx
|
||||
this.client = ctx.client
|
||||
}
|
||||
|
||||
getUser = async (id: string): Promise<?User> => {
|
||||
try {
|
||||
return await this.client.getRESTUser(id)
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
getMember = async (server: string, user: string): Promise<?Member> => {
|
||||
try {
|
||||
return await this.client.getRESTGuildMember(server, user)
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
getGuild = async (server: string): Promise<?Guild> => {
|
||||
try {
|
||||
return await this.client.getRESTGuild(server)
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
14
packages/roleypoly-server/services/discord/types.js
Normal file
14
packages/roleypoly-server/services/discord/types.js
Normal file
|
@ -0,0 +1,14 @@
|
|||
// @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>;
|
||||
}
|
107
packages/roleypoly-server/services/presentation.js
Normal file
107
packages/roleypoly-server/services/presentation.js
Normal file
|
@ -0,0 +1,107 @@
|
|||
// @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, { Permissions } from './discord'
|
||||
import {
|
||||
type Guild,
|
||||
type Member,
|
||||
type Collection
|
||||
} from 'eris'
|
||||
import areduce from '../util/areduce'
|
||||
|
||||
export type ServerSlug = {
|
||||
id: string,
|
||||
name: string,
|
||||
ownerID: string,
|
||||
icon: string
|
||||
}
|
||||
|
||||
export type PresentableRole = {
|
||||
id: string,
|
||||
color: number,
|
||||
name: string,
|
||||
position: number,
|
||||
safe: boolean
|
||||
}
|
||||
|
||||
export type PresentableServer = ServerModel & {
|
||||
id: string,
|
||||
gm?: {
|
||||
color: number | string,
|
||||
nickname: string,
|
||||
roles: string[]
|
||||
},
|
||||
server: ServerSlug,
|
||||
roles: ?PresentableRole[],
|
||||
perms: Permissions
|
||||
}
|
||||
|
||||
class PresentationService extends Service {
|
||||
cache: LRU
|
||||
M: Models
|
||||
discord: DiscordService
|
||||
|
||||
constructor (ctx: AppContext) {
|
||||
super(ctx)
|
||||
this.M = ctx.M
|
||||
this.discord = ctx.discord
|
||||
|
||||
this.cache = new LRU({ max: 500, maxAge: 100 * 60 * 5 })
|
||||
}
|
||||
|
||||
serverSlug (server: Guild): ServerSlug {
|
||||
return {
|
||||
id: server.id,
|
||||
name: server.name,
|
||||
ownerID: server.ownerID,
|
||||
icon: server.icon
|
||||
}
|
||||
}
|
||||
|
||||
presentableServers (collection: Collection<string, Guild>, userId: string) {
|
||||
return areduce(collection.array(), async (acc, server) => {
|
||||
const gm = server.members.get(userId)
|
||||
if (gm == null) {
|
||||
throw new Error(`somehow this guildmember ${userId} of ${server.id} didn't exist.`)
|
||||
}
|
||||
|
||||
acc.push(await this.presentableServer(server, gm, { incRoles: false }))
|
||||
return acc
|
||||
}, [])
|
||||
}
|
||||
|
||||
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.nickname || gm.user.username,
|
||||
color: gm.displayHexColor,
|
||||
roles: gm.roles.keyArray()
|
||||
},
|
||||
server: this.serverSlug(server),
|
||||
roles: (incRoles) ? (await this.rolesByServer(server, sd)).map(r => ({ ...r, selected: gm.roles.has(r.id) })) : [],
|
||||
message: sd.message,
|
||||
categories: sd.categories,
|
||||
perms: this.discord.getPermissions(gm)
|
||||
}
|
||||
}
|
||||
|
||||
async rolesByServer (server: Guild, sd: ServerModel): Promise<PresentableRole[]> {
|
||||
return server.roles
|
||||
.filter(r => r.id !== server.id) // get rid of @everyone
|
||||
.map(r => ({
|
||||
id: r.id,
|
||||
color: r.color,
|
||||
name: r.name,
|
||||
position: r.position,
|
||||
safe: this.discord.safeRole(server.id, r.id)
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PresentationService
|
75
packages/roleypoly-server/services/server.js
Normal file
75
packages/roleypoly-server/services/server.js
Normal file
|
@ -0,0 +1,75 @@
|
|||
// @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 '../models/Server'
|
||||
|
||||
export default class ServerService extends Service {
|
||||
Server: any // Model<ServerModel> but flowtype is bugged
|
||||
P: PresentationService
|
||||
constructor (ctx: AppContext) {
|
||||
super(ctx)
|
||||
this.Server = ctx.M.Server
|
||||
this.P = ctx.P
|
||||
}
|
||||
|
||||
async ensure (server: Guild) {
|
||||
let srv
|
||||
try {
|
||||
srv = await this.get(server.id)
|
||||
} catch (e) {
|
||||
|
||||
}
|
||||
|
||||
if (srv == null) {
|
||||
return this.create({
|
||||
id: server.id,
|
||||
message: '',
|
||||
categories: {}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
create ({ id, message, categories }: ServerModel) {
|
||||
const srv = this.Server.build({ id, message, categories })
|
||||
|
||||
return srv.save()
|
||||
}
|
||||
|
||||
async update (id: string, newData: $Shape<ServerModel>) { // eslint-disable-line no-undef
|
||||
const srv = await this.get(id, false)
|
||||
|
||||
return srv.update(newData)
|
||||
}
|
||||
|
||||
async get (id: string, plain: boolean = true) {
|
||||
const s = await this.Server.findOne({
|
||||
where: {
|
||||
id
|
||||
}
|
||||
})
|
||||
|
||||
if (!plain) {
|
||||
return s
|
||||
}
|
||||
|
||||
return s.get({ plain: true })
|
||||
}
|
||||
|
||||
async getAllowedRoles (id: string) {
|
||||
const server: ServerModel = await this.get(id)
|
||||
const categories: Category[] = (Object.values(server.categories): any)
|
||||
const allowed: string[] = []
|
||||
|
||||
for (const c of categories) {
|
||||
if (c.hidden !== true) {
|
||||
allowed.concat(c.roles)
|
||||
}
|
||||
}
|
||||
|
||||
return allowed
|
||||
}
|
||||
}
|
49
packages/roleypoly-server/services/sessions.js
Normal file
49
packages/roleypoly-server/services/sessions.js
Normal file
|
@ -0,0 +1,49 @@
|
|||
// @flow
|
||||
import Service from './Service'
|
||||
import { type AppContext } from '../Roleypoly'
|
||||
|
||||
type SessionData = {
|
||||
rolling: boolean,
|
||||
maxAge: number,
|
||||
changed: boolean
|
||||
}
|
||||
|
||||
export default class SessionsService extends Service {
|
||||
Session: any
|
||||
constructor (ctx: AppContext) {
|
||||
super(ctx)
|
||||
this.Session = ctx.M.Session
|
||||
}
|
||||
|
||||
async get (id: string, { rolling }: SessionData) {
|
||||
const user = await this.Session.findOne({ where: { id } })
|
||||
|
||||
if (user === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return user.data
|
||||
}
|
||||
|
||||
async set (id: string, data: any, { maxAge, rolling, changed }: SessionData) {
|
||||
let session = await this.Session.findOne({ where: { id } })
|
||||
if (session === null) {
|
||||
session = this.Session.build({ id })
|
||||
}
|
||||
|
||||
console.log(maxAge)
|
||||
|
||||
session.data = data
|
||||
session.maxAge = maxAge
|
||||
|
||||
return session.save()
|
||||
}
|
||||
|
||||
async destroy (id: string) {
|
||||
const sess = await this.Session.findOne({ where: { id } })
|
||||
|
||||
if (sess != null) {
|
||||
return sess.destroy()
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue