feat: 봇 / 서버 - 소유주 간 관계도 개선 (#659)

* feat: implement owners mapping table

* feat: add manual deploy

* fix: query by vanity does not work

* fix: do not fetch users on list queries

* fix: updating owners does not work properly

* fix: owner is not shown properly

* ci: remove branch input on manual publish

---------

Co-authored-by: Eunwoo Choi <choi@eunwoo.dev>
This commit is contained in:
SKINMAKER 2024-10-20 00:28:17 +09:00 committed by GitHub
parent 4ec2ff13ee
commit a89cb6d813
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 175 additions and 155 deletions

View File

@ -35,6 +35,7 @@ module.exports = {
'@typescript-eslint/explicit-function-return-type': 'off', '@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off', '@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-unused-vars': ['warn'], '@typescript-eslint/no-unused-vars': ['warn'],
'@typescript-eslint/no-explicit-any': 'off',
quotes: ['error', 'single'], quotes: ['error', 'single'],
semi: ['error', 'never'], semi: ['error', 'never'],
}, },

View File

@ -7,7 +7,7 @@ import { get } from '@utils/Query'
import { BotSubmissionDenyReasonPresetsName, git } from '@utils/Constants' import { BotSubmissionDenyReasonPresetsName, git } from '@utils/Constants'
import Day from '@utils/Day' import Day from '@utils/Day'
import { SubmittedBot, User } from '@types' import { SubmittedBot } from '@types'
import useClipboard from 'react-use-clipboard' import useClipboard from 'react-use-clipboard'
import { ParsedUrlQuery } from 'querystring' import { ParsedUrlQuery } from 'querystring'
@ -146,15 +146,12 @@ const PendingBot: NextPage<PendingBotProps> = ({ data }) => {
))} ))}
</div> </div>
<h2 className='3xl mb-2 mt-2 font-bold'></h2> <h2 className='3xl mb-2 mt-2 font-bold'></h2>
{(data.owners as User[]).map((el) => ( <Owner
<Owner id={data.owner.id}
key={el.id} tag={data.owner.tag}
id={el.id} globalName={data.owner.globalName}
tag={el.tag} username={data.owner.username}
globalName={el.globalName} />
username={el.username}
/>
))}
<div className='list grid'> <div className='list grid'>
{data.discord && ( {data.discord && (
<a <a

View File

@ -12,7 +12,7 @@
"types/index.ts" "types/index.ts"
] ]
}, },
"target": "es5", "target": "es6",
"lib": [ "lib": [
"dom", "dom",
"dom.iterable", "dom.iterable",

View File

@ -206,7 +206,7 @@ export interface List<T> {
export interface SubmittedBot { export interface SubmittedBot {
id: string id: string
date: number date: number
owners: User[] owner: User
lib: Library lib: Library
prefix: string prefix: string
intro: string intro: string
@ -378,3 +378,8 @@ export interface ResponseProps<T = Data> {
interface Data<T = unknown> { interface Data<T = unknown> {
[key: string]: T [key: string]: T
} }
export enum ObjectType {
Bot = 1,
Server = 2,
}

View File

@ -21,6 +21,7 @@ import {
Webhook, Webhook,
BotSpec, BotSpec,
ServerSpec, ServerSpec,
ObjectType,
} from '@types' } from '@types'
import { import {
botCategories, botCategories,
@ -35,143 +36,148 @@ import knex from './Knex'
import { Bots, Servers } from './Mongo' import { Bots, Servers } from './Mongo'
import { DiscordBot, getMainGuild } from './DiscordBot' import { DiscordBot, getMainGuild } from './DiscordBot'
import { sign, verify } from './Jwt' import { sign, verify } from './Jwt'
import { camoUrl, formData, getYYMMDD, serialize } from './Tools' import { areArraysEqual, camoUrl, formData, getYYMMDD, serialize } from './Tools'
import { AddBotSubmit, AddServerSubmit, ManageBot, ManageServer } from './Yup' import { AddBotSubmit, AddServerSubmit, ManageBot, ManageServer } from './Yup'
import { markdownImage } from './Regex' import { markdownImage } from './Regex'
export const imageRateLimit = new TLRU<unknown, number>({ maxAgeMs: 60000 }) export const imageRateLimit = new TLRU<unknown, number>({ maxAgeMs: 60000 })
async function getBot(id: string, topLevel = true): Promise<Bot> { async function getBot(id: string, topLevel = true): Promise<Bot> {
const res = await knex('bots') const [res] = (await knex('bots')
.select([ .select([
'id', 'bots.id',
'flags', 'bots.flags',
'owners', 'bots.lib',
'lib', 'bots.prefix',
'prefix', 'bots.votes',
'votes', 'bots.servers',
'servers', 'bots.shards',
'shards', 'bots.intro',
'intro', 'bots.desc',
'desc', 'bots.web',
'web', 'bots.git',
'git', 'bots.url',
'url', 'bots.category',
'category', 'bots.status',
'status', 'bots.trusted',
'trusted', 'bots.partnered',
'partnered', 'bots.discord',
'discord', 'bots.state',
'state', 'bots.vanity',
'vanity', 'bots.bg',
'bg', 'bots.banner',
'banner', knex.raw('JSON_ARRAYAGG(owners_mapping.user_id) as owners'),
]) ])
.where({ id }) .leftJoin('owners_mapping', 'bots.id', 'owners_mapping.target_id')
.where({ 'bots.id': id })
.orWhere({ vanity: id, trusted: true }) .orWhere({ vanity: id, trusted: true })
.orWhere({ vanity: id, partnered: true }) .orWhere({ vanity: id, partnered: true })
if (res[0]) { .groupBy('bots.id')) as any[]
const discordBot = await get.discord.user.load(res[0].id)
if (res) {
const discordBot = await get.discord.user.load(res.id)
if (!discordBot) return null if (!discordBot) return null
const botMember = (await getMainGuild() const botMember = (await getMainGuild()
?.members?.fetch(res[0].id) ?.members?.fetch(res.id)
.catch((e) => e)) as GuildMember .catch((e) => e)) as GuildMember
const name = discordBot.displayName const name = discordBot.displayName
res[0].flags = res.flags =
res[0].flags | res.flags |
(discordBot.flags.bitfield & DiscordUserFlags.VERIFIED_BOT ? BotFlags.verified : 0) | (discordBot.flags.bitfield & DiscordUserFlags.VERIFIED_BOT ? BotFlags.verified : 0) |
(res[0].trusted ? BotFlags.trusted : 0) | (res.trusted ? BotFlags.trusted : 0) |
(res[0].partnered ? BotFlags.partnered : 0) (res.partnered ? BotFlags.partnered : 0)
res[0].tag = discordBot.discriminator res.tag = discordBot.discriminator
res[0].avatar = discordBot.avatar res.avatar = discordBot.avatar
res[0].name = name res.name = name
res[0].category = JSON.parse(res[0].category) res.category = JSON.parse(res.category)
res[0].owners = JSON.parse(res[0].owners) res.owners = JSON.parse(res.owners)
if (discordBot.flags.bitfield & UserFlags.BotHTTPInteractions) { if (discordBot.flags.bitfield & UserFlags.BotHTTPInteractions) {
res[0].status = 'online' res.status = 'online'
} else if (botMember) { } else if (botMember) {
if (!botMember.presence) { if (!botMember.presence) {
res[0].status = 'offline' res.status = 'offline'
} else { } else {
res[0].status = botMember.presence.activities.some((r) => r.type === ActivityType.Streaming) res.status = botMember.presence.activities.some((r) => r.type === ActivityType.Streaming)
? 'streaming' ? 'streaming'
: botMember.presence.status : botMember.presence.status
} }
} else { } else {
res[0].status = null res.status = null
} }
delete res[0].trusted delete res.trusted
delete res[0].partnered delete res.partnered
if (topLevel) { if (topLevel) {
res[0].owners = await Promise.all( res.owners = await Promise.all(
res[0].owners.map(async (u: string) => await get._rawUser.load(u)) res.owners.map(async (u: string) => await get._rawUser.load(u))
) )
res[0].owners = res[0].owners.filter((el: User | null) => el).map((row: User) => ({ ...row })) res.owners = res.owners.filter((el: User | null) => el).map((row: User) => ({ ...row }))
} }
await knex('bots').update({ name }).where({ id }) await knex('bots').update({ name }).where({ id })
} }
return res[0] ?? null return res ?? null
} }
async function getServer(id: string, topLevel = true): Promise<Server> { async function getServer(id: string, topLevel = true): Promise<Server> {
const res = await knex('servers') const [res] = await knex('servers')
.select([ .select([
'id', 'servers.id',
'name', 'servers.name',
'flags', 'servers.flags',
'intro', 'servers.intro',
'desc', 'servers.desc',
'votes', 'servers.votes',
'owners', knex.raw('JSON_ARRAYAGG(owners_mapping.user_id) as owners'),
'category', 'servers.category',
'invite', 'servers.invite',
'state', 'servers.state',
'vanity', 'servers.vanity',
'bg', 'servers.bg',
'banner', 'servers.banner',
'flags', 'servers.flags',
]) ])
.where({ id }) .leftJoin('owners_mapping', 'servers.id', 'owners_mapping.target_id')
.orWhereRaw(`(flags & ${ServerFlags.trusted}) and vanity=?`, [id]) .where({ 'servers.id': id })
.orWhereRaw(`(flags & ${ServerFlags.partnered}) and vanity=?`, [id]) .orWhereRaw(`(servers.flags & ${ServerFlags.trusted}) and servers.vanity=?`, [id])
if (res[0]) { .orWhereRaw(`(servers.flags & ${ServerFlags.partnered}) and servers.vanity=?`, [id])
const data = await getServerData(res[0].id) .groupBy('servers.id') as any[]
if (res) {
const data = await getServerData(res.id)
res.owners = JSON.parse(res.owners)
if (!data || +new Date() - +new Date(data.updatedAt) > 3 * 60 * 1000) { if (!data || +new Date() - +new Date(data.updatedAt) > 3 * 60 * 1000) {
if (res[0].state === 'ok') res[0].state = 'unreachable' if (res.state === 'ok') res.state = 'unreachable'
} else { } else {
res[0].flags = res.flags =
res[0].flags | res.flags |
(data.features.includes(GuildFeature.Partnered) && ServerFlags.discord_partnered) | (data.features.includes(GuildFeature.Partnered) && ServerFlags.discord_partnered) |
(data.features.includes(GuildFeature.Verified) && ServerFlags.verified) (data.features.includes(GuildFeature.Verified) && ServerFlags.verified)
if ( if (res.name !== data.name)
res[0].owners !== JSON.stringify([data.owner, ...data.admins]) || await knex('servers').update({ name: data.name }).where({ id: res.id })
res[0].name !== data.name if (!areArraysEqual(res.owners, [data.owner, ...data.admins])) {
) updateOwners(res.id, [data.owner, ...data.admins], 'server')
await knex('servers') }
.update({ name: data.name, owners: JSON.stringify([data.owner, ...data.admins]) })
.where({ id: res[0].id })
} }
delete res[0].owners delete res.owners
res[0].icon = data?.icon || null res.icon = data?.icon || null
res[0].members = data?.memberCount || null res.members = data?.memberCount || null
res[0].emojis = data?.emojis || [] res.emojis = data?.emojis || []
res[0].category = JSON.parse(res[0].category) res.category = JSON.parse(res.category)
res[0].boostTier = data?.boostTier ?? null res.boostTier = data?.boostTier ?? null
if (topLevel) { if (topLevel) {
res[0].owner = (await get._rawUser.load(data?.owner || '')) || null res.owner = (await get._rawUser.load(data?.owner || '')) || null
res[0].bots = res.bots =
(await Promise.all(data?.bots.slice(0, 3).map((el) => get._rawBot.load(el)) || [])).filter( (await Promise.all(data?.bots.slice(0, 3).map((el) => get._rawBot.load(el)) || [])).filter(
(el) => el (el) => el
) || null ) || null
} else { } else {
res[0].owner = data?.owner || null res.owner = data?.owner || null
res[0].bots = data?.bots || null res.bots = data?.bots || null
} }
} }
return res[0] ?? null return res ?? null
} }
async function fetchServerOwners(id: string): Promise<User[] | null> { async function fetchServerOwners(id: string): Promise<User[] | null> {
@ -191,14 +197,12 @@ async function getServerData(id: string): Promise<ServerData | null> {
async function getUser(id: string, topLevel = true): Promise<User> { async function getUser(id: string, topLevel = true): Promise<User> {
const res = await knex('users').select(['id', 'flags', 'github']).where({ id }) const res = await knex('users').select(['id', 'flags', 'github']).where({ id })
if (res[0]) { if (res[0]) {
const ownedBots = await knex('bots') const ownedList = await knex('owners_mapping')
.select(['id']) .select(['target_id', 'type'])
.where('owners', 'like', `%${id}%`) .where({ user_id: id })
.orderBy('date', 'asc')
const ownedServer = await knex('servers') const ownedBots = ownedList.filter((i) => i.type === ObjectType.Bot)
.select(['id']) const ownedServers = ownedList.filter((i) => i.type === ObjectType.Server)
.where('owners', 'like', `%${id}%`)
.orderBy('date', 'asc')
const discordUser = await get.discord.user.load(id) const discordUser = await get.discord.user.load(id)
res[0].tag = discordUser?.discriminator || '0000' res[0].tag = discordUser?.discriminator || '0000'
@ -206,14 +210,14 @@ async function getUser(id: string, topLevel = true): Promise<User> {
res[0].globalName = discordUser?.displayName || 'Unknown User' res[0].globalName = discordUser?.displayName || 'Unknown User'
if (topLevel) { if (topLevel) {
res[0].bots = ( res[0].bots = (
await Promise.all(ownedBots.map(async (b) => await get._rawBot.load(b.id))) await Promise.all(ownedBots.map(async (b) => await get._rawBot.load(b.target_id)))
).filter((el: Bot | null) => el) ).filter((el: Bot | null) => el)
res[0].servers = ( res[0].servers = (
await Promise.all(ownedServer.map(async (b) => await get._rawServer.load(b.id))) await Promise.all(ownedServers.map(async (b) => await get._rawServer.load(b.target_id)))
).filter((el: Server | null) => el) ).filter((el: Server | null) => el)
} else { } else {
res[0].bots = ownedBots.map((el) => el.id) res[0].bots = ownedBots.map((el) => el.target_id)
res[0].servers = ownedServer.map((el) => el.id) res[0].servers = ownedServers.map((el) => el.target_id)
} }
} }
return res[0] || null return res[0] || null
@ -312,7 +316,7 @@ async function getBotList(type: ListType, page = 1, query?: string): Promise<Lis
return { return {
type, type,
data: (await Promise.all(res.map(async (el) => await getBot(el.id)))).map((r) => ({ ...r })), data: (await Promise.all(res.map(async (el) => await getBot(el.id, false)))).map((r) => ({ ...r })),
currentPage: page, currentPage: page,
totalPage: Math.ceil(Number(count) / (type === 'SEARCH' ? 8 : 16)), totalPage: Math.ceil(Number(count) / (type === 'SEARCH' ? 8 : 16)),
} }
@ -417,7 +421,7 @@ async function getServerList(type: ListType, page = 1, query?: string): Promise<
} }
return { return {
type, type,
data: (await Promise.all(res.map(async (el) => await getServer(el.id)))).map((r) => ({ ...r })), data: (await Promise.all(res.map(async (el) => await getServer(el.id, false)))).map((r) => ({ ...r })),
currentPage: page, currentPage: page,
totalPage: Math.ceil(Number(count) / (type === 'SEARCH' ? 8 : 16)), totalPage: Math.ceil(Number(count) / (type === 'SEARCH' ? 8 : 16)),
} }
@ -438,15 +442,13 @@ async function getBotSubmit(id: string, date: number): Promise<SubmittedBot> {
'git', 'git',
'discord', 'discord',
'state', 'state',
'owners', 'owner',
'reason', 'reason',
]) ])
.where({ id, date }) .where({ id, date })
if (res.length === 0) return null if (res.length === 0) return null
res[0].category = JSON.parse(res[0].category) res[0].category = JSON.parse(res[0].category)
res[0].owners = await Promise.all( res[0].owner = await get.user.load(res[0].owner)
JSON.parse(res[0].owners).map(async (u: string) => await get.user.load(u))
)
return res[0] return res[0]
} }
@ -466,17 +468,16 @@ async function getBotSubmits(id: string): Promise<SubmittedBot[]> {
'git', 'git',
'discord', 'discord',
'state', 'state',
'owners', 'owner',
'reason', 'reason',
]) ])
.orderBy('date', 'desc') .orderBy('date', 'desc')
.where('owners', 'LIKE', `%${id}%`) .where({ owner: id })
const owner = await get.user.load(id)
res = await Promise.all( res = await Promise.all(
res.map(async (el) => { res.map(async (el) => {
el.category = JSON.parse(el.category) el.category = JSON.parse(el.category)
el.owners = await Promise.all( el.owner = owner
JSON.parse(el.owners).map(async (u: string) => await get.user.load(u))
)
return el return el
}) })
) )
@ -569,10 +570,7 @@ async function submitBot(
id: string, id: string,
data: AddBotSubmit data: AddBotSubmit
): Promise<1 | 2 | 3 | 4 | 5 | SubmittedBot> { ): Promise<1 | 2 | 3 | 4 | 5 | SubmittedBot> {
const submits = await knex('submitted') const submits = await knex('submitted').select(['id']).where({ state: 0 })
.select(['id'])
.where({ state: 0 })
.andWhere('owners', 'LIKE', `%${id}%`)
if (submits.length > 1) return 1 if (submits.length > 1) return 1
const botId = data.id const botId = data.id
const strikes = await get.botSubmitStrikes(botId) const strikes = await get.botSubmitStrikes(botId)
@ -591,7 +589,7 @@ async function submitBot(
await knex('submitted').insert({ await knex('submitted').insert({
id: botId, id: botId,
date: date, date: date,
owners: JSON.stringify([id]), owner: id,
lib: data.library, lib: data.library,
prefix: data.prefix, prefix: data.prefix,
intro: data.intro, intro: data.intro,
@ -629,22 +627,25 @@ async function submitServer(
await knex('servers').insert({ await knex('servers').insert({
id: id, id: id,
name: serverData.name, name: serverData.name,
owners: JSON.stringify([serverData.owner, ...serverData.admins]),
intro: data.intro, intro: data.intro,
desc: data.desc, desc: data.desc,
category: JSON.stringify(data.category), category: JSON.stringify(data.category),
invite: data.invite, invite: data.invite,
token: sign({ id }), token: sign({ id }),
}) })
await updateOwners(id, [serverData.owner, ...serverData.admins], 'server')
get.server.clear(id) get.server.clear(id)
return true return true
} }
async function getBotSpec(id: string, userID: string): Promise<BotSpec | null> { async function getBotSpec(id: string, userID: string): Promise<BotSpec | null> {
const res = await knex('bots') const res = await knex('bots')
.select(['id', 'token', 'webhook_url', 'webhook_status']) .select(['bots.id', 'bots.token', 'bots.webhook_url', 'bots.webhook_status'])
.where({ id }) .leftJoin('owners_mapping', 'bots.id', 'owners_mapping.target_id')
.andWhere('owners', 'like', `%${userID}%`) .where('owners_mapping.user_id', userID)
.andWhere('owners_mapping.type', ObjectType.Bot)
.andWhere('bots.id', id)
if (!res[0]) return null if (!res[0]) return null
return { return {
id: res[0].id, id: res[0].id,
@ -656,9 +657,12 @@ async function getBotSpec(id: string, userID: string): Promise<BotSpec | null> {
async function getServerSpec(id: string, userID: string): Promise<ServerSpec | null> { async function getServerSpec(id: string, userID: string): Promise<ServerSpec | null> {
const res = await knex('servers') const res = await knex('servers')
.select(['id', 'token', 'webhook_url', 'webhook_status']) .select(['servers.id', 'servers.token', 'servers.webhook_url', 'servers.webhook_status'])
.where({ id }) .leftJoin('owners_mapping', 'servers.id', 'owners_mapping.target_id')
.andWhere('owners', 'like', `%${userID}%`) .where('owners_mapping.user_id', userID)
.andWhere('owners_mapping.type', ObjectType.Server)
.andWhere('servers.id', id)
if (!res[0]) return null if (!res[0]) return null
return { return {
id: res[0].id, id: res[0].id,
@ -754,10 +758,15 @@ async function updateWebhook(id: string, type: 'bots' | 'servers', value: Partia
return true return true
} }
async function updateOwner(id: string, owners: string[]): Promise<void> { async function updateOwners(id: string, owners: string[], type: 'bot' | 'server'): Promise<void> {
await knex('bots') await knex('owners_mapping').where({ target_id: id }).del()
.where({ id }) await knex('owners_mapping').insert(
.update({ owners: JSON.stringify(owners) }) owners.map((el) => ({
user_id: el,
target_id: id,
type: type === 'bot' ? ObjectType.Bot : ObjectType.Server,
}))
)
get.bot.clear(id) get.bot.clear(id)
} }
@ -957,7 +966,7 @@ async function getBotSubmitStrikes(id: string) {
} }
async function approveBotSubmission(id: string, date: number) { async function approveBotSubmission(id: string, date: number) {
const data = await knex('submitted') const [data] = await knex('submitted')
.select([ .select([
'id', 'id',
'date', 'date',
@ -971,27 +980,27 @@ async function approveBotSubmission(id: string, date: number) {
'git', 'git',
'discord', 'discord',
'state', 'state',
'owners', 'owner',
'reason', 'reason',
]) ])
.where({ state: 0, id, date }) .where({ state: 0, id, date })
if (!data[0]) return false if (!data) return false
await knex('submitted').where({ state: 0, id, date }).update({ state: 1 }) await knex('submitted').where({ state: 0, id, date }).update({ state: 1 })
await knex('bots').insert({ await knex('bots').insert({
id, id,
date, date,
owners: data[0].owners, lib: data.lib,
lib: data[0].lib, prefix: data.prefix,
prefix: data[0].prefix, intro: data.intro,
intro: data[0].intro, desc: data.desc,
desc: data[0].desc, url: data.url,
url: data[0].url, web: data.web,
web: data[0].web, git: data.git,
git: data[0].git, category: data.category,
category: data[0].category, discord: data.discord,
discord: data[0].discord,
token: sign({ id }), token: sign({ id }),
}) })
updateOwners(id, [data.owner], 'bot')
return true return true
} }
@ -1246,7 +1255,9 @@ export const update = {
Github, Github,
bot: updateBot, bot: updateBot,
server: updatedServer, server: updatedServer,
botOwners: updateOwner, botOwners: (id: string, owners: string[]) => {
return updateOwners(id, owners, 'bot')
},
webhook: updateWebhook, webhook: updateWebhook,
denyBotSubmission, denyBotSubmission,
approveBotSubmission, approveBotSubmission,

View File

@ -248,4 +248,10 @@ export function convertMetrixToGraph(data: MetrixData[], keyname?: string) {
})) }))
} }
export function areArraysEqual<T>(a: T[], b: T[]): boolean {
return (
new Set(a).size === new Set(b).size && [...new Set(a)].every((item) => new Set(b).has(item))
)
}
export * from './ShowdownExtensions' export * from './ShowdownExtensions'