initial commit: fork

This commit is contained in:
2023-10-27 10:31:20 +02:00
commit a52dbbc103
195 changed files with 17484 additions and 0 deletions

18
packages/api/index.ts Normal file
View File

@ -0,0 +1,18 @@
import type { inferRouterInputs, inferRouterOutputs } from '@trpc/server';
import type { AppRouter } from './src/root';
export { appRouter, type AppRouter } from './src/root';
export { createTRPCContext } from './src/trpc';
/**
* Inference helpers for input types
* @example type HelloInput = RouterInputs['example']['hello']
**/
export type RouterInputs = inferRouterInputs<AppRouter>;
/**
* Inference helpers for output types
* @example type HelloOutput = RouterOutputs['example']['hello']
**/
export type RouterOutputs = inferRouterOutputs<AppRouter>;

36
packages/api/package.json Normal file
View File

@ -0,0 +1,36 @@
{
"name": "@master-bot/api",
"version": "0.1.0",
"main": "./index.ts",
"types": "./index.ts",
"license": "ISC",
"scripts": {
"clean": "rm -rf .turbo node_modules",
"lint": "eslint .",
"lint:fix": "pnpm lint --fix",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@master-bot/auth": "^0.1.0",
"@master-bot/db": "^0.1.0",
"@t3-oss/env-core": "^0.6.0",
"@trpc/client": "^10.37.1",
"@trpc/server": "^10.37.1",
"axios": "^1.4.0",
"discord-api-types": "^0.37.51",
"superjson": "1.13.1",
"zod": "^3.21.4"
},
"devDependencies": {
"@master-bot/eslint-config": "^0.2.0",
"dotenv": "^16.3.1",
"eslint": "^8.46.0",
"typescript": "^5.1.6"
},
"eslintConfig": {
"root": true,
"extends": [
"@master-bot/eslint-config/base"
]
}
}

34
packages/api/src/env.mjs Normal file
View File

@ -0,0 +1,34 @@
import { createEnv } from '@t3-oss/env-core';
import { z } from 'zod';
export const env = createEnv({
clientPrefix: '',
/**
* Specify your server-side environment variables schema here. This way you can ensure the app isn't
* built with invalid env vars.
*/
server: {
DATABASE_URL: z.string(),
DISCORD_TOKEN: z.string(),
DISCORD_CLIENT_ID: z.string(),
DISCORD_CLIENT_SECRET: z.string()
},
/**
* Specify your client-side environment variables schema here.
* For them to be exposed to the client, prefix them with `NEXT_PUBLIC_`.
*/
client: {
// NEXT_PUBLIC_CLIENTVAR: z.string(),
},
/**
* Destructure all variables from `process.env` to make sure they aren't tree-shaken away.
*/
runtimeEnv: {
DATABASE_URL: process.env.DATABASE_URL,
DISCORD_TOKEN: process.env.DISCORD_TOKEN,
DISCORD_CLIENT_ID: process.env.DISCORD_CLIENT_ID,
DISCORD_CLIENT_SECRET: process.env.DISCORD_CLIENT_SECRET
// NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
},
skipValidation: !!process.env.CI || !!process.env.SKIP_ENV_VALIDATION
});

27
packages/api/src/root.ts Normal file
View File

@ -0,0 +1,27 @@
import { channelRouter } from './routers/channel';
import { commandRouter } from './routers/command';
import { guildRouter } from './routers/guild';
import { hubRouter } from './routers/hub';
import { playlistRouter } from './routers/playlist';
import { reminderRouter } from './routers/reminder';
import { songRouter } from './routers/song';
import { twitchRouter } from './routers/twitch';
import { userRouter } from './routers/user';
import { welcomeRouter } from './routers/welcome';
import { createTRPCRouter } from './trpc';
export const appRouter = createTRPCRouter({
user: userRouter,
guild: guildRouter,
playlist: playlistRouter,
song: songRouter,
twitch: twitchRouter,
channel: channelRouter,
welcome: welcomeRouter,
command: commandRouter,
hub: hubRouter,
reminder: reminderRouter
});
// export type definition of API
export type AppRouter = typeof appRouter;

View File

@ -0,0 +1,42 @@
import { getFetch } from '@trpc/client';
import type {
APIGuildChannel,
APIGuildTextChannel
} from 'discord-api-types/v10';
import { z } from 'zod';
import { env } from '../env.mjs';
import { createTRPCRouter, publicProcedure } from '../trpc';
const fetch = getFetch();
export const channelRouter = createTRPCRouter({
getAll: publicProcedure
.input(
z.object({
guildId: z.string()
})
)
.query(async ({ input }) => {
const { guildId } = input;
const token = env.DISCORD_TOKEN;
// call the discord api with the token and the guildId and get all the guild's text channels
const response = await fetch(
`https://discordapp.com/api/guilds/${guildId}/channels`,
{
headers: {
Authorization: `Bot ${token}`
}
}
);
const responseChannels =
(await response.json()) as APIGuildChannel<any>[];
const channels: APIGuildTextChannel<0>[] = responseChannels.filter(
channel => channel.type === 0
);
return { channels };
})
});

View File

@ -0,0 +1,361 @@
import { getFetch } from '@trpc/client';
import { TRPCError } from '@trpc/server';
import type {
APIApplicationCommandPermission,
APIGuildChannel,
APIRole,
ChannelType
} from 'discord-api-types/v10';
import { z } from 'zod';
import { env } from '../env.mjs';
import { createTRPCRouter, publicProcedure } from '../trpc';
import { discordApi } from '../utils/axiosWithRefresh';
const fetch = getFetch();
export interface CommandType {
code: number;
id: string;
applicationId: string;
version: string;
default_permission: string;
default_member_permissions: null | string[];
type: number;
name: string;
description: string;
dm_permission: boolean;
options: any[];
}
export interface CommandPermissionsResponseOkay {
id: string;
application_id: string;
guild_id: string;
permissions: APIApplicationCommandPermission[];
}
export interface CommandPermissionsResponseNotOkay {
message: string;
code: number;
}
export const commandRouter = createTRPCRouter({
getDisabledCommands: publicProcedure
.input(
z.object({
guildId: z.string()
})
)
.query(async ({ ctx, input }) => {
const { guildId } = input;
const guild = await ctx.prisma.guild.findUnique({
where: {
id: guildId
},
select: {
disabledCommands: true
}
});
if (!guild) {
throw new TRPCError({
message: 'Guild not found',
code: 'NOT_FOUND'
});
}
return { disabledCommands: guild.disabledCommands };
}),
getCommands: publicProcedure
.input(
z.object({
guildId: z.string()
})
)
.query(async () => {
try {
const token = env.DISCORD_TOKEN;
const response = await fetch(
`https://discordapp.com/api/applications/${env.DISCORD_CLIENT_ID}/commands`,
{
headers: {
Authorization: `Bot ${token}`
}
}
);
const commands = (await response.json()) as CommandType[];
return { commands };
} catch (e) {
console.error(e);
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Something went wrong when trying to fetch guilds'
});
}
}),
getCommandAndGuildChannels: publicProcedure
.input(
z.object({
guildId: z.string(),
commandId: z.string()
})
)
.query(async ({ ctx, input }) => {
if (!ctx.session) {
throw new TRPCError({
message: 'Not Authenticated',
code: 'UNAUTHORIZED'
});
}
const token = env.DISCORD_TOKEN;
const clientID = env.DISCORD_CLIENT_ID;
const { guildId, commandId } = input;
const account = await ctx.prisma.account.findFirst({
where: {
userId: ctx.session?.user?.id
},
select: {
access_token: true,
providerAccountId: true,
user: {
select: {
discordId: true
}
}
}
});
try {
const [
guildChannelsResponse,
guildRolesResponse,
commandResponse,
permissionsResponse
] = await Promise.all([
fetch(`https://discord.com/api/guilds/${guildId}/channels`, {
headers: {
Authorization: `Bot ${token}`
}
}).then((res: any) => res.json()) as Promise<unknown>,
fetch(`https://discord.com/api/guilds/${guildId}/roles`, {
headers: {
Authorization: `Bot ${token}`
}
}).then((res: any) => res.json()) as Promise<unknown>,
fetch(
`https://discord.com/api/applications/${clientID}/commands/${commandId}`,
{
headers: {
Authorization: `Bot ${token}`
}
}
).then((res: any) => res.json()) as Promise<unknown>,
discordApi
.get(
`https://discord.com/api/v10/applications/${clientID}/guilds/${guildId}/commands/${commandId}/permissions`,
{
headers: {
Authorization: `Bearer ${account?.access_token}`
}
}
)
.then((res: any) => res.data)
]);
const channels =
guildChannelsResponse as APIGuildChannel<ChannelType>[];
const roles = guildRolesResponse as APIRole[];
const command = commandResponse as CommandType;
const permissions = permissionsResponse;
return { channels, roles, command, permissions };
} catch {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Something went wrong when trying to fetch guilds'
});
}
}),
getCommandPermissions: publicProcedure
.input(
z.object({
guildId: z.string(),
commandId: z.string()
})
)
.query(async ({ ctx, input }) => {
const clientID = env.DISCORD_CLIENT_ID;
const { guildId, commandId } = input;
if (!ctx.session) {
throw new TRPCError({
message: 'Not Authenticated',
code: 'UNAUTHORIZED'
});
}
const account = await ctx.prisma.account.findFirst({
where: {
userId: ctx.session?.user?.id
},
select: {
access_token: true,
providerAccountId: true,
user: {
select: {
discordId: true
}
}
}
});
try {
const response = await fetch(
`https://discord.com/api/applications/${clientID}/guilds/${guildId}/commands/${commandId}/permissions`,
{
headers: {
Authorization: `Bearer ${account?.access_token}`
}
}
);
const command = await response.json();
if (!command) throw new Error();
return { command };
} catch {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Something went wrong when trying to fetch guilds'
});
}
}),
editCommandPermissions: publicProcedure
.input(
z.object({
guildId: z.string(),
commandId: z.string(),
permissions: z.array(
z.object({
id: z.string(),
type: z.number(),
permission: z.boolean()
})
),
type: z.string()
})
)
.mutation(async ({ ctx, input }) => {
const clientID = env.DISCORD_CLIENT_ID;
const { guildId, commandId, permissions, type } = input;
if (!ctx.session) {
throw new TRPCError({
message: 'Not Authenticated',
code: 'UNAUTHORIZED'
});
}
const account = await ctx.prisma.account.findFirst({
where: {
userId: ctx.session?.user?.id
},
select: {
access_token: true,
providerAccountId: true,
user: {
select: {
discordId: true
}
}
}
});
const everyone = {
id: guildId,
type: 1,
permission: type === 'allow' ? true : false
};
try {
const response = await fetch(
`https://discord.com/api/applications/${clientID}/guilds/${guildId}/commands/${commandId}/permissions`,
{
method: 'PUT',
headers: {
Authorization: `Bearer ${account?.access_token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ permissions: [everyone, ...permissions] })
}
);
const command = await response.json();
if (!command) throw new Error();
return { command };
} catch {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Something went wrong when trying to fetch guilds'
});
}
}),
toggleCommand: publicProcedure
.input(
z.object({
guildId: z.string(),
commandId: z.string(),
status: z.boolean()
})
)
.mutation(async ({ ctx, input }) => {
const { guildId, commandId, status } = input;
const guild = await ctx.prisma.guild.findUnique({
where: {
id: guildId
},
select: {
disabledCommands: true
}
});
if (!guild) {
throw new TRPCError({
message: 'Guild not found',
code: 'NOT_FOUND'
});
}
let updatedGuild;
if (status) {
updatedGuild = await ctx.prisma.guild.update({
where: {
id: guildId
},
data: {
disabledCommands: {
set: [...guild.disabledCommands, commandId]
}
}
});
} else {
updatedGuild = await ctx.prisma.guild.update({
where: {
id: guildId
},
data: {
disabledCommands: {
set: guild?.disabledCommands.filter(cid => cid !== commandId)
}
}
});
}
return { updatedGuild };
})
});

View File

@ -0,0 +1,178 @@
import { getFetch } from '@trpc/client';
import { TRPCError } from '@trpc/server';
import type { APIGuild, APIRole } from 'discord-api-types/v10';
import { z } from 'zod';
import { createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc';
import { discordApi } from '../utils/axiosWithRefresh';
const fetch = getFetch();
function getUserGuilds(
access_token: string,
refresh_token: string,
user_id: string
) {
return discordApi.get('https://discord.com/api/v10/users/@me/guilds', {
headers: {
Authorization: `Bearer ${access_token}`,
// set user agent
'User-Agent':
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Safari/537.36',
'X-User-Id': user_id,
'X-Refresh-Token': refresh_token
}
});
}
export const guildRouter = createTRPCRouter({
getGuild: publicProcedure
.input(
z.object({
id: z.string()
})
)
.query(async ({ ctx, input }) => {
const { id } = input;
const guild = await ctx.prisma.guild.findUnique({
where: {
id
}
});
return { guild };
}),
create: publicProcedure
.input(
z.object({
id: z.string(),
ownerId: z.string(),
name: z.string()
})
)
.mutation(async ({ ctx, input }) => {
const { id, ownerId, name } = input;
const guild = await ctx.prisma.guild.upsert({
where: {
id: id
},
update: {},
create: {
id: id,
ownerId: ownerId,
volume: 100,
name: name
}
});
return { guild };
}),
delete: publicProcedure
.input(
z.object({
id: z.string()
})
)
.mutation(async ({ ctx, input }) => {
const { id } = input;
const guild = await ctx.prisma.guild.delete({
where: {
id: id
}
});
return { guild };
}),
updateVolume: publicProcedure
.input(
z.object({
guildId: z.string(),
volume: z.number()
})
)
.mutation(async ({ ctx, input }) => {
const { guildId, volume } = input;
await ctx.prisma.guild.update({
where: { id: guildId },
data: { volume }
});
}),
getRoles: publicProcedure
.input(
z.object({
guildId: z.string()
})
)
.query(async ({ ctx, input }) => {
const { guildId } = input;
const token = process.env.DISCORD_TOKEN;
if (!ctx.session) {
throw new TRPCError({
message: 'Not Authenticated',
code: 'UNAUTHORIZED'
});
}
const response = await fetch(
`https://discord.com/api/guilds/${guildId}/roles`,
{
headers: {
Authorization: `Bot ${token}`
}
}
);
const roles = (await response.json()) as APIRole[];
return { roles };
}),
getAll: protectedProcedure.query(async ({ ctx }) => {
const account = await ctx.prisma.account.findFirst({
where: {
userId: ctx.session?.user?.id
}
});
if (!account?.access_token || !account?.refresh_token) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Account not found'
});
}
try {
const dbGuilds = await ctx.prisma.guild.findMany({
where: {
ownerId: account.providerAccountId
}
});
const response = await getUserGuilds(
account.access_token,
account.refresh_token,
account.userId
);
// get the guilds from response data
const apiGuilds = response.data as APIGuild[];
const apiGuildsOwns = apiGuilds.filter(guild => guild.owner);
return {
apiGuilds: apiGuildsOwns,
dbGuilds,
apiGuildsIds: apiGuildsOwns.map(guild => guild.id),
dbGuildsIds: dbGuilds.map(guild => guild.id)
};
} catch (error) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Something went wrong when trying to fetch guilds from DB'
});
}
})
});

View File

@ -0,0 +1,203 @@
import { getFetch } from '@trpc/client';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { createTRPCRouter, publicProcedure } from '../trpc';
const fetch = getFetch();
export const hubRouter = createTRPCRouter({
create: publicProcedure
.input(
z.object({
guildId: z.string(),
name: z.string()
})
)
.mutation(async ({ ctx, input }) => {
const { guildId, name } = input;
const token = process.env.DISCORD_TOKEN;
let parent;
try {
const response = await fetch(
`https://discordapp.com/api/guilds/${guildId}/channels`,
{
headers: {
Authorization: `Bot ${token}`,
'Content-Type': 'application/json'
},
method: 'POST',
body: JSON.stringify({
name,
type: 4
})
}
);
parent = (await response.json()) as any;
} catch (e) {
console.log(e);
throw new TRPCError({
message: 'Could not create channel',
code: 'INTERNAL_SERVER_ERROR'
});
}
let hubChannel;
try {
const response = await fetch(
`https://discordapp.com/api/guilds/${guildId}/channels`,
{
headers: {
Authorization: `Bot ${token}`,
'Content-Type': 'application/json'
},
method: 'POST',
body: JSON.stringify({
name: 'Join To Create',
type: 2,
parent_id: parent.id
})
}
);
hubChannel = (await response.json()) as any;
} catch {
throw new TRPCError({
message: 'Could not create channel',
code: 'INTERNAL_SERVER_ERROR'
});
}
const updatedGuild = await ctx.prisma.guild.update({
where: {
id: guildId
},
data: {
hub: parent.id,
hubChannel: hubChannel.id
}
});
return {
guild: updatedGuild
};
}),
delete: publicProcedure
.input(
z.object({
guildId: z.string()
})
)
.mutation(async ({ ctx, input }) => {
const { guildId } = input;
const token = process.env.DISCORD_TOKEN;
const guild = await ctx.prisma.guild.findUnique({
where: {
id: guildId
},
select: {
hub: true,
hubChannel: true
}
});
if (!guild) {
throw new TRPCError({
message: 'Guild not found',
code: 'NOT_FOUND'
});
}
try {
Promise.all([
fetch(`https://discordapp.com/api/channels/${guild.hubChannel}`, {
headers: {
Authorization: `Bot ${token}`
},
method: 'DELETE'
}),
fetch(`https://discordapp.com/api/channels/${guild.hub}`, {
headers: {
Authorization: `Bot ${token}`
},
method: 'DELETE'
})
]).then(async () => {
await ctx.prisma.guild.update({
where: {
id: guildId
},
data: {
hub: null,
hubChannel: null
}
});
});
} catch (e) {
console.log(e);
throw new TRPCError({
message: 'Could not delete channel',
code: 'INTERNAL_SERVER_ERROR'
});
}
}),
getTempChannel: publicProcedure
.input(
z.object({
guildId: z.string(),
ownerId: z.string()
})
)
.query(async ({ ctx, input }) => {
const { guildId, ownerId } = input;
const tempChannel = await ctx.prisma.tempChannel.findFirst({
where: {
guildId,
ownerId
}
});
return { tempChannel };
}),
createTempChannel: publicProcedure
.input(
z.object({
guildId: z.string(),
ownerId: z.string(),
channelId: z.string()
})
)
.mutation(async ({ ctx, input }) => {
const { guildId, ownerId, channelId } = input;
const tempChannel = await ctx.prisma.tempChannel.create({
data: {
guildId,
ownerId,
id: channelId
}
});
return { tempChannel };
}),
deleteTempChannel: publicProcedure
.input(
z.object({
channelId: z.string()
})
)
.mutation(async ({ ctx, input }) => {
const { channelId } = input;
const tempChannel = await ctx.prisma.tempChannel.delete({
where: {
id: channelId
}
});
return { tempChannel };
})
});

View File

@ -0,0 +1,33 @@
import { createTRPCRouter } from '../trpc';
import { channelRouter } from './channel';
import { commandRouter } from './command';
import { guildRouter } from './guild';
import { hubRouter } from './hub';
import { playlistRouter } from './playlist';
import { reminderRouter } from './reminder';
import { songRouter } from './song';
import { twitchRouter } from './twitch';
import { userRouter } from './user';
import { welcomeRouter } from './welcome';
/**
* Create your application's root router
* If you want to use SSG, you need export this
* @link https://trpc.io/docs/ssg
* @link https://trpc.io/docs/router
*/
export const appRouter = createTRPCRouter({
user: userRouter,
guild: guildRouter,
playlist: playlistRouter,
song: songRouter,
twitch: twitchRouter,
channel: channelRouter,
welcome: welcomeRouter,
command: commandRouter,
hub: hubRouter,
reminder: reminderRouter
});
export type AppRouter = typeof appRouter;

View File

@ -0,0 +1,93 @@
import { z } from 'zod';
import { createTRPCRouter, publicProcedure } from '../trpc';
export const playlistRouter = createTRPCRouter({
getPlaylist: publicProcedure
.input(
z.object({
userId: z.string(),
name: z.string()
})
)
.query(async ({ ctx, input }) => {
const { userId, name } = input;
const playlist = await ctx.prisma.playlist.findFirst({
where: {
userId,
name
},
include: {
songs: true
}
});
return { playlist };
}),
getAll: publicProcedure
.input(
z.object({
userId: z.string()
})
)
.query(async ({ ctx, input }) => {
const { userId } = input;
const playlists = await ctx.prisma.playlist.findMany({
where: {
userId
},
include: {
songs: true
},
orderBy: {
id: 'asc'
}
});
return { playlists };
}),
create: publicProcedure
.input(
z.object({
userId: z.string(),
name: z.string()
})
)
.mutation(async ({ ctx, input }) => {
const { userId, name } = input;
const playlist = await ctx.prisma.playlist.create({
data: {
name,
user: {
connect: {
id: userId
}
}
}
});
return { playlist };
}),
delete: publicProcedure
.input(
z.object({
userId: z.string(),
name: z.string()
})
)
.mutation(async ({ ctx, input }) => {
const { userId, name } = input;
const playlist = await ctx.prisma.playlist.deleteMany({
where: {
userId,
name
}
});
return { playlist };
})
});

View File

@ -0,0 +1,103 @@
import { z } from 'zod';
import { createTRPCRouter, publicProcedure } from '../trpc';
export const reminderRouter = createTRPCRouter({
getAll: publicProcedure.query(async ({ ctx }) => {
const reminders = await ctx.prisma.reminder.findMany();
return { reminders };
}),
getReminder: publicProcedure
.input(
z.object({
userId: z.string(),
event: z.string()
})
)
.mutation(async ({ ctx, input }) => {
const { userId, event } = input;
const reminder = await ctx.prisma.reminder.findFirst({
where: {
userId,
event
},
include: { user: true }
});
return { reminder };
}),
getByUserId: publicProcedure
.input(
z.object({
userId: z.string()
})
)
.mutation(async ({ ctx, input }) => {
const { userId } = input;
const reminders = await ctx.prisma.reminder.findMany({
where: {
userId
},
select: {
event: true,
dateTime: true,
description: true
},
orderBy: {
id: 'asc'
}
});
return { reminders };
}),
create: publicProcedure
.input(
z.object({
userId: z.string(),
event: z.string(),
description: z.nullable(z.string()),
dateTime: z.string(),
repeat: z.nullable(z.string()),
timeOffset: z.number()
})
)
.mutation(async ({ ctx, input }) => {
const { userId, event, description, dateTime, repeat, timeOffset } =
input;
const reminder = await ctx.prisma.reminder.create({
data: {
event,
description,
dateTime,
repeat,
timeOffset,
user: { connect: { discordId: userId } }
}
});
return { reminder };
}),
delete: publicProcedure
.input(
z.object({
userId: z.string(),
event: z.string()
})
)
.mutation(async ({ ctx, input }) => {
const { userId, event } = input;
const reminder = await ctx.prisma.reminder.deleteMany({
where: {
userId,
event
}
});
return { reminder };
})
});

View File

@ -0,0 +1,38 @@
import { z } from 'zod';
import { createTRPCRouter, publicProcedure } from '../trpc';
export const songRouter = createTRPCRouter({
createMany: publicProcedure
.input(
z.object({
songs: z.array(z.any())
})
)
.mutation(async ({ ctx, input }) => {
const { songs } = input;
const songsCreated = await ctx.prisma.song.createMany({
data: songs
});
return { songsCreated };
}),
delete: publicProcedure
.input(
z.object({
id: z.number()
})
)
.mutation(async ({ ctx, input }) => {
const { id } = input;
const song = await ctx.prisma.song.delete({
where: {
id: id
}
});
return { song };
})
});

View File

@ -0,0 +1,106 @@
import { z } from 'zod';
import { createTRPCRouter, publicProcedure } from '../trpc';
export const twitchRouter = createTRPCRouter({
getAll: publicProcedure.query(async ({ ctx }) => {
const notifications = await ctx.prisma.twitchNotify.findMany();
return { notifications };
}),
findUserById: publicProcedure
.input(
z.object({
id: z.string()
})
)
.query(async ({ ctx, input }) => {
const { id } = input;
const notification = await ctx.prisma.twitchNotify.findFirst({
where: {
twitchId: id
}
});
return { notification };
}),
create: publicProcedure
.input(
z.object({
userId: z.string(),
userImage: z.string(),
channelId: z.string(),
sendTo: z.array(z.string())
})
)
.mutation(async ({ ctx, input }) => {
const { userId, userImage, channelId, sendTo } = input;
await ctx.prisma.twitchNotify.upsert({
create: {
twitchId: userId,
channelIds: [channelId],
logo: userImage,
sent: false
},
update: { channelIds: sendTo },
where: { twitchId: userId }
});
}),
updateNotification: publicProcedure
.input(
z.object({
userId: z.string(),
channelIds: z.array(z.string())
})
)
.mutation(async ({ ctx, input }) => {
const { userId, channelIds } = input;
const notification = await ctx.prisma.twitchNotify.update({
where: {
twitchId: userId
},
data: {
channelIds
}
});
return { notification };
}),
delete: publicProcedure
.input(
z.object({
userId: z.string()
})
)
.mutation(async ({ ctx, input }) => {
const { userId } = input;
const notification = await ctx.prisma.twitchNotify.delete({
where: {
twitchId: userId
}
});
return { notification };
}),
updateNotificationStatus: publicProcedure
.input(
z.object({
userId: z.string(),
live: z.boolean(),
sent: z.boolean()
})
)
.mutation(async ({ ctx, input }) => {
const { live, sent, userId } = input;
const notification = await ctx.prisma.twitchNotify.update({
where: { twitchId: userId },
data: { live, sent }
});
return { notification };
})
});

View File

@ -0,0 +1,80 @@
import { z } from 'zod';
import { createTRPCRouter, publicProcedure } from '../trpc';
export const userRouter = createTRPCRouter({
getUserById: publicProcedure
.input(
z.object({
id: z.string()
})
)
.query(async ({ ctx, input }) => {
const { id } = input;
const user = await ctx.prisma.user.findUnique({
where: {
discordId: id
}
});
return { user };
}),
create: publicProcedure
.input(
z.object({
id: z.string(),
name: z.string()
})
)
.mutation(async ({ ctx, input }) => {
const { id, name } = input;
const user = await ctx.prisma.user.upsert({
where: {
discordId: id
},
update: {},
create: {
discordId: id,
name
}
});
return { user };
}),
delete: publicProcedure
.input(
z.object({
id: z.string()
})
)
.mutation(async ({ ctx, input }) => {
const { id } = input;
const user = await ctx.prisma.user.delete({
where: {
discordId: id
}
});
return { user };
}),
updateTimeOffset: publicProcedure
.input(
z.object({
id: z.string(),
timeOffset: z.number()
})
)
.mutation(async ({ ctx, input }) => {
const { id, timeOffset } = input;
const userTime = await ctx.prisma.user.update({
where: {
discordId: id
},
data: { timeOffset: timeOffset },
select: { timeOffset: true }
});
return { userTime };
})
});

View File

@ -0,0 +1,128 @@
import { z } from 'zod';
import { createTRPCRouter, publicProcedure } from '../trpc';
export const welcomeRouter = createTRPCRouter({
getMessage: publicProcedure
.input(
z.object({
guildId: z.string()
})
)
.query(async ({ ctx, input }) => {
const { guildId } = input;
const guild = await ctx.prisma.guild.findUnique({
where: {
id: guildId
}
});
return {
message: guild?.welcomeMessage
};
}),
setMessage: publicProcedure
.input(
z.object({
message: z.string().min(4).max(100),
guildId: z.string()
})
)
.mutation(async ({ ctx, input }) => {
const { message, guildId } = input;
const guild = await ctx.prisma.guild.update({
where: {
id: guildId
},
data: {
welcomeMessage: message
}
});
return { guild };
}),
setChannel: publicProcedure
.input(
z.object({
channelId: z.string(),
guildId: z.string()
})
)
.mutation(async ({ ctx, input }) => {
const { channelId, guildId } = input;
const guild = await ctx.prisma.guild.update({
where: {
id: guildId
},
data: {
welcomeMessageChannel: channelId
}
});
return { guild };
}),
getChannel: publicProcedure
.input(
z.object({
guildId: z.string()
})
)
.query(async ({ ctx, input }) => {
const { guildId } = input;
const guild = await ctx.prisma.guild.findUnique({
where: {
id: guildId
},
select: {
welcomeMessageChannel: true
}
});
return { guild };
}),
getStatus: publicProcedure
.input(
z.object({
guildId: z.string()
})
)
.query(async ({ ctx, input }) => {
const { guildId } = input;
const guild = await ctx.prisma.guild.findUnique({
where: {
id: guildId
},
select: {
welcomeMessageEnabled: true
}
});
return { guild };
}),
toggle: publicProcedure
.input(
z.object({
guildId: z.string(),
status: z.boolean()
})
)
.mutation(async ({ ctx, input }) => {
const { guildId, status } = input;
const guild = await ctx.prisma.guild.update({
where: {
id: guildId
},
data: {
welcomeMessageEnabled: status
}
});
return { guild };
})
});

131
packages/api/src/trpc.ts Normal file
View File

@ -0,0 +1,131 @@
/**
* YOU PROBABLY DON'T NEED TO EDIT THIS FILE, UNLESS:
* 1. You want to modify request context (see Part 1)
* 2. You want to create a new middleware or type of procedure (see Part 3)
*
* tl;dr - this is where all the tRPC server stuff is created and plugged in.
* The pieces you will need to use are documented accordingly near the end
*/
import { initTRPC, TRPCError } from '@trpc/server';
import superjson from 'superjson';
import { ZodError } from 'zod';
import { auth } from '@master-bot/auth';
import type { Session } from '@master-bot/auth';
import { prisma } from '@master-bot/db';
/**
* 1. CONTEXT
*
* This section defines the "contexts" that are available in the backend API
*
* These allow you to access things like the database, the session, etc, when
* processing a request
*
*/
interface CreateContextOptions {
session: Session | null;
}
/**
* This helper generates the "internals" for a tRPC context. If you need to use
* it, you can export it from here
*
* Examples of things you may need it for:
* - testing, so we dont have to mock Next.js' req/res
* - trpc's `createSSGHelpers` where we don't have req/res
* @see https://create.t3.gg/en/usage/trpc#-servertrpccontextts
*/
const createInnerTRPCContext = (opts: CreateContextOptions) => {
return {
session: opts.session,
prisma
};
};
/**
* This is the actual context you'll use in your router. It will be used to
* process every request that goes through your tRPC endpoint
* @link https://trpc.io/docs/context
*/
export const createTRPCContext = async (opts: {
req?: Request;
auth?: Session;
}) => {
const session = opts.auth ?? (await auth());
// const source = opts.req?.headers.get('x-trpc-source') ?? 'unknown';
// console.log('>>> tRPC Request from', source, 'by', session?.user);
return createInnerTRPCContext({
session
});
};
/**
* 2. INITIALIZATION
*
* This is where the trpc api is initialized, connecting the context and
* transformer
*/
const t = initTRPC.context<typeof createTRPCContext>().create({
transformer: superjson,
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError: error.cause instanceof ZodError ? error.cause.flatten() : null
}
};
}
});
/**
* 3. ROUTER & PROCEDURE (THE IMPORTANT BIT)
*
* These are the pieces you use to build your tRPC API. You should import these
* a lot in the /src/server/api/routers folder
*/
/**
* This is how you create new routers and subrouters in your tRPC API
* @see https://trpc.io/docs/router
*/
export const createTRPCRouter = t.router;
/**
* Public (unauthed) procedure
*
* This is the base piece you use to build new queries and mutations on your
* tRPC API. It does not guarantee that a user querying is authorized, but you
* can still access user session data if they are logged in
*/
export const publicProcedure = t.procedure;
/**
* Reusable middleware that enforces users are logged in before running the
* procedure
*/
const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({
ctx: {
// infers the `session` as non-nullable
session: { ...ctx.session, user: ctx.session.user }
}
});
});
/**
* Protected (authed) procedure
*
* If you want a query or mutation to ONLY be accessible to logged in users, use
* this. It verifies the session is valid and guarantees ctx.session.user is not
* null
*
* @see https://trpc.io/docs/procedures
*/
export const protectedProcedure = t.procedure.use(enforceUserIsAuthed);

View File

@ -0,0 +1,137 @@
import axios, { type AxiosError } from 'axios';
import { prisma } from '@master-bot/db';
import { env } from '../env.mjs';
// const baseURL = 'https://discord.com/api/v10'; // Update to the appropriate Discord API version
const discordApi = axios.create();
async function refreshAccessToken(refreshToken: string, userId: string) {
try {
const params = new URLSearchParams({
client_id: env.DISCORD_CLIENT_ID,
client_secret: env.DISCORD_CLIENT_SECRET,
grant_type: 'refresh_token',
refresh_token: refreshToken
});
let response;
try {
response = await discordApi.post(
'https://discord.com/api/v10/oauth2/token',
params,
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
}
);
} catch (error) {
console.error('error in refreshing token', error);
throw error;
}
const {
access_token,
refresh_token: newRefreshToken,
expires_in
} = response.data;
// Update the access and refresh tokens in the database
await prisma.account.update({
where: {
userId
},
data: {
access_token,
refresh_token: newRefreshToken,
expires_at: expires_in
}
});
return {
accessToken: access_token,
refreshToken: newRefreshToken,
expiresIn: expires_in
};
} catch (error) {
console.error('Error refreshing access token:', error);
return null;
}
}
async function updateUserTokens(
newTokens: {
accessToken: string;
refreshToken: string;
expiresIn: number;
},
userId: string
) {
try {
const updatedAccount = await prisma.account.update({
where: {
userId
},
data: {
access_token: newTokens.accessToken,
refresh_token: newTokens.refreshToken,
expires_at: newTokens.expiresIn
}
});
return updatedAccount;
} catch (error) {
console.error('Error updating user tokens:', error);
return null;
}
}
discordApi.interceptors.response.use(
response => {
// if response is ok return it
return response;
},
async (error: Error | AxiosError) => {
if (axios.isAxiosError(error)) {
const originalRequest = error.config;
if (error.code === 'ERR_BAD_REQUEST') {
const { 'X-User-Id': userId, 'X-Refresh-Token': refreshToken } =
originalRequest!.headers;
if (typeof userId !== 'string' || typeof refreshToken !== 'string') {
throw error;
}
const newTokens = await refreshAccessToken(refreshToken, userId);
if (!newTokens?.accessToken) {
throw error;
}
// Save the new access token and refresh token to the DB
try {
await updateUserTokens(newTokens, userId);
} catch {
throw error;
}
// Set the new access token in the header and retry the original request
originalRequest!.headers[
'Authorization'
] = `Bearer ${newTokens.accessToken}`;
return discordApi(originalRequest!);
}
return Promise.reject(error);
} else {
return Promise.reject(error);
}
}
);
export { discordApi };

View File

@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.json",
"include": ["src", "*.ts"]
}

28
packages/auth/env.mjs Normal file
View File

@ -0,0 +1,28 @@
import { createEnv } from '@t3-oss/env-nextjs';
import { z } from 'zod';
export const env = createEnv({
server: {
DISCORD_CLIENT_ID: z.string().min(1),
DISCORD_CLIENT_SECRET: z.string().min(1),
NEXTAUTH_SECRET:
process.env.NODE_ENV === 'production'
? z.string().min(1)
: z.string().min(1).optional(),
NEXTAUTH_URL: z.preprocess(
// This makes Vercel deployments not fail if you don't set NEXTAUTH_URL
// Since NextAuth.js automatically uses the VERCEL_URL if present.
str => process.env.VERCEL_URL ?? str,
// VERCEL_URL doesn't include `https` so it cant be validated as a URL
process.env.VERCEL ? z.string() : z.string().url()
)
},
client: {},
runtimeEnv: {
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
NEXTAUTH_URL: process.env.NEXTAUTH_URL,
DISCORD_CLIENT_ID: process.env.DISCORD_CLIENT_ID,
DISCORD_CLIENT_SECRET: process.env.DISCORD_CLIENT_SECRET
},
skipValidation: !!process.env.CI || !!process.env.SKIP_ENV_VALIDATION
});

134
packages/auth/index.ts Normal file
View File

@ -0,0 +1,134 @@
// @ts-nocheck
import Discord, { type DiscordProfile } from '@auth/core/providers/discord';
import type { DefaultSession as DefaultSessionType } from '@auth/core/types';
import { PrismaAdapter } from '@auth/prisma-adapter';
import { prisma } from '@master-bot/db';
import NextAuth from 'next-auth';
import { env } from './env.mjs';
export type { Session } from 'next-auth';
// Update this whenever adding new providers so that the client can
export const providers = ['discord'] as const;
export type OAuthProviders = (typeof providers)[number];
declare module 'next-auth' {
interface Session {
user: {
id: string;
discordId: string;
} & DefaultSessionType['user'];
}
}
const scope = ['identify', 'guilds', 'email'].join(' ');
export const {
handlers: { GET, POST },
auth,
CSRF_experimental
} = NextAuth({
adapter: {
...PrismaAdapter(prisma),
createUser: async data => {
return await prisma.user.upsert({
where: { discordId: data.discordId },
update: data,
create: data
});
}
},
providers: [
Discord({
clientId: env.DISCORD_CLIENT_ID,
clientSecret: env.DISCORD_CLIENT_SECRET,
authorization: {
params: {
scope
}
},
profile(profile: DiscordProfile) {
return {
id: profile.id,
name: profile.username,
email: profile.email,
image: profile.avatar,
discordId: profile.id
};
}
})
],
callbacks: {
session: async ({ session, user }) => {
const account = await prisma.account.findUnique({
where: {
userId: user.id
}
});
if (account?.expires_at * 1000 < Date.now()) {
// refresh token
try {
const response = await fetch(
'https://discord.com/api/v10/oauth2/token',
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
method: 'POST',
body: new URLSearchParams({
grant_type: 'refresh_token',
client_id: env.DISCORD_CLIENT_ID,
client_secret: env.DISCORD_CLIENT_SECRET,
refresh_token: account.refresh_token
})
}
);
if (!response.ok) {
throw new Error('Failed to refresh token');
}
const data = await response.json();
await prisma.account.update({
where: {
userId: user.id
},
data: {
access_token: data.access_token,
refresh_token: data.refresh_token,
expires_at: data.expires_in
}
});
} catch (error) {
console.log(error);
}
}
return {
...session,
user: {
...session.user,
id: user.id,
discordId: user.discordId
}
};
}
// @TODO - if you wanna have auth on the edge
// jwt: ({ token, profile }) => {
// if (profile?.id) {
// token.id = profile.id;
// token.image = profile.picture;
// }
// return token;
// },
// @TODO
// authorized({ request, auth }) {
// return !!auth?.user
// }
}
});

View File

@ -0,0 +1,35 @@
{
"name": "@master-bot/auth",
"version": "0.1.0",
"main": "./index.ts",
"types": "./index.ts",
"license": "ISC",
"scripts": {
"clean": "rm -rf .turbo node_modules",
"lint": "eslint .",
"lint:fix": "pnpm lint --fix",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@auth/core": "^0.10.0",
"@auth/prisma-adapter": "^1.0.1",
"@master-bot/db": "^0.1.0",
"@t3-oss/env-nextjs": "^0.6.0",
"next": "^13.4.12",
"next-auth": "^0.0.0-manual.b53ca00b",
"react": "18.2.0",
"react-dom": "18.2.0",
"zod": "^3.21.4"
},
"devDependencies": {
"@master-bot/eslint-config": "^0.2.0",
"eslint": "^8.46.0",
"typescript": "^5.1.6"
},
"eslintConfig": {
"root": true,
"extends": [
"@master-bot/eslint-config/base"
]
}
}

View File

@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.json",
"include": ["src", "*.ts", "env.mjs"]
}

View File

@ -0,0 +1,53 @@
/** @type {import("eslint").Linter.Config} */
const config = {
extends: [
'turbo',
'eslint:recommended',
'plugin:@typescript-eslint/recommended-type-checked',
'plugin:@typescript-eslint/stylistic-type-checked',
'prettier'
],
env: {
es2022: true,
node: true
},
parser: '@typescript-eslint/parser',
parserOptions: {
project: true
},
plugins: ['@typescript-eslint', 'import'],
rules: {
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' }
],
'@typescript-eslint/consistent-type-imports': [
'warn',
{ prefer: 'type-imports', fixStyle: 'separate-type-imports' }
],
'@typescript-eslint/no-misused-promises': [
2,
{ checksVoidReturn: { attributes: false } }
],
'@typescript-eslint/no-unsafe-member-access': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unsafe-assignment': 'off',
'@typescript-eslint/dot-notation': 'off',
'@typescript-eslint/no-misused-promises': 'off',
'@typescript-eslint/ban-ts-comment': 'off',
'@typescript-eslint/no-unsafe-return': 'off',
'@typescript-eslint/no-unsafe-call': 'off'
},
ignorePatterns: [
'**/.eslintrc.cjs',
'**/*.config.js',
'**/*.config.cjs',
'packages/config/**',
'.next',
'dist',
'pnpm-lock.yaml'
],
reportUnusedDisableDirectives: true
};
module.exports = config;

View File

@ -0,0 +1,9 @@
/** @type {import('eslint').Linter.Config} */
const config = {
extends: ['plugin:@next/next/recommended'],
rules: {
'@next/next/no-html-link-for-pages': 'off'
}
};
module.exports = config;

View File

@ -0,0 +1,26 @@
{
"name": "@master-bot/eslint-config",
"version": "0.2.0",
"license": "ISC",
"files": [
"./base.js",
"./nextjs.js",
"./react.js"
],
"dependencies": {
"@next/eslint-plugin-next": "^13.4.12",
"@types/eslint": "^8.44.1",
"@typescript-eslint/eslint-plugin": "^6.2.1",
"@typescript-eslint/parser": "^6.2.1",
"eslint-config-prettier": "^8.10.0",
"eslint-config-turbo": "^1.10.12",
"eslint-plugin-import": "^2.28.0",
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-react": "^7.33.1",
"eslint-plugin-react-hooks": "^4.6.0"
},
"devDependencies": {
"eslint": "^8.46.0",
"typescript": "^5.1.6"
}
}

24
packages/config/eslint/react.js vendored Normal file
View File

@ -0,0 +1,24 @@
/** @type {import('eslint').Linter.Config} */
const config = {
extends: [
'plugin:react/recommended',
'plugin:react-hooks/recommended',
'plugin:jsx-a11y/recommended'
],
rules: {
'react/prop-types': 'off'
},
globals: {
React: 'writable'
},
settings: {
react: {
version: 'detect'
}
},
env: {
browser: true
}
};
module.exports = config;

View File

@ -0,0 +1,9 @@
import type { Config } from 'tailwindcss';
export default {
content: [''],
theme: {
extend: {}
},
plugins: []
} satisfies Config;

View File

@ -0,0 +1,15 @@
{
"name": "@master-bot/tailwind-config",
"version": "0.1.0",
"main": "index.ts",
"license": "ISC",
"files": [
"index.ts",
"postcss.js"
],
"devDependencies": {
"autoprefixer": "^10.4.14",
"postcss": "^8.4.27",
"tailwindcss": "^3.3.3"
}
}

View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};

16
packages/db/index.ts Normal file
View File

@ -0,0 +1,16 @@
import { PrismaClient } from '@prisma/client';
export * from '@prisma/client';
const globalForPrisma = globalThis as { prisma?: PrismaClient };
export const prisma =
globalForPrisma.prisma ||
new PrismaClient({
log:
process.env.NODE_ENV === 'development'
? ['query', 'error', 'warn']
: ['error']
});
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;

23
packages/db/package.json Normal file
View File

@ -0,0 +1,23 @@
{
"name": "@master-bot/db",
"version": "0.1.0",
"main": "./index.ts",
"types": "./index.ts",
"license": "ISC",
"scripts": {
"clean": "rm -rf .turbo node_modules",
"db:generate": "pnpm with-env prisma generate",
"db:push": "pnpm with-env prisma db push --skip-generate",
"db:reset": "pnpm with-env prisma db push --force-reset",
"with-env": "dotenv -e ../../.env --"
},
"dependencies": {
"@prisma/client": "^5.1.1"
},
"devDependencies": {
"@types/node": "^20.4.6",
"dotenv-cli": "^7.2.1",
"prisma": "^5.1.1",
"typescript": "^5.1.6"
}
}

View File

@ -0,0 +1,133 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
shadowDatabaseUrl = env("SHADOW_DB_URL")
}
// Necessary for Next auth
model Account {
id String @id @default(cuid())
userId String @unique
type String
provider String
providerAccountId String
refresh_token String? // @db.Text
access_token String? // @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? // @db.Text
session_state String?
user User @relation(fields: [userId], references: [id])
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model User {
id String @id @default(cuid())
name String?
discordId String @unique
email String? @unique
emailVerified DateTime?
image String?
account Account?
sessions Session[]
playlists Playlist[]
guilds Guild[]
reminders Reminder[]
timeOffset Int?
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}
model Song {
id Int @id @default(autoincrement())
length Int
track String
identifier String
author String
isStream Boolean
position Int
title String
uri String
isSeekable Boolean
sourceName String
thumbnail String
added Int
playlistId Int
playlist Playlist @relation(fields: [playlistId], references: [id], onDelete: Cascade)
}
model Playlist {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
name String
userId String?
user User? @relation(fields: [userId], references: [id])
songs Song[]
}
model Guild {
id String @id
name String
added DateTime @default(now())
volume Int @default(100)
notifyList String[]
ownerId String
owner User @relation(fields: [ownerId], references: [discordId])
// Settings
disabledCommands String[] @map("disabled_commands")
logChannel String? @map("log_channel")
welcomeMessageChannel String? @map("welcome_message_channel")
welcomeMessage String? @map("welcome_message")
welcomeMessageEnabled Boolean @default(false) @map("welcome_message_enabled")
// Temp Channels
hub String?
hubChannel String? @map("hub_channel") // The channel that users enter to get redirected
tempChannels TempChannel[]
}
model TempChannel {
id String @id
guildId String
guild Guild @relation(fields: [guildId], references: [id])
ownerId String @unique
}
model TwitchNotify {
twitchId String @id
logo String
live Boolean @default(false)
channelIds String[]
sent Boolean
}
model Reminder {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
repeat String?
event String
description String?
dateTime String
userId String
user User? @relation(fields: [userId], references: [discordId])
timeOffset Int
}

View File

@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.json",
"include": ["index.ts"]
}