├── .husky
├── commit-msg
└── pre-commit
├── apps
└── website
│ ├── .env.development
│ ├── tsconfig.eslint.json
│ ├── postcss.config.js
│ ├── public
│ └── assets
│ │ ├── chatsift.png
│ │ ├── favicon.ico
│ │ └── fonts
│ │ ├── Author-Bold.eot
│ │ ├── Author-Bold.ttf
│ │ ├── Author-Bold.woff
│ │ ├── Author-Bold.woff2
│ │ ├── Author-Italic.eot
│ │ ├── Author-Italic.ttf
│ │ ├── Author-Light.eot
│ │ ├── Author-Light.ttf
│ │ ├── Author-Light.woff
│ │ ├── Author-Medium.eot
│ │ ├── Author-Medium.ttf
│ │ ├── Author-Italic.woff
│ │ ├── Author-Italic.woff2
│ │ ├── Author-Light.woff2
│ │ ├── Author-Medium.woff
│ │ ├── Author-Medium.woff2
│ │ ├── Author-Regular.eot
│ │ ├── Author-Regular.ttf
│ │ ├── Author-Regular.woff
│ │ ├── Author-Semibold.eot
│ │ ├── Author-Semibold.ttf
│ │ ├── Author-Variable.eot
│ │ ├── Author-Variable.ttf
│ │ ├── Author-BoldItalic.eot
│ │ ├── Author-BoldItalic.ttf
│ │ ├── Author-BoldItalic.woff
│ │ ├── Author-Extralight.eot
│ │ ├── Author-Extralight.ttf
│ │ ├── Author-Extralight.woff
│ │ ├── Author-LightItalic.eot
│ │ ├── Author-LightItalic.ttf
│ │ ├── Author-Regular.woff2
│ │ ├── Author-Semibold.woff
│ │ ├── Author-Semibold.woff2
│ │ ├── Author-Variable.woff
│ │ ├── Author-Variable.woff2
│ │ ├── Author-BoldItalic.woff2
│ │ ├── Author-Extralight.woff2
│ │ ├── Author-LightItalic.woff
│ │ ├── Author-LightItalic.woff2
│ │ ├── Author-MediumItalic.eot
│ │ ├── Author-MediumItalic.ttf
│ │ ├── Author-MediumItalic.woff
│ │ ├── Author-ExtralightItalic.eot
│ │ ├── Author-ExtralightItalic.ttf
│ │ ├── Author-MediumItalic.woff2
│ │ ├── Author-SemiboldItalic.eot
│ │ ├── Author-SemiboldItalic.ttf
│ │ ├── Author-SemiboldItalic.woff
│ │ ├── Author-SemiboldItalic.woff2
│ │ ├── Author-VariableItalic.eot
│ │ ├── Author-VariableItalic.ttf
│ │ ├── Author-VariableItalic.woff
│ │ ├── Author-VariableItalic.woff2
│ │ ├── Author-ExtralightItalic.woff
│ │ └── Author-ExtralightItalic.woff2
│ ├── src
│ ├── app
│ │ ├── page.tsx
│ │ ├── dashboard
│ │ │ ├── layout.tsx
│ │ │ ├── [id]
│ │ │ │ ├── ama
│ │ │ │ │ ├── amas
│ │ │ │ │ │ ├── [amaId]
│ │ │ │ │ │ │ └── page.tsx
│ │ │ │ │ │ ├── _components
│ │ │ │ │ │ │ ├── CreateAMACard.tsx
│ │ │ │ │ │ │ ├── AMASessionCard.tsx
│ │ │ │ │ │ │ ├── IncludeEndedToggle.tsx
│ │ │ │ │ │ │ └── AMASessionsList.tsx
│ │ │ │ │ │ ├── new
│ │ │ │ │ │ │ ├── page.tsx
│ │ │ │ │ │ │ └── _components
│ │ │ │ │ │ │ │ ├── RefreshServerDataButton.tsx
│ │ │ │ │ │ │ │ ├── PromptModeToggle.tsx
│ │ │ │ │ │ │ │ ├── SnowflakeInput.tsx
│ │ │ │ │ │ │ │ └── RawPromptField.tsx
│ │ │ │ │ │ └── page.tsx
│ │ │ │ │ ├── _components
│ │ │ │ │ │ └── AMADashboardCrumbs.tsx
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── settings
│ │ │ │ │ ├── page.tsx
│ │ │ │ │ └── _components
│ │ │ │ │ │ ├── GrantsList.tsx
│ │ │ │ │ │ ├── AddGrantCard.tsx
│ │ │ │ │ │ └── GrantCard.tsx
│ │ │ │ └── layout.tsx
│ │ │ ├── _components
│ │ │ │ ├── RefreshGuildsButton.tsx
│ │ │ │ ├── GuildList.tsx
│ │ │ │ └── GuildCard.tsx
│ │ │ └── page.tsx
│ │ ├── not-found.tsx
│ │ └── layout.tsx
│ ├── utils
│ │ ├── urls.ts
│ │ ├── bots.tsx
│ │ ├── util.ts
│ │ └── channels.tsx
│ ├── hooks
│ │ └── isMounted.ts
│ ├── components
│ │ ├── user
│ │ │ ├── LoginButton.tsx
│ │ │ ├── UserErrorHandler.tsx
│ │ │ ├── UserAvatarMe.tsx
│ │ │ ├── UserDesktop.tsx
│ │ │ ├── LogoutButton.tsx
│ │ │ ├── UserAvatar.tsx
│ │ │ └── UserMobile.tsx
│ │ ├── icons
│ │ │ ├── SvgHamburger.tsx
│ │ │ ├── SvgClose.tsx
│ │ │ ├── SvgPlus.tsx
│ │ │ ├── SvgChevronDown.tsx
│ │ │ ├── SvgChatSift.tsx
│ │ │ ├── SvgAutoModerator.tsx
│ │ │ ├── channels
│ │ │ │ ├── SvgChannelForum.tsx
│ │ │ │ ├── SvgChannelText.tsx
│ │ │ │ ├── SvgChannelThread.tsx
│ │ │ │ └── SvgChannelCategory.tsx
│ │ │ ├── SvgRefresh.tsx
│ │ │ ├── SvgDarkTheme.tsx
│ │ │ ├── SvgLightTheme.tsx
│ │ │ ├── SvgGitHub.tsx
│ │ │ ├── SvgAMA.tsx
│ │ │ └── SvgDiscord.tsx
│ │ ├── common
│ │ │ ├── Skeleton.tsx
│ │ │ ├── Logo.tsx
│ │ │ ├── Heading.tsx
│ │ │ ├── GenericAvatar.tsx
│ │ │ ├── GenericAvatarImages.tsx
│ │ │ ├── Button.tsx
│ │ │ ├── ScrollArea.tsx
│ │ │ ├── Avatar.tsx
│ │ │ ├── Providers.tsx
│ │ │ ├── GuildIcon.tsx
│ │ │ └── SearchBar.tsx
│ │ ├── nav
│ │ │ ├── navbarItems.ts
│ │ │ ├── Navbar.tsx
│ │ │ └── NavbarDesktop.tsx
│ │ └── footer
│ │ │ ├── ThemeSwitchButton.tsx
│ │ │ └── Footer.tsx
│ ├── styles
│ │ └── globals.css
│ └── middleware.ts
│ ├── next-env.d.ts
│ ├── tsconfig.json
│ ├── next.config.mjs
│ ├── tailwind.config.ts
│ └── package.json
├── packages
├── public
│ ├── discord-utils
│ │ ├── vitest.config.ts
│ │ ├── src
│ │ │ ├── index.ts
│ │ │ └── __tests__
│ │ │ │ ├── sortChannels.test.ts
│ │ │ │ └── embed.test.ts
│ │ ├── tsconfig.eslint.json
│ │ ├── tsup.config.ts
│ │ ├── tsconfig.json
│ │ ├── README.md
│ │ └── package.json
│ ├── pino-rotate-file
│ │ ├── vitest.config.ts
│ │ ├── tsconfig.eslint.json
│ │ ├── tsup.config.ts
│ │ ├── tsconfig.json
│ │ ├── README.md
│ │ └── package.json
│ └── parse-relative-time
│ │ ├── vitest.config.ts
│ │ ├── tsconfig.eslint.json
│ │ ├── tsup.config.ts
│ │ ├── tsconfig.json
│ │ ├── README.md
│ │ ├── package.json
│ │ └── src
│ │ └── __tests__
│ │ └── index.test.ts
└── private
│ ├── core
│ ├── tsconfig.eslint.json
│ ├── src
│ │ ├── lib
│ │ │ ├── constants.ts
│ │ │ ├── discordPermissions.ts
│ │ │ ├── util.ts
│ │ │ └── promiseAllObject.ts
│ │ ├── index.ts
│ │ └── types
│ │ │ └── entities.ts
│ ├── tsconfig.json
│ └── package.json
│ └── backend-core
│ ├── tsconfig.eslint.json
│ ├── tsconfig.json
│ ├── src
│ ├── index.ts
│ └── lib
│ │ ├── redis.ts
│ │ ├── data
│ │ ├── bots.ts
│ │ ├── _entity.ts
│ │ └── _store.ts
│ │ ├── logger.ts
│ │ ├── database.ts
│ │ ├── context.ts
│ │ └── env.ts
│ └── package.json
├── services
├── api
│ ├── tsconfig.eslint.json
│ ├── tsconfig.json
│ ├── src
│ │ ├── util
│ │ │ ├── setEquals.ts
│ │ │ ├── schemas.ts
│ │ │ ├── constants.ts
│ │ │ ├── sendBoom.ts
│ │ │ ├── discordAPI.ts
│ │ │ ├── crypt.ts
│ │ │ ├── stateCookie.ts
│ │ │ ├── __tests__
│ │ │ │ └── crypt.test.ts
│ │ │ └── channels.ts
│ │ ├── bin.ts
│ │ ├── routes
│ │ │ ├── _types
│ │ │ │ ├── routeTypes.ts
│ │ │ │ └── index.ts
│ │ │ ├── routes.ts
│ │ │ ├── auth
│ │ │ │ ├── logout.ts
│ │ │ │ ├── me.ts
│ │ │ │ └── discord.ts
│ │ │ ├── guilds
│ │ │ │ ├── deleteGrant.ts
│ │ │ │ ├── getGrants.ts
│ │ │ │ ├── get.ts
│ │ │ │ └── createGrant.ts
│ │ │ └── ama
│ │ │ │ ├── updateAMA.ts
│ │ │ │ └── getAMAs.ts
│ │ ├── middleware
│ │ │ ├── validate.ts
│ │ │ ├── __tests__
│ │ │ │ ├── validate.test.ts
│ │ │ │ └── jsonParser.test.ts
│ │ │ ├── jsonParser.ts
│ │ │ └── attachHttpUtils.ts
│ │ └── index.ts
│ └── package.json
└── ama-bot
│ ├── tsconfig.eslint.json
│ ├── src
│ ├── lib
│ │ ├── rest.ts
│ │ ├── queues.ts
│ │ ├── collector.ts
│ │ ├── client.ts
│ │ ├── gateway.ts
│ │ └── components.ts
│ ├── index.ts
│ └── bin.ts
│ ├── tsconfig.json
│ └── package.json
├── .github
├── auto_assign.yml
├── workflows
│ ├── pr-automation.yml
│ ├── sync-labels.yml
│ ├── test.yml
│ ├── deploy-manual.yml
│ └── deploy.yml
└── labels.yml
├── prisma
├── migrations
│ └── migration_lock.toml
└── schema.prisma
├── .prettierignore
├── tsconfig.json
├── README.md
├── .prettierrc.json
├── .vscode
└── settings.json
├── compose
├── .yarnrc.yml
├── .env.private.example
├── .commitlintrc.json
├── tsconfig.eslint.json
├── .dockerignore
├── .env.public
├── LICENSE
├── vitest.config.ts
├── turbo.json
├── .gitattributes
├── tsup.config.ts
├── .gitignore
├── tsconfig.base.json
├── Dockerfile
├── actions
└── yarnCache
│ └── action.yml
├── docker-compose.yml
└── package.json
/.husky/commit-msg:
--------------------------------------------------------------------------------
1 | yarn commitlint --edit $1
2 |
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | yarn build && yarn lint && yarn test:nocov
2 |
--------------------------------------------------------------------------------
/apps/website/.env.development:
--------------------------------------------------------------------------------
1 | NEXT_PUBLIC_API_URL=http://localhost:7004
2 |
--------------------------------------------------------------------------------
/packages/public/discord-utils/vitest.config.ts:
--------------------------------------------------------------------------------
1 | export { default } from '../../../vitest.config';
2 |
--------------------------------------------------------------------------------
/packages/public/pino-rotate-file/vitest.config.ts:
--------------------------------------------------------------------------------
1 | export { default } from '../../../vitest.config';
2 |
--------------------------------------------------------------------------------
/packages/public/parse-relative-time/vitest.config.ts:
--------------------------------------------------------------------------------
1 | export { default } from '../../../vitest.config';
2 |
--------------------------------------------------------------------------------
/packages/public/discord-utils/src/index.ts:
--------------------------------------------------------------------------------
1 | export * from './embed.js';
2 | export * from './sortChannels.js';
3 |
--------------------------------------------------------------------------------
/apps/website/tsconfig.eslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.eslint.json",
3 | "include": ["src/**/*"]
4 | }
5 |
--------------------------------------------------------------------------------
/services/api/tsconfig.eslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.eslint.json",
3 | "include": ["src/**/*"]
4 | }
5 |
--------------------------------------------------------------------------------
/.github/auto_assign.yml:
--------------------------------------------------------------------------------
1 | addReviewers: true
2 | reviewers:
3 | - didinele
4 | numberOfReviewers: 0
5 | runOnDraft: true
6 |
--------------------------------------------------------------------------------
/services/ama-bot/tsconfig.eslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.eslint.json",
3 | "include": ["src/**/*"]
4 | }
5 |
--------------------------------------------------------------------------------
/apps/website/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/apps/website/public/assets/chatsift.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/HEAD/apps/website/public/assets/chatsift.png
--------------------------------------------------------------------------------
/apps/website/public/assets/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/HEAD/apps/website/public/assets/favicon.ico
--------------------------------------------------------------------------------
/packages/private/core/tsconfig.eslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../../tsconfig.eslint.json",
3 | "include": ["src/**/*"]
4 | }
5 |
--------------------------------------------------------------------------------
/apps/website/src/app/page.tsx:
--------------------------------------------------------------------------------
1 | export default function HomePage() {
2 | return
:)
;
3 | }
4 |
--------------------------------------------------------------------------------
/packages/private/backend-core/tsconfig.eslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../../tsconfig.eslint.json",
3 | "include": ["src/**/*"]
4 | }
5 |
--------------------------------------------------------------------------------
/packages/public/discord-utils/tsconfig.eslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../../tsconfig.eslint.json",
3 | "include": ["src/**/*"]
4 | }
5 |
--------------------------------------------------------------------------------
/packages/public/pino-rotate-file/tsconfig.eslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../../tsconfig.eslint.json",
3 | "include": ["src/**/*"]
4 | }
5 |
--------------------------------------------------------------------------------
/packages/public/parse-relative-time/tsconfig.eslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../../tsconfig.eslint.json",
3 | "include": ["src/**/*"]
4 | }
5 |
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-Bold.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/HEAD/apps/website/public/assets/fonts/Author-Bold.eot
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-Bold.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/HEAD/apps/website/public/assets/fonts/Author-Bold.ttf
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-Bold.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/HEAD/apps/website/public/assets/fonts/Author-Bold.woff
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-Bold.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/HEAD/apps/website/public/assets/fonts/Author-Bold.woff2
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-Italic.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/HEAD/apps/website/public/assets/fonts/Author-Italic.eot
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-Italic.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/HEAD/apps/website/public/assets/fonts/Author-Italic.ttf
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-Light.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/HEAD/apps/website/public/assets/fonts/Author-Light.eot
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-Light.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/HEAD/apps/website/public/assets/fonts/Author-Light.ttf
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-Light.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/HEAD/apps/website/public/assets/fonts/Author-Light.woff
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-Medium.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/HEAD/apps/website/public/assets/fonts/Author-Medium.eot
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-Medium.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/HEAD/apps/website/public/assets/fonts/Author-Medium.ttf
--------------------------------------------------------------------------------
/packages/public/discord-utils/tsup.config.ts:
--------------------------------------------------------------------------------
1 | import { createTsupConfig } from '../../../tsup.config';
2 |
3 | export default createTsupConfig();
4 |
--------------------------------------------------------------------------------
/packages/public/pino-rotate-file/tsup.config.ts:
--------------------------------------------------------------------------------
1 | import { createTsupConfig } from '../../../tsup.config';
2 |
3 | export default createTsupConfig();
4 |
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-Italic.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/HEAD/apps/website/public/assets/fonts/Author-Italic.woff
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-Italic.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/HEAD/apps/website/public/assets/fonts/Author-Italic.woff2
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-Light.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/HEAD/apps/website/public/assets/fonts/Author-Light.woff2
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-Medium.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/HEAD/apps/website/public/assets/fonts/Author-Medium.woff
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-Medium.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/HEAD/apps/website/public/assets/fonts/Author-Medium.woff2
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-Regular.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/HEAD/apps/website/public/assets/fonts/Author-Regular.eot
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/HEAD/apps/website/public/assets/fonts/Author-Regular.ttf
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-Regular.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/HEAD/apps/website/public/assets/fonts/Author-Regular.woff
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-Semibold.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/HEAD/apps/website/public/assets/fonts/Author-Semibold.eot
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-Semibold.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/HEAD/apps/website/public/assets/fonts/Author-Semibold.ttf
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-Variable.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/HEAD/apps/website/public/assets/fonts/Author-Variable.eot
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-Variable.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/HEAD/apps/website/public/assets/fonts/Author-Variable.ttf
--------------------------------------------------------------------------------
/packages/public/parse-relative-time/tsup.config.ts:
--------------------------------------------------------------------------------
1 | import { createTsupConfig } from '../../../tsup.config';
2 |
3 | export default createTsupConfig();
4 |
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-BoldItalic.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/HEAD/apps/website/public/assets/fonts/Author-BoldItalic.eot
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-BoldItalic.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/HEAD/apps/website/public/assets/fonts/Author-BoldItalic.ttf
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-BoldItalic.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/HEAD/apps/website/public/assets/fonts/Author-BoldItalic.woff
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-Extralight.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/HEAD/apps/website/public/assets/fonts/Author-Extralight.eot
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-Extralight.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/HEAD/apps/website/public/assets/fonts/Author-Extralight.ttf
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-Extralight.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/HEAD/apps/website/public/assets/fonts/Author-Extralight.woff
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-LightItalic.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/HEAD/apps/website/public/assets/fonts/Author-LightItalic.eot
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-LightItalic.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/HEAD/apps/website/public/assets/fonts/Author-LightItalic.ttf
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-Regular.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/HEAD/apps/website/public/assets/fonts/Author-Regular.woff2
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-Semibold.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/HEAD/apps/website/public/assets/fonts/Author-Semibold.woff
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-Semibold.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/HEAD/apps/website/public/assets/fonts/Author-Semibold.woff2
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-Variable.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/HEAD/apps/website/public/assets/fonts/Author-Variable.woff
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-Variable.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/HEAD/apps/website/public/assets/fonts/Author-Variable.woff2
--------------------------------------------------------------------------------
/apps/website/src/utils/urls.ts:
--------------------------------------------------------------------------------
1 | export const URLS = {
2 | API: {
3 | LOGIN: `${process.env['NEXT_PUBLIC_API_URL']}/v3/auth/discord`,
4 | },
5 | } as const;
6 |
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-BoldItalic.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/HEAD/apps/website/public/assets/fonts/Author-BoldItalic.woff2
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-Extralight.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/HEAD/apps/website/public/assets/fonts/Author-Extralight.woff2
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-LightItalic.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/HEAD/apps/website/public/assets/fonts/Author-LightItalic.woff
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-LightItalic.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/HEAD/apps/website/public/assets/fonts/Author-LightItalic.woff2
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-MediumItalic.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/HEAD/apps/website/public/assets/fonts/Author-MediumItalic.eot
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-MediumItalic.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/HEAD/apps/website/public/assets/fonts/Author-MediumItalic.ttf
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-MediumItalic.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/HEAD/apps/website/public/assets/fonts/Author-MediumItalic.woff
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-ExtralightItalic.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/HEAD/apps/website/public/assets/fonts/Author-ExtralightItalic.eot
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-ExtralightItalic.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/HEAD/apps/website/public/assets/fonts/Author-ExtralightItalic.ttf
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-MediumItalic.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/HEAD/apps/website/public/assets/fonts/Author-MediumItalic.woff2
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-SemiboldItalic.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/HEAD/apps/website/public/assets/fonts/Author-SemiboldItalic.eot
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-SemiboldItalic.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/HEAD/apps/website/public/assets/fonts/Author-SemiboldItalic.ttf
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-SemiboldItalic.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/HEAD/apps/website/public/assets/fonts/Author-SemiboldItalic.woff
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-SemiboldItalic.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/HEAD/apps/website/public/assets/fonts/Author-SemiboldItalic.woff2
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-VariableItalic.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/HEAD/apps/website/public/assets/fonts/Author-VariableItalic.eot
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-VariableItalic.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/HEAD/apps/website/public/assets/fonts/Author-VariableItalic.ttf
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-VariableItalic.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/HEAD/apps/website/public/assets/fonts/Author-VariableItalic.woff
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-VariableItalic.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/HEAD/apps/website/public/assets/fonts/Author-VariableItalic.woff2
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-ExtralightItalic.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/HEAD/apps/website/public/assets/fonts/Author-ExtralightItalic.woff
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-ExtralightItalic.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/HEAD/apps/website/public/assets/fonts/Author-ExtralightItalic.woff2
--------------------------------------------------------------------------------
/prisma/migrations/migration_lock.toml:
--------------------------------------------------------------------------------
1 | # Please do not edit this file manually
2 | # It should be added in your version-control system (e.g., Git)
3 | provider = "postgresql"
4 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | **/.turbo
2 | **/node_modules
3 | **/dist
4 | **/coverage
5 | **/.next
6 | **/build
7 | **/out
8 | .yarn
9 |
10 | packages/private/core/src/types/entities.ts
11 |
--------------------------------------------------------------------------------
/packages/private/core/src/lib/constants.ts:
--------------------------------------------------------------------------------
1 | export const BOTS = ['AMA'] as const;
2 |
3 | export type BotId = (typeof BOTS)[number];
4 |
5 | export const NewAccessTokenHeader = 'X-Update-Access-Token' as const;
6 |
--------------------------------------------------------------------------------
/services/ama-bot/src/lib/rest.ts:
--------------------------------------------------------------------------------
1 | import { getContext } from '@chatsift/backend-core';
2 | import { REST } from '@discordjs/rest';
3 |
4 | export const rest = new REST({ version: '10' }).setToken(getContext().env.AMA_BOT_TOKEN);
5 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/tsconfig",
3 | "display": "Root",
4 | "extends": "./tsconfig.base.json",
5 | "compilerOptions": {
6 | "noEmit": true
7 | },
8 | "include": []
9 | }
10 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # chatsift
2 |
3 | Monorepo for all of our bots and their common utilities, along with some NPM packages.
4 |
5 | ## Licensing
6 |
7 | This project is lincensed under the GNU AGPLv3 license. View the full file [here](./LICENSE).
8 |
--------------------------------------------------------------------------------
/services/api/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.base.json",
3 | "compilerOptions": {
4 | "outDir": "./dist",
5 | "rootDir": "./src"
6 | },
7 | "include": ["src/**/*"],
8 | "exclude": ["dist", "node_modules"]
9 | }
10 |
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/prettierrc.json",
3 | "printWidth": 120,
4 | "useTabs": true,
5 | "singleQuote": true,
6 | "quoteProps": "as-needed",
7 | "trailingComma": "all",
8 | "endOfLine": "lf"
9 | }
10 |
--------------------------------------------------------------------------------
/packages/private/core/src/index.ts:
--------------------------------------------------------------------------------
1 | export * from './lib/constants.js';
2 | export * from './lib/discordPermissions.js';
3 | export * from './lib/promiseAllObject.js';
4 | export * from './lib/util.js';
5 |
6 | export type * from './types/entities.js';
7 |
--------------------------------------------------------------------------------
/packages/private/core/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../../tsconfig.base.json",
3 | "compilerOptions": {
4 | "outDir": "./dist",
5 | "rootDir": "./src"
6 | },
7 | "include": ["src/**/*"],
8 | "exclude": ["dist", "node_modules"]
9 | }
10 |
--------------------------------------------------------------------------------
/apps/website/src/utils/bots.tsx:
--------------------------------------------------------------------------------
1 | import type { BotId } from '@chatsift/core';
2 | import { SvgAMA } from '@/components/icons/SvgAMA';
3 |
4 | export const Bots = {
5 | AMA: { Icon: SvgAMA },
6 | } as const satisfies Record;
7 |
--------------------------------------------------------------------------------
/packages/private/backend-core/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../../tsconfig.base.json",
3 | "compilerOptions": {
4 | "outDir": "./dist",
5 | "rootDir": "./src"
6 | },
7 | "include": ["src/**/*"],
8 | "exclude": ["dist", "node_modules"]
9 | }
10 |
--------------------------------------------------------------------------------
/packages/public/discord-utils/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../../tsconfig.base.json",
3 | "compilerOptions": {
4 | "noEmit": true,
5 | "rootDir": "./src"
6 | },
7 | "include": ["src/**/*"],
8 | "exclude": ["dist", "node_modules"]
9 | }
10 |
--------------------------------------------------------------------------------
/packages/public/pino-rotate-file/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../../tsconfig.base.json",
3 | "compilerOptions": {
4 | "noEmit": true,
5 | "rootDir": "./src"
6 | },
7 | "include": ["src/**/*"],
8 | "exclude": ["dist", "node_modules"]
9 | }
10 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "typescript.tsdk": "node_modules/typescript/lib",
3 | "workbench.editor.customLabels.patterns": {
4 | // Next JS
5 | "**/src/app/**/page.tsx": "${dirname} - Page",
6 | "**/src/app/**/layout.tsx": "${dirname} - Layout",
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/packages/public/parse-relative-time/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../../tsconfig.base.json",
3 | "compilerOptions": {
4 | "noEmit": true,
5 | "rootDir": "./src"
6 | },
7 | "include": ["src/**/*"],
8 | "exclude": ["dist", "node_modules"]
9 | }
10 |
--------------------------------------------------------------------------------
/compose:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | if [ -f .env.public ]
4 | then
5 | export $(cat .env.public | xargs)
6 | fi
7 |
8 | if [ -f .env.private ]
9 | then
10 | export $(cat .env.private | xargs)
11 | fi
12 |
13 | docker compose \
14 | -f docker-compose.yml \
15 | ${@%$0}
16 |
--------------------------------------------------------------------------------
/apps/website/src/hooks/isMounted.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 |
3 | export function useIsMounted(): boolean {
4 | const [mounted, setMounted] = useState(false);
5 |
6 | useEffect(() => {
7 | setMounted(true);
8 | }, []);
9 |
10 | return mounted;
11 | }
12 |
--------------------------------------------------------------------------------
/services/api/src/util/setEquals.ts:
--------------------------------------------------------------------------------
1 | export function setEquals(a: Set, b: Set): boolean {
2 | if (a.size !== b.size) {
3 | return false;
4 | }
5 |
6 | for (const aItem of a) {
7 | if (!b.has(aItem)) {
8 | return false;
9 | }
10 | }
11 |
12 | return true;
13 | }
14 |
--------------------------------------------------------------------------------
/apps/website/next-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 | ///
4 |
5 | // NOTE: This file should not be edited
6 | // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
7 |
--------------------------------------------------------------------------------
/apps/website/src/app/dashboard/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { PropsWithChildren } from 'react';
2 | import { NavGateProvider } from '@/components/common/NavGate';
3 |
4 | export default function DashboardLayout({ children }: PropsWithChildren) {
5 | return {children};
6 | }
7 |
--------------------------------------------------------------------------------
/apps/website/src/components/user/LoginButton.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from '@/components/common/Button';
2 | import { URLS } from '@/utils/urls';
3 |
4 | export function LoginButton() {
5 | return (
6 |
9 | );
10 | }
11 |
--------------------------------------------------------------------------------
/services/ama-bot/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.base.json",
3 | "compilerOptions": {
4 | "outDir": "./dist",
5 | "rootDir": "./src",
6 | "declaration": false,
7 | "declarationMap": false,
8 | },
9 | "include": ["src/**/*"],
10 | "exclude": ["dist", "node_modules"]
11 | }
12 |
--------------------------------------------------------------------------------
/services/api/src/util/schemas.ts:
--------------------------------------------------------------------------------
1 | import { SnowflakeRegex } from '@sapphire/discord-utilities';
2 | import z from 'zod';
3 |
4 | export const snowflakeSchema = z.string().regex(SnowflakeRegex);
5 |
6 | export const queryWithFreshSchema = z.strictObject({
7 | force_fresh: z.stringbool().optional().default(false),
8 | });
9 |
--------------------------------------------------------------------------------
/.yarnrc.yml:
--------------------------------------------------------------------------------
1 | enableGlobalCache: false
2 |
3 | logFilters:
4 | - code: YN0002
5 | level: discard
6 | - code: YN0013
7 | level: discard
8 | - code: YN0032
9 | level: discard
10 | - code: YN0060
11 | level: discard
12 |
13 | nodeLinker: node-modules
14 |
15 | yarnPath: .yarn/releases/yarn-4.9.4.cjs
16 |
--------------------------------------------------------------------------------
/.github/workflows/pr-automation.yml:
--------------------------------------------------------------------------------
1 | name: 'PR Automation'
2 |
3 | on:
4 | pull_request_target:
5 |
6 | jobs:
7 | triage:
8 | runs-on: ubuntu-latest
9 | steps:
10 | - name: Automatically assign reviewers
11 | if: github.event.action == 'opened'
12 | uses: kentaro-m/auto-assign-action@v1.2.1
13 |
--------------------------------------------------------------------------------
/apps/website/src/components/icons/SvgHamburger.tsx:
--------------------------------------------------------------------------------
1 | export function SvgHamburger() {
2 | return (
3 |
6 | );
7 | }
8 |
--------------------------------------------------------------------------------
/services/ama-bot/src/index.ts:
--------------------------------------------------------------------------------
1 | import { startGuildSyncing } from './lib/client.js';
2 | import { registerHandlers } from './lib/components.js';
3 | import { gateway } from './lib/gateway.js';
4 |
5 | export async function bin(): Promise {
6 | await registerHandlers();
7 |
8 | await gateway.connect();
9 | startGuildSyncing();
10 | }
11 |
--------------------------------------------------------------------------------
/services/api/src/util/constants.ts:
--------------------------------------------------------------------------------
1 | import { getContext } from '@chatsift/backend-core';
2 | import type { SerializeOptions } from 'cookie';
3 |
4 | export const cookieWithDomain = (cookie: Cookie): Cookie => ({
5 | ...cookie,
6 | domain: getContext().env.IS_PRODUCTION ? getContext().env.ROOT_DOMAIN : undefined,
7 | });
8 |
--------------------------------------------------------------------------------
/apps/website/src/components/common/Skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from '@/utils/util';
2 |
3 | export function Skeleton({ className, ...props }: React.HTMLAttributes) {
4 | return (
5 |
9 | );
10 | }
11 |
--------------------------------------------------------------------------------
/.env.private.example:
--------------------------------------------------------------------------------
1 | IS_PRODUCTION=false # obviously, set to true in prod.
2 |
3 | ENCRYPTION_KEY=boop # generate using node -e "console.log(require('crypto').randomBytes(32).toString('base64'));"
4 | OAUTH_DISCORD_CLIENT_SECRET=boop # from your discord application page
5 |
6 | LOCAL_DATABASE_PORT=5432
7 | LOCAL_DOZZLE_PORT=8080
8 |
9 | AMA_BOT_TOKEN=boop
10 |
--------------------------------------------------------------------------------
/packages/private/core/src/lib/discordPermissions.ts:
--------------------------------------------------------------------------------
1 | import type { ValueResolvable } from '@sapphire/bitfield';
2 | import { BitField } from '@sapphire/bitfield';
3 | import { PermissionFlagsBits } from 'discord-api-types/v10';
4 |
5 | export const PermissionsBitField = new BitField(PermissionFlagsBits);
6 |
7 | export type PermissionsResolvable = ValueResolvable;
8 |
--------------------------------------------------------------------------------
/.commitlintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/commitlintrc.json",
3 | "extends": ["@commitlint/config-angular"],
4 | "rules": {
5 | "type-enum": [
6 | 2,
7 | "always",
8 | ["chore", "build", "ci", "docs", "feat", "fix", "perf", "refactor", "revert", "style", "test", "types"]
9 | ],
10 | "scope-case": [0],
11 | "subject-exclamation-mark": [0]
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/packages/private/backend-core/src/index.ts:
--------------------------------------------------------------------------------
1 | export * from '@chatsift/core';
2 |
3 | export type * from './lib/data/_entity.js';
4 | export * from './lib/data/_store.js';
5 | export * from './lib/data/bots.js';
6 |
7 | export * from './lib/context.js';
8 | export * from './lib/database.js';
9 | export * from './lib/env.js';
10 | export * from './lib/logger.js';
11 | export * from './lib/redis.js';
12 |
--------------------------------------------------------------------------------
/apps/website/src/components/nav/navbarItems.ts:
--------------------------------------------------------------------------------
1 | interface NavbarItem {
2 | readonly href: string;
3 | readonly name: string;
4 | }
5 |
6 | export const navbarItems = [
7 | {
8 | name: 'Dashboard',
9 | href: '/dashboard',
10 | },
11 | {
12 | name: 'GitHub',
13 | href: '/github',
14 | },
15 | {
16 | name: 'Support',
17 | href: '/support',
18 | },
19 | ] as const satisfies readonly NavbarItem[];
20 |
--------------------------------------------------------------------------------
/apps/website/src/components/user/UserErrorHandler.tsx:
--------------------------------------------------------------------------------
1 | import { LoginButton } from './LoginButton';
2 | import { APIError } from '@/utils/fetcher';
3 |
4 | // TODO?
5 | export function UserErrorHandler({ error }: { readonly error: Error }) {
6 | if (error instanceof APIError && error.payload.statusCode === 401) {
7 | return ;
8 | }
9 |
10 | console.error(error);
11 | return <>Error>;
12 | }
13 |
--------------------------------------------------------------------------------
/tsconfig.eslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/tsconfig",
3 | "display": "ESLint",
4 | "extends": "./tsconfig.base.json",
5 | "compilerOptions": {
6 | "allowJs": true,
7 | "checkJs": false,
8 | "noEmit": true
9 | },
10 | "include": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx", "**/*.mjs", "**/*.cjs"],
11 | "exclude": ["**/node_modules", "**/dist", "**/.turbo", "**/coverage"]
12 | }
13 |
--------------------------------------------------------------------------------
/services/api/src/bin.ts:
--------------------------------------------------------------------------------
1 | import { createDatabase, createLogger, createRedis, initContext } from '@chatsift/backend-core';
2 |
3 | const logger = createLogger('api');
4 | const db = createDatabase(logger);
5 | const redis = await createRedis(logger);
6 | initContext({ db, logger, redis });
7 |
8 | // Make sure to import anything else AFTER initializing the context
9 | const { bin } = await import('./index.js');
10 | await bin();
11 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | .git
2 | .github
3 | .idea
4 | .vscode
5 | .husky
6 |
7 | **/node_modules
8 | **/types
9 | **/.next
10 | **/dist
11 |
12 | **/vitest.config.ts
13 | **/eslint.config.js
14 | **/.prettierrc.json
15 | **/.prettierrc.cjs
16 | **/.prettierignore
17 | **/tsconfig.eslint.json
18 |
19 | .commitlinrrc.json
20 | .gitattributes
21 | .gitignore
22 | .env.private.example
23 |
24 | LICENSE
25 | **/README.md
26 |
27 | .DS_Store
28 |
--------------------------------------------------------------------------------
/packages/private/core/src/lib/util.ts:
--------------------------------------------------------------------------------
1 | export interface ModuleWithDefault {
2 | default: Type;
3 | }
4 |
5 | export function isModuleWithDefault(
6 | mod: any,
7 | typePredicate?: (value: any) => value is Type,
8 | ): mod is ModuleWithDefault {
9 | const predicateIsTrue = typePredicate ? typePredicate(mod?.default) : true;
10 | return mod && typeof mod === 'object' && 'default' in mod && predicateIsTrue;
11 | }
12 |
--------------------------------------------------------------------------------
/apps/website/src/components/common/Logo.tsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link';
2 | import React from 'react';
3 | import { SvgChatSift } from '@/components/icons/SvgChatSift';
4 |
5 | export function Logo() {
6 | return (
7 |
8 |
9 | ChatSift
10 |
11 | );
12 | }
13 |
--------------------------------------------------------------------------------
/services/ama-bot/src/bin.ts:
--------------------------------------------------------------------------------
1 | import { createDatabase, createLogger, createRedis, initContext } from '@chatsift/backend-core';
2 |
3 | const logger = createLogger('ama-bot');
4 | const db = createDatabase(logger);
5 | const redis = await createRedis(logger);
6 | initContext({ db, logger, redis });
7 |
8 | // Make sure to import anything else AFTER initializing the context
9 | const { bin } = await import('./index.js');
10 | await bin();
11 |
--------------------------------------------------------------------------------
/apps/website/src/components/icons/SvgClose.tsx:
--------------------------------------------------------------------------------
1 | export function SvgClose() {
2 | return (
3 |
13 | );
14 | }
15 |
--------------------------------------------------------------------------------
/apps/website/src/components/icons/SvgPlus.tsx:
--------------------------------------------------------------------------------
1 | export function SvgPlus() {
2 | return (
3 |
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/packages/private/backend-core/src/lib/redis.ts:
--------------------------------------------------------------------------------
1 | import { Buffer } from 'node:buffer';
2 | import type { Logger } from 'pino';
3 | import { createClient, RESP_TYPES } from 'redis';
4 |
5 | export async function createRedis(logger: Logger) {
6 | return createClient({ url: 'redis://redis:6379' })
7 | .withTypeMapping({
8 | [RESP_TYPES.BLOB_STRING]: Buffer,
9 | })
10 | .on('error', (err) => logger.error(err, 'redis error'))
11 | .connect();
12 | }
13 |
--------------------------------------------------------------------------------
/packages/private/backend-core/src/lib/data/bots.ts:
--------------------------------------------------------------------------------
1 | import type { BotId } from '@chatsift/core';
2 | import { createRecipe, DataType } from 'bin-rw';
3 | import { RedisStore } from './_store.js';
4 |
5 | interface BotInfo {
6 | guilds: string[];
7 | }
8 |
9 | export const GuildList = new RedisStore({
10 | TTL: null,
11 | recipe: createRecipe({
12 | guilds: [DataType.String],
13 | }),
14 | makeKey: (id: BotId) => `bot:${id}`,
15 | storeOld: false,
16 | });
17 |
--------------------------------------------------------------------------------
/apps/website/src/styles/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @import url('author.css');
6 |
7 | body {
8 | font-family: Author-Variable, sans-serif;
9 | font-feature-settings:
10 | 'pnum' on,
11 | 'lnum' on;
12 | }
13 |
14 | .hide-for-mobile-override {
15 | & > *:nth-child(1) {
16 | display: none;
17 | }
18 |
19 | /* tailwind md: */
20 | @media (max-width: 768px) {
21 | & > * {
22 | display: none;
23 | }
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/apps/website/src/components/common/Heading.tsx:
--------------------------------------------------------------------------------
1 | interface HeadingProps {
2 | readonly subtitle?: string | undefined;
3 | readonly title: string;
4 | }
5 |
6 | export function Heading({ title, subtitle }: HeadingProps) {
7 | return (
8 |
9 |
{title}
10 | {subtitle &&
{subtitle}
}
11 |
12 | );
13 | }
14 |
--------------------------------------------------------------------------------
/.env.public:
--------------------------------------------------------------------------------
1 | ROOT_DOMAIN=automoderator.app
2 |
3 | PRISMA_DATABASE_URL=postgres://chatsift:admin@127.0.0.1:${LOCAL_DATABASE_PORT}/chatsift
4 | REDIS_URL=redis://redis:6379
5 |
6 | API_PORT=7004
7 | OAUTH_DISCORD_CLIENT_ID=1005791929075769344
8 | CORS="http:\/\/localhost:3000|https:\/\/canary\.automoderator\.app"
9 |
10 | API_URL_DEV=http://localhost:7004
11 | API_URL_PROD=https://api-canary.automoderator.app
12 |
13 | FRONTEND_URL_DEV=http://localhost:3000
14 | FRONTEND_URL_PROD=https://canary.automoderator.app
15 |
--------------------------------------------------------------------------------
/apps/website/src/middleware.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import type { NextRequest } from 'next/server';
3 | import { URLS } from './utils/urls';
4 |
5 | export async function middleware(request: NextRequest) {
6 | const cookies = request.cookies;
7 |
8 | if (!cookies.has('refresh_token')) {
9 | return NextResponse.redirect(new URL(URLS.API.LOGIN, request.url));
10 | }
11 |
12 | return NextResponse.next();
13 | }
14 |
15 | export const config = {
16 | matcher: '/dashboard/:path*',
17 | };
18 |
--------------------------------------------------------------------------------
/apps/website/src/components/user/UserAvatarMe.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { UserAvatar } from './UserAvatar';
4 | import { client } from '@/data/client';
5 |
6 | interface UserAvatarMeProps {
7 | readonly className: string;
8 | }
9 |
10 | export function UserAvatarMe({ className }: UserAvatarMeProps) {
11 | const { isLoading, data: user } = client.auth.useMe();
12 |
13 | if (user === null) {
14 | return null;
15 | }
16 |
17 | return ;
18 | }
19 |
--------------------------------------------------------------------------------
/apps/website/src/components/nav/Navbar.tsx:
--------------------------------------------------------------------------------
1 | import { NavbarDesktop } from './NavbarDesktop';
2 | import { NavbarMobile } from './NavbarMobile';
3 |
4 | export function Navbar() {
5 | return (
6 |
12 | );
13 | }
14 |
--------------------------------------------------------------------------------
/apps/website/src/app/dashboard/[id]/ama/amas/[amaId]/page.tsx:
--------------------------------------------------------------------------------
1 | import { AMADashboardCrumbs } from '../../_components/AMADashboardCrumbs';
2 | import { AMADetails } from './_components/AMADetails';
3 | import { Heading } from '@/components/common/Heading';
4 |
5 | export default function AMADetailPage() {
6 | return (
7 |
12 | );
13 | }
14 |
--------------------------------------------------------------------------------
/.github/workflows/sync-labels.yml:
--------------------------------------------------------------------------------
1 | name: Sync Labels
2 |
3 | on:
4 | schedule:
5 | - cron: '0 0 * * *'
6 | workflow_dispatch:
7 | push:
8 | branches:
9 | - main
10 | paths:
11 | - '.github/labels.yml'
12 |
13 | jobs:
14 | synclabels:
15 | name: Sync Labels
16 | runs-on: ubuntu-latest
17 | steps:
18 | - name: Checkout repository
19 | uses: actions/checkout@v2
20 |
21 | - name: Sync labels
22 | uses: crazy-max/ghaction-github-labeler@v3
23 | with:
24 | github-token: ${{ secrets.GITHUB_TOKEN }}
25 |
--------------------------------------------------------------------------------
/apps/website/src/components/icons/SvgChevronDown.tsx:
--------------------------------------------------------------------------------
1 | interface SvgChevronDownProps {
2 | readonly className?: string;
3 | readonly size?: number;
4 | }
5 |
6 | export function SvgChevronDown({ className, size = 16 }: SvgChevronDownProps) {
7 | return (
8 |
20 | );
21 | }
22 |
--------------------------------------------------------------------------------
/apps/website/src/components/icons/SvgChatSift.tsx:
--------------------------------------------------------------------------------
1 | export function SvgChatSift() {
2 | return (
3 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/services/api/src/routes/_types/routeTypes.ts:
--------------------------------------------------------------------------------
1 | export type * from '../ama/createAMA.js';
2 | export type * from '../ama/getAMA.js';
3 | export type * from '../ama/getAMAs.js';
4 | export type * from '../ama/updateAMA.js';
5 | export type * from '../ama/repostPrompt.js';
6 |
7 | export type * from '../auth/discord.js';
8 | export type * from '../auth/discordCallback.js';
9 | export type * from '../auth/logout.js';
10 | export type * from '../auth/me.js';
11 |
12 | export type * from '../guilds/get.js';
13 | export type * from '../guilds/createGrant.js';
14 | export type * from '../guilds/deleteGrant.js';
15 | export type * from '../guilds/getGrants.js';
16 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (C) 2021-2023 ChatSift
2 |
3 | This program is free software: you can redistribute it and/or modify
4 | it under the terms of the GNU Affero General Public License as
5 | published by the Free Software Foundation, either version 3 of the
6 | License, or (at your option) any later version.
7 |
8 | This program is distributed in the hope that it will be useful,
9 | but WITHOUT ANY WARRANTY; without even the implied warranty of
10 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 | GNU Affero General Public License for more details.
12 |
13 | You should have received a copy of the GNU Affero General Public License
14 | along with this program. If not, see .
15 |
--------------------------------------------------------------------------------
/apps/website/src/app/dashboard/_components/RefreshGuildsButton.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { Button } from '@/components/common/Button';
4 | import { SvgRefresh } from '@/components/icons/SvgRefresh';
5 | import { client } from '@/data/client';
6 |
7 | export function RefreshGuildsButton() {
8 | const { refetch, isLoading } = client.auth.useMe({ force_fresh: 'true' });
9 |
10 | return (
11 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/apps/website/src/app/not-found.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useRouter } from 'next/navigation';
4 | import { Button } from '@/components/common/Button';
5 |
6 | export default function NotFound() {
7 | const router = useRouter();
8 |
9 | return (
10 |
11 |
12 | The page you are looking for could not be found
13 |
14 |
20 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/packages/private/backend-core/src/lib/data/_entity.ts:
--------------------------------------------------------------------------------
1 | import type { Recipe } from 'bin-rw';
2 |
3 | /**
4 | * Responsible for defining the behavior of a given entity.
5 | */
6 | export interface IEntity {
7 | /**
8 | * How long an entity of this entity should remain in the store without any operations being performed on it.
9 | */
10 | readonly TTL: number | null;
11 | /**
12 | * Generates a redis key for this entity.
13 | */
14 | makeKey(id: string): string;
15 | /**
16 | * Recipe for encoding and decoding TData.
17 | */
18 | readonly recipe: Recipe;
19 | /**
20 | * Whether or not to store the previous version of the entity when setting a new value.
21 | */
22 | readonly storeOld: boolean;
23 | }
24 |
--------------------------------------------------------------------------------
/vitest.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vitest/config';
2 |
3 | export default defineConfig({
4 | test: {
5 | exclude: ['**/node_modules', '**/dist', '.idea', '.git', '.cache'],
6 | passWithNoTests: true,
7 | typecheck: {
8 | enabled: true,
9 | include: ['**/__tests__/types.test.ts'],
10 | tsconfig: 'tsconfig.json',
11 | },
12 | coverage: {
13 | enabled: true,
14 | reporter: ['text', 'lcov', 'clover'],
15 | exclude: [
16 | '**/dist',
17 | '**/__tests__',
18 | '**/__mocks__',
19 | '**/coverage',
20 | '**/tsup.config.ts',
21 | '**/vitest.config.ts',
22 | '**/.next',
23 | 'eslint.config.js',
24 | '.yarn',
25 | 'apps/website',
26 | ],
27 | },
28 | },
29 | });
30 |
--------------------------------------------------------------------------------
/apps/website/src/components/icons/SvgAutoModerator.tsx:
--------------------------------------------------------------------------------
1 | export function SvgAutoModerator({ width, height }: { readonly height?: number; readonly width?: number }) {
2 | return (
3 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/apps/website/src/app/dashboard/[id]/settings/page.tsx:
--------------------------------------------------------------------------------
1 | import { DashboardCrumbs } from '../../_components/DashboardCrumbs';
2 | import { GrantsList } from './_components/GrantsList';
3 | import { Heading } from '@/components/common/Heading';
4 |
5 | export default function SettingsPage() {
6 | return (
7 | <>
8 |
9 |
10 |
14 |
15 |
16 |
17 |
18 |
19 | >
20 | );
21 | }
22 |
--------------------------------------------------------------------------------
/turbo.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://turbo.build/schema.json",
3 | "tasks": {
4 | "build": {
5 | "dependsOn": ["^build"],
6 | "outputs": ["dist/**", ".next/**", "!.next/cache/**"]
7 | },
8 | "lint": {
9 | "dependsOn": ["^build"]
10 | },
11 | "lint:fix": {
12 | "dependsOn": ["^build"],
13 | "cache": false
14 | },
15 | "test": {
16 | "dependsOn": ["^build"]
17 | },
18 | "clean": {
19 | "cache": false
20 | },
21 | "tag-docker": {
22 | "outputs": [],
23 | "inputs": ["src/**/*.ts", "Dockerfile"]
24 | }
25 | },
26 | "globalDependencies": [
27 | "eslint.config.js",
28 | ".prettierrc.json",
29 | "tsconfig.json",
30 | "tsconfig.*.json",
31 | "vitest.config.ts",
32 | "prisma/schema.prisma"
33 | ]
34 | }
35 |
--------------------------------------------------------------------------------
/apps/website/src/components/icons/channels/SvgChannelForum.tsx:
--------------------------------------------------------------------------------
1 | interface SvgChannelForumProps {
2 | readonly className?: string;
3 | readonly size?: number;
4 | }
5 |
6 | export function SvgChannelForum({ className, size = 20 }: SvgChannelForumProps) {
7 | return (
8 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/packages/private/core/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/package.json",
3 | "name": "@chatsift/core",
4 | "version": "0.1.0",
5 | "description": "Core utils and types for cross-service interaction",
6 | "type": "module",
7 | "exports": {
8 | ".": "./dist/index.js"
9 | },
10 | "types": "./dist/index.d.ts",
11 | "scripts": {
12 | "build": "yarn run db:generate && tsc",
13 | "test": "vitest run",
14 | "test:watch": "vitest",
15 | "lint": "eslint src",
16 | "lint:fix": "eslint src --fix",
17 | "clean": "rimraf dist"
18 | },
19 | "devDependencies": {
20 | "@types/node": "^24.9.1",
21 | "typescript": "~5.9.3"
22 | },
23 | "dependencies": {
24 | "@sapphire/bitfield": "^1.2.4",
25 | "discord-api-types": "^0.38.30"
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | # Enforce Unix-style line endings for all text files
2 | * text=auto eol=lf
3 |
4 | # Windows-specific files should have CRLF line endings
5 | *.bat text eol=crlf
6 |
7 | # Binary files
8 | *.png binary
9 | *.jpg binary
10 | *.jpeg binary
11 | *.gif binary
12 | *.ico binary
13 | *.mp4 binary
14 | *.mp3 binary
15 | *.flv binary
16 | *.fla binary
17 | *.swf binary
18 | *.gz binary
19 | *.zip binary
20 | *.7z binary
21 | *.ttf binary
22 | *.eot binary
23 | *.woff binary
24 | *.woff2 binary
25 | *.pdf binary
26 |
27 | # Archives
28 | *.7z binary
29 | *.jar binary
30 | *.rar binary
31 | *.zip binary
32 | *.gz binary
33 | *.bz2 binary
34 | *.tar binary
35 | *.tgz binary
36 |
37 | # Fonts
38 | *.ttf binary
39 | *.eot binary
40 | *.otf binary
41 | *.woff binary
42 | *.woff2 binary
--------------------------------------------------------------------------------
/apps/website/src/app/dashboard/[id]/ama/amas/_components/CreateAMACard.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import Link from 'next/link';
4 | import { useParams } from 'next/navigation';
5 | import { SvgPlus } from '@/components/icons/SvgPlus';
6 |
7 | export function CreateAMACard() {
8 | const params = useParams<{ id: string }>();
9 |
10 | return (
11 |
15 |
16 | Create AMA
17 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/services/api/src/util/sendBoom.ts:
--------------------------------------------------------------------------------
1 | import type { Boom } from '@hapi/boom';
2 | import type { Response } from 'polka';
3 | import { treeifyError, ZodError } from 'zod';
4 |
5 | /**
6 | * Send a Boom error to the client
7 | *
8 | * @param error - \@hapi/boom `Boom` instance
9 | * @param res - Response to send the error to
10 | */
11 | export function sendBoom(error: Boom, res: Response) {
12 | res.statusCode = error.output.statusCode;
13 | for (const [header, value] of Object.entries(error.output.headers)) {
14 | res.setHeader(header, value!);
15 | }
16 |
17 | if (error.data instanceof ZodError) {
18 | error.output.payload = {
19 | ...error.output.payload,
20 | ...treeifyError(error.data),
21 | };
22 | }
23 |
24 | return res.end(JSON.stringify(error.output.payload));
25 | }
26 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: Quality Check
2 |
3 | on: [push, pull_request]
4 |
5 | jobs:
6 | quality:
7 | name: Quality Check
8 | runs-on: ubuntu-latest
9 | env:
10 | TURBO_TEAM: ${{ vars.TURBO_TEAM }}
11 | TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
12 | steps:
13 | - name: Checkout repository
14 | uses: actions/checkout@v3
15 |
16 | - name: Install node.js
17 | uses: actions/setup-node@v3
18 | with:
19 | node-version: 22
20 |
21 | - name: Install dependencies
22 | uses: ./actions/yarnCache
23 |
24 | - name: Ensure prisma schema is up to date
25 | run: yarn prisma generate
26 |
27 | - name: Build
28 | run: yarn build
29 |
30 | - name: ESLint
31 | run: yarn lint
32 |
--------------------------------------------------------------------------------
/apps/website/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.base.json",
3 | "compilerOptions": {
4 | "lib": ["DOM", "DOM.Iterable", "ES2022"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "noEmit": true,
8 | "esModuleInterop": true,
9 | "module": "ESNext",
10 | "moduleDetection": "force",
11 | "moduleResolution": "bundler",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "preserve",
15 | "incremental": true,
16 | "plugins": [
17 | {
18 | "name": "next"
19 | }
20 | ],
21 | "baseUrl": ".",
22 | "paths": {
23 | "@/*": ["./src/*"]
24 | },
25 | "noUncheckedSideEffectImports": false
26 | },
27 | "include": ["next-env.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx"],
28 | "exclude": ["node_modules", ".next", "dist"]
29 | }
30 |
--------------------------------------------------------------------------------
/apps/website/src/app/dashboard/[id]/ama/amas/new/page.tsx:
--------------------------------------------------------------------------------
1 | import { AMADashboardCrumbs } from '../../_components/AMADashboardCrumbs';
2 | import { CreateAMAForm } from './_components/CreateAMAForm';
3 | import { RefreshServerDataButton } from './_components/RefreshServerDataButton';
4 | import { Heading } from '@/components/common/Heading';
5 |
6 | export default function NewAMAPage() {
7 | return (
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 | );
17 | }
18 |
--------------------------------------------------------------------------------
/packages/private/backend-core/src/lib/logger.ts:
--------------------------------------------------------------------------------
1 | import type { TransportTargetOptions } from 'pino';
2 | import { pino as createPinoLogger, transport as pinoTransport, stdTimeFunctions } from 'pino';
3 |
4 | export function createLogger(name: string) {
5 | // TODO: File rotations for prod?
6 | const targets: TransportTargetOptions[] = [
7 | {
8 | target: 'pino/file',
9 | level: 'trace',
10 | options: {
11 | destination: 1, // stdout
12 | },
13 | },
14 | ];
15 |
16 | const transport = pinoTransport({
17 | targets,
18 | level: 'trace',
19 | });
20 |
21 | return createPinoLogger(
22 | {
23 | level: 'trace',
24 | name,
25 | timestamp: stdTimeFunctions.isoTime,
26 | formatters: {
27 | level: (levelLabel, level) => ({ level, levelLabel }),
28 | },
29 | },
30 | transport,
31 | );
32 | }
33 |
--------------------------------------------------------------------------------
/apps/website/src/app/dashboard/[id]/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from 'next';
2 | import { NavGateCheck } from '@/components/common/NavGate';
3 | import { server } from '@/data/server';
4 |
5 | export async function generateMetadata({ params }: LayoutProps<'/dashboard/[id]'>): Promise {
6 | const { id } = await params;
7 | let name;
8 |
9 | try {
10 | const { data: me } = await server.auth.me.fetch();
11 | const guild = me?.guilds.find((g) => g.id === id);
12 | name = guild?.name;
13 | } catch (error) {
14 | console.error(error);
15 | name = null;
16 | }
17 |
18 | return {
19 | title: name ?? 'Server not found',
20 | };
21 | }
22 |
23 | export default async function GuildLayout({ children }: LayoutProps<'/dashboard/[id]'>) {
24 | return {children};
25 | }
26 |
--------------------------------------------------------------------------------
/apps/website/src/app/dashboard/[id]/ama/amas/page.tsx:
--------------------------------------------------------------------------------
1 | import { DashboardCrumbs } from '../../../_components/DashboardCrumbs';
2 | import { AMASessionsList } from './_components/AMASessionsList';
3 | import { IncludeEndedToggle } from './_components/IncludeEndedToggle';
4 | import { Heading } from '@/components/common/Heading';
5 | import { SearchBar } from '@/components/common/SearchBar';
6 |
7 | export default function AMAMangementPage() {
8 | return (
9 | <>
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | >
20 | );
21 | }
22 |
--------------------------------------------------------------------------------
/packages/private/backend-core/src/lib/database.ts:
--------------------------------------------------------------------------------
1 | import type { DB } from '@chatsift/core';
2 | import { Kysely, PostgresDialect } from 'kysely';
3 | import { Pool } from 'pg';
4 | import type { Logger } from 'pino';
5 |
6 | export function createDatabase(logger: Logger): Kysely {
7 | const pool = new Pool({
8 | connectionString: 'postgres://chatsift:admin@postgres:5432/chatsift',
9 | });
10 |
11 | const dialect = new PostgresDialect({
12 | pool,
13 | });
14 |
15 | return new Kysely({
16 | dialect,
17 | log: (event) => {
18 | if (event.level === 'error') {
19 | logger.error({ query: event.query.sql, err: event.error }, 'Query responsible for error');
20 | } else if (event.level === 'query') {
21 | logger.debug({ query: event.query.sql, duration: event.queryDurationMillis }, 'Executed query');
22 | }
23 | },
24 | });
25 | }
26 |
--------------------------------------------------------------------------------
/apps/website/src/components/icons/SvgRefresh.tsx:
--------------------------------------------------------------------------------
1 | export function SvgRefresh() {
2 | return (
3 |
11 | );
12 | }
13 |
--------------------------------------------------------------------------------
/services/ama-bot/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@chatsift/ama-bot",
3 | "private": true,
4 | "version": "1.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "build": "tsc",
8 | "test": "vitest run",
9 | "test:watch": "vitest",
10 | "lint": "eslint src",
11 | "lint:fix": "eslint src --fix",
12 | "clean": "rimraf dist",
13 | "tag-docker": "docker image tag chatsift/chatsift-next:latest chatsift/chatsift-next:ama"
14 | },
15 | "devDependencies": {
16 | "@types/node": "^24.9.1",
17 | "kysely": "^0.28.8",
18 | "typescript": "~5.9.3",
19 | "vitest": "^3.2.4"
20 | },
21 | "dependencies": {
22 | "@chatsift/backend-core": "workspace:^",
23 | "@discordjs/core": "^3.0.0-dev.1759363313-f510b5ffa",
24 | "@discordjs/rest": "^3.0.0-dev.1759363313-f510b5ffa",
25 | "@discordjs/ws": "^3.0.0-dev.1759363313-f510b5ffa",
26 | "bin-rw": "^0.1.1",
27 | "nanoid": "^5.1.6"
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/apps/website/src/components/user/UserDesktop.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { LoginButton } from './LoginButton';
4 | import { LogoutButton } from './LogoutButton';
5 | import { UserAvatarMe } from './UserAvatarMe';
6 | import { UserErrorHandler } from './UserErrorHandler';
7 | import { Skeleton } from '@/components/common/Skeleton';
8 | import { client } from '@/data/client';
9 |
10 | export function UserDesktop() {
11 | const { data: user, error, isLoading } = client.auth.useMe();
12 |
13 | if (error) {
14 | return ;
15 | }
16 |
17 | if (isLoading) {
18 | return ;
19 | }
20 |
21 | if (!user) {
22 | return ;
23 | }
24 |
25 | return (
26 |
27 |
28 |
29 |
30 | );
31 | }
32 |
--------------------------------------------------------------------------------
/tsup.config.ts:
--------------------------------------------------------------------------------
1 | import { relative, resolve } from 'node:path';
2 | import process from 'node:process';
3 | import { defineConfig, type Options } from 'tsup';
4 |
5 | type ConfigOptions = Pick<
6 | Options,
7 | 'entry' | 'esbuildOptions' | 'format' | 'globalName' | 'minify' | 'noExternal' | 'sourcemap' | 'target'
8 | >;
9 |
10 | export const createTsupConfig = ({
11 | globalName,
12 | format = ['esm', 'cjs'],
13 | target = 'es2021',
14 | sourcemap = true,
15 | minify = false,
16 | entry = ['src/index.ts'],
17 | noExternal,
18 | esbuildOptions,
19 | }: ConfigOptions = {}) =>
20 | defineConfig({
21 | clean: true,
22 | entry,
23 | format,
24 | minify,
25 | skipNodeModulesBundle: true,
26 | sourcemap,
27 | target,
28 | tsconfig: relative(__dirname, resolve(process.cwd(), 'tsconfig.json')),
29 | keepNames: true,
30 | globalName,
31 | noExternal,
32 | esbuildOptions,
33 | });
34 |
--------------------------------------------------------------------------------
/apps/website/src/components/footer/ThemeSwitchButton.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useTheme } from 'next-themes';
4 | import { Button } from '@/components/common/Button';
5 | import { Skeleton } from '@/components/common/Skeleton';
6 | import { SvgDarkTheme } from '@/components/icons/SvgDarkTheme';
7 | import { SvgLightTheme } from '@/components/icons/SvgLightTheme';
8 | import { useIsMounted } from '@/hooks/isMounted';
9 |
10 | export function ThemeSwitchButton() {
11 | const isMounted = useIsMounted();
12 | const { theme, setTheme } = useTheme();
13 |
14 | if (!isMounted) {
15 | return ;
16 | }
17 |
18 | return (
19 |
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Dependencies
2 | node_modules/
3 | npm-debug.log*
4 | yarn-debug.log*
5 | yarn-error.log*
6 | pnpm-debug.log*
7 | lerna-debug.log*
8 |
9 | # Runtime data
10 | pids
11 | *.pid
12 | *.seed
13 | *.pid.lock
14 |
15 | user-assets
16 |
17 | # Build outputs
18 | dist/
19 | build/
20 | out/
21 | .turbo/
22 |
23 | # Coverage directory used by tools like istanbul
24 | coverage/
25 | *.lcov
26 |
27 | # nyc test coverage
28 | .nyc_output
29 |
30 | # Environment variables
31 | .env
32 | .env.local
33 | .env.development.local
34 | .env.test.local
35 | .env.production.local
36 | .env.private
37 |
38 | # OS
39 | .DS_Store
40 | Thumbs.db
41 |
42 | # IDEs and editors
43 | .idea/
44 | *.swp
45 | *.swo
46 | *~
47 |
48 | # Logs
49 | logs
50 | *.log
51 |
52 | # Temporary folders
53 | tmp/
54 | temp/
55 |
56 | # Next.js
57 | .next/
58 | .yarn/*
59 | !.yarn/releases/
60 | .vercel
61 |
62 | # Changesets
63 | .changeset/pre.json
64 |
--------------------------------------------------------------------------------
/apps/website/src/app/dashboard/page.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from 'next';
2 | import { GuildList } from './_components/GuildList';
3 | import { RefreshGuildsButton } from './_components/RefreshGuildsButton';
4 | import { Heading } from '@/components/common/Heading';
5 | import { SearchBar } from '@/components/common/SearchBar';
6 |
7 | export const metadata: Metadata = {
8 | title: 'Dashboard',
9 | };
10 |
11 | export default function DashboardPage() {
12 | return (
13 | <>
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | >
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/packages/private/backend-core/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/package.json",
3 | "name": "@chatsift/backend-core",
4 | "version": "0.1.0",
5 | "description": "Core backend utilities",
6 | "type": "module",
7 | "exports": {
8 | ".": "./dist/index.js"
9 | },
10 | "types": "./dist/index.d.ts",
11 | "scripts": {
12 | "build": "tsc",
13 | "test": "vitest run",
14 | "test:watch": "vitest",
15 | "lint": "eslint src",
16 | "lint:fix": "eslint src --fix",
17 | "clean": "rimraf dist"
18 | },
19 | "devDependencies": {
20 | "@chatsift/core": "workspace:^",
21 | "@types/node": "^24.9.1",
22 | "@types/pg": "^8.15.5",
23 | "typescript": "~5.9.3"
24 | },
25 | "dependencies": {
26 | "@sapphire/discord-utilities": "^3.5.0",
27 | "bin-rw": "^0.1.1",
28 | "kysely": "^0.28.8",
29 | "pg": "^8.16.3",
30 | "pino": "^9.14.0",
31 | "redis": "^5.8.3",
32 | "zod": "^4.1.12"
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/apps/website/src/components/user/LogoutButton.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useRouter } from 'next/navigation';
4 | import { Button } from '../common/Button';
5 | import { client } from '@/data/client';
6 |
7 | interface LogoutButtonProps {
8 | // eslint-disable-next-line @typescript-eslint/method-signature-style
9 | readonly additionally?: () => void;
10 | readonly className?: string;
11 | }
12 |
13 | export function LogoutButton({ className, additionally }: LogoutButtonProps) {
14 | const logoutMutation = client.auth.useLogout();
15 | const router = useRouter();
16 |
17 | return (
18 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/apps/website/src/components/icons/SvgDarkTheme.tsx:
--------------------------------------------------------------------------------
1 | export function SvgDarkTheme() {
2 | return (
3 |
4 |
8 |
12 |
16 |
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/services/ama-bot/src/lib/queues.ts:
--------------------------------------------------------------------------------
1 | import type { AMASession } from '@chatsift/backend-core';
2 | import type { Selectable } from 'kysely';
3 |
4 | export enum CurrentlyInQueue {
5 | mod,
6 | guest,
7 | answers,
8 | }
9 |
10 | interface GetNextQueueResult {
11 | kind: CurrentlyInQueue;
12 | queueId: string;
13 | }
14 |
15 | export function getNextQueue(currently: CurrentlyInQueue, session: Selectable): GetNextQueueResult | null {
16 | switch (currently) {
17 | case CurrentlyInQueue.answers: {
18 | return null;
19 | }
20 |
21 | case CurrentlyInQueue.guest: {
22 | return { kind: CurrentlyInQueue.answers, queueId: session.answersChannelId };
23 | }
24 |
25 | case CurrentlyInQueue.mod: {
26 | if (session.guestQueueId) {
27 | return { kind: CurrentlyInQueue.guest, queueId: session.guestQueueId };
28 | }
29 |
30 | return { kind: CurrentlyInQueue.answers, queueId: session.answersChannelId };
31 | }
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/packages/private/core/src/lib/promiseAllObject.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable tsdoc/syntax */
2 |
3 | /**
4 | * Transforms an object of promises into a promise of an object where all the values are awaited, much like
5 | * {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/all | Promise.all}.
6 | *
7 | * @remarks
8 | * This is the flow we follow:
9 | *
10 | * { a: Promise, b: Promise }
11 | *
12 | * => [ ['a', Promise], ['b', Promise] ]
13 | *
14 | * => [ Promise<['a', X]>, Promise<'b', Y>] ]
15 | *
16 | * => (via awaited Promise.all) [ ['a', X], ['b', Y] ]
17 | *
18 | * => Promise<{ a: X, b: Y }>
19 | */
20 | export async function promiseAllObject>>(
21 | obj: TRecord,
22 | ): Promise<{ [K in keyof TRecord]: Awaited }> {
23 | return Object.fromEntries(await Promise.all(Object.entries(obj).map(async ([key, value]) => [key, await value])));
24 | }
25 |
--------------------------------------------------------------------------------
/services/api/src/routes/routes.ts:
--------------------------------------------------------------------------------
1 | // This file should exclusively re-export the default exports from each route file
2 |
3 | export { default as CreateAMA } from './ama/createAMA.js';
4 | export { default as GetAMA } from './ama/getAMA.js';
5 | export { default as GetAMAs } from './ama/getAMAs.js';
6 | export { default as UpdateAMA } from './ama/updateAMA.js';
7 | export { default as RepostPrompt } from './ama/repostPrompt.js';
8 |
9 | export { default as GetAuthDiscord } from './auth/discord.js';
10 | export { default as GetAuthDiscordCallback } from './auth/discordCallback.js';
11 | export { default as PostAuthLogout } from './auth/logout.js';
12 | export { default as GetAuthMe } from './auth/me.js';
13 |
14 | export { default as GetGuild } from './guilds/get.js';
15 | export { default as CreateGrant } from './guilds/createGrant.js';
16 | export { default as DeleteGrant } from './guilds/deleteGrant.js';
17 | export { default as GetGrants } from './guilds/getGrants.js';
18 |
--------------------------------------------------------------------------------
/apps/website/src/app/dashboard/[id]/settings/_components/GrantsList.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useParams } from 'next/navigation';
4 | import { AddGrantCard } from './AddGrantCard';
5 | import { GrantCard } from './GrantCard';
6 | import { Skeleton } from '@/components/common/Skeleton';
7 | import { client } from '@/data/client';
8 |
9 | export function GrantsList() {
10 | const { id: guildId } = useParams<{ id: string }>();
11 | const { data, isLoading } = client.guilds.grants.useGrants(guildId);
12 |
13 | if (isLoading) {
14 | return (
15 | <>
16 |
17 |
18 |
19 | >
20 | );
21 | }
22 |
23 | return (
24 | <>
25 |
26 | {data!.users.map((user, index) => (
27 |
28 | ))}
29 | >
30 | );
31 | }
32 |
--------------------------------------------------------------------------------
/apps/website/src/components/user/UserAvatar.tsx:
--------------------------------------------------------------------------------
1 | import type { DefaultUserAvatarAssets, APIUser } from 'discord-api-types/v10';
2 | import { CDNRoutes, ImageFormat, RouteBases } from 'discord-api-types/v10';
3 | import { GenericAvatar } from '../common/GenericAvatar';
4 |
5 | interface UserAvatarProps {
6 | readonly className: string;
7 | readonly isLoading: boolean;
8 | readonly user: APIUser | undefined;
9 | }
10 |
11 | export function UserAvatar({ className, isLoading, user }: UserAvatarProps) {
12 | const assetURL = user?.avatar
13 | ? `${RouteBases.cdn}${CDNRoutes.userAvatar(user.id, user.avatar, ImageFormat.PNG)}`
14 | : `${RouteBases.cdn}${CDNRoutes.defaultUserAvatar(Number((BigInt(user?.id ?? '0') >> 22n) % 6n) as DefaultUserAvatarAssets)}`;
15 |
16 | return (
17 |
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/tsconfig.base.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/tsconfig",
3 | "display": "Base",
4 | "compilerOptions": {
5 | "target": "es2024",
6 | "lib": ["ES2024"],
7 | "module": "ESNext",
8 | "moduleResolution": "Bundler",
9 | "allowSyntheticDefaultImports": true,
10 | "esModuleInterop": true,
11 | "allowJs": false,
12 | "checkJs": false,
13 | "strict": true,
14 | "forceConsistentCasingInFileNames": true,
15 | "skipLibCheck": true,
16 | "declaration": true,
17 | "declarationMap": true,
18 | "sourceMap": true,
19 | "noUncheckedIndexedAccess": true,
20 | "exactOptionalPropertyTypes": true,
21 | "noImplicitReturns": true,
22 | "noImplicitOverride": true,
23 | "noPropertyAccessFromIndexSignature": true,
24 | "noFallthroughCasesInSwitch": true,
25 | "noUncheckedSideEffectImports": true,
26 | "useDefineForClassFields": true,
27 | "resolveJsonModule": true,
28 | "isolatedModules": true
29 | },
30 | "exclude": ["**/node_modules", "**/dist", "**/.turbo", "**/coverage"]
31 | }
32 |
--------------------------------------------------------------------------------
/services/api/src/util/discordAPI.ts:
--------------------------------------------------------------------------------
1 | import { getContext, type BotId } from '@chatsift/backend-core';
2 | import type { Snowflake } from '@discordjs/core';
3 | import { API } from '@discordjs/core';
4 | import { REST } from '@discordjs/rest';
5 | import type { MeGuild } from './me.js';
6 |
7 | const oauthREST = new REST({ version: '10' });
8 | export const discordAPIOAuth = new API(oauthREST);
9 |
10 | const amaREST = new REST({ version: '10' }).setToken(getContext().env.AMA_BOT_TOKEN);
11 | export const discordAPIAma = new API(amaREST);
12 |
13 | export const APIMapping: Record = {
14 | AMA: discordAPIAma,
15 | };
16 |
17 | const latest = new Map();
18 | export function roundRobinAPI(guild: MeGuild): API {
19 | if (guild.bots.length === 1) {
20 | return APIMapping[guild.bots[0]!];
21 | }
22 |
23 | const index = latest.get(guild.id) ?? -1;
24 | const nextIndex = (index + 1) % guild.bots.length;
25 | latest.set(guild.id, nextIndex);
26 |
27 | return APIMapping[guild.bots[nextIndex]!];
28 | }
29 |
--------------------------------------------------------------------------------
/apps/website/src/app/dashboard/[id]/ama/_components/AMADashboardCrumbs.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import type { AMASessionDetailed, AMASessionWithCount } from '@chatsift/api';
4 | import { useParams } from 'next/navigation';
5 | import { DashboardCrumbs } from '../../../_components/DashboardCrumbs';
6 | import { client } from '@/data/client';
7 |
8 | function useCurrentAMA(id: string, amaId?: string) {
9 | if (!amaId) return undefined;
10 | const { data: currentAMA } = client.guilds.ama.useAMA(id, amaId);
11 | return currentAMA as AMASessionDetailed | undefined;
12 | }
13 |
14 | export function AMADashboardCrumbs() {
15 | const params = useParams<{ amaId?: string; id: string }>();
16 |
17 | const { data: amaSessions } = client.guilds.ama.useAMAs(params.id, {
18 | include_ended: 'false',
19 | });
20 | const currentAMA = useCurrentAMA(params.id, params.amaId);
21 |
22 | return (
23 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/apps/website/src/components/common/GenericAvatar.tsx:
--------------------------------------------------------------------------------
1 | import { Avatar } from './Avatar';
2 | import { GenericAvatarImage, GenericAvatarInitials } from './GenericAvatarImages';
3 | import { Skeleton } from './Skeleton';
4 | import { cn } from '@/utils/util';
5 |
6 | interface GenericAvatarProps {
7 | readonly assetURL: string | undefined;
8 | readonly className: string;
9 | readonly disableLink?: boolean;
10 | readonly href: string;
11 | readonly initials: string;
12 | readonly isLoading: boolean;
13 | }
14 |
15 | export function GenericAvatar({ className, href, disableLink, isLoading, assetURL, initials }: GenericAvatarProps) {
16 | return (
17 |
18 |
19 | {isLoading ? (
20 |
21 | ) : assetURL ? (
22 |
23 | ) : (
24 |
25 | )}
26 |
27 |
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/packages/public/discord-utils/src/__tests__/sortChannels.test.ts:
--------------------------------------------------------------------------------
1 | import type { APIChannel } from 'discord-api-types/v10';
2 | import { ChannelType } from 'discord-api-types/v10';
3 | import { test, expect } from 'vitest';
4 | import { sortChannels } from '../sortChannels.js';
5 |
6 | test('sorting a list of channels', () => {
7 | // Higher position than the category, but should come out on top
8 | const first = {
9 | id: '1',
10 | position: 1,
11 | type: ChannelType.GuildText,
12 | } as unknown as APIChannel;
13 |
14 | const second = {
15 | id: '0',
16 | position: 0,
17 | type: ChannelType.GuildCategory,
18 | } as unknown as APIChannel;
19 |
20 | const third = {
21 | id: '2',
22 | position: 0,
23 | type: ChannelType.GuildText,
24 | parent_id: '0',
25 | } as unknown as APIChannel;
26 |
27 | const fourth = {
28 | id: '3',
29 | position: 1,
30 | type: ChannelType.GuildText,
31 | parent_id: '0',
32 | } as unknown as APIChannel;
33 |
34 | expect(sortChannels([first, second, third, fourth])).toStrictEqual([first, second, third, fourth]);
35 | });
36 |
--------------------------------------------------------------------------------
/services/api/src/middleware/validate.ts:
--------------------------------------------------------------------------------
1 | import { badData } from '@hapi/boom';
2 | import type { NextHandler, Request, Response } from 'polka';
3 | import type { ZodType } from 'zod';
4 |
5 | /**
6 | * Request properties that can be validated
7 | */
8 | export type ValidateMiddlewareProp = 'body' | 'headers' | 'params' | 'query';
9 |
10 | /**
11 | * Creates a request handler that validates a given request property - also potentially mutates the property, applying defaults and sane type conversions
12 | *
13 | * @param schema - A shapeshift schema to validate the property against - please refer to the zod documentation for more information
14 | * @param prop - The property to validate
15 | */
16 | export function validate(schema: ZodType, prop: ValidateMiddlewareProp) {
17 | return async (req: Request, _: Response, next: NextHandler) => {
18 | const result = schema.safeParse(req[prop]);
19 |
20 | if (!result.success) {
21 | return next(badData(result.error?.message, result.error));
22 | }
23 |
24 | req[prop] = result.data as unknown;
25 | return next();
26 | };
27 | }
28 |
--------------------------------------------------------------------------------
/apps/website/src/app/dashboard/[id]/ama/amas/new/_components/RefreshServerDataButton.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import type { BotId } from '@chatsift/core';
4 | import { useParams } from 'next/navigation';
5 | import { Button } from '@/components/common/Button';
6 | import { SvgRefresh } from '@/components/icons/SvgRefresh';
7 | import { client } from '@/data/client';
8 |
9 | interface RefreshServerDataButtonProps {
10 | readonly for_bot: BotId;
11 | }
12 |
13 | export function RefreshServerDataButton({ for_bot }: RefreshServerDataButtonProps) {
14 | const params = useParams<{ id: string }>();
15 | const { id: guildId } = params;
16 |
17 | const { refetch, isLoading } = client.guilds.useInfo(guildId, { for_bot, force_fresh: 'true' });
18 |
19 | return (
20 |
30 | );
31 | }
32 |
--------------------------------------------------------------------------------
/apps/website/src/components/icons/SvgLightTheme.tsx:
--------------------------------------------------------------------------------
1 | export function SvgLightTheme() {
2 | return (
3 |
4 |
8 |
9 | );
10 | }
11 |
--------------------------------------------------------------------------------
/apps/website/src/components/common/GenericAvatarImages.tsx:
--------------------------------------------------------------------------------
1 | import { AvatarFallback, AvatarImage } from './Avatar';
2 | import { cn } from '@/utils/util';
3 |
4 | interface GenericAvatarInitialsProps {
5 | readonly className?: string;
6 | readonly initials: string;
7 | }
8 |
9 | export function GenericAvatarInitials({ className, initials }: GenericAvatarInitialsProps) {
10 | return (
11 |
17 | {initials.toUpperCase()}
18 |
19 | );
20 | }
21 |
22 | interface GenericAvatarImageProps {
23 | readonly assetURL: string;
24 | readonly className?: string;
25 | }
26 |
27 | export function GenericAvatarImage({ className, assetURL }: GenericAvatarImageProps) {
28 | return (
29 | <>
30 |
31 |
32 | >
33 | );
34 | }
35 |
--------------------------------------------------------------------------------
/packages/public/discord-utils/README.md:
--------------------------------------------------------------------------------
1 | # `@chatsift/discord-utils`
2 |
3 | [](https://github.com/chatsift/chatsift/blob/main/LICENSE)
4 | [](https://www.npmjs.com/package/@chatsift/discord-utils)
5 | [](https://github.com/chatsift/chatsift/actions/workflows/test.yml)
6 |
7 | Niche utilities for working with Discord's API
8 |
9 | ## Installation
10 |
11 | - `npm install @chatsift/discord-utils`
12 | - `pnpm install @chatsift/discord-utils`
13 | - `yarn add @chatsift/discord-utils`
14 |
15 | ## Contributing
16 |
17 | Please see the main [README.md](https://github.com/chatsift/chatsift) for info on how to contribute to this package or the other `@chatsift` packages.
18 |
19 | ## LICENSE
20 |
21 | This project is licensed under the GNU AGPLv3 license.
22 |
23 | It should, however, be noted that some packages are forks of other open source projects, and are therefore, sub-licensed.
24 |
--------------------------------------------------------------------------------
/packages/public/pino-rotate-file/README.md:
--------------------------------------------------------------------------------
1 | # `@chatsift/pino-rotate-file`
2 |
3 | [](https://github.com/chatsift/chatsift/blob/main/LICENSE)
4 | [](https://www.npmjs.com/package/@chatsift/pino-rotate-file)
5 | [](https://github.com/chatsift/chatsift/actions/workflows/test.yml)
6 |
7 | Simple pino transport for rotating files
8 |
9 | ## Installation
10 |
11 | - `npm install @chatsift/pino-rotate-file`
12 | - `pnpm install @chatsift/pino-rotate-file`
13 | - `yarn add @chatsift/pino-rotate-file`
14 |
15 | ## Contributing
16 |
17 | Please see the main [README.md](https://github.com/chatsift/chatsift) for info on how to contribute to this package or the other `@chatsift` packages.
18 |
19 | ## LICENSE
20 |
21 | This project is licensed under the MIT license.
22 |
23 | It should, however, be noted that some packages are forks of other open source projects, and are therefore, sub-licensed.
24 |
--------------------------------------------------------------------------------
/services/api/src/util/crypt.ts:
--------------------------------------------------------------------------------
1 | import { Buffer } from 'node:buffer';
2 | import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
3 | import { getContext } from '@chatsift/backend-core';
4 |
5 | /**
6 | * Returns a base64-encoded string containing the IV and the encrypted given `data`.
7 | */
8 | export function encrypt(data: string): string {
9 | const key = Buffer.from(getContext().env.ENCRYPTION_KEY, 'base64');
10 | const iv = randomBytes(16);
11 |
12 | const cipher = createCipheriv('aes-256-ctr', key, iv);
13 | return Buffer.concat([iv, cipher.update(data, 'utf8'), cipher.final()]).toString('base64');
14 | }
15 |
16 | /**
17 | * Decodes a string created by `encrypt` and returns the original data.
18 | */
19 | export function decrypt(data: string): string {
20 | const buffer = Buffer.from(data, 'base64');
21 |
22 | const key = Buffer.from(getContext().env.ENCRYPTION_KEY, 'base64');
23 | const iv = buffer.subarray(0, 16);
24 |
25 | const decipher = createDecipheriv('aes-256-ctr', key, iv);
26 |
27 | return Buffer.concat([decipher.update(buffer.subarray(16)), decipher.final()]).toString('utf8');
28 | }
29 |
--------------------------------------------------------------------------------
/.github/labels.yml:
--------------------------------------------------------------------------------
1 | - name: 'backlog'
2 | color: '7ef7ef'
3 | - name: 'bug'
4 | color: 'd73a4a'
5 | - name: 'chore'
6 | color: 'ffffff'
7 | - name: 'ci'
8 | color: '0075ca'
9 | - name: 'dependencies'
10 | color: '276bd1'
11 | - name: 'documentation'
12 | color: '0075ca'
13 | - name: 'duplicate'
14 | color: 'cfd3d7'
15 | - name: 'feature request'
16 | color: 'fcf95a'
17 | - name: 'good first issue'
18 | color: '7057ff'
19 | - name: 'has PR'
20 | color: '4b1f8e'
21 | - name: 'help wanted'
22 | color: '008672'
23 | - name: 'in progress'
24 | color: 'ffccd7'
25 | - name: 'in review'
26 | color: 'aed5fc'
27 | - name: 'invalid'
28 | color: 'e4e669'
29 | - name: 'need repro'
30 | color: 'c66037'
31 | - name: 'performance'
32 | color: '80c042'
33 | - name: 'priority:high'
34 | color: 'fc1423'
35 | - name: 'refactor'
36 | color: '1d637f'
37 | - name: 'regression'
38 | color: 'ea8785'
39 | - name: 'semver:major'
40 | color: 'c10f47'
41 | - name: 'semver:minor'
42 | color: 'e4f486'
43 | - name: 'semver:patch'
44 | color: 'e8be8b'
45 | - name: 'tests'
46 | color: 'f06dff'
47 | - name: 'wontfix'
48 | color: 'ffffff'
49 |
--------------------------------------------------------------------------------
/apps/website/src/components/user/UserMobile.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { LoginButton } from './LoginButton';
4 | import { LogoutButton } from './LogoutButton';
5 | import { UserAvatarMe } from './UserAvatarMe';
6 | import { UserErrorHandler } from './UserErrorHandler';
7 | import { client } from '@/data/client';
8 |
9 | interface UserMobileProps {
10 | // eslint-disable-next-line @typescript-eslint/method-signature-style
11 | readonly setMobileNavOpen: (open: boolean) => void;
12 | }
13 |
14 | export function UserMobile({ setMobileNavOpen }: UserMobileProps) {
15 | const { data: user, error } = client.auth.useMe();
16 |
17 | if (error) {
18 | return ;
19 | }
20 |
21 | if (!user) {
22 | return ;
23 | }
24 |
25 | return (
26 |
27 |
28 |
{user.username}
29 |
setMobileNavOpen(false)}
31 | className="ml-auto text-secondary dark:text-secondary-dark"
32 | />
33 |
34 | );
35 | }
36 |
--------------------------------------------------------------------------------
/services/api/src/util/stateCookie.ts:
--------------------------------------------------------------------------------
1 | import { Buffer } from 'node:buffer';
2 | import { randomBytes } from 'node:crypto';
3 |
4 | export class StateCookie {
5 | public static from(data: string): StateCookie {
6 | const bytes = Buffer.from(data, 'base64');
7 | const nonce = bytes.subarray(0, 16);
8 | const createdAt = new Date(bytes.readUInt32LE(16) * 1_000);
9 | const redirectURI = bytes.subarray(20).toString();
10 |
11 | return new this(redirectURI, nonce, createdAt);
12 | }
13 |
14 | public constructor(redirectURI: string);
15 | public constructor(redirectURI: string, nonce: Buffer, createdAt: Date);
16 |
17 | public constructor(
18 | public readonly redirectURI: string,
19 | private readonly nonce: Buffer = randomBytes(16),
20 | public readonly createdAt: Date = new Date(),
21 | ) {}
22 |
23 | public toBytes(): Buffer {
24 | const time = Buffer.allocUnsafe(4);
25 | time.writeUInt32LE(Math.floor(this.createdAt.getTime() / 1_000));
26 | return Buffer.concat([this.nonce, time, Buffer.from(this.redirectURI)]);
27 | }
28 |
29 | public toCookie(): string {
30 | return this.toBytes().toString('base64');
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/apps/website/src/app/dashboard/_components/GuildList.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useSearchParams } from 'next/navigation';
4 | import { useMemo } from 'react';
5 | import GuildCard from './GuildCard';
6 | import { client } from '@/data/client';
7 | import { sortGuilds } from '@/utils/util';
8 |
9 | export function GuildList() {
10 | const { data: me } = client.auth.useMe();
11 | const searchParams = useSearchParams();
12 |
13 | const searchQuery = searchParams.get('search') ?? '';
14 |
15 | const manageable = useMemo(() => me?.guilds.filter((g) => g.meCanManage) ?? [], [me]);
16 | const sorted = useMemo(() => {
17 | const lower = searchQuery.toLowerCase();
18 |
19 | if (!manageable.length) {
20 | return [];
21 | }
22 |
23 | const filtered = manageable.filter((guild) => guild.name.toLowerCase().includes(lower));
24 | return sortGuilds(filtered);
25 | }, [manageable, searchQuery]);
26 |
27 | return (
28 |
29 | {sorted.map((guild) => (
30 | -
31 |
32 |
33 | ))}
34 |
35 | );
36 | }
37 |
--------------------------------------------------------------------------------
/.github/workflows/deploy-manual.yml:
--------------------------------------------------------------------------------
1 | name: Deploy manual
2 |
3 | on:
4 | workflow_dispatch:
5 |
6 | jobs:
7 | deploy:
8 | name: Manual deploy
9 | runs-on: ubuntu-latest
10 | env:
11 | TURBO_TEAM: ${{ vars.TURBO_TEAM }}
12 | TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
13 |
14 | steps:
15 | - name: Checkout repository
16 | uses: actions/checkout@v2
17 |
18 | - name: Install node.js
19 | uses: actions/setup-node@v3
20 | with:
21 | node-version: 22
22 |
23 | - name: Set up Docker Buildx
24 | uses: docker/setup-buildx-action@v1
25 |
26 | - name: Login to DockerHub
27 | run: docker login -u ${{ secrets.DOCKERHUB_USERNAME }} -p ${{ secrets.DOCKERHUB_TOKEN }}
28 |
29 | - name: Install dependencies
30 | uses: ./actions/yarnCache
31 |
32 | - name: Build the images
33 | run: docker build -t chatsift/chatsift-next:latest -f ./Dockerfile .
34 |
35 | - name: Tag all
36 | run: yarn turbo run tag-docker --force --no-cache
37 |
38 | - name: Push to DockerHub
39 | run: docker image push --all-tags chatsift/chatsift-next
40 |
--------------------------------------------------------------------------------
/services/api/src/util/__tests__/crypt.test.ts:
--------------------------------------------------------------------------------
1 | import { Buffer } from 'node:buffer';
2 | import { randomBytes } from 'node:crypto';
3 | import { expect, test, vi } from 'vitest';
4 | import { encrypt, decrypt } from '../crypt.js';
5 |
6 | vi.mock('crypto', async () => {
7 | // eslint-disable-next-line @typescript-eslint/consistent-type-imports
8 | const original: typeof import('crypto') = await vi.importActual('crypto');
9 | // eslint-disable-next-line unicorn/consistent-function-scoping
10 | const randomBytes = (len: number) => Buffer.from(Array.from({ length: len }).fill(1));
11 | return {
12 | ...original,
13 | randomBytes,
14 | };
15 | });
16 |
17 | vi.mock('@chatsift/backend-core', () => ({
18 | getContext: () => ({
19 | env: {
20 | ENCRYPTION_KEY: randomBytes(32).toString('base64'),
21 | },
22 | }),
23 | }));
24 |
25 | const PLAIN_DATA = 'this is very sensitive';
26 | const SECRET_DATA = encrypt(PLAIN_DATA);
27 |
28 | test('encrypt', () => {
29 | expect(SECRET_DATA).toBe('AQEBAQEBAQEBAQEBAQEBAejE/bWU7BLYic/V/zbJLfwqp2c5B/8=');
30 | });
31 |
32 | test('decrypt', () => {
33 | expect(decrypt(SECRET_DATA)).toBe(PLAIN_DATA);
34 | });
35 |
--------------------------------------------------------------------------------
/apps/website/src/app/dashboard/[id]/ama/amas/new/_components/PromptModeToggle.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from '@/components/common/Button';
2 |
3 | type PromptMode = 'normal' | 'raw';
4 |
5 | interface PromptModeToggleProps {
6 | readonly mode: PromptMode;
7 | onModeChange(mode: PromptMode): void;
8 | }
9 |
10 | export function PromptModeToggle({ mode, onModeChange }: PromptModeToggleProps) {
11 | return (
12 |
13 |
24 |
34 |
35 | );
36 | }
37 |
--------------------------------------------------------------------------------
/packages/public/parse-relative-time/README.md:
--------------------------------------------------------------------------------
1 | # `@chatsift/parse-relative-time`
2 |
3 | [](https://github.com/chatsift/chatsift/blob/main/LICENSE)
4 | [](https://www.npmjs.com/package/@chatsift/parse-relative-time)
5 | [](https://github.com/chatsift/chatsift/actions/workflows/test.yml)
6 |
7 | Relative time parser, similar to [vercel/ms](https://github.com/vercel/ms)
8 |
9 | ## Installation
10 |
11 | - `npm install @chatsift/parse-relative-time`
12 | - `pnpm install @chatsift/parse-relative-time`
13 | - `yarn add @chatsift/parse-relative-time`
14 |
15 | ## Contributing
16 |
17 | Please see the main [README.md](https://github.com/chatsift/chatsift) for info on how to contribute to this package or the other `@chatsift` packages.
18 |
19 | ## LICENSE
20 |
21 | This project is licensed under the MIT license.
22 |
23 | It should, however, be noted that some packages are forks of other open source projects, and are therefore, sub-licensed.
24 |
--------------------------------------------------------------------------------
/apps/website/src/utils/util.ts:
--------------------------------------------------------------------------------
1 | import type { MeGuild } from '@chatsift/api';
2 | import { clsx, type ClassValue } from 'clsx';
3 | import { twMerge } from 'tailwind-merge';
4 |
5 | export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));
6 |
7 | export const retryWrapper = (retry: (retries: number, error: Error) => boolean) => (retries: number, error: Error) => {
8 | if (process.env.NODE_ENV === 'development') {
9 | return false;
10 | }
11 |
12 | return retry(retries, error);
13 | };
14 |
15 | export const exponentialBackOff = (failureCount: number) => 2 ** failureCount * 1_000;
16 |
17 | export const sortGuilds = (guilds: MeGuild[]) =>
18 | guilds
19 | .slice()
20 | .reverse()
21 | .sort((a, b) => b.bots.length - a.bots.length);
22 |
23 | export const getGuildAcronym = (guildName: string) =>
24 | guildName
25 | .replaceAll("'s ", ' ')
26 | .replaceAll(/\w+/g, (substring) => substring[0]!)
27 | .replaceAll(/\s/g, '');
28 |
29 | export const formatDate = (date: Date) =>
30 | new Intl.DateTimeFormat('en-US', {
31 | year: 'numeric',
32 | month: 'long',
33 | day: 'numeric',
34 | hour: '2-digit',
35 | minute: '2-digit',
36 | }).format(date);
37 |
--------------------------------------------------------------------------------
/packages/public/parse-relative-time/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@chatsift/parse-relative-time",
3 | "description": "Relative time parser, similar to vercel/ms",
4 | "version": "0.3.1",
5 | "main": "./dist/index.js",
6 | "types": "./dist/index.d.ts",
7 | "exports": {
8 | "types": "./dist/index.d.ts",
9 | "import": "./dist/index.mjs",
10 | "require": "./dist/index.js"
11 | },
12 | "directories": {
13 | "lib": "src"
14 | },
15 | "files": [
16 | "dist"
17 | ],
18 | "scripts": {
19 | "build": "tsup && tsc",
20 | "test": "vitest run",
21 | "test:watch": "vitest",
22 | "lint": "eslint src",
23 | "lint:fix": "eslint src --fix",
24 | "clean": "rimraf dist"
25 | },
26 | "repository": {
27 | "type": "git",
28 | "url": "https://github.com/chatsift/chatsift.git",
29 | "directory": "packages/npm/parse-relative-time"
30 | },
31 | "bugs": {
32 | "url": "https://github.com/chatsift/chatsift/issues"
33 | },
34 | "homepage": "https://github.com/chatsift/chatsift",
35 | "devDependencies": {
36 | "@types/node": "^24.9.1",
37 | "tsup": "^8.5.0",
38 | "typescript": "~5.9.3",
39 | "vitest": "^2.1.9"
40 | },
41 | "dependencies": {
42 | "tslib": "^2.8.1"
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/apps/website/src/components/footer/Footer.tsx:
--------------------------------------------------------------------------------
1 | import { ThemeSwitchButton } from './ThemeSwitchButton';
2 | import SvgDiscord from '@/components/icons/SvgDiscord';
3 | import { SvgGitHub } from '@/components/icons/SvgGitHub';
4 |
5 | export function Footer() {
6 | return (
7 |
24 | );
25 | }
26 |
--------------------------------------------------------------------------------
/packages/public/discord-utils/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@chatsift/discord-utils",
3 | "description": "Niche utilities for working with Discord's raw API",
4 | "version": "0.5.1",
5 | "main": "./dist/index.js",
6 | "types": "./dist/index.d.ts",
7 | "exports": {
8 | "types": "./dist/index.d.ts",
9 | "import": "./dist/index.mjs",
10 | "require": "./dist/index.js"
11 | },
12 | "directories": {
13 | "lib": "src"
14 | },
15 | "files": [
16 | "dist"
17 | ],
18 | "scripts": {
19 | "build": "tsup && tsc",
20 | "test": "vitest run",
21 | "test:watch": "vitest",
22 | "lint": "eslint src",
23 | "lint:fix": "eslint src --fix",
24 | "clean": "rimraf dist"
25 | },
26 | "repository": {
27 | "type": "git",
28 | "url": "https://github.com/chatsift/chatsift.git",
29 | "directory": "packages/npm/discord-utils"
30 | },
31 | "bugs": {
32 | "url": "https://github.com/chatsift/chatsift/issues"
33 | },
34 | "homepage": "https://github.com/chatsift/chatsift",
35 | "devDependencies": {
36 | "@types/node": "^24.9.1",
37 | "tsup": "^8.5.0",
38 | "typescript": "~5.9.3",
39 | "vitest": "^2.1.9"
40 | },
41 | "dependencies": {
42 | "discord-api-types": "^0.38.30",
43 | "tslib": "^2.8.1"
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/packages/private/backend-core/src/lib/context.ts:
--------------------------------------------------------------------------------
1 | import type { DB } from '@chatsift/core';
2 | import type { Kysely } from 'kysely';
3 | import type { Logger } from 'pino';
4 | import { ENV } from './env.js';
5 | import type { createRedis } from './redis.js';
6 |
7 | export interface Context {
8 | API_URL: string;
9 | BCRYPT_SALT_ROUNDS: number;
10 | FRONTEND_URL: string;
11 | UP_SINCE: number;
12 |
13 | db: Kysely;
14 | env: typeof ENV;
15 | logger: Logger;
16 | redis: Awaited>;
17 | }
18 |
19 | let context: Context | null = null;
20 |
21 | export function initContext(given: Pick): void {
22 | if (context !== null) {
23 | throw new Error('Context has already been initialized');
24 | }
25 |
26 | context = {
27 | API_URL: ENV.IS_PRODUCTION ? ENV.API_URL_PROD : ENV.API_URL_DEV,
28 | BCRYPT_SALT_ROUNDS: 14,
29 | FRONTEND_URL: ENV.IS_PRODUCTION ? ENV.FRONTEND_URL_PROD : ENV.FRONTEND_URL_DEV,
30 | UP_SINCE: Date.now(),
31 |
32 | env: ENV,
33 | ...given,
34 | };
35 | }
36 |
37 | export function getContext(): Context {
38 | if (!context) {
39 | throw new Error('Context has not been initialized yet');
40 | }
41 |
42 | return context;
43 | }
44 |
--------------------------------------------------------------------------------
/services/ama-bot/src/lib/collector.ts:
--------------------------------------------------------------------------------
1 | import { setTimeout, clearTimeout } from 'node:timers';
2 | import type { APIInteraction, APIModalSubmitInteraction } from '@discordjs/core';
3 | import { GatewayDispatchEvents, InteractionType } from '@discordjs/core';
4 | import { client } from './client.js';
5 |
6 | export async function collectModal(id: string, waitFor: number): Promise {
7 | return new Promise((resolve, reject) => {
8 | const cleanup = () => {
9 | /* eslint-disable @typescript-eslint/no-use-before-define */
10 | client.off(GatewayDispatchEvents.InteractionCreate, handler);
11 | clearTimeout(timeout);
12 | /* eslint-enable @typescript-eslint/no-use-before-define */
13 | };
14 |
15 | const handler = ({ data: interaction }: { data: APIInteraction }) => {
16 | if (interaction.type === InteractionType.ModalSubmit && interaction.data.custom_id === id) {
17 | resolve(interaction);
18 | cleanup();
19 | }
20 | };
21 |
22 | const timeout = setTimeout(() => {
23 | reject(new Error('Modal submission timed out'));
24 | cleanup();
25 | }, waitFor).unref();
26 |
27 | client.on(GatewayDispatchEvents.InteractionCreate, handler);
28 | });
29 | }
30 |
--------------------------------------------------------------------------------
/apps/website/src/app/dashboard/[id]/ama/amas/new/_components/SnowflakeInput.tsx:
--------------------------------------------------------------------------------
1 | interface SnowflakeInputProps {
2 | readonly error?: string | undefined;
3 | readonly id: string;
4 | readonly label: string;
5 | onChange(value: string): void;
6 | readonly placeholder?: string;
7 | readonly required?: boolean;
8 | readonly value: string;
9 | }
10 |
11 | export function SnowflakeInput({
12 | id,
13 | label,
14 | value,
15 | onChange,
16 | error,
17 | placeholder = '123456789012345678',
18 | required = false,
19 | }: SnowflakeInputProps) {
20 | return (
21 |
22 |
25 |
onChange(e.target.value)}
29 | placeholder={placeholder}
30 | type="text"
31 | value={value}
32 | />
33 | {error &&
{error}
}
34 |
35 | );
36 | }
37 |
--------------------------------------------------------------------------------
/apps/website/next.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | export default {
3 | reactStrictMode: true,
4 | images: {
5 | contentDispositionType: 'attachment',
6 | contentSecurityPolicy: "default-src 'self'; frame-src 'none'; sandbox;",
7 | remotePatterns: [
8 | {
9 | protocol: 'https',
10 | hostname: 'cdn.discordapp.com',
11 | pathname: '/icons/**',
12 | },
13 | ],
14 | },
15 | productionBrowserSourceMaps: true,
16 | logging: {
17 | fetches: {
18 | fullUrl: true,
19 | },
20 | },
21 | eslint: {
22 | ignoreDuringBuilds: true,
23 | },
24 | typescript: {
25 | ignoreBuildErrors: false,
26 | },
27 | experimental: {
28 | reactCompiler: true,
29 | },
30 | async redirects() {
31 | return [
32 | {
33 | source: '/github',
34 | destination: 'https://github.com/chatsift',
35 | permanent: true,
36 | },
37 | {
38 | source: '/support',
39 | destination: 'https://discord.gg/tgZ2pSgXXv',
40 | permanent: true,
41 | },
42 | {
43 | source: '/invites/ama',
44 | destination:
45 | 'https://discord.com/oauth2/authorize?client_id=1427232824854970409&permissions=274878024704&scope=applications.commands%20bot',
46 | permanent: true,
47 | },
48 | ];
49 | },
50 | };
51 |
--------------------------------------------------------------------------------
/services/api/src/routes/auth/logout.ts:
--------------------------------------------------------------------------------
1 | import { getContext } from '@chatsift/backend-core';
2 | import type { NextHandler, Response } from 'polka';
3 | import { isAuthed } from '../../middleware/isAuthed.js';
4 | import { discordAPIOAuth } from '../../util/discordAPI.js';
5 | import { noopAccessToken, noopRefreshToken } from '../../util/tokens.js';
6 | import type { TRequest } from '../route.js';
7 | import { Route, RouteMethod } from '../route.js';
8 |
9 | export default class PostAuthLogout extends Route {
10 | public readonly info = {
11 | method: RouteMethod.post,
12 | path: '/v3/auth/logout',
13 | } as const;
14 |
15 | public override readonly middleware = [
16 | ...isAuthed({ fallthrough: false, isGlobalAdmin: false, isGuildManager: false }),
17 | ];
18 |
19 | public override async handle(req: TRequest, res: Response, next: NextHandler) {
20 | await discordAPIOAuth.oauth2.revokeToken(
21 | getContext().env.OAUTH_DISCORD_CLIENT_ID,
22 | getContext().env.OAUTH_DISCORD_CLIENT_SECRET,
23 | { token: req.tokens!.refresh.discordRefreshToken, token_type_hint: 'refresh_token' },
24 | );
25 |
26 | noopAccessToken(res);
27 | noopRefreshToken(res);
28 |
29 | res.statusCode = 200;
30 | return res.end();
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/apps/website/src/components/common/Button.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useState } from 'react';
4 | import type { ButtonProps } from 'react-aria-components';
5 | import { Button as AriaButton } from 'react-aria-components';
6 | import { cn } from '@/utils/util';
7 |
8 | export function Button(props: ButtonProps) {
9 | const { className, ...rest } = props;
10 | const [isLoading, setIsLoading] = useState(false);
11 |
12 | return (
13 | {
21 | if (props.onPress) {
22 | try {
23 | setIsLoading(true);
24 | // eslint-disable-next-line @typescript-eslint/await-thenable, @typescript-eslint/no-confusing-void-expression
25 | await props.onPress?.(event);
26 | } finally {
27 | setIsLoading(false);
28 | }
29 | }
30 | }}
31 | >
32 | {props.children}
33 |
34 | );
35 | }
36 |
--------------------------------------------------------------------------------
/services/api/src/middleware/__tests__/validate.test.ts:
--------------------------------------------------------------------------------
1 | import { Boom } from '@hapi/boom';
2 | import type { Request, Response } from 'polka';
3 | import { afterEach, expect, test, vi } from 'vitest';
4 | import z from 'zod';
5 | import { validate } from '../validate.js';
6 |
7 | const next = vi.fn();
8 |
9 | afterEach(() => {
10 | vi.resetAllMocks();
11 | });
12 |
13 | const makeMockedRequest = (data: any) => data as Request;
14 | const mockedResponse = {} as unknown as Response;
15 |
16 | test('invalid schema', () => {
17 | const validator = validate(
18 | z
19 | .object({
20 | foo: z.string(),
21 | })
22 | .strict(),
23 | 'body',
24 | );
25 |
26 | void validator(makeMockedRequest({ body: { foo: 1 } }), mockedResponse, next);
27 | expect(next).toHaveBeenCalledWith(expect.any(Boom));
28 | });
29 |
30 | test('valid schema', () => {
31 | const validator = validate(
32 | z
33 | .object({
34 | foo: z.string(),
35 | bar: z.number().default(5),
36 | })
37 | .strict(),
38 | 'body',
39 | );
40 |
41 | const req = { body: { foo: 'bar' } };
42 |
43 | void validator(makeMockedRequest(req), mockedResponse, next);
44 | expect(next).toHaveBeenCalledWith();
45 | expect(req).toHaveProperty('body', { foo: 'bar', bar: 5 });
46 | });
47 |
--------------------------------------------------------------------------------
/apps/website/src/components/icons/SvgGitHub.tsx:
--------------------------------------------------------------------------------
1 | export function SvgGitHub() {
2 | return (
3 |
4 |
8 |
9 | );
10 | }
11 |
--------------------------------------------------------------------------------
/apps/website/src/app/dashboard/[id]/ama/amas/_components/AMASessionCard.tsx:
--------------------------------------------------------------------------------
1 | import type { AMASessionWithCount } from '@chatsift/api';
2 | import Link from 'next/link';
3 |
4 | interface AMASessionCardProps {
5 | readonly data: AMASessionWithCount;
6 | }
7 |
8 | export function AMASessionCard({ data }: AMASessionCardProps) {
9 | return (
10 |
14 |
15 |
16 | {data.title}
17 |
18 |
19 | {data.questionCount} {data.questionCount === 1 ? 'question' : 'questions'}
20 |
21 |
22 |
23 |
26 | {data.ended ? 'Ended' : 'Active'}
27 |
28 |
29 |
30 | );
31 | }
32 |
--------------------------------------------------------------------------------
/apps/website/src/components/nav/NavbarDesktop.tsx:
--------------------------------------------------------------------------------
1 | import * as NavigationMenu from '@radix-ui/react-navigation-menu';
2 | import { navbarItems } from './navbarItems';
3 | import { Logo } from '@/components/common/Logo';
4 | import { UserDesktop } from '@/components/user/UserDesktop';
5 |
6 | export function NavbarDesktop() {
7 | return (
8 |
9 | -
10 |
11 |
12 |
13 |
29 |
30 | -
31 |
32 |
33 |
34 | );
35 | }
36 |
--------------------------------------------------------------------------------
/packages/public/pino-rotate-file/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@chatsift/pino-rotate-file",
3 | "description": "Simple pino transport for rotating files",
4 | "version": "0.5.1",
5 | "main": "./dist/index.js",
6 | "types": "./dist/index.d.ts",
7 | "exports": {
8 | "types": "./dist/index.d.ts",
9 | "import": "./dist/index.mjs",
10 | "require": "./dist/index.js"
11 | },
12 | "directories": {
13 | "lib": "src"
14 | },
15 | "files": [
16 | "dist"
17 | ],
18 | "scripts": {
19 | "build": "tsup && tsc",
20 | "test": "vitest run",
21 | "test:watch": "vitest",
22 | "lint": "eslint src",
23 | "lint:fix": "eslint src --fix",
24 | "clean": "rimraf dist"
25 | },
26 | "repository": {
27 | "type": "git",
28 | "url": "https://github.com/chatsift/chatsift.git",
29 | "directory": "packages/npm/pino-rotate-file"
30 | },
31 | "bugs": {
32 | "url": "https://github.com/chatsift/chatsift/issues"
33 | },
34 | "homepage": "https://github.com/chatsift/chatsift",
35 | "devDependencies": {
36 | "@types/node": "^24.9.1",
37 | "sonic-boom": "^4.2.0",
38 | "tsup": "^8.5.0",
39 | "typescript": "~5.9.3",
40 | "vitest": "^2.1.9"
41 | },
42 | "dependencies": {
43 | "pino": "^9.14.0",
44 | "pino-abstract-transport": "^1.2.0",
45 | "pino-pretty": "^13.1.2",
46 | "tslib": "^2.8.1"
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/apps/website/src/components/icons/SvgAMA.tsx:
--------------------------------------------------------------------------------
1 | export function SvgAMA({ width, height }: { readonly height?: number; readonly width?: number }) {
2 | return (
3 |
4 |
10 |
14 |
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/packages/private/backend-core/src/lib/env.ts:
--------------------------------------------------------------------------------
1 | import process from 'node:process';
2 | import { SnowflakeRegex } from '@sapphire/discord-utilities';
3 | import z from 'zod';
4 |
5 | const envSchema = z.object({
6 | // General
7 | IS_PRODUCTION: z.stringbool().default(false),
8 | ROOT_DOMAIN: z.string(),
9 | ADMINS: z
10 | .string()
11 | .optional()
12 | .transform((value) => value?.split(', '))
13 | .pipe(z.array(z.string().regex(SnowflakeRegex)).optional())
14 | .transform((value) => (value ? new Set(value) : new Set())),
15 |
16 | // API
17 | API_PORT: z.string().pipe(z.coerce.number()),
18 | OAUTH_DISCORD_CLIENT_ID: z.string().regex(SnowflakeRegex),
19 | OAUTH_DISCORD_CLIENT_SECRET: z.string(),
20 | CORS: z.string().transform((value, ctx) => {
21 | try {
22 | return new RegExp(value);
23 | } catch {
24 | ctx.addIssue({
25 | code: 'custom',
26 | message: 'Not a valid regular expression',
27 | });
28 | return z.NEVER;
29 | }
30 | }),
31 | // Length of a base64-encoded 32-byte key. Used for JWT signing and encryption
32 | ENCRYPTION_KEY: z.string().length(44),
33 | API_URL_DEV: z.url(),
34 | API_URL_PROD: z.url(),
35 | FRONTEND_URL_DEV: z.url(),
36 | FRONTEND_URL_PROD: z.url(),
37 |
38 | // AMA
39 | AMA_BOT_TOKEN: z.string(),
40 | });
41 |
42 | export const ENV = envSchema.parse(process.env);
43 |
--------------------------------------------------------------------------------
/apps/website/src/app/dashboard/[id]/ama/page.tsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link';
2 | import { DashboardCrumbs } from '../../_components/DashboardCrumbs';
3 | import { Heading } from '@/components/common/Heading';
4 |
5 | export default async function AMAPage({ params }: PageProps<'/dashboard/[id]/ama/amas'>) {
6 | const { id } = await params;
7 |
8 | return (
9 |
10 |
11 |
12 |
13 |
18 | {/* TODO */}
19 |
20 | Q
21 |
22 |
23 |
Manage AMAs
24 |
Create, and manage AMAs in your community
25 |
26 |
27 |
28 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/services/api/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@chatsift/api",
3 | "private": true,
4 | "version": "1.0.0",
5 | "type": "module",
6 | "exports": {
7 | ".": "./dist/index.js"
8 | },
9 | "types": "./dist/index.d.ts",
10 | "scripts": {
11 | "build": "tsc",
12 | "test": "vitest run",
13 | "test:watch": "vitest",
14 | "lint": "eslint src",
15 | "lint:fix": "eslint src --fix",
16 | "clean": "rimraf dist",
17 | "tag-docker": "docker image tag chatsift/chatsift-next:latest chatsift/chatsift-next:api"
18 | },
19 | "devDependencies": {
20 | "@types/bcrypt": "^6.0.0",
21 | "@types/busboy": "^1.5.4",
22 | "@types/cors": "^2.8.19",
23 | "@types/jsonwebtoken": "^9.0.10",
24 | "@types/node": "^24.9.1",
25 | "kysely": "^0.28.8",
26 | "typescript": "~5.9.3",
27 | "vitest": "^3.2.4"
28 | },
29 | "dependencies": {
30 | "@chatsift/backend-core": "workspace:^",
31 | "@discordjs/core": "^3.0.0-dev.1759363313-f510b5ffa",
32 | "@discordjs/rest": "^3.0.0-dev.1759363313-f510b5ffa",
33 | "@hapi/boom": "^10.0.1",
34 | "@sapphire/discord-utilities": "^3.5.0",
35 | "bcrypt": "^6.0.0",
36 | "busboy": "^1.6.0",
37 | "cookie": "^1.0.2",
38 | "cors": "^2.8.5",
39 | "helmet": "^8.1.0",
40 | "jsonwebtoken": "^9.0.2",
41 | "nanoid": "^5.1.6",
42 | "polka": "^1.0.0-next.28",
43 | "sharp": "^0.34.4",
44 | "zod": "^4.1.12"
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/apps/website/src/app/dashboard/[id]/ama/amas/_components/IncludeEndedToggle.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { usePathname, useRouter, useSearchParams } from 'next/navigation';
4 | import { useCallback } from 'react';
5 | import { Button } from '@/components/common/Button';
6 |
7 | export function IncludeEndedToggle() {
8 | const searchParams = useSearchParams();
9 | const pathname = usePathname();
10 | const router = useRouter();
11 |
12 | const includeEnded = searchParams.get('include_ended') === 'true';
13 |
14 | const handleToggle = useCallback(() => {
15 | const params = new URLSearchParams(searchParams);
16 | if (includeEnded) {
17 | params.delete('include_ended');
18 | } else {
19 | params.set('include_ended', 'true');
20 | }
21 |
22 | const newUrl = params.toString() ? `${pathname}?${params.toString()}` : pathname;
23 | router.push(newUrl);
24 | }, [includeEnded, pathname, router, searchParams]);
25 |
26 | return (
27 |
38 | );
39 | }
40 |
--------------------------------------------------------------------------------
/.github/workflows/deploy.yml:
--------------------------------------------------------------------------------
1 | name: Deploy
2 |
3 | on:
4 | workflow_run:
5 | workflows:
6 | - 'Quality Check'
7 | branches:
8 | - main
9 | types:
10 | - completed
11 |
12 | jobs:
13 | deploy:
14 | name: Deploy
15 | runs-on: ubuntu-latest
16 | if: ${{ github.event.workflow_run.conclusion == 'success' }}
17 | env:
18 | TURBO_TEAM: ${{ vars.TURBO_TEAM }}
19 | TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
20 |
21 | steps:
22 | - name: Checkout repository
23 | uses: actions/checkout@v2
24 | with:
25 | fetch-depth: 2
26 |
27 | - name: Install node.js
28 | uses: actions/setup-node@v3
29 | with:
30 | node-version: 22
31 |
32 | - name: Set up Docker Buildx
33 | uses: docker/setup-buildx-action@v1
34 |
35 | - name: Login to DockerHub
36 | run: docker login -u ${{ secrets.DOCKERHUB_USERNAME }} -p ${{ secrets.DOCKERHUB_TOKEN }}
37 |
38 | - name: Install dependencies
39 | uses: ./actions/yarnCache
40 |
41 | - name: Build the images
42 | run: docker build -t chatsift/chatsift-next:latest -f ./Dockerfile .
43 |
44 | - name: Tag changes
45 | run: yarn turbo run tag-docker --filter '...[HEAD~1...HEAD~0]'
46 |
47 | - name: Push to DockerHub
48 | run: docker image push --all-tags chatsift/chatsift-next
49 |
--------------------------------------------------------------------------------
/apps/website/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import typographyPlugin from '@tailwindcss/typography';
2 | import type { Config } from 'tailwindcss';
3 | import tailwindAnimate from 'tailwindcss-animate';
4 |
5 | const config: Config = {
6 | content: [
7 | './src/pages/**/*.{js,ts,jsx,tsx,mdx}',
8 | './src/components/**/*.{js,ts,jsx,tsx,mdx}',
9 | './src/app/**/*.{js,ts,jsx,tsx,mdx}',
10 | ],
11 | darkMode: 'class',
12 | theme: {
13 | colors: {
14 | base: {
15 | DEFAULT: '#F1F2F5',
16 | dark: '#151519',
17 | },
18 | primary: {
19 | DEFAULT: '#1d274e',
20 | dark: '#F6F6FB',
21 | },
22 | secondary: {
23 | DEFAULT: 'rgba(29, 39, 78, 0.75)',
24 | dark: '#F6F6FBB2',
25 | },
26 | accent: '#ffffff',
27 | disabled: {
28 | DEFAULT: '#1E284F80',
29 | dark: '#F5F5FC66',
30 | },
31 | on: {
32 | primary: {
33 | DEFAULT: '#1E284F40',
34 | dark: '#F4F4FD33',
35 | },
36 | secondary: {
37 | DEFAULT: 'rgba(29, 39, 78, 0.15)',
38 | dark: '#F4F4FD1A',
39 | },
40 | tertiary: {
41 | DEFAULT: '#1E284F0D',
42 | dark: '#F4F4FD0D',
43 | },
44 | },
45 | misc: {
46 | accent: '#2f8fee',
47 | danger: '#ff5052',
48 | },
49 | card: {
50 | DEFAULT: '#FFFFFF',
51 | dark: '#1C1C21',
52 | },
53 | },
54 | },
55 | plugins: [typographyPlugin, tailwindAnimate],
56 | };
57 |
58 | export default config;
59 |
--------------------------------------------------------------------------------
/services/api/src/routes/auth/me.ts:
--------------------------------------------------------------------------------
1 | import type { NextHandler, Response } from 'polka';
2 | import type z from 'zod';
3 | import { isAuthed } from '../../middleware/isAuthed.js';
4 | import type { Me } from '../../util/me.js';
5 | import { fetchMe } from '../../util/me.js';
6 | import { queryWithFreshSchema } from '../../util/schemas.js';
7 | import type { TRequest } from '../route.js';
8 | import { Route, RouteMethod } from '../route.js';
9 |
10 | export type { Me, MeGuild } from '../../util/me.js';
11 |
12 | const querySchema = queryWithFreshSchema;
13 | export type GetAuthMeQuery = z.input;
14 |
15 | export default class GetAuthMe extends Route {
16 | public readonly info = {
17 | method: RouteMethod.get,
18 | path: '/v3/auth/me',
19 | } as const;
20 |
21 | public override readonly queryValidationSchema = querySchema;
22 |
23 | public override readonly middleware = [
24 | ...isAuthed({ fallthrough: false, isGlobalAdmin: false, isGuildManager: false }),
25 | ];
26 |
27 | public override async handle(req: TRequest, res: Response, next: NextHandler) {
28 | const { force_fresh } = req.query;
29 |
30 | const result: Me = await fetchMe(req.tokens!.access.discordAccessToken, force_fresh);
31 |
32 | res.statusCode = 200;
33 | res.setHeader('Content-Type', 'application/json');
34 | return res.end(JSON.stringify(result));
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/services/api/src/middleware/jsonParser.ts:
--------------------------------------------------------------------------------
1 | import { badData, badRequest } from '@hapi/boom';
2 | import type { NextHandler, Request, Response } from 'polka';
3 |
4 | declare module 'polka' {
5 | interface Request {
6 | /**
7 | * Raw (unparsed) JSON body of the request - present if `wantRaw` is set to `false` in {@link jsonParser}
8 | */
9 | rawBody?: string;
10 | }
11 | }
12 |
13 | /**
14 | * Creates a request handler that parses the request body as JSON
15 | *
16 | * @param wantRaw - Whether to set the value of {@link Request.rawBody}
17 | */
18 | export function jsonParser(wantRaw = false) {
19 | return async (req: Request, _: Response, next: NextHandler) => {
20 | if (!req.headers['content-type']?.startsWith('application/json')) {
21 | return next(badRequest('unexpected content type'));
22 | }
23 |
24 | req.setEncoding('utf8');
25 |
26 | try {
27 | let data = '';
28 | for await (const chunk of req) {
29 | data += chunk;
30 | }
31 |
32 | if (wantRaw) {
33 | req.rawBody = data;
34 | }
35 |
36 | if (data === '') {
37 | // eslint-disable-next-line n/callback-return
38 | await next();
39 | return;
40 | }
41 |
42 | req.body = JSON.parse(data) as unknown;
43 |
44 | // eslint-disable-next-line n/callback-return
45 | await next();
46 | } catch (error_) {
47 | const error = error_ as Error;
48 | return next(badData(error.message));
49 | }
50 | };
51 | }
52 |
--------------------------------------------------------------------------------
/apps/website/src/components/common/ScrollArea.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import * as ScrollAreaBase from '@radix-ui/react-scroll-area';
4 | import type { ReactNode } from 'react';
5 |
6 | interface ScrollAreaProps {
7 | readonly children: ReactNode;
8 | readonly className?: string;
9 | readonly rootClassName?: string;
10 | }
11 |
12 | export function ScrollArea({ children, className, rootClassName }: ScrollAreaProps) {
13 | return (
14 |
15 | {children}
16 |
20 |
21 |
22 |
26 |
27 |
28 |
29 |
30 | );
31 | }
32 |
--------------------------------------------------------------------------------
/apps/website/src/components/icons/channels/SvgChannelText.tsx:
--------------------------------------------------------------------------------
1 | interface SvgChannelTextProps {
2 | readonly className?: string;
3 | readonly size?: number;
4 | }
5 |
6 | export function SvgChannelText({ className, size = 20 }: SvgChannelTextProps) {
7 | return (
8 |
16 |
20 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/services/ama-bot/src/lib/client.ts:
--------------------------------------------------------------------------------
1 | import { setInterval } from 'node:timers';
2 | import { getContext, GuildList } from '@chatsift/backend-core';
3 | import type { Snowflake } from '@discordjs/core';
4 | import { InteractionType, Client, GatewayDispatchEvents } from '@discordjs/core';
5 | import { handleComponentInteraction } from './components.js';
6 | import { gateway } from './gateway.js';
7 | import { rest } from './rest.js';
8 |
9 | // keep a copy of the guild ids we manage here to easily patch redis
10 | const guildIds = new Set();
11 |
12 | export function startGuildSyncing(): void {
13 | setInterval(async () => {
14 | void GuildList.set('AMA', { guilds: [...guildIds] });
15 | }, 10_000).unref();
16 | }
17 |
18 | export const client = new Client({ rest, gateway });
19 |
20 | client
21 | .on(GatewayDispatchEvents.GuildCreate, ({ data: guild }) => {
22 | guildIds.add(guild.id);
23 | })
24 | .on(GatewayDispatchEvents.GuildDelete, ({ data: guild }) => {
25 | if (!guild.unavailable) {
26 | guildIds.delete(guild.id);
27 | }
28 | })
29 | .on(GatewayDispatchEvents.InteractionCreate, async ({ data: interaction }) => {
30 | if (interaction.type === InteractionType.MessageComponent) {
31 | await handleComponentInteraction(interaction);
32 | } else {
33 | getContext().logger.warn({ interactionType: interaction.type }, 'Unhandled interaction type');
34 | }
35 | })
36 | .once(GatewayDispatchEvents.Ready, () => {
37 | getContext().logger.info('Logged in successfully');
38 | });
39 |
--------------------------------------------------------------------------------
/apps/website/src/components/common/Avatar.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import * as AvatarPrimitive from '@radix-ui/react-avatar';
4 | import * as React from 'react';
5 | import { cn } from '@/utils/util';
6 |
7 | export const Avatar = React.forwardRef<
8 | React.ElementRef,
9 | React.ComponentPropsWithoutRef
10 | >(({ className, ...props }, ref) => (
11 |
16 | ));
17 | Avatar.displayName = AvatarPrimitive.Root.displayName;
18 |
19 | export const AvatarImage = React.forwardRef<
20 | React.ElementRef,
21 | React.ComponentPropsWithoutRef
22 | >(({ className, ...props }, ref) => (
23 |
24 | ));
25 | AvatarImage.displayName = AvatarPrimitive.Image.displayName;
26 |
27 | export const AvatarFallback = React.forwardRef<
28 | React.ElementRef,
29 | React.ComponentPropsWithoutRef
30 | >(({ className, ...props }, ref) => (
31 |
36 | ));
37 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
38 |
--------------------------------------------------------------------------------
/services/ama-bot/src/lib/gateway.ts:
--------------------------------------------------------------------------------
1 | import { getContext } from '@chatsift/backend-core';
2 | import type { RESTGetAPIGatewayBotResult } from '@discordjs/core';
3 | import { GatewayIntentBits, Routes } from '@discordjs/core';
4 | import { CompressionMethod, WebSocketManager, WebSocketShardEvents } from '@discordjs/ws';
5 | import { rest } from './rest.js';
6 |
7 | export const gateway = new WebSocketManager({
8 | token: getContext().env.AMA_BOT_TOKEN,
9 | intents: GatewayIntentBits.Guilds,
10 | fetchGatewayInformation: async () => rest.get(Routes.gatewayBot()) as Promise,
11 | compression: CompressionMethod.ZlibNative,
12 | });
13 |
14 | gateway
15 | .on(WebSocketShardEvents.Closed, (code, shardId) => getContext().logger.info({ shardId, code }, 'Shard CLOSED'))
16 | .on(WebSocketShardEvents.HeartbeatComplete, ({ ackAt, heartbeatAt, latency }, shardId) =>
17 | getContext().logger.debug({ shardId, ackAt, heartbeatAt, latency }, 'Shard HEARTBEAT'),
18 | )
19 | .on(WebSocketShardEvents.Error, (shardId, error) => getContext().logger.error({ shardId, error }, 'Shard ERROR'))
20 | .on(WebSocketShardEvents.Debug, (message, shardId) => getContext().logger.debug({ shardId }, message))
21 | .on(WebSocketShardEvents.Hello, (shardId) => getContext().logger.debug({ shardId }, 'Shard HELLO'))
22 | .on(WebSocketShardEvents.Ready, (data, shardId) => getContext().logger.debug({ data, shardId }, 'Shard READY'))
23 | .on(WebSocketShardEvents.Resumed, (shardId) => getContext().logger.debug({ shardId }, 'Shard RESUMED'));
24 |
--------------------------------------------------------------------------------
/apps/website/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import { HydrationBoundary } from '@tanstack/react-query';
2 | import type { Metadata } from 'next';
3 | import type { PropsWithChildren } from 'react';
4 | import { Providers } from '@/components/common/Providers';
5 | import { ScrollArea } from '@/components/common/ScrollArea';
6 | import { Footer } from '@/components/footer/Footer';
7 | import { Navbar } from '@/components/nav/Navbar';
8 | import { server } from '@/data/server';
9 |
10 | import '@/styles/globals.css';
11 |
12 | export const metadata: Metadata = {
13 | title: {
14 | template: '%s | ChatSift',
15 | default: 'ChatSift',
16 | },
17 | icons: {
18 | other: [{ rel: 'icon', url: '/assets/favicon.ico' }],
19 | },
20 | };
21 |
22 | export default async function RootLayout({ children }: PropsWithChildren) {
23 | return (
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 | {children}
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 | );
44 | }
45 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:22-alpine
2 | LABEL name="chatsift"
3 |
4 | WORKDIR /usr/chatsift
5 |
6 | RUN apk add --update \
7 | && apk add --no-cache ca-certificates \
8 | && apk add --no-cache --virtual .build-deps curl git python3 alpine-sdk
9 |
10 | COPY turbo.json package.json tsconfig.base.json tsconfig.json tsup.config.ts yarn.lock .yarnrc.yml ./
11 | COPY .yarn ./.yarn
12 |
13 | COPY packages/public/discord-utils/package.json ./packages/public/discord-utils/package.json
14 | COPY packages/public/parse-relative-time/package.json ./packages/public/parse-relative-time/package.json
15 | COPY packages/public/pino-rotate-file/package.json ./packages/public/pino-rotate-file/package.json
16 | COPY packages/private/backend-core/package.json ./packages/private/backend-core/package.json
17 | COPY packages/private/core/package.json ./packages/private/core/package.json
18 |
19 | COPY services/ama-bot/package.json ./services/ama-bot/package.json
20 | COPY services/api/package.json ./services/api/package.json
21 |
22 | RUN yarn workspaces focus --all
23 |
24 | COPY prisma ./prisma
25 |
26 | COPY packages/public/discord-utils ./packages/public/discord-utils
27 | COPY packages/public/parse-relative-time ./packages/public/parse-relative-time
28 | COPY packages/public/pino-rotate-file ./packages/public/pino-rotate-file
29 | COPY packages/private/backend-core ./packages/private/backend-core
30 | COPY packages/private/core ./packages/private/core
31 |
32 | COPY services/ama-bot ./services/ama-bot
33 | COPY services/api ./services/api
34 |
35 | RUN yarn turbo run build
36 | RUN yarn workspaces focus --all --production
37 |
--------------------------------------------------------------------------------
/actions/yarnCache/action.yml:
--------------------------------------------------------------------------------
1 | # Source: https://github.com/discordjs/discord.js/blob/7196fe36e8089dde7bcaf0db4dd09cf524125e0c/packages/actions/src/yarnCache/action.yml
2 |
3 | # Full credits to discord.js and its contributors, below is the original Apache 2.0 LICENSE file:
4 | # https://github.com/discordjs/discord.js/blob/7196fe36e8089dde7bcaf0db4dd09cf524125e0c/LICENSE
5 |
6 | name: 'yarn install'
7 | description: 'Run yarn install with node_modules linker and cache enabled'
8 | runs:
9 | using: 'composite'
10 | steps:
11 | - name: Expose yarn config as "$GITHUB_OUTPUT"
12 | id: yarn-config
13 | shell: bash
14 | run: |
15 | echo "CACHE_FOLDER=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT
16 |
17 | - name: Restore yarn cache
18 | uses: actions/cache@v3
19 | id: yarn-download-cache
20 | with:
21 | path: ${{ steps.yarn-config.outputs.CACHE_FOLDER }}
22 | key: yarn-download-cache-${{ hashFiles('yarn.lock') }}
23 | restore-keys: |
24 | yarn-download-cache-
25 |
26 | - name: Restore yarn install state
27 | id: yarn-install-state-cache
28 | uses: actions/cache@v3
29 | with:
30 | path: .yarn/ci-cache/
31 | key: ${{ runner.os }}-yarn-install-state-cache-${{ hashFiles('yarn.lock', '.yarnrc.yml') }}
32 |
33 | - name: Install dependencies
34 | shell: bash
35 | run: |
36 | yarn install --immutable --inline-builds
37 | env:
38 | YARN_ENABLE_GLOBAL_CACHE: 'false'
39 | YARN_NM_MODE: 'hardlinks-local'
40 | YARN_INSTALL_STATE_PATH: .yarn/ci-cache/install-state.gz
41 |
--------------------------------------------------------------------------------
/apps/website/src/components/common/Providers.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import type { QueryClientConfig } from '@tanstack/react-query';
4 | import { isServer, QueryCache, QueryClient, QueryClientProvider } from '@tanstack/react-query';
5 | import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
6 | import { Provider as JotaiProvider } from 'jotai';
7 | import { ThemeProvider } from 'next-themes';
8 | import type { PropsWithChildren } from 'react';
9 | import { APIError } from '@/utils/fetcher';
10 |
11 | let browserQueryClient: QueryClient | undefined;
12 |
13 | function getQueryClient() {
14 | const base: QueryClientConfig = {
15 | defaultOptions: {
16 | queries: {
17 | staleTime: 60 * 1_000,
18 | },
19 | },
20 | };
21 |
22 | if (isServer) {
23 | return new QueryClient(base);
24 | }
25 |
26 | return (browserQueryClient ??= new QueryClient({
27 | ...base,
28 | queryCache: new QueryCache({
29 | // TODO: Handle in some way
30 | onError: (error) => {
31 | if (error instanceof APIError) {
32 | console.error('Query error:', error.payload);
33 | } else {
34 | console.error('Network Error:', error);
35 | }
36 | },
37 | }),
38 | }));
39 | }
40 |
41 | export function Providers({ children }: PropsWithChildren) {
42 | const queryClient = getQueryClient();
43 |
44 | return (
45 |
46 |
47 | {children}
48 |
49 |
50 |
51 | );
52 | }
53 |
--------------------------------------------------------------------------------
/services/api/src/routes/_types/index.ts:
--------------------------------------------------------------------------------
1 | import type { InferRouteBodyOrQuery, InferRouteMethod, InferRouteResult, RouteMethod } from '../route.js';
2 | import type * as routes from '../routes.js';
3 |
4 | type Narrow = Narrowed extends Narowee ? Narrowed : never;
5 | type ConstructorToType = TConstructor extends new (...args: any[]) => infer T ? T : never;
6 | type RoutesByClass = {
7 | [K in keyof typeof routes]: ConstructorToType<(typeof routes)[K]>;
8 | };
9 | type RoutesByPath = {
10 | [Path in RoutesByClass[keyof RoutesByClass]['info']['path']]: Narrow<
11 | RoutesByClass[keyof RoutesByClass],
12 | { info: { path: Path } }
13 | >;
14 | };
15 |
16 | interface RouteMethodMap {
17 | [RouteMethod.get]: 'GET';
18 | [RouteMethod.post]: 'POST';
19 | [RouteMethod.put]: 'PUT';
20 | [RouteMethod.delete]: 'DELETE';
21 | [RouteMethod.patch]: 'PATCH';
22 | }
23 |
24 | export type { ParseHTTPParameters } from '../route.js';
25 |
26 | export type APIRoutes = {
27 | [Path in keyof RoutesByPath]: {
28 | [Method in RouteMethodMap[InferRouteMethod]]: Narrow<
29 | RoutesByPath[Path],
30 | { info: { method: Lowercase } }
31 | >;
32 | };
33 | };
34 |
35 | // TODO: Look into Date -> string
36 |
37 | export type InferAPIRouteBodyOrQuery<
38 | TPath extends keyof APIRoutes,
39 | TMethod extends keyof APIRoutes[TPath],
40 | > = InferRouteBodyOrQuery;
41 |
42 | export type InferAPIRouteResult<
43 | TPath extends keyof APIRoutes,
44 | TMethod extends keyof APIRoutes[TPath],
45 | > = InferRouteResult;
46 |
47 | export type * from './routeTypes.js';
48 |
--------------------------------------------------------------------------------
/apps/website/src/components/icons/channels/SvgChannelThread.tsx:
--------------------------------------------------------------------------------
1 | interface SvgChannelThreadProps {
2 | readonly className?: string;
3 | readonly size?: number;
4 | }
5 |
6 | export function SvgChannelThread({ className, size = 20 }: SvgChannelThreadProps) {
7 | return (
8 |
16 |
20 |
21 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/apps/website/src/app/dashboard/[id]/ama/amas/new/_components/RawPromptField.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from '@/components/common/Button';
2 |
3 | interface RawPromptFieldProps {
4 | onFormatClick(): void;
5 | onPaste(e: React.ClipboardEvent): void;
6 | onValueChange(value: string): void;
7 | readonly value: string;
8 | }
9 |
10 | export function RawPromptField({ value, onValueChange, onFormatClick, onPaste }: RawPromptFieldProps) {
11 | return (
12 |
13 |
14 |
17 |
23 |
24 |
37 | );
38 | }
39 |
--------------------------------------------------------------------------------
/packages/private/core/src/types/entities.ts:
--------------------------------------------------------------------------------
1 | import type { ColumnType } from "kysely";
2 | export type Generated = T extends ColumnType
3 | ? ColumnType
4 | : ColumnType;
5 | export type Timestamp = ColumnType;
6 |
7 | export type AMAPromptData = {
8 | id: Generated;
9 | amaId: number;
10 | promptMessageId: string;
11 | promptJSONData: string;
12 | };
13 | export type AMAQuestion = {
14 | id: Generated;
15 | amaId: number;
16 | authorId: string;
17 | };
18 | export type AMASession = {
19 | id: Generated;
20 | guildId: string;
21 | modQueueId: string | null;
22 | flaggedQueueId: string | null;
23 | guestQueueId: string | null;
24 | title: string;
25 | answersChannelId: string;
26 | promptChannelId: string;
27 | ended: Generated;
28 | createdAt: Generated;
29 | };
30 | export type DashboardGrant = {
31 | id: Generated;
32 | guildId: string;
33 | userId: string;
34 | createdById: string;
35 | };
36 | export type Experiment = {
37 | name: string;
38 | createdAt: Generated;
39 | updatedAt: Timestamp | null;
40 | rangeStart: number;
41 | rangeEnd: number;
42 | };
43 | export type ExperimentOverride = {
44 | id: Generated;
45 | guildId: string;
46 | experimentName: string;
47 | };
48 | export type DB = {
49 | AMAPromptData: AMAPromptData;
50 | AMAQuestion: AMAQuestion;
51 | AMASession: AMASession;
52 | DashboardGrant: DashboardGrant;
53 | Experiment: Experiment;
54 | ExperimentOverride: ExperimentOverride;
55 | };
56 |
--------------------------------------------------------------------------------
/apps/website/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/package.json",
3 | "name": "@chatsift/website",
4 | "version": "0.1.0",
5 | "description": "ChatSift primary website",
6 | "private": true,
7 | "scripts": {
8 | "build": "next build",
9 | "dev": "next dev",
10 | "start": "next start",
11 | "lint": "eslint src",
12 | "lint:fix": "eslint src --fix"
13 | },
14 | "dependencies": {
15 | "@chatsift/core": "workspace:^",
16 | "@chatsift/discord-utils": "workspace:^",
17 | "@radix-ui/react-avatar": "^1.1.10",
18 | "@radix-ui/react-dropdown-menu": "^2.1.16",
19 | "@radix-ui/react-navigation-menu": "^1.2.14",
20 | "@radix-ui/react-scroll-area": "^1.2.10",
21 | "@tanstack/react-query": "^5.90.5",
22 | "@tanstack/react-query-devtools": "^5.90.2",
23 | "babel-plugin-react-compiler": "^19.1.0-rc.3",
24 | "clsx": "^2.1.1",
25 | "cookie": "^1.0.2",
26 | "jotai": "^2.15.0",
27 | "luxon": "^3.7.2",
28 | "next": "^15.5.6",
29 | "next-themes": "^0.4.6",
30 | "react": "^18.3.1",
31 | "react-aria-components": "^1.13.0",
32 | "react-dom": "^18.3.1",
33 | "react-icons": "^5.5.0",
34 | "tailwind-merge": "^3.3.1",
35 | "usehooks-ts": "^3.1.1"
36 | },
37 | "devDependencies": {
38 | "@chatsift/api": "workspace:^",
39 | "@tailwindcss/typography": "^0.5.19",
40 | "@types/luxon": "^3.7.1",
41 | "@types/node": "^24.9.1",
42 | "@types/react": "^18.3.26",
43 | "@types/react-dom": "^18.3.7",
44 | "autoprefixer": "^10.4.21",
45 | "discord-api-types": "^0.38.30",
46 | "eslint": "^9.35.0",
47 | "eslint-config-next": "^14.2.32",
48 | "postcss": "^8.5.6",
49 | "tailwindcss": "^3.4.18",
50 | "tailwindcss-animate": "^1.0.7",
51 | "typescript": "~5.9.3"
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/apps/website/src/components/common/GuildIcon.tsx:
--------------------------------------------------------------------------------
1 | import type { MeGuild } from '@chatsift/api';
2 | import Image from 'next/image';
3 | import Link from 'next/link';
4 | import type { PropsWithChildren } from 'react';
5 | import { getGuildAcronym } from '@/utils/util';
6 |
7 | export interface GuildIconProps {
8 | readonly data: MeGuild;
9 | readonly disableLink?: boolean;
10 | readonly hasBots: boolean;
11 | }
12 |
13 | interface ParentProps extends PropsWithChildren {
14 | readonly disableLink: boolean | undefined;
15 | readonly url: string | undefined;
16 | }
17 |
18 | function Parent({ children, disableLink, url }: ParentProps) {
19 | if (disableLink || !url) {
20 | return <>{children}>;
21 | }
22 |
23 | return {children};
24 | }
25 |
26 | export function GuildIcon({ data, hasBots, disableLink }: GuildIconProps) {
27 | const icon = data?.icon ? `https://cdn.discordapp.com/icons/${data.id}/${data.icon}.png` : null;
28 | const url = hasBots ? `/dashboard/${data.id}` : undefined;
29 |
30 | return (
31 |
32 |
33 | {icon ? (
34 |
41 | ) : (
42 |
43 | {getGuildAcronym(data.name)}
44 |
45 | )}
46 |
47 |
48 | );
49 | }
50 |
--------------------------------------------------------------------------------
/apps/website/src/components/icons/channels/SvgChannelCategory.tsx:
--------------------------------------------------------------------------------
1 | interface SvgChannelCategoryProps {
2 | readonly className?: string;
3 | readonly size?: number;
4 | }
5 |
6 | export function SvgChannelCategory({ className, size = 20 }: SvgChannelCategoryProps) {
7 | return (
8 |
16 |
20 |
24 |
29 |
33 |
37 |
42 |
43 |
44 | );
45 | }
46 |
--------------------------------------------------------------------------------
/services/api/src/routes/guilds/deleteGrant.ts:
--------------------------------------------------------------------------------
1 | import { getContext } from '@chatsift/backend-core';
2 | import { notFound } from '@hapi/boom';
3 | import type { NextHandler, Response } from 'polka';
4 | import { z } from 'zod';
5 | import { isAuthed } from '../../middleware/isAuthed.js';
6 | import { snowflakeSchema } from '../../util/schemas.js';
7 | import type { TRequest } from '../route.js';
8 | import { Route, RouteMethod } from '../route.js';
9 |
10 | const bodySchema = z.strictObject({
11 | userId: snowflakeSchema,
12 | });
13 |
14 | export type DeleteGrantBody = z.input;
15 |
16 | export default class DeleteGrant extends Route {
17 | public readonly info = {
18 | method: RouteMethod.delete,
19 | path: '/v3/guilds/:guildId/grants',
20 | } as const;
21 |
22 | public override readonly bodyValidationSchema = bodySchema;
23 |
24 | public override readonly middleware = [
25 | ...isAuthed({ fallthrough: false, isGlobalAdmin: false, isGuildManager: true }),
26 | ];
27 |
28 | public override async handle(req: TRequest, res: Response, next: NextHandler) {
29 | const { userId } = req.body;
30 | const { guildId } = req.params as { guildId: string };
31 |
32 | // Check if the grant exists
33 | const existingGrant = await getContext()
34 | .db.selectFrom('DashboardGrant')
35 | .select('id')
36 | .where('guildId', '=', guildId)
37 | .where('userId', '=', userId)
38 | .executeTakeFirst();
39 |
40 | if (!existingGrant) {
41 | return next(notFound('grant not found for this user'));
42 | }
43 |
44 | // Delete the grant
45 | await getContext()
46 | .db.deleteFrom('DashboardGrant')
47 | .where('guildId', '=', guildId)
48 | .where('userId', '=', userId)
49 | .execute();
50 |
51 | res.statusCode = 200;
52 | return res.end();
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/services/api/src/routes/guilds/getGrants.ts:
--------------------------------------------------------------------------------
1 | import { getContext } from '@chatsift/backend-core';
2 | import type { APIUser, Snowflake } from '@discordjs/core';
3 | import { DiscordAPIError } from '@discordjs/rest';
4 | import type { NextHandler, Response } from 'polka';
5 | import { isAuthed } from '../../middleware/isAuthed.js';
6 | import { roundRobinAPI } from '../../util/discordAPI.js';
7 | import type { TRequest } from '../route.js';
8 | import { Route, RouteMethod } from '../route.js';
9 |
10 | export interface GetGrantsResult {
11 | users: (APIUser | Snowflake)[];
12 | }
13 |
14 | export default class GetGrants extends Route {
15 | public readonly info = {
16 | method: RouteMethod.get,
17 | path: '/v3/guilds/:guildId/grants',
18 | } as const;
19 |
20 | public override readonly middleware = [
21 | ...isAuthed({ fallthrough: false, isGlobalAdmin: false, isGuildManager: true }),
22 | ];
23 |
24 | public override async handle(req: TRequest, res: Response, next: NextHandler) {
25 | const { guildId } = req.params as { guildId: string };
26 |
27 | const grants = await getContext()
28 | .db.selectFrom('DashboardGrant')
29 | .select('userId')
30 | .where('guildId', '=', guildId)
31 | .execute();
32 |
33 | const api = roundRobinAPI(req.guild!);
34 | const users = await Promise.all(
35 | grants.map(async ({ userId }) => {
36 | try {
37 | return await api.users.get(userId);
38 | } catch (error) {
39 | if (error instanceof DiscordAPIError && error.status === 404) {
40 | return userId;
41 | }
42 |
43 | throw error;
44 | }
45 | }),
46 | );
47 |
48 | const result: GetGrantsResult = {
49 | users,
50 | };
51 |
52 | res.statusCode = 200;
53 | res.setHeader('Content-Type', 'application/json');
54 | return res.end(JSON.stringify(result));
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/packages/public/parse-relative-time/src/__tests__/index.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, test } from 'vitest';
2 | import { parseRelativeTime } from '../index';
3 |
4 | test('empty input', () => {
5 | const result = parseRelativeTime('');
6 | expect(result).toEqual(0);
7 | });
8 |
9 | test('simple ms', () => {
10 | const result = parseRelativeTime('100ms');
11 | expect(result).toEqual(100);
12 | });
13 |
14 | test('single unit', () => {
15 | const result = parseRelativeTime('1m');
16 | expect(result).toEqual(60_000);
17 | });
18 |
19 | test('multiple units', () => {
20 | const result = parseRelativeTime('1d 2h 3m 4s 5ms');
21 | expect(result).toEqual(93_784_005);
22 | });
23 |
24 | test('multiple units without spaces', () => {
25 | const result = parseRelativeTime('1d2h3m4s5ms');
26 | expect(result).toEqual(93_784_005);
27 | });
28 |
29 | test('unknown unit', () => {
30 | expect(() => parseRelativeTime('1x')).toThrow('Unknown time unit "x"');
31 | });
32 |
33 | test('no number to parse', () => {
34 | expect(() => parseRelativeTime('s')).toThrow('There was no number associated with one of the units.');
35 | });
36 |
37 | test('empty chunk', () => {
38 | const result = parseRelativeTime('1s ');
39 | expect(result).toEqual(1_000);
40 | });
41 |
42 | test('plural unit', () => {
43 | const result = parseRelativeTime('2weeks');
44 | expect(result).toEqual(1_209_600_000);
45 | });
46 |
47 | test('alias unit', () => {
48 | const result = parseRelativeTime('1hr');
49 | expect(result).toEqual(3_600_000);
50 | });
51 |
52 | test('alias unit with plural', () => {
53 | const result = parseRelativeTime('2mos');
54 | expect(result).toEqual(4_838_400_000);
55 | });
56 |
57 | test('longer input with inconsistent spacing and implicit ms', () => {
58 | const result = parseRelativeTime('1d 2h3m 4s 5ms 6');
59 | expect(result).toEqual(94_144_005);
60 | });
61 |
--------------------------------------------------------------------------------
/services/api/src/routes/auth/discord.ts:
--------------------------------------------------------------------------------
1 | import { URLSearchParams } from 'node:url';
2 | import { getContext } from '@chatsift/backend-core';
3 | import type { RESTOAuth2AuthorizationQuery } from '@discordjs/core';
4 | import type { NextHandler, Response } from 'polka';
5 | import { isAuthed } from '../../middleware/isAuthed.js';
6 | import { cookieWithDomain } from '../../util/constants.js';
7 | import { StateCookie } from '../../util/stateCookie.js';
8 | import type { TRequest } from '../route.js';
9 | import { Route, RouteMethod } from '../route.js';
10 |
11 | export const DISCORD_AUTH_SCOPES = new Set(['identify', 'email', 'guilds', 'guilds.members.read'] as const);
12 |
13 | export default class GetAuthDiscord extends Route {
14 | public readonly info = {
15 | method: RouteMethod.get,
16 | path: '/v3/auth/discord',
17 | } as const;
18 |
19 | public override readonly middleware = [...isAuthed({ fallthrough: true, isGlobalAdmin: false })];
20 |
21 | public override async handle(req: TRequest, res: Response, next: NextHandler) {
22 | if (req.tokens) {
23 | res.redirect(getContext().FRONTEND_URL);
24 | return res.end();
25 | }
26 |
27 | const state = new StateCookie(`${getContext().FRONTEND_URL}/dashboard`).toCookie();
28 | res.cookie(
29 | 'state',
30 | state,
31 | cookieWithDomain({
32 | httpOnly: true,
33 | path: '/',
34 | sameSite: 'lax',
35 | secure: getContext().env.IS_PRODUCTION,
36 | maxAge: 10 * 60 * 1_000,
37 | }),
38 | );
39 |
40 | const params = {
41 | client_id: getContext().env.OAUTH_DISCORD_CLIENT_ID,
42 | redirect_uri: `${getContext().API_URL}/v3/auth/discord/callback`,
43 | response_type: 'code',
44 | scope: [...DISCORD_AUTH_SCOPES].join(' '),
45 | state,
46 | } satisfies RESTOAuth2AuthorizationQuery;
47 |
48 | res.redirect(`https://discord.com/oauth2/authorize?${new URLSearchParams(params).toString()}`);
49 | return res.end();
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/services/api/src/routes/guilds/get.ts:
--------------------------------------------------------------------------------
1 | import { BOTS } from '@chatsift/backend-core';
2 | import { notFound } from '@hapi/boom';
3 | import type { NextHandler, Response } from 'polka';
4 | import z from 'zod';
5 | import { isAuthed } from '../../middleware/isAuthed.js';
6 | import { fetchGuildChannels, type GuildChannelInfo } from '../../util/channels.js';
7 | import { APIMapping } from '../../util/discordAPI.js';
8 | import { queryWithFreshSchema } from '../../util/schemas.js';
9 | import type { TRequest } from '../route.js';
10 | import { Route, RouteMethod } from '../route.js';
11 |
12 | export type { GuildChannelInfo, PossiblyMissingChannelInfo } from '../../util/channels.js';
13 |
14 | const querySchema = queryWithFreshSchema.safeExtend({
15 | for_bot: z.enum(BOTS),
16 | });
17 | export type GetGuildQuery = z.input;
18 |
19 | export interface GetGuildResult {
20 | channels: GuildChannelInfo[];
21 | }
22 |
23 | export default class GetGuild extends Route {
24 | public readonly info = {
25 | method: RouteMethod.get,
26 | path: '/v3/guilds/:guildId',
27 | } as const;
28 |
29 | public override readonly queryValidationSchema = querySchema;
30 |
31 | public override readonly middleware = [
32 | ...isAuthed({ fallthrough: false, isGlobalAdmin: false, isGuildManager: true }),
33 | ];
34 |
35 | public override async handle(req: TRequest, res: Response, next: NextHandler) {
36 | const { guildId } = req.params as { guildId: string };
37 | const { force_fresh, for_bot } = req.query;
38 |
39 | const channels = await fetchGuildChannels(guildId, APIMapping[for_bot], force_fresh);
40 | if (!channels) {
41 | return next(notFound('guild not found or bot not in guild'));
42 | }
43 |
44 | const result: GetGuildResult = {
45 | channels,
46 | };
47 |
48 | res.statusCode = 200;
49 | res.setHeader('Content-Type', 'application/json');
50 | return res.end(JSON.stringify(result));
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/apps/website/src/app/dashboard/_components/GuildCard.tsx:
--------------------------------------------------------------------------------
1 | import type { MeGuild } from '@chatsift/api';
2 | import Link from 'next/link';
3 | import { GuildIcon } from '@/components/common/GuildIcon';
4 | import { SvgAMA } from '@/components/icons/SvgAMA';
5 | import { cn } from '@/utils/util';
6 |
7 | interface GuildCardProps {
8 | readonly data: MeGuild;
9 | }
10 |
11 | export default function GuildCard({ data }: GuildCardProps) {
12 | const hasBots = data.bots.length > 0;
13 | const url = hasBots ? `/dashboard/${data.id}` : undefined;
14 |
15 | return (
16 |
22 |
23 |
24 |
25 | {url ? (
26 |
27 | {data.name}
28 |
29 | ) : (
30 | data.name
31 | )}
32 |
33 |
34 | {hasBots ? (
35 |
36 | {data.bots.includes('AMA') && (
37 | -
38 |
39 |
40 | )}
41 |
42 | ) : (
43 | <>
44 |
45 | Not invited
46 |
47 |
48 |
Invite a bot:
49 |
56 |
57 | >
58 | )}
59 |
60 |
61 | );
62 | }
63 |
--------------------------------------------------------------------------------
/prisma/schema.prisma:
--------------------------------------------------------------------------------
1 | generator kysely {
2 | provider = "prisma-kysely"
3 | output = "../packages/private/core/src/types"
4 | fileName = "entities.ts"
5 | }
6 |
7 | datasource db {
8 | provider = "postgresql"
9 | url = env("PRISMA_DATABASE_URL")
10 | }
11 |
12 | // SECTION general
13 |
14 | model Experiment {
15 | name String @id
16 | createdAt DateTime @default(now())
17 | updatedAt DateTime?
18 | rangeStart Int
19 | rangeEnd Int
20 | overrides ExperimentOverride[]
21 | }
22 |
23 | model ExperimentOverride {
24 | id Int @id @default(autoincrement())
25 | guildId String
26 | experimentName String
27 | experiment Experiment @relation(fields: [experimentName], references: [name], onDelete: Cascade)
28 |
29 | @@unique([guildId, experimentName])
30 | }
31 |
32 | model DashboardGrant {
33 | id Int @id @default(autoincrement())
34 | guildId String
35 | userId String
36 | createdById String
37 |
38 | @@unique([guildId, userId])
39 | }
40 |
41 | // SECTION ama bot
42 |
43 | model AMASession {
44 | id Int @id @default(autoincrement())
45 | guildId String
46 | modQueueId String?
47 | flaggedQueueId String?
48 | guestQueueId String?
49 | title String
50 | answersChannelId String
51 | promptChannelId String
52 | ended Boolean @default(false)
53 | createdAt DateTime @default(now())
54 |
55 | questions AMAQuestion[]
56 | promptData AMAPromptData?
57 | }
58 |
59 | model AMAPromptData {
60 | id Int @id @default(autoincrement())
61 | amaId Int @unique
62 | ama AMASession @relation(fields: [amaId], references: [id], onDelete: Cascade)
63 | promptMessageId String @unique
64 | promptJSONData String
65 | }
66 |
67 | model AMAQuestion {
68 | id Int @id @default(autoincrement())
69 | amaId Int
70 | ama AMASession @relation(fields: [amaId], references: [id], onDelete: Cascade)
71 | authorId String
72 | }
73 |
--------------------------------------------------------------------------------
/apps/website/src/components/icons/SvgDiscord.tsx:
--------------------------------------------------------------------------------
1 | export default function SvgDiscord() {
2 | return (
3 |
4 |
8 |
9 | );
10 | }
11 |
--------------------------------------------------------------------------------
/apps/website/src/app/dashboard/[id]/settings/_components/AddGrantCard.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useState } from 'react';
4 | import { SnowflakeInput } from '../../ama/amas/new/_components/SnowflakeInput';
5 | import { Button } from '@/components/common/Button';
6 | import { client } from '@/data/client';
7 | import { APIError } from '@/utils/fetcher';
8 |
9 | interface AddGrantCardProps {
10 | readonly guildId: string;
11 | }
12 |
13 | export function AddGrantCard({ guildId }: AddGrantCardProps) {
14 | const [userId, setUserId] = useState('');
15 | const [error, setError] = useState(null);
16 | const createGrant = client.guilds.grants.useCreateGrant(guildId);
17 |
18 | const handleSubmit = async () => {
19 | if (!userId.trim()) {
20 | setError('User ID cannot be empty');
21 | return;
22 | }
23 |
24 | setError(null);
25 |
26 | try {
27 | await createGrant.mutateAsync({ userId: userId.trim() });
28 | setUserId('');
29 | } catch (error) {
30 | if (error instanceof APIError) {
31 | if (error.payload.statusCode === 404) {
32 | setError('User not found');
33 | } else if (error.payload.statusCode === 400) {
34 | setError('Grant already exists for this user');
35 | } else if (error.payload.statusCode === 422) {
36 | setError('Invalid User ID');
37 | }
38 |
39 | return;
40 | }
41 |
42 | setError('Failed to add grant');
43 | console.error('Failed to add grant', error);
44 | }
45 | };
46 |
47 | return (
48 |
49 |
{
54 | setUserId(value);
55 | setError(null);
56 | }}
57 | placeholder="Enter user ID..."
58 | value={userId}
59 | />
60 |
61 |
64 |
65 |
66 | );
67 | }
68 |
--------------------------------------------------------------------------------
/packages/private/backend-core/src/lib/data/_store.ts:
--------------------------------------------------------------------------------
1 | import { getContext } from '../context.js';
2 | import type { IEntity } from './_entity.js';
3 |
4 | export class RedisStore {
5 | public constructor(private readonly entity: IEntity) {}
6 |
7 | public async has(id: KeyType): Promise {
8 | return Boolean(await getContext().redis.exists(this.entity.makeKey(id)));
9 | }
10 |
11 | public async get(id: KeyType): Promise {
12 | const key = this.entity.makeKey(id);
13 | const raw = await getContext().redis.get(key);
14 |
15 | if (!raw) {
16 | return null;
17 | }
18 |
19 | if (this.entity.TTL) {
20 | await getContext().redis.pExpire(key, this.entity.TTL);
21 | }
22 |
23 | return this.entity.recipe.decode(raw);
24 | }
25 |
26 | public async getOld(id: KeyType): Promise {
27 | if (!this.entity.storeOld) {
28 | throw new Error('Old value storage is not enabled for this entity.');
29 | }
30 |
31 | const key = `old:${this.entity.makeKey(id)}`;
32 | const raw = await getContext().redis.get(key);
33 |
34 | if (!raw) {
35 | return null;
36 | }
37 |
38 | if (this.entity.TTL) {
39 | await getContext().redis.pExpire(key, this.entity.TTL);
40 | }
41 |
42 | return this.entity.recipe.decode(raw);
43 | }
44 |
45 | public async set(id: KeyType, value: ValueType): Promise {
46 | const key = this.entity.makeKey(id);
47 | if (this.entity.storeOld && (await getContext().redis.exists(key))) {
48 | await getContext().redis.rename(key, `old:${key}`);
49 | if (this.entity.TTL) {
50 | await getContext().redis.pExpire(`old:${key}`, this.entity.TTL);
51 | }
52 | }
53 |
54 | const raw = this.entity.recipe.encode(value);
55 | if (this.entity.TTL) {
56 | await getContext().redis.set(key, raw, { expiration: { type: 'PX', value: this.entity.TTL } });
57 | } else {
58 | await getContext().redis.set(key, raw);
59 | }
60 | }
61 |
62 | public async delete(id: KeyType) {
63 | const key = this.entity.makeKey(id);
64 | await getContext().redis.del([key, `old:${key}`]);
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | name: chatsift-v3
2 |
3 | services:
4 | # caddy:
5 | # build:
6 | # context: ./build/caddy
7 | # dockerfile: ./Dockerfile
8 | # env_file:
9 | # - ./.env.private
10 | # ports:
11 | # - '80:80'
12 | # - '443:443'
13 |
14 | postgres:
15 | image: postgres:17-alpine
16 | environment:
17 | POSTGRES_USER: 'chatsift'
18 | POSTGRES_PASSWORD: 'admin'
19 | POSTGRES_DB: 'chatsift'
20 | volumes:
21 | - postgres-data:/var/lib/postgresql/data
22 | restart: unless-stopped
23 | env_file:
24 | - ./.env.public
25 | - ./.env.private
26 | ports:
27 | - 127.0.0.1:${LOCAL_DATABASE_PORT}:5432
28 | healthcheck:
29 | test: ['CMD-SHELL', 'pg_isready -U chatsift']
30 | interval: 10s
31 | timeout: 5s
32 |
33 | dozzle:
34 | image: amir20/dozzle:latest
35 | volumes:
36 | - /var/run/docker.sock:/var/run/docker.sock
37 | restart: unless-stopped
38 | environment:
39 | DOZZLE_ENABLE_ACTIONS: true
40 | ports:
41 | - 127.0.0.1:${LOCAL_DOZZLE_PORT}:8080
42 |
43 | redis:
44 | image: redis:6-alpine
45 | restart: unless-stopped
46 | healthcheck:
47 | test: ['CMD-SHELL', 'redis-cli ping']
48 | interval: 10s
49 | timeout: 5s
50 |
51 | api:
52 | image: chatsift/chatsift-next:api
53 | build:
54 | context: ./
55 | dockerfile: ./Dockerfile
56 | restart: unless-stopped
57 | env_file:
58 | - ./.env.public
59 | - ./.env.private
60 | command: ['node', '--enable-source-maps', './services/api/dist/bin.js']
61 | ports:
62 | - 127.0.0.1:${API_PORT}:${API_PORT}
63 |
64 | ama-bot:
65 | image: chatsift/chatsift-next:ama
66 | build:
67 | context: ./
68 | dockerfile: ./Dockerfile
69 | restart: unless-stopped
70 | env_file:
71 | - ./.env.public
72 | - ./.env.private
73 | command: ['node', '--enable-source-maps', './services/ama-bot/dist/bin.js']
74 |
75 | volumes:
76 | postgres-data:
77 | name: 'chatsift-v3-postgres-data'
78 |
79 | networks:
80 | default:
81 | name: chatsift-v3
82 |
--------------------------------------------------------------------------------
/apps/website/src/app/dashboard/[id]/settings/_components/GrantCard.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import type { APIUser, Snowflake } from '@discordjs/core';
4 | import { Button } from '@/components/common/Button';
5 | import { UserAvatar } from '@/components/user/UserAvatar';
6 | import { client } from '@/data/client';
7 |
8 | interface GrantCardProps {
9 | readonly guildId: string;
10 | readonly isLoading: boolean;
11 | readonly user: APIUser | Snowflake;
12 | }
13 |
14 | export function GrantCard({ guildId, user, isLoading }: GrantCardProps) {
15 | const deleteGrant = client.guilds.grants.useDeleteGrant(guildId);
16 |
17 | const handleRemove = async () => {
18 | const userId = typeof user === 'string' ? user : user.id;
19 | await deleteGrant.mutateAsync({ userId });
20 | };
21 |
22 | const isUserObject = typeof user !== 'string';
23 | const userId = typeof user === 'string' ? user : user.id;
24 | const username = isUserObject
25 | ? `${user.username}${user.discriminator === '0' ? '' : `#${user.discriminator}`}`
26 | : userId;
27 | const globalName = isUserObject && user.global_name ? user.global_name : undefined;
28 |
29 | return (
30 |
31 |
32 | {(isLoading || user) && (
33 |
34 | )}
35 |
36 | {globalName && (
37 |
38 | {globalName}
39 |
40 | )}
41 |
42 | {username}
43 |
44 |
45 |
46 |
47 |
50 |
51 |
52 | );
53 | }
54 |
--------------------------------------------------------------------------------
/packages/public/discord-utils/src/__tests__/embed.test.ts:
--------------------------------------------------------------------------------
1 | import type { APIEmbed, APIEmbedField } from 'discord-api-types/v10';
2 | import { describe, test, expect } from 'vitest';
3 | import { addFields, ellipsis, MESSAGE_LIMITS, truncateEmbed } from '../embed.js';
4 |
5 | describe('addFields', () => {
6 | test('no existing fields', () => {
7 | const embed: APIEmbed = {};
8 |
9 | const field: APIEmbedField = { name: 'foo', value: 'bar' };
10 | expect(addFields(embed, field)).toStrictEqual({ ...embed, fields: [field] });
11 | });
12 |
13 | test('existing fields', () => {
14 | const field: APIEmbedField = { name: 'foo', value: 'bar' };
15 | const embed: APIEmbed = { fields: [field] };
16 |
17 | expect(addFields(embed, field)).toStrictEqual({ ...embed, fields: [field, field] });
18 | });
19 | });
20 |
21 | describe('ellipsis', () => {
22 | test('no ellipsis', () => {
23 | expect(ellipsis('foo', 5)).toBe('foo');
24 | });
25 |
26 | test('ellipsis', () => {
27 | expect(ellipsis('foobar', 4)).toBe('f...');
28 | });
29 |
30 | test('too long for ellipsis', () => {
31 | expect(ellipsis('foo', 2)).toBe('fo');
32 | });
33 | });
34 |
35 | describe('truncateEmbed', () => {
36 | test('basic embed properties', () => {
37 | const embed: APIEmbed = {
38 | title: 'foo'.repeat(256),
39 | description: 'bar'.repeat(4_096),
40 | author: { name: 'baz'.repeat(256) },
41 | footer: { text: 'qux'.repeat(2_048) },
42 | };
43 |
44 | const truncated = truncateEmbed(embed);
45 |
46 | expect(truncated.title).toBe(ellipsis(embed.title!, MESSAGE_LIMITS.EMBEDS.TITLE));
47 | expect(truncated.description).toBe(ellipsis(embed.description!, MESSAGE_LIMITS.EMBEDS.DESCRIPTION));
48 | expect(truncated.author?.name).toBe(ellipsis(embed.author!.name, MESSAGE_LIMITS.EMBEDS.AUTHOR));
49 | expect(truncated.footer?.text).toBe(ellipsis(embed.footer!.text, MESSAGE_LIMITS.EMBEDS.FOOTER));
50 | });
51 |
52 | test('fields', () => {
53 | const embed: APIEmbed = {
54 | fields: Array.from({ length: 30 }).fill({ name: 'foo', value: 'bar' }),
55 | };
56 |
57 | expect(truncateEmbed(embed).fields).toHaveLength(MESSAGE_LIMITS.EMBEDS.FIELD_COUNT);
58 | });
59 | });
60 |
--------------------------------------------------------------------------------
/services/api/src/routes/ama/updateAMA.ts:
--------------------------------------------------------------------------------
1 | import { getContext } from '@chatsift/backend-core';
2 | import type { AMASession } from '@chatsift/core';
3 | import { badData, notFound } from '@hapi/boom';
4 | import type { Selectable } from 'kysely';
5 | import type { NextHandler, Response } from 'polka';
6 | import { z } from 'zod';
7 | import { isAuthed } from '../../middleware/isAuthed.js';
8 | import type { TRequest } from '../route.js';
9 | import { Route, RouteMethod } from '../route.js';
10 |
11 | const bodySchema = z.strictObject({
12 | ended: z.literal(true),
13 | });
14 |
15 | export type UpdateAMABody = z.input;
16 |
17 | export type UpdateAMAResult = Selectable;
18 |
19 | export default class UpdateAMA extends Route {
20 | public readonly info = {
21 | method: RouteMethod.patch,
22 | path: '/v3/guilds/:guildId/ama/amas/:amaId',
23 | } as const;
24 |
25 | public override readonly bodyValidationSchema = bodySchema;
26 |
27 | public override readonly middleware = [
28 | ...isAuthed({ fallthrough: false, isGlobalAdmin: false, isGuildManager: true }),
29 | ];
30 |
31 | public override async handle(req: TRequest, res: Response, next: NextHandler) {
32 | const data = req.body;
33 | const { guildId, amaId } = req.params as { amaId: string; guildId: string };
34 |
35 | const existingAMA = await getContext()
36 | .db.selectFrom('AMASession')
37 | .selectAll()
38 | .where('guildId', '=', guildId)
39 | .where('id', '=', Number(amaId))
40 | .executeTakeFirst();
41 |
42 | if (!existingAMA) {
43 | return next(notFound('AMA session not found'));
44 | }
45 |
46 | if (existingAMA.ended) {
47 | return next(badData('AMA session is already ended'));
48 | }
49 |
50 | const updated: UpdateAMAResult = await getContext()
51 | .db.updateTable('AMASession')
52 | .set({ ended: data.ended })
53 | .where('id', '=', Number(amaId))
54 | .where('guildId', '=', guildId)
55 | .returningAll()
56 | .executeTakeFirstOrThrow();
57 |
58 | res.statusCode = 200;
59 | res.setHeader('Content-Type', 'application/json');
60 | return res.end(JSON.stringify(updated));
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/services/api/src/routes/guilds/createGrant.ts:
--------------------------------------------------------------------------------
1 | import { getContext } from '@chatsift/backend-core';
2 | import { DiscordAPIError } from '@discordjs/rest';
3 | import { badData, notFound } from '@hapi/boom';
4 | import type { NextHandler, Response } from 'polka';
5 | import { z } from 'zod';
6 | import { isAuthed } from '../../middleware/isAuthed.js';
7 | import { roundRobinAPI } from '../../util/discordAPI.js';
8 | import { snowflakeSchema } from '../../util/schemas.js';
9 | import type { TRequest } from '../route.js';
10 | import { Route, RouteMethod } from '../route.js';
11 |
12 | const bodySchema = z.strictObject({
13 | userId: snowflakeSchema,
14 | });
15 |
16 | export type CreateGrantBody = z.input;
17 |
18 | export default class CreateGrant extends Route {
19 | public readonly info = {
20 | method: RouteMethod.put,
21 | path: '/v3/guilds/:guildId/grants',
22 | } as const;
23 |
24 | public override readonly bodyValidationSchema = bodySchema;
25 |
26 | public override readonly middleware = [
27 | ...isAuthed({ fallthrough: false, isGlobalAdmin: false, isGuildManager: true }),
28 | ];
29 |
30 | public override async handle(req: TRequest, res: Response, next: NextHandler) {
31 | const { userId } = req.body;
32 | const { guildId } = req.params as { guildId: string };
33 |
34 | const existingGrant = await getContext()
35 | .db.selectFrom('DashboardGrant')
36 | .select('id')
37 | .where('guildId', '=', guildId)
38 | .where('userId', '=', userId)
39 | .executeTakeFirst();
40 |
41 | if (existingGrant) {
42 | return next(badData('grant already exists for this user'));
43 | }
44 |
45 | try {
46 | await roundRobinAPI(req.guild!).users.get(userId);
47 | } catch (error) {
48 | if (error instanceof DiscordAPIError && error.status === 404) {
49 | return next(notFound('user not found'));
50 | }
51 |
52 | throw error;
53 | }
54 |
55 | await getContext()
56 | .db.insertInto('DashboardGrant')
57 | .values({
58 | guildId,
59 | userId,
60 | createdById: req.tokens!.access.sub,
61 | })
62 | .execute();
63 |
64 | res.statusCode = 200;
65 | return res.end();
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/apps/website/src/app/dashboard/[id]/ama/amas/_components/AMASessionsList.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useParams, useSearchParams } from 'next/navigation';
4 | import { useMemo } from 'react';
5 | import { AMASessionCard } from './AMASessionCard';
6 | import { CreateAMACard } from './CreateAMACard';
7 | import { Skeleton } from '@/components/common/Skeleton';
8 | import { client } from '@/data/client';
9 |
10 | function AMASessionSkeleton() {
11 | return (
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 | );
22 | }
23 |
24 | export function AMASessionsList() {
25 | const params = useParams<{ id: string }>();
26 | const searchParams = useSearchParams();
27 |
28 | const searchQuery = searchParams.get('search') ?? '';
29 |
30 | const { data: sessions, isLoading } = client.guilds.ama.useAMAs(params.id, {
31 | include_ended: searchParams.get('include_ended') ?? 'false',
32 | });
33 |
34 | const filtered = useMemo(() => {
35 | if (!sessions?.length) {
36 | return [];
37 | }
38 |
39 | const lower = searchQuery.toLowerCase();
40 | return sessions.filter((session) => session.title.toLowerCase().includes(lower));
41 | }, [sessions, searchQuery]);
42 |
43 | if (isLoading) {
44 | return (
45 |
46 | -
47 |
48 |
49 | {Array.from({ length: 3 }).map((_, index) => (
50 | -
51 |
52 |
53 | ))}
54 |
55 | );
56 | }
57 |
58 | return (
59 |
60 | -
61 |
62 |
63 | {filtered.map((session) => (
64 | -
65 |
66 |
67 | ))}
68 |
69 | );
70 | }
71 |
--------------------------------------------------------------------------------
/apps/website/src/utils/channels.tsx:
--------------------------------------------------------------------------------
1 | import { ChannelType } from 'discord-api-types/v10';
2 | import type { ComponentType } from 'react';
3 | import { SvgChannelAnnouncement } from '../components/icons/channels/SvgChannelAnnouncement';
4 | import { SvgChannelCategory } from '../components/icons/channels/SvgChannelCategory';
5 | import { SvgChannelForum } from '../components/icons/channels/SvgChannelForum';
6 | import { SvgChannelText } from '../components/icons/channels/SvgChannelText';
7 | import { SvgChannelThread } from '../components/icons/channels/SvgChannelThread';
8 |
9 | function Oops() {
10 | return <>oops, someone forgot to make an icon for this>;
11 | }
12 |
13 | export function getChannelIcon(channelType: ChannelType): ComponentType<{ className?: string; size?: number }> {
14 | switch (channelType) {
15 | case ChannelType.GuildText:
16 | return SvgChannelText;
17 | case ChannelType.DM:
18 | return Oops;
19 | case ChannelType.GuildVoice:
20 | return Oops;
21 | case ChannelType.GroupDM:
22 | return Oops;
23 | case ChannelType.GuildCategory:
24 | return SvgChannelCategory;
25 | case ChannelType.GuildAnnouncement:
26 | return SvgChannelAnnouncement;
27 | case ChannelType.AnnouncementThread:
28 | case ChannelType.PublicThread:
29 | case ChannelType.PrivateThread:
30 | return SvgChannelThread;
31 | case ChannelType.GuildStageVoice:
32 | return Oops;
33 | case ChannelType.GuildDirectory:
34 | return Oops;
35 | case ChannelType.GuildForum:
36 | return SvgChannelForum;
37 | case ChannelType.GuildMedia:
38 | return Oops;
39 | default:
40 | return Oops;
41 | }
42 | }
43 |
44 | export function isTextBasedChannel(channelType: ChannelType): boolean {
45 | return (
46 | channelType === ChannelType.GuildText ||
47 | channelType === ChannelType.GuildAnnouncement ||
48 | channelType === ChannelType.GuildForum ||
49 | channelType === ChannelType.PublicThread ||
50 | channelType === ChannelType.PrivateThread ||
51 | channelType === ChannelType.AnnouncementThread
52 | );
53 | }
54 |
55 | export function isVoiceBasedChannel(channelType: ChannelType): boolean {
56 | return channelType === ChannelType.GuildVoice || channelType === ChannelType.GuildStageVoice;
57 | }
58 |
--------------------------------------------------------------------------------
/apps/website/src/components/common/SearchBar.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { usePathname, useRouter, useSearchParams } from 'next/navigation';
4 | import { useState } from 'react';
5 | import { useDebounceCallback } from 'usehooks-ts';
6 | import { Button } from '@/components/common/Button';
7 |
8 | const DEBOUNCE_TIME = 300;
9 |
10 | interface SearchBarProps {
11 | readonly children?: React.ReactNode;
12 | readonly placeholder: string;
13 | }
14 |
15 | export function SearchBar({ children, placeholder }: SearchBarProps) {
16 | const router = useRouter();
17 | const pathname = usePathname();
18 | const searchParams = useSearchParams();
19 | const [searchValue, setSearchValue] = useState(searchParams.get('search') ?? '');
20 |
21 | const updateCallback = (newSearchValue: string) => {
22 | const params = new URLSearchParams(searchParams);
23 | if (newSearchValue.trim()) {
24 | params.set('search', newSearchValue.trim());
25 | } else {
26 | params.delete('search');
27 | }
28 |
29 | params.delete('page');
30 |
31 | router.replace(`${pathname}?${params.toString()}`);
32 | };
33 |
34 | const update = useDebounceCallback(updateCallback, DEBOUNCE_TIME);
35 |
36 | const handleClear = () => {
37 | setSearchValue('');
38 |
39 | const params = new URLSearchParams(searchParams);
40 | params.delete('search');
41 | params.delete('page');
42 |
43 | router.replace(`${pathname}?${params.toString()}`);
44 | };
45 |
46 | return (
47 |
48 | {
51 | update(e.target.value);
52 | setSearchValue(e.target.value);
53 | }}
54 | placeholder={placeholder}
55 | type="text"
56 | value={searchValue}
57 | />
58 |
66 | {children}
67 |
68 | );
69 | }
70 |
--------------------------------------------------------------------------------
/services/api/src/index.ts:
--------------------------------------------------------------------------------
1 | import { glob } from 'node:fs/promises';
2 | import { dirname, join } from 'node:path';
3 | import { fileURLToPath } from 'node:url';
4 | import { getContext, NewAccessTokenHeader } from '@chatsift/backend-core';
5 | import { Boom, isBoom, notFound } from '@hapi/boom';
6 | import cors from 'cors';
7 | import helmet from 'helmet';
8 | import type { Middleware } from 'polka';
9 | import polka from 'polka';
10 | import { attachHttpUtils } from './middleware/attachHttpUtils.js';
11 | import type { Route, TRequest } from './routes/route.js';
12 | import { sendBoom } from './util/sendBoom.js';
13 |
14 | export type * from './routes/_types/index.js';
15 |
16 | export async function bin(): Promise {
17 | const app = polka({
18 | onError(err, req, res) {
19 | getContext().logger.error({ err, trackingId: (req as TRequest).trackingId }, 'request error');
20 |
21 | if (res.writableEnded) {
22 | return;
23 | }
24 |
25 | if (res.headersSent) {
26 | getContext().logger.warn('weird edge case we have no clue how to handle');
27 | res.end();
28 | return;
29 | }
30 |
31 | res.setHeader('content-type', 'application/json');
32 | const boom = isBoom(err) ? err : new Boom(err);
33 |
34 | if (boom.output.statusCode === 500) {
35 | getContext().logger.error(boom, boom.message);
36 | }
37 |
38 | sendBoom(boom, res);
39 | },
40 | onNoMatch(_, res) {
41 | res.setHeader('content-type', 'application/json');
42 | sendBoom(notFound(), res);
43 | },
44 | }).use(
45 | cors({
46 | origin: getContext().env.CORS,
47 | credentials: true,
48 | exposedHeaders: [NewAccessTokenHeader],
49 | }),
50 | helmet(getContext().env.IS_PRODUCTION ? {} : { contentSecurityPolicy: false }) as Middleware,
51 | attachHttpUtils(),
52 | );
53 |
54 | const path = join(dirname(fileURLToPath(import.meta.url)), 'routes');
55 | const files = glob(`${path}/**/*.js`);
56 |
57 | getContext().logger.info({ path }, 'Found route files');
58 |
59 | for await (const file of files) {
60 | const mod = (await import(file)) as { default?: new () => Route };
61 | if (mod.default) {
62 | const route = new mod.default();
63 | getContext().logger.info(route.info, 'Registering route');
64 | route.register(app);
65 | } else {
66 | getContext().logger.warn({ file }, 'No default export found on route file');
67 | }
68 | }
69 |
70 | app.listen(getContext().env.API_PORT, () =>
71 | getContext().logger.info({ port: getContext().env.API_PORT }, 'Listening to requests'),
72 | );
73 | }
74 |
--------------------------------------------------------------------------------
/services/ama-bot/src/lib/components.ts:
--------------------------------------------------------------------------------
1 | import { glob } from 'node:fs/promises';
2 | import { dirname, join } from 'node:path';
3 | import { fileURLToPath } from 'node:url';
4 | import type { RedisStore } from '@chatsift/backend-core';
5 | import { getContext, isModuleWithDefault } from '@chatsift/backend-core';
6 | import type { APIMessageComponentInteraction } from '@discordjs/core';
7 |
8 | export interface ComponentHandler {
9 | handle(interaction: APIMessageComponentInteraction, state: State): Promise;
10 | readonly name: string;
11 | readonly stateStore: RedisStore | null;
12 | }
13 |
14 | type ComponentHandlerConstructor = new () => ComponentHandler;
15 |
16 | function isComponentHandlerConstructor(input: unknown): input is ComponentHandlerConstructor {
17 | return typeof input === 'function' && input.length === 0 && 'handle' in input.prototype;
18 | }
19 |
20 | const components = new Map>();
21 |
22 | export async function registerHandlers(): Promise {
23 | const path = join(dirname(fileURLToPath(import.meta.url)), '..', 'components');
24 | const files = glob(`${path}/**/*.js`);
25 |
26 | for await (const file of files) {
27 | const mod = await import(file);
28 | if (!isModuleWithDefault(mod, isComponentHandlerConstructor)) {
29 | getContext().logger.warn({ file }, 'Skipped invalid component handler module');
30 | continue;
31 | }
32 |
33 | const handler = new mod.default();
34 | components.set(handler.name, handler);
35 | getContext().logger.info({ component: handler.name }, 'Registered component handler');
36 | }
37 | }
38 |
39 | export async function handleComponentInteraction(interaction: APIMessageComponentInteraction): Promise {
40 | const [componentName, stateId] = interaction.data.custom_id.split(':') as [string, string?];
41 |
42 | const handler = components.get(componentName);
43 | if (!handler) {
44 | getContext().logger.warn({ componentName }, 'No handler found for component interaction');
45 | return;
46 | }
47 |
48 | if (handler.stateStore && !stateId) {
49 | getContext().logger.warn({ componentName }, 'State ID missing for component interaction requiring state');
50 | return;
51 | }
52 |
53 | if (!handler.stateStore && stateId) {
54 | getContext().logger.warn({ componentName }, 'Unexpected State ID for component interaction not requiring state');
55 | return;
56 | }
57 |
58 | const state = stateId ? await handler.stateStore?.get(stateId) : undefined;
59 | await handler.handle(interaction, state);
60 | }
61 |
--------------------------------------------------------------------------------
/services/api/src/middleware/__tests__/jsonParser.test.ts:
--------------------------------------------------------------------------------
1 | import { Http2ServerResponse } from 'node:http2';
2 | import { Boom } from '@hapi/boom';
3 | import type { Request, Response } from 'polka';
4 | import { afterEach, expect, test, vi } from 'vitest';
5 | import { jsonParser } from '../jsonParser.js';
6 |
7 | vi.mock('http2');
8 |
9 | const makeMockedRequest = (requestInfo: any, data?: any): Request => ({
10 | setEncoding: vi.fn(),
11 | *[Symbol.asyncIterator]() {
12 | yield data;
13 | },
14 | ...requestInfo,
15 | });
16 |
17 | const MockedResponse = Http2ServerResponse as unknown as new () => Response;
18 | const next = vi.fn();
19 |
20 | afterEach(() => {
21 | vi.resetAllMocks();
22 | });
23 |
24 | test('missing content type', async () => {
25 | const parser = jsonParser(false);
26 |
27 | await parser(makeMockedRequest({ headers: {} }), new MockedResponse(), next);
28 | expect(next).toHaveBeenCalled();
29 | expect(next.mock.calls[0]![0]).toBeInstanceOf(Boom);
30 | expect(next.mock.calls[0]![0].output.statusCode).toBe(400);
31 | });
32 |
33 | test('invalid data', async () => {
34 | const parser = jsonParser();
35 |
36 | await parser(makeMockedRequest({ headers: { 'content-type': 'application/json' } }, 'a'), new MockedResponse(), next);
37 | expect(next).toHaveBeenCalled();
38 | expect(next.mock.calls[0]![0]).toBeInstanceOf(Boom);
39 | expect(next.mock.calls[0]![0].output.statusCode).toBe(422);
40 | });
41 |
42 | test('empty data', async () => {
43 | const parser = jsonParser();
44 |
45 | await parser(makeMockedRequest({ headers: { 'content-type': 'application/json' } }, ''), new MockedResponse(), next);
46 | expect(next).toHaveBeenCalled();
47 | expect(next.mock.calls[0]![0]).not.toBeInstanceOf(Boom);
48 | });
49 |
50 | test('valid data', async () => {
51 | const parser = jsonParser();
52 |
53 | const data = { foo: 'bar' };
54 | const req = makeMockedRequest({ headers: { 'content-type': 'application/json' } }, JSON.stringify(data));
55 |
56 | await parser(req, new MockedResponse(), next);
57 | expect(next).toHaveBeenCalledWith();
58 | expect(req.body).toStrictEqual(data);
59 | });
60 |
61 | test('valid data with raw body', async () => {
62 | const parser = jsonParser(true);
63 |
64 | const data = { foo: 'bar' };
65 | const req = makeMockedRequest({ headers: { 'content-type': 'application/json' } }, JSON.stringify(data));
66 |
67 | await parser(req, new MockedResponse(), next);
68 | expect(next).toHaveBeenCalledWith();
69 | expect(req.rawBody).toStrictEqual(JSON.stringify(data));
70 | expect(req.body).toStrictEqual(data);
71 | });
72 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "http://json.schemastore.org/package",
3 | "name": "chatsift",
4 | "packageManager": "yarn@4.3.1",
5 | "private": true,
6 | "version": "0.0.0",
7 | "type": "module",
8 | "workspaces": [
9 | "apps/*",
10 | "packages/public/*",
11 | "packages/private/*",
12 | "services/*"
13 | ],
14 | "repository": {
15 | "type": "git",
16 | "url": "git+https://github.com/chatsift/chatsift.git"
17 | },
18 | "bugs": {
19 | "url": "https://github.com/chatsift/chatsift/issues"
20 | },
21 | "homepage": "https://github.com/chatsift/chatsift#readme",
22 | "scripts": {
23 | "build": "turbo run build",
24 | "test": "vitest run",
25 | "test:watch": "vitest",
26 | "test:nocov": "vitest run --coverage.enabled=false",
27 | "lint": "turbo run lint",
28 | "lint:fix": "turbo run lint:fix",
29 | "format": "prettier --write .",
30 | "format:check": "prettier --check .",
31 | "clean": "turbo run clean && rimraf **/node_modules",
32 | "clean:deps": "rimraf **/node_modules",
33 | "prisma": "dotenv -e .env.private -e .env.public -- prisma",
34 | "db:generate": "dotenv -e .env.private -e .env.public -- prisma generate --schema=prisma/schema.prisma",
35 | "db:migrate": "dotenv -e .env.private -e .env.public -- prisma migrate dev --schema=prisma/schema.prisma",
36 | "db:format": "dotenv -e .env.private -e .env.public -- prisma format --schema=prisma/schema.prisma",
37 | "db:reset": "dotenv -e .env.private -e .env.public -- prisma migrate reset --schema=prisma/schema.prisma",
38 | "db:deploy": "dotenv -e .env.private -e .env.public -- prisma migrate deploy --schema=prisma/schema.prisma",
39 | "db:studio": "dotenv -e .env.private -e .env.public -- prisma studio --schema=prisma/schema.prisma",
40 | "postinstall": "is-ci || husky || true",
41 | "update": "yarn upgrade-interactive"
42 | },
43 | "devDependencies": {
44 | "@commitlint/cli": "^19.8.1",
45 | "@commitlint/config-angular": "^19.8.1",
46 | "@types/lodash.merge": "^4.6.9",
47 | "@types/node": "^24.9.1",
48 | "@vitest/coverage-v8": "^3.2.4",
49 | "dotenv-cli": "^10.0.0",
50 | "eslint": "^9.35.0",
51 | "eslint-config-neon": "^0.2.7",
52 | "eslint-import-resolver-typescript": "^4.4.4",
53 | "eslint-plugin-react-compiler": "19.1.0-rc.2",
54 | "husky": "^9.1.7",
55 | "is-ci": "^4.1.0",
56 | "lodash.merge": "^4.6.2",
57 | "prettier": "^3.6.2",
58 | "prisma": "^6.17.1",
59 | "prisma-kysely": "^1.8.0",
60 | "rimraf": "^6.0.1",
61 | "turbo": "^2.5.8",
62 | "typescript": "~5.9.3",
63 | "typescript-eslint": "^8.45.0",
64 | "vitest": "^3.2.4"
65 | },
66 | "resolutions": {
67 | "eslint-plugin-sonarjs": "3.0.2"
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/services/api/src/util/channels.ts:
--------------------------------------------------------------------------------
1 | import { setTimeout, clearTimeout } from 'node:timers';
2 | import type {
3 | API,
4 | APIGuildChannel,
5 | APISortableChannel,
6 | APIThreadChannel,
7 | GuildChannelType,
8 | Snowflake,
9 | } from '@discordjs/core';
10 | import { DiscordAPIError } from '@discordjs/rest';
11 |
12 | export interface PossiblyMissingChannelInfo {
13 | id: string;
14 | }
15 |
16 | export type GuildChannelInfo = APISortableChannel &
17 | Pick, 'id' | 'name' | 'parent_id' | 'type'>;
18 |
19 | // TODO(DD): Should probably move this to redis
20 | const CACHE = new Map();
21 | const CACHE_TIMEOUTS = new Map();
22 | const CACHE_TTL = 5 * 60 * 1_000; // 5 minutes
23 |
24 | export function clearCache() {
25 | CACHE.clear();
26 | for (const timeout of CACHE_TIMEOUTS.values()) {
27 | clearTimeout(timeout);
28 | }
29 |
30 | CACHE_TIMEOUTS.clear();
31 | }
32 |
33 | export async function fetchGuildChannels(guildId: string, api: API, force = false): Promise {
34 | if (CACHE.has(guildId) && !force) {
35 | return CACHE.get(guildId)!;
36 | }
37 |
38 | // TODO(DD): https://github.com/discordjs/discord-api-types/pull/1397
39 | const channelsRaw = await (
40 | api.guilds.getChannels(guildId) as Promise<(APIGuildChannel & APISortableChannel)[]>
41 | ).catch((error) => {
42 | if (error instanceof DiscordAPIError && (error.status === 403 || error.status === 404)) {
43 | return null;
44 | }
45 |
46 | throw error;
47 | });
48 |
49 | if (!channelsRaw) {
50 | return null;
51 | }
52 |
53 | const channels: GuildChannelInfo[] = channelsRaw.map(({ id, name, parent_id, type, position }) => ({
54 | id,
55 | name,
56 | parent_id: parent_id ?? null,
57 | type,
58 | position,
59 | }));
60 |
61 | const { threads: threadsRaw } = await api.guilds.getActiveThreads(guildId);
62 | const threads: GuildChannelInfo[] = (threadsRaw as APIThreadChannel[]).map(({ id, name, parent_id, type }) => ({
63 | id,
64 | name,
65 | parent_id: parent_id!,
66 | type,
67 | position: 0, // Threads don't have a position, this should be good enough
68 | }));
69 |
70 | const allChannels = channels.concat(threads);
71 |
72 | CACHE.set(guildId, allChannels);
73 | if (CACHE_TIMEOUTS.has(guildId)) {
74 | const timeout = CACHE_TIMEOUTS.get(guildId)!;
75 | timeout.refresh();
76 | } else {
77 | const timeout = setTimeout(() => {
78 | CACHE.delete(guildId);
79 | CACHE_TIMEOUTS.delete(guildId);
80 | }, CACHE_TTL).unref();
81 |
82 | CACHE_TIMEOUTS.set(guildId, timeout);
83 | }
84 |
85 | return allChannels;
86 | }
87 |
--------------------------------------------------------------------------------
/services/api/src/routes/ama/getAMAs.ts:
--------------------------------------------------------------------------------
1 | import { getContext } from '@chatsift/backend-core';
2 | import type { AMASession } from '@chatsift/core';
3 | import type { Selectable } from 'kysely';
4 | import type { NextHandler, Response } from 'polka';
5 | import { z } from 'zod';
6 | import { isAuthed } from '../../middleware/isAuthed.js';
7 | import type { TRequest } from '../route.js';
8 | import { Route, RouteMethod } from '../route.js';
9 |
10 | const querySchema = z.strictObject({
11 | include_ended: z.stringbool().optional().default(false),
12 | });
13 |
14 | export type GetAMAsQuery = z.input;
15 |
16 | export interface AMASessionWithCount extends Selectable {
17 | questionCount: number;
18 | }
19 |
20 | export default class GetAMAs extends Route {
21 | public readonly info = {
22 | method: RouteMethod.get,
23 | path: '/v3/guilds/:guildId/ama/amas',
24 | } as const;
25 |
26 | public override readonly queryValidationSchema = querySchema;
27 |
28 | public override readonly middleware = [
29 | ...isAuthed({ fallthrough: false, isGlobalAdmin: false, isGuildManager: true }),
30 | ];
31 |
32 | public override async handle(req: TRequest, res: Response, next: NextHandler) {
33 | const { include_ended } = req.query;
34 | const { guildId } = req.params as { guildId: string };
35 |
36 | let query = getContext().db.selectFrom('AMASession').selectAll().where('guildId', '=', guildId);
37 |
38 | if (!include_ended) {
39 | query = query.where('ended', '=', false);
40 | }
41 |
42 | const sessions = await query.orderBy('id', 'desc').execute();
43 |
44 | // Fetch question counts for all sessions
45 | const sessionIds = sessions.map((s) => s.id);
46 | const questionCounts = sessionIds.length
47 | ? await getContext()
48 | .db.selectFrom('AMAQuestion')
49 | .select(['amaId'])
50 | .select((eb) => eb.fn.count('id').as('count'))
51 | .where('amaId', 'in', sessionIds)
52 | .groupBy('amaId')
53 | .execute()
54 | : [];
55 |
56 | // Create a map of session ID to question count
57 | const countsBySession = new Map();
58 | for (const { amaId, count } of questionCounts) {
59 | countsBySession.set(amaId, Number(count));
60 | }
61 |
62 | // Combine sessions with their question counts
63 | const result: AMASessionWithCount[] = sessions.map((session) => ({
64 | ...session,
65 | questionCount: countsBySession.get(session.id) ?? 0,
66 | }));
67 |
68 | res.statusCode = 200;
69 | res.setHeader('Content-Type', 'application/json');
70 | return res.end(JSON.stringify(result));
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/services/api/src/middleware/attachHttpUtils.ts:
--------------------------------------------------------------------------------
1 | import cookie from 'cookie';
2 | import type { NextHandler, Request, Response } from 'polka';
3 |
4 | declare module 'http' {
5 | export interface ServerResponse {
6 | /**
7 | * Appends a header to the request - handling some nicher cases in regards to array values.
8 | * It's preferred this is used over the usual `res.setHeader`
9 | *
10 | * @param header - The name of the header to append
11 | * @param value - The value to set for this header
12 | */
13 | append(header: string, value: string[] | number | string): void;
14 | /**
15 | * Appends a cookie to the response
16 | *
17 | * @param name - Name of the cookie
18 | * @param data - Data to set for this cookie
19 | * @param options - Options to set for this cookie - please refer to https://github.com/jshttp/cookie#options-1 for further documentation
20 | */
21 | cookie(name: string, data: string, options?: cookie.SerializeOptions): void;
22 | /**
23 | * Correctly redirects a user to a new location
24 | *
25 | * @param redirect - The URL to redirect to
26 | */
27 | redirect(redirect: string): void;
28 | }
29 | }
30 |
31 | /**
32 | * Creates a request handler that attaches some utils to the response object - documentation for those can be found under ServerResponse
33 | */
34 | export function attachHttpUtils() {
35 | return async (_: Request, res: Response, next: NextHandler) => {
36 | res.append = (header, value) => {
37 | const prev = res.getHeader(header);
38 | if (prev) {
39 | // eslint-disable-next-line no-param-reassign
40 | value = Array.isArray(prev) ? prev.concat(value as string) : ([prev].concat(value) as string[]);
41 | }
42 |
43 | res.setHeader(header, value);
44 | };
45 |
46 | res.redirect = (redirect) => {
47 | Reflect.set(res, 'statusCode', 302);
48 | res.append('Location', redirect);
49 | res.append('Content-Length', 0);
50 | };
51 |
52 | res.cookie = (name, data, options) => {
53 | const value = cookie.serialize(name, data, options);
54 |
55 | const existing = cookie.parse(res.getHeader('Set-Cookie')?.toString() ?? '');
56 | if (existing[name]) {
57 | // If the cookie already exists, we need to replace it. We also have to keep in mind we can have string | string[];
58 | const existingSet = res.getHeader('Set-Cookie');
59 | const existingArray = Array.isArray(existingSet) ? existingSet : existingSet ? [existingSet.toString()] : [];
60 | const filtered = existingArray.filter((c) => !c.startsWith(`${name}=`));
61 |
62 | res.setHeader('Set-Cookie', [...filtered, value]);
63 | } else {
64 | res.append('Set-Cookie', value);
65 | }
66 | };
67 |
68 | return next();
69 | };
70 | }
71 |
--------------------------------------------------------------------------------