1
1
mirror of https://github.com/ARK-Unity/ark-unity.git synced 2024-07-12 15:30:12 +02:00

chore: initial commit

This commit is contained in:
Théo LUDWIG 2023-10-16 21:49:35 +02:00
commit 003f0ca307
Signed by: theoludwig
GPG Key ID: ADFE5A563D718F3B
67 changed files with 9152 additions and 0 deletions

4
.dockerignore Normal file
View File

@ -0,0 +1,4 @@
build
.next
coverage
node_modules

11
.editorconfig Normal file
View File

@ -0,0 +1,11 @@
# <https://editorconfig.org/>
root = true
[*]
indent_style = space
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
indent_size = 2

4
.eslintrc.json Normal file
View File

@ -0,0 +1,4 @@
{
"root": true,
"extends": ["@ark-unity/eslint-config-custom"]
}

1
.gitattributes vendored Normal file
View File

@ -0,0 +1 @@
* text=auto eol=lf

55
.gitignore vendored Normal file
View File

@ -0,0 +1,55 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
node_modules
.npm
# next.js
.next
out
# production
build
dist
.swc
# testing
coverage
cypress/screenshots
cypress/videos
cypress/downloads
# envs
.env
.env.production
.env.development
secrets
# debug
npm-debug.log*
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# misc
.DS_Store
*.pem
.turbo
bin/
# typescript
*.tsbuildinfo
next-env.d.ts

1
.npmrc Normal file
View File

@ -0,0 +1 @@
save-exact=true

6
.prettierrc.json Normal file
View File

@ -0,0 +1,6 @@
{
"singleQuote": true,
"jsxSingleQuote": true,
"semi": false,
"trailingComma": "none"
}

11
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,11 @@
{
"recommendations": [
"editorconfig.editorconfig",
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint",
"bradlc.vscode-tailwindcss",
"prisma.prisma",
"mikestead.dotenv",
"ms-azuretools.vscode-docker"
]
}

17
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,17 @@
{
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true,
"eslint.workingDirectories": [
{
"mode": "auto"
}
],
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll": true
},
"[prisma]": {
"editor.defaultFormatter": "Prisma.prisma"
}
}

62
README.md Normal file
View File

@ -0,0 +1,62 @@
# ARK Unity
## About
Prominent presence across the [ARK Franchise](https://playark.com)! 🦖
## Getting Started
### Prerequisites
- [Node.js](https://nodejs.org/) >= 20.0.0
- [npm](https://www.npmjs.com/) >= 10.0.0
### Installation
```sh
# Go to the project root
cd ark-unity
# Install dependencies
npm clean-install
```
#### Usage
```sh
npm run dev
```
##### Commands
```sh
# Build, Lint and Test
npm run build
npm run lint
npm run lint:editorconfig
npm run lint:prettier
npm run test
# Test only one workspace (e.g: `apps/ark-ascended-discord-bot`)
npm run test --workspace=apps/ark-ascended-discord-bot
# Register commands for `DISCORD_GUILD_ID` provided in `.env`
npm run discord:register-commands
# Deploy to Production
npx railway login
npx railway link
npm run deploy
```
### Production environment (with [Docker](https://www.docker.com/))
```sh
docker compose up --build
```
## Useful Links
[Discord Documentation](https://discord.com/developers/applications).
[Discord.js Documentation](https://discord.js.org/) and [Discord.js Guide](https://discordjs.guide/).

View File

@ -0,0 +1,40 @@
NODE_ENV=development
COMPOSE_PROJECT_NAME=ark-ascended-discord-bot
DISCORD_GUILD_ID=1010010
DISCORD_SURVIVOR_ROLE_ID=101010
DISCORD_SERVER_BOOSTER_ROLE_ID=101010101
DISCORD_CONTENT_CREATOR_ROLE_ID=101010101
DISCORD_ADMINISTRATOR_ROLE_ID=101010101
DISCORD_BLOCK_CONTENT_CREATOR_ROLE_ID=101010101
DISCORD_VOICE_LOBBY_CATEGORY_ID=101010101
DISCORD_VOICE_LOBBY_CHANNEL_ID=101010101
DISCORD_NEWCOMERS_CHANNEL_ID=101010
DISCORD_BOT_COMMANDS_CHANNEL_ID=101010101
DISCORD_RULES_CHANNEL_ID=101010101
DISCORD_ASA_NEWS_CHANNEL_ID=101010101
DISCORD_TWEET_CHANNEL_ID=101010101
DISCORD_CLIENT_ID=clientId
DISCORD_TOKEN=token
DISCORD_TOKEN_USER=token
GOOGLE_API_KEY=key
TWITCH_CLIENT_ID=clientId
TWITCH_CLIENT_SECRET=clientSecret
STEAM_API_KEY=key
DISCORD_ADMIN_COMMANDS_CHANNEL_ID=101010101
DISCORD_QOTD_REACT_CHANNEL_ID=101010101
DISCORD_QOTD_ROLE_ID=101010101
DISCORD_QOTD_QUESTIONS_CHANNEL_ID=101010101
DISCORD_QOTD_ANSWERS_CHANNEL_ID=101010101
DISCORD_QUESTIONS_IDEAS_CHANNEL_ID=101010101
DISCORD_SUPPORT_TICKETS_CHANNEL_ID=101010101
DISCORD_SUPPORT_TICKETS_OPEN_CATEGORY_ID=101010101
DISCORD_SUPPORT_TICKETS_CLOSED_CATEGORY_ID=101010101

View File

@ -0,0 +1,10 @@
{
"root": true,
"extends": ["@ark-unity/eslint-config-custom"],
"parserOptions": {
"project": "./tsconfig.json"
},
"rules": {
"import/extensions": ["error", "always"]
}
}

View File

@ -0,0 +1,13 @@
{
"sourceMaps": true,
"jsc": {
"parser": {
"syntax": "typescript",
"dynamicImport": true
},
"target": "esnext"
},
"module": {
"type": "es6"
}
}

View File

@ -0,0 +1,38 @@
{
"name": "@ark-unity/ark-ascended-discord-bot",
"version": "1.0.0",
"private": true,
"type": "module",
"imports": {
"#src/*": "./build/*"
},
"scripts": {
"build": "rimraf ./build && swc ./src --out-dir ./build && tsc",
"start": "node --enable-source-maps build/index.js",
"dev:build": "swc ./src --out-dir ./build --watch",
"dev:tsc": "tsc --watch --preserveWatchOutput",
"dev": "concurrently --names \"swc,tsc\" \"npm run dev:build\" \"npm run dev:tsc\"",
"lint": "eslint ./src --max-warnings 0 --report-unused-disable-directives",
"test": "cross-env NODE_ENV=test node --enable-source-maps --test build/"
},
"dependencies": {
"@ark-unity/utils": "*",
"@googleapis/youtube": "12.0.0",
"axios": "1.5.1",
"discord.js": "14.13.0",
"dotenv": "16.3.1"
},
"devDependencies": {
"@ark-unity/eslint-config-custom": "*",
"@ark-unity/tsconfig": "*",
"@swc/cli": "0.1.62",
"@swc/core": "1.3.93",
"@types/node": "20.8.6",
"chokidar": "3.5.3",
"concurrently": "8.2.1",
"cross-env": "7.0.3",
"eslint": "8.51.0",
"rimraf": "5.0.5",
"typescript": "5.2.2"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 MiB

View File

@ -0,0 +1,21 @@
import { SlashCommandBuilder, channelMention } from 'discord.js'
import type { DiscordCommand } from '#src/services/discord/DiscordCommand.js'
import { DISCORD_ASA_NEWS_CHANNEL_ID } from '#src/configuration/global.js'
const asa: DiscordCommand = {
data: new SlashCommandBuilder()
.setName('asa')
.setDescription('ASA news and updates!')
.toJSON(),
execute: async (interaction) => {
await interaction.reply(`
**ARK: Survival Ascended** is an Unreal Engine 5 remake of the original ARK game.
Planned to be released in **October, 2023**, on PC (Steam), Xbox Series X|S, and PlayStation 5 for **$39.99 USD** at launch.
More information and updates in ${channelMention(DISCORD_ASA_NEWS_CHANNEL_ID)}.
`)
}
}
export default asa

View File

@ -0,0 +1,123 @@
import { GuildMember, SlashCommandBuilder, userMention, bold } from 'discord.js'
import { LOCALE } from '@ark-unity/utils'
import { getYouTubeSubscriberCount } from '#src/services/google.js'
import { getTwitchFollowerCount } from '#src/services/twitch.js'
import {
DISCORD_BLOCK_CONTENT_CREATOR_ROLE_ID,
DISCORD_CONTENT_CREATOR_ROLE_ID
} from '#src/configuration/global.js'
import type { DiscordGetUserProfileResponse } from '#src/services/discord/DiscordClient.js'
import { discordAPI } from '#src/services/discord/DiscordClient.js'
import type { DiscordCommand } from '#src/services/discord/DiscordCommand.js'
const MINIMUM_CONTENT_CREATOR_ROLE = 5_000
const DISCORD_CONNECTIONS_MORE_INFORMATION =
'See: <https://support.discord.com/hc/en-us/articles/8063233404823-Connections-Community-Members> for more information.'
const medias = [
{ name: 'YouTube', value: 'youtube', fansType: 'subscribers' },
{ name: 'Twitch', value: 'twitch', fansType: 'followers' }
] as const
const contentCreatorRole: DiscordCommand = {
data: new SlashCommandBuilder()
.setName('content-creator-role')
.setDescription(
`Adds the Content Creator role to the user who executed the command.`
)
.addStringOption((option) => {
return option
.setName('media')
.setDescription('The media you create content for.')
.addChoices(...medias)
.setRequired(true)
})
.toJSON(),
execute: async (interaction) => {
if (
interaction.member == null ||
!(interaction.member instanceof GuildMember)
) {
throw new Error('Member is null')
}
if (interaction.member.roles.cache.has(DISCORD_CONTENT_CREATOR_ROLE_ID)) {
await interaction.reply(
`You already have the Content Creator role. :star_struck:`
)
return
}
if (
interaction.member.roles.cache.has(DISCORD_BLOCK_CONTENT_CREATOR_ROLE_ID)
) {
await interaction.reply(
`You can't have the Content Creator role. Refer to a moderator for more information.`
)
return
}
const media = interaction.options.get('media')
const mediaChoice = medias.find((mediasChoice) => {
return mediasChoice.value === media?.value
})
if (mediaChoice == null) {
throw new Error('Media invalid')
}
const { data } = await discordAPI.get<DiscordGetUserProfileResponse>(
`/users/${interaction.user.id}/profile`
)
const account = data.connected_accounts.find((connectedAccount) => {
return mediaChoice.value === connectedAccount.type
})
if (account == null) {
await interaction.reply(
`${userMention(interaction.user.id)} You need to connect your ${
mediaChoice.name
} account to your Discord account to use this command.\n${DISCORD_CONNECTIONS_MORE_INFORMATION}`
)
return
}
if (!account.verified) {
await interaction.reply(
`${userMention(interaction.user.id)} You need to verify your ${
mediaChoice.name
} account on Discord to use this command.\n${DISCORD_CONNECTIONS_MORE_INFORMATION}`
)
return
}
let mediaURL = ''
let fansCount = 0
if (account.type === 'youtube') {
mediaURL = `https://www.youtube.com/channel/${account.id}`
fansCount = await getYouTubeSubscriberCount(account)
} else {
mediaURL = `https://www.twitch.tv/${account.name}`
fansCount = await getTwitchFollowerCount(account)
}
const fansCountString = `You currently have ${bold(
`${fansCount.toLocaleString(LOCALE)} ${mediaChoice.fansType} on ${
mediaChoice.name
}`
)}`
if (fansCount < MINIMUM_CONTENT_CREATOR_ROLE) {
await interaction.reply(
`${userMention(
interaction.user.id
)} You need at least ${MINIMUM_CONTENT_CREATOR_ROLE.toLocaleString(
LOCALE
)} ${
mediaChoice.fansType
} to use this command.\n${fansCountString}, you can make it. :muscle:\n${mediaURL}`
)
return
}
await interaction.member.roles.add(DISCORD_CONTENT_CREATOR_ROLE_ID)
await interaction.reply(
`${userMention(
interaction.user.id
)} You have been given the Content Creator role. :tada:\n${fansCountString}.\n${mediaURL}`
)
}
}
export default contentCreatorRole

View File

@ -0,0 +1,24 @@
import { PermissionsBitField, SlashCommandBuilder } from 'discord.js'
import type { DiscordCommand } from '#src/services/discord/DiscordCommand.js'
import { invitationsLeaderboardReply } from '#src/commands/invitations-leaderboard.js'
const invitationsLeaderboardPing: DiscordCommand = {
data: new SlashCommandBuilder()
.setName('invitations-leaderboard-ping')
.setDescription(
'Replies with the leaderboard of the members with the most invites (with ping).'
)
.setDMPermission(false)
.setDefaultMemberPermissions(
new PermissionsBitField('Administrator').valueOf()
)
.toJSON(),
execute: async (interaction) => {
await interaction.reply({
content: await invitationsLeaderboardReply(interaction)
})
}
}
export default invitationsLeaderboardPing

View File

@ -0,0 +1,62 @@
import type { CacheType, ChatInputCommandInteraction } from 'discord.js'
import {
bold,
GuildMember,
SlashCommandBuilder,
underscore,
userMention
} from 'discord.js'
import { pluralizeWord } from '@ark-unity/utils'
import { DiscordClient } from '#src/services/discord/DiscordClient.js'
import type { DiscordCommand } from '#src/services/discord/DiscordCommand.js'
const LEADERBOARD_SIZE = 10
export const invitationsLeaderboardReply = async (
interaction: ChatInputCommandInteraction<CacheType>
): Promise<string> => {
if (
interaction.member == null ||
!(interaction.member instanceof GuildMember)
) {
throw new Error('Member is null')
}
const discordClient = await DiscordClient.getInstance()
const invitations = await interaction.guild?.invites.fetch()
const usersInvitations = await discordClient.getUsersInvitations(invitations)
const sortedInvitationsLeaderboard = Array.from(
usersInvitations.values()
).sort((a, b) => {
return b.invitationsCount - a.invitationsCount
})
const leaderboard = sortedInvitationsLeaderboard
.slice(0, LEADERBOARD_SIZE)
.map((userInvitations, index) => {
return `${index + 1}. ${userMention(userInvitations.inviter.id)}: ${bold(
userInvitations.invitationsCount.toString()
)} ${pluralizeWord('member', userInvitations.invitationsCount)} invited.`
})
return `${underscore(
bold(`Invitations Leaderboard - Top ${LEADERBOARD_SIZE}`)
)}\n\n${leaderboard.join('\n')}`
}
const invitationsLeaderboard: DiscordCommand = {
data: new SlashCommandBuilder()
.setName('invitations-leaderboard')
.setDescription(
'Replies with the leaderboard of the members with the most invites.'
)
.toJSON(),
execute: async (interaction) => {
await interaction.reply({
content: await invitationsLeaderboardReply(interaction),
allowedMentions: {
users: []
}
})
}
}
export default invitationsLeaderboard

View File

@ -0,0 +1,32 @@
import { SlashCommandBuilder, userMention, bold } from 'discord.js'
import { pluralizeWord } from '@ark-unity/utils'
import { DiscordClient } from '#src/services/discord/DiscordClient.js'
import type { DiscordCommand } from '#src/services/discord/DiscordCommand.js'
const invitations: DiscordCommand = {
data: new SlashCommandBuilder()
.setName('invitations')
.setDescription(
'Replies with the number of invitations made by the user who executed the command.'
)
.toJSON(),
execute: async (interaction) => {
if (interaction.member == null) {
throw new Error('Member is null')
}
const discordClient = await DiscordClient.getInstance()
const invitations = await interaction.guild?.invites.fetch()
const usersInvitations =
await discordClient.getUsersInvitations(invitations)
const userInvitations = usersInvitations.get(interaction.member.user.id)
const invitationsCount = userInvitations?.invitationsCount ?? 0
await interaction.reply({
content: `${userMention(interaction.user.id)} You invited ${bold(
invitationsCount.toString()
)} ${pluralizeWord('member', invitationsCount)}.`
})
}
}
export default invitations

View File

@ -0,0 +1,16 @@
import { SlashCommandBuilder } from 'discord.js'
import type { DiscordCommand } from '#src/services/discord/DiscordCommand.js'
const ping: DiscordCommand = {
data: new SlashCommandBuilder()
.setName('ping')
.setDescription('Replies with Pong!')
.toJSON(),
execute: async (interaction) => {
const latency = Math.round(interaction.client.ws.ping)
await interaction.reply(`:ping_pong: Pong! Latency is ${latency}ms.`)
}
}
export default ping

View File

@ -0,0 +1,33 @@
import { PermissionsBitField, SlashCommandBuilder } from 'discord.js'
import type { DiscordCommand } from '#src/services/discord/DiscordCommand.js'
import { DISCORD_QUESTIONS_IDEAS_CHANNEL_ID } from '#src/configuration/qotd.js'
const randomQuestionIdea: DiscordCommand = {
data: new SlashCommandBuilder()
.setName('random-question-idea')
.setDescription('Pick a random question idea for QOTD!')
.setDMPermission(false)
.setDefaultMemberPermissions(
new PermissionsBitField('MuteMembers').valueOf()
)
.toJSON(),
execute: async (interaction) => {
const channel = await interaction.guild?.channels.fetch(
DISCORD_QUESTIONS_IDEAS_CHANNEL_ID
)
if (channel == null || !channel.isTextBased()) {
await interaction.reply('Questions ideas channel not found.')
return
}
const messages = await channel.messages.fetch()
const message = messages.random()
if (message == null) {
await interaction.reply('No question idea found.')
return
}
await interaction.reply(message.content)
}
}
export default randomQuestionIdea

View File

@ -0,0 +1,37 @@
import { bold, SlashCommandBuilder } from 'discord.js'
import { LOCALE } from '@ark-unity/utils'
import type { DiscordCommand } from '#src/services/discord/DiscordCommand.js'
import type { SteamGetNumberOfCurrentPlayersResponse } from '#src/services/steam.js'
import { ARK_SURVIVAL_EVOLVED_APP_ID, steamAPI } from '#src/services/steam.js'
const steamARKSurvivalEvolvedCurrentPlayersCount: DiscordCommand = {
data: new SlashCommandBuilder()
.setName('steam-ark-se-players-count')
.setDescription(
'Replies with the current number of players on Steam on ARK: Survival Evolved.'
)
.toJSON(),
execute: async (interaction) => {
let numberOfCurrentPlayers: number = 0
try {
const { data } =
await steamAPI.get<SteamGetNumberOfCurrentPlayersResponse>(
'/ISteamUserStats/GetNumberOfCurrentPlayers/v1',
{
params: {
appid: ARK_SURVIVAL_EVOLVED_APP_ID
}
}
)
numberOfCurrentPlayers = data.response.player_count
} catch {}
await interaction.reply(
`There are currently ${bold(
numberOfCurrentPlayers.toLocaleString(LOCALE)
)} players on ARK: Survival Evolved (Steam).\nhttps://steamcharts.com/app/${ARK_SURVIVAL_EVOLVED_APP_ID}`
)
}
}
export default steamARKSurvivalEvolvedCurrentPlayersCount

View File

@ -0,0 +1,37 @@
import { bold, SlashCommandBuilder } from 'discord.js'
import { LOCALE } from '@ark-unity/utils'
import type { DiscordCommand } from '#src/services/discord/DiscordCommand.js'
import type { SteamGetNumberOfCurrentPlayersResponse } from '#src/services/steam.js'
import { ARK2_APP_ID, steamAPI } from '#src/services/steam.js'
const steamARK2CurrentPlayersCount: DiscordCommand = {
data: new SlashCommandBuilder()
.setName('steam-ark2-current-players-count')
.setDescription(
'Replies with the current number of players on Steam on ARK 2.'
)
.toJSON(),
execute: async (interaction) => {
let numberOfCurrentPlayers: number = 0
try {
const { data } =
await steamAPI.get<SteamGetNumberOfCurrentPlayersResponse>(
'/ISteamUserStats/GetNumberOfCurrentPlayers/v1',
{
params: {
appid: ARK2_APP_ID
}
}
)
numberOfCurrentPlayers = data.response.player_count
} catch {}
await interaction.reply(
`There are currently ${bold(
numberOfCurrentPlayers.toLocaleString(LOCALE)
)} players on ARK 2 (Steam).\nhttps://steamcharts.com/app/${ARK2_APP_ID}`
)
}
}
export default steamARK2CurrentPlayersCount

View File

@ -0,0 +1,48 @@
import dotenv from 'dotenv'
dotenv.config()
export const NODE_ENV = process.env['NODE_ENV'] ?? 'development'
export const DISCORD_GUILD_ID = process.env['DISCORD_GUILD_ID'] ?? '101010101'
export const DISCORD_SURVIVOR_ROLE_ID =
process.env['DISCORD_SURVIVOR_ROLE_ID'] ?? '101010101'
export const DISCORD_SERVER_BOOSTER_ROLE_ID =
process.env['DISCORD_SERVER_BOOSTER_ROLE_ID'] ?? '101010101'
export const DISCORD_CONTENT_CREATOR_ROLE_ID =
process.env['DISCORD_CONTENT_CREATOR_ROLE_ID'] ?? '101010101'
export const DISCORD_ADMINISTRATOR_ROLE_ID =
process.env['DISCORD_ADMINISTRATOR_ROLE_ID'] ?? '101010101'
export const DISCORD_BLOCK_CONTENT_CREATOR_ROLE_ID =
process.env['DISCORD_BLOCK_CONTENT_CREATOR_ROLE_ID'] ?? '101010101'
export const DISCORD_VOICE_LOBBY_CATEGORY_ID =
process.env['DISCORD_VOICE_LOBBY_CATEGORY_ID'] ?? '101010101'
export const DISCORD_VOICE_LOBBY_CHANNEL_ID =
process.env['DISCORD_VOICE_LOBBY_CHANNEL_ID'] ?? '101010101'
export const DISCORD_NEWCOMERS_CHANNEL_ID =
process.env['DISCORD_NEWCOMERS_CHANNEL_ID'] ?? '101010101'
export const DISCORD_BOT_COMMANDS_CHANNEL_ID =
process.env['DISCORD_BOT_COMMANDS_CHANNEL_ID'] ?? '101010101'
export const DISCORD_RULES_CHANNEL_ID =
process.env['DISCORD_RULES_CHANNEL_ID'] ?? '101010101'
export const DISCORD_ASA_NEWS_CHANNEL_ID =
process.env['DISCORD_ASA_NEWS_CHANNEL_ID'] ?? '101010101'
export const DISCORD_TWEET_CHANNEL_ID =
process.env['DISCORD_TWEET_CHANNEL_ID'] ?? '101010101'
export const DISCORD_CLIENT_ID = process.env['DISCORD_CLIENT_ID'] ?? 'clientId'
export const DISCORD_TOKEN = process.env['DISCORD_TOKEN'] ?? 'token'
export const DISCORD_TOKEN_USER = process.env['DISCORD_TOKEN_USER'] ?? 'token'
export const GOOGLE_API_KEY = process.env['GOOGLE_API_KEY'] ?? 'key'
export const TWITCH_CLIENT_ID = process.env['TWITCH_CLIENT_ID'] ?? 'clientId'
export const TWITCH_CLIENT_SECRET =
process.env['TWITCH_CLIENT_SECRET'] ?? 'clientSecret'
export const STEAM_API_KEY = process.env['STEAM_API_KEY'] ?? 'key'
export const DISCORD_ADMIN_COMMANDS_CHANNEL_ID =
process.env['DISCORD_ADMIN_COMMANDS_CHANNEL_ID'] ?? '101010101'
export const SURVIVOR_BUTTON_ID = 'survivor-button-id'

View File

@ -0,0 +1,18 @@
import dotenv from 'dotenv'
dotenv.config()
/**
* QOTD (Question Of The Day)
*/
export const DISCORD_QOTD_REACT_CHANNEL_ID =
process.env['DISCORD_QOTD_REACT_CHANNEL_ID'] ?? '101010101'
export const DISCORD_QOTD_ROLE_ID =
process.env['DISCORD_QOTD_ROLE_ID'] ?? '101010101'
export const DISCORD_QOTD_QUESTIONS_CHANNEL_ID =
process.env['DISCORD_QOTD_QUESTIONS_CHANNEL_ID'] ?? '101010101'
export const DISCORD_QOTD_ANSWERS_CHANNEL_ID =
process.env['DISCORD_QOTD_ANSWERS_CHANNEL_ID'] ?? '101010101'
export const DISCORD_QUESTIONS_IDEAS_CHANNEL_ID =
process.env['DISCORD_QUESTIONS_IDEAS_CHANNEL_ID'] ?? '101010101'
export const QOTD_BUTTON_ID = 'qotd-button-id'

View File

@ -0,0 +1,163 @@
import { TextInputBuilder, TextInputStyle } from 'discord.js'
import dotenv from 'dotenv'
dotenv.config()
/**
* Support Tickets
*/
export const DISCORD_SUPPORT_TICKETS_CHANNEL_ID =
process.env['DISCORD_SUPPORT_TICKETS_CHANNEL_ID'] ?? '101010101'
export const DISCORD_SUPPORT_TICKETS_OPEN_CATEGORY_ID =
process.env['DISCORD_SUPPORT_TICKETS_OPEN_CATEGORY_ID'] ?? '101010101'
export const DISCORD_SUPPORT_TICKETS_CLOSED_CATEGORY_ID =
process.env['DISCORD_SUPPORT_TICKETS_CLOSED_CATEGORY_ID'] ?? '101010101'
export const SUPPORT_TICKETS_IMAGE_URL = 'https://i.imgur.com/6Amtfsx.png'
export const SUPPORT_TICKET_BUTTON_CLOSE_ID = 'support-ticket-button-close-id'
export const SUPPORT_TICKET_BUTTON_DELETE_ID = 'support-ticket-button-delete-id'
export const SUPPORT_TICKETS_SELECT_MENU_ID = 'support-tickets-select-menu-id'
export const SUPPORT_TICKETS_MEMBER_REPORT_ID =
'support-tickets-member-report-id'
export const SUPPORT_TICKETS_STAFF_ID = 'support-tickets-staff-id'
export const SUPPORT_TICKETS_PARTNER_ID = 'support-tickets-partner-id'
export const SUPPORT_TICKETS_ID_LIST = [
SUPPORT_TICKETS_MEMBER_REPORT_ID,
SUPPORT_TICKETS_STAFF_ID,
SUPPORT_TICKETS_PARTNER_ID
] as const
export type SupportTicketId = (typeof SUPPORT_TICKETS_ID_LIST)[number]
export interface SupportTicketCategory {
title: string
description: string
id: string
emoji: string
textInputs: TextInputBuilder[]
channelName: string
}
export const SUPPORT_TICKETS_CATEGORIES: ReadonlyMap<
SupportTicketId,
SupportTicketCategory
> = new Map([
[
SUPPORT_TICKETS_MEMBER_REPORT_ID,
{
title: 'Member Report',
description: 'Want to report a member?',
id: SUPPORT_TICKETS_MEMBER_REPORT_ID,
emoji: '❗',
textInputs: [
new TextInputBuilder()
.setCustomId('reportedMemberDiscordUsername')
.setLabel('Reported Member Discord Username')
.setPlaceholder('tieko.')
.setMinLength(1)
.setRequired(true)
.setStyle(TextInputStyle.Short),
new TextInputBuilder()
.setCustomId('rulesViolation')
.setLabel('Which rules did this member violate?')
.setPlaceholder('e.g: aggressive / toxic behaviour')
.setMinLength(1)
.setRequired(true)
.setStyle(TextInputStyle.Paragraph)
],
channelName: 'member-report'
}
],
[
SUPPORT_TICKETS_STAFF_ID,
{
title: 'Staff Application',
description: 'Want to apply for staff?',
id: SUPPORT_TICKETS_STAFF_ID,
emoji: '🛡️',
textInputs: [
new TextInputBuilder()
.setCustomId('name')
.setLabel('What is your name?')
.setPlaceholder('Tieko')
.setMinLength(1)
.setRequired(true)
.setStyle(TextInputStyle.Short),
new TextInputBuilder()
.setCustomId('timezone')
.setLabel('What is your time zone?')
.setPlaceholder('CEST')
.setMinLength(1)
.setRequired(true)
.setStyle(TextInputStyle.Short),
new TextInputBuilder()
.setCustomId('age')
.setLabel('How old are you?')
.setPlaceholder('18')
.setMinLength(1)
.setRequired(true)
.setStyle(TextInputStyle.Short),
new TextInputBuilder()
.setCustomId('alreadyStaff')
.setLabel('Have you been staff on a server before?')
.setPlaceholder('Yes/No...')
.setMinLength(1)
.setRequired(true)
.setStyle(TextInputStyle.Paragraph),
new TextInputBuilder()
.setCustomId('motivations')
.setLabel('Tell us what motivates you for this role?')
.setPlaceholder('I would like to help the ARK community...')
.setMinLength(1)
.setRequired(true)
.setStyle(TextInputStyle.Paragraph)
],
channelName: 'staff-apply'
}
],
[
SUPPORT_TICKETS_PARTNER_ID,
{
title: 'Partnerships',
description: 'Want to become a partner?',
id: SUPPORT_TICKETS_PARTNER_ID,
emoji: '🤝',
textInputs: [
new TextInputBuilder()
.setCustomId('interests')
.setLabel('Why are you interested in becoming a partner?')
.setPlaceholder(
'I believe that we can each contribute something valuable...'
)
.setMinLength(1)
.setRequired(true)
.setStyle(TextInputStyle.Paragraph),
new TextInputBuilder()
.setCustomId('partnerType')
.setLabel('What kind of partnership are you looking for?')
.setPlaceholder(
'A channel in our Discord with a link to your website.'
)
.setMinLength(1)
.setRequired(true)
.setStyle(TextInputStyle.Short),
new TextInputBuilder()
.setCustomId('getReturns')
.setLabel('What do we get in return?')
.setPlaceholder('A link to our Discord on your website.')
.setMinLength(1)
.setRequired(true)
.setStyle(TextInputStyle.Short),
new TextInputBuilder()
.setCustomId('otherInfo')
.setLabel('Other information')
.setPlaceholder('Other information you would like to share.')
.setMinLength(1)
.setRequired(true)
.setStyle(TextInputStyle.Paragraph)
],
channelName: 'partnership'
}
]
])

View File

@ -0,0 +1,46 @@
import { fileURLToPath } from 'node:url'
import type { TextChannel } from 'discord.js'
import { bold, channelMention, Events, userMention } from 'discord.js'
import { formatNumberOrdinals } from '@ark-unity/utils'
import {
DISCORD_RULES_CHANNEL_ID,
DISCORD_NEWCOMERS_CHANNEL_ID,
DISCORD_SURVIVOR_ROLE_ID
} from '#src/configuration/global.js'
import type { DiscordEvent } from '#src/services/discord/DiscordEvent.js'
import { DiscordClient } from '#src/services/discord/DiscordClient.js'
const guildMemberAdd: DiscordEvent<Events.GuildMemberAdd> = {
name: Events.GuildMemberAdd,
execute: async (member) => {
await member.roles.add(DISCORD_SURVIVOR_ROLE_ID)
const discordClient = await DiscordClient.getInstance()
let channel = discordClient.channels.cache.get(DISCORD_NEWCOMERS_CHANNEL_ID)
if (channel != null && channel.isTextBased()) {
channel = channel as TextChannel
const content = `Welcome to the ${bold(
member.guild.name
)} Discord server! ${userMention(member.user.id)}
You are the ${bold(
formatNumberOrdinals(member.guild.memberCount)
)} member! :star_struck:
Please read and accept the ${channelMention(
DISCORD_RULES_CHANNEL_ID
)} and most importantly, have fun!`
await channel.send({
content,
files: [
{
attachment: fileURLToPath(
new URL('../../public/ARKCommunity.jpg', import.meta.url)
)
}
]
})
}
}
}
export default guildMemberAdd

View File

@ -0,0 +1,49 @@
import { Events } from 'discord.js'
import { firstTruePromise } from '@ark-unity/utils'
import { DiscordClient } from '#src/services/discord/DiscordClient.js'
import type { DiscordEvent } from '#src/services/discord/DiscordEvent.js'
import { handleButtonInteractionRoles } from '#src/interactionCreate/handleButtonInteractionRoles.js'
import { handleSupportTicketsSelectMenu } from '#src/interactionCreate/handleSupportTicketsSelectMenu.js'
import { handleSupportTicketsModalSubmit } from '#src/interactionCreate/handleSupportTicketsModalSubmit.js'
import { handleSupportTicketsButtonCloseDelete } from '#src/interactionCreate/handleSupportTicketsButtonCloseDelete.js'
const interactionCreate: DiscordEvent<Events.InteractionCreate> = {
name: Events.InteractionCreate,
execute: async (interaction) => {
if (interaction.isStringSelectMenu()) {
await handleSupportTicketsSelectMenu(interaction)
return
}
if (interaction.isButton()) {
await firstTruePromise([
handleButtonInteractionRoles(interaction),
handleSupportTicketsButtonCloseDelete(interaction)
])
return
}
if (interaction.isModalSubmit()) {
await handleSupportTicketsModalSubmit(interaction)
return
}
if (!interaction.isChatInputCommand()) {
return
}
const discordClient = await DiscordClient.getInstance()
const command = discordClient.commands.get(interaction.commandName)
if (command == null) {
console.error(`Command \`${interaction.commandName}\` not found.`)
return
}
try {
await command.execute(interaction)
} catch (error) {
console.error(error)
await interaction.reply(
'There was an error while executing this command. Please try again later.'
)
}
}
}
export default interactionCreate

View File

@ -0,0 +1,20 @@
import { Events } from 'discord.js'
import type { DiscordEvent } from '#src/services/discord/DiscordEvent.js'
const ready: DiscordEvent<Events.ClientReady> = {
name: Events.ClientReady,
once: true,
execute: async (discordClient) => {
if (discordClient.user == null) {
return
}
await discordClient.user.setUsername('ARK Ascended Bot')
console.log(`Logged in as \`${discordClient.user.tag}\`!`)
console.log(
`Invite link: <https://discord.com/oauth2/authorize?client_id=${discordClient.user.id}&permissions=8&scope=bot>`
)
}
}
export default ready

View File

@ -0,0 +1,54 @@
import type { VoiceChannel } from 'discord.js'
import { ChannelType, Events, GuildMember } from 'discord.js'
import {
DISCORD_VOICE_LOBBY_CATEGORY_ID,
DISCORD_VOICE_LOBBY_CHANNEL_ID
} from '#src/configuration/global.js'
import type { DiscordEvent } from '#src/services/discord/DiscordEvent.js'
const deleteVoiceChannel = async (channel: VoiceChannel): Promise<void> => {
if (channel.deletable) {
try {
await channel.delete()
} catch {}
}
}
const voiceStateUpdate: DiscordEvent<Events.VoiceStateUpdate> = {
name: Events.VoiceStateUpdate,
execute: async (oldState, newState) => {
if (newState.channelId === oldState.channelId) {
return
}
if (
newState.channelId === DISCORD_VOICE_LOBBY_CHANNEL_ID &&
newState.member instanceof GuildMember
) {
const channel = await newState.guild.channels.create({
name: `${newState.member.displayName}'s Channel`,
type: ChannelType.GuildVoice,
parent: newState.channel?.parentId,
permissionOverwrites: [
{
id: newState.member.id,
allow: ['ManageChannels', 'MoveMembers']
}
]
})
await newState.member.voice.setChannel(channel.id)
}
if (
oldState.channelId !== DISCORD_VOICE_LOBBY_CHANNEL_ID &&
oldState.channel?.parentId === DISCORD_VOICE_LOBBY_CATEGORY_ID &&
oldState.channel?.type === ChannelType.GuildVoice &&
oldState.channel?.members.size === 0
) {
await deleteVoiceChannel(oldState.channel)
}
}
}
export default voiceStateUpdate

View File

@ -0,0 +1,4 @@
import { DiscordClient } from '#src/services/discord/DiscordClient.js'
const discordClient = await DiscordClient.getInstance()
await discordClient.login()

View File

@ -0,0 +1,108 @@
import { GuildMember } from 'discord.js'
import type { ButtonInteraction, CacheType } from 'discord.js'
import { firstTruePromise } from '@ark-unity/utils'
import {
DISCORD_SURVIVOR_ROLE_ID,
SURVIVOR_BUTTON_ID
} from '#src/configuration/global.js'
import {
DISCORD_QOTD_ROLE_ID,
QOTD_BUTTON_ID
} from '#src/configuration/qotd.js'
interface HandleButtonInteractionRoleOptions {
interaction: ButtonInteraction<CacheType>
buttonId: string
roleId: string
survivorRoleNeeded?: boolean
addRoleMessage: string
removeRoleMessage: string
shouldDisableRoleRemoval?: boolean
disableRoleRemovalMessage?: string
}
const handleButtonInteractionRole = async (
options: HandleButtonInteractionRoleOptions
): Promise<boolean> => {
const {
interaction,
buttonId,
roleId,
survivorRoleNeeded = false,
addRoleMessage,
removeRoleMessage,
shouldDisableRoleRemoval = false,
disableRoleRemovalMessage = 'You already have this role.'
} = options
if (interaction.replied) {
return false
}
if (!(interaction.member instanceof GuildMember)) {
return false
}
if (interaction.customId !== buttonId) {
return false
}
if (interaction.member.roles.cache.has(roleId)) {
if (shouldDisableRoleRemoval) {
await interaction.reply({
content: disableRoleRemovalMessage,
ephemeral: true
})
return true
}
await interaction.member.roles.remove(roleId)
await interaction.reply({
content: removeRoleMessage,
ephemeral: true
})
return true
}
if (
survivorRoleNeeded &&
!interaction.member.roles.cache.has(DISCORD_SURVIVOR_ROLE_ID)
) {
await interaction.reply({
content:
'You must have the Survivor role first before getting this role. <:Bob:1045749690022494208>',
ephemeral: true
})
return true
}
await interaction.member.roles.add(roleId)
await interaction.reply({
content: addRoleMessage,
ephemeral: true
})
return true
}
export const handleButtonInteractionRoles = async (
interaction: ButtonInteraction<CacheType>
): Promise<boolean> => {
return await firstTruePromise([
handleButtonInteractionRole({
interaction,
buttonId: SURVIVOR_BUTTON_ID,
roleId: DISCORD_SURVIVOR_ROLE_ID,
survivorRoleNeeded: false,
addRoleMessage: 'You are now a Survivor! <:Bob:1083009379890106471>',
disableRoleRemovalMessage:
'You are already a Survivor. <:Bob:1083009379890106471>',
removeRoleMessage:
'You are no longer a Survivor. <:dinocry:1085546900226318336>',
shouldDisableRoleRemoval: true
}),
handleButtonInteractionRole({
interaction,
buttonId: QOTD_BUTTON_ID,
roleId: DISCORD_QOTD_ROLE_ID,
survivorRoleNeeded: true,
addRoleMessage: 'You will now be notified for questions.',
removeRoleMessage: 'You will no longer be notified for questions.'
})
])
}

View File

@ -0,0 +1,121 @@
import {
GuildMember,
type ButtonInteraction,
type CacheType,
GuildChannel,
userMention,
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
EmbedBuilder,
bold
} from 'discord.js'
import { firstTruePromise } from '@ark-unity/utils'
import {
DISCORD_SUPPORT_TICKETS_CLOSED_CATEGORY_ID,
DISCORD_SUPPORT_TICKETS_OPEN_CATEGORY_ID,
SUPPORT_TICKET_BUTTON_CLOSE_ID,
SUPPORT_TICKET_BUTTON_DELETE_ID
} from '#src/configuration/support-tickets.js'
const handleSupportTicketsButtonClose = async (
interaction: ButtonInteraction<CacheType>
): Promise<boolean> => {
if (interaction.replied) {
return false
}
if (!(interaction.member instanceof GuildMember)) {
return false
}
if (!(interaction.channel instanceof GuildChannel)) {
return false
}
if (interaction.customId !== SUPPORT_TICKET_BUTTON_CLOSE_ID) {
return false
}
if (
interaction.channel.parentId !== DISCORD_SUPPORT_TICKETS_OPEN_CATEGORY_ID
) {
await interaction.reply({
ephemeral: true,
embeds: [
new EmbedBuilder()
.setDescription(
`⚠️ This ticket is ${bold(
'already closed'
)} or not in the correct category.`
)
.setColor([255, 97, 97])
]
})
return true
}
await interaction.channel.edit({
parent: DISCORD_SUPPORT_TICKETS_CLOSED_CATEGORY_ID,
lockPermissions: true
})
await interaction.reply({
embeds: [
new EmbedBuilder()
.setDescription(
`Ticket Closed by ${userMention(interaction.member.id)}.`
)
.setColor([255, 97, 97])
],
components: [
new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId(SUPPORT_TICKET_BUTTON_DELETE_ID)
.setLabel('Delete')
.setStyle(ButtonStyle.Danger)
)
]
})
return true
}
const handleSupportTicketsButtonDelete = async (
interaction: ButtonInteraction<CacheType>
): Promise<boolean> => {
if (interaction.replied) {
return false
}
if (!(interaction.member instanceof GuildMember)) {
return false
}
if (!(interaction.channel instanceof GuildChannel)) {
return false
}
if (interaction.customId !== SUPPORT_TICKET_BUTTON_DELETE_ID) {
return false
}
if (
interaction.channel.parentId !== DISCORD_SUPPORT_TICKETS_CLOSED_CATEGORY_ID
) {
await interaction.reply({
ephemeral: true,
embeds: [
new EmbedBuilder()
.setDescription(
`⚠️ This ticket is ${bold(
'already open'
)} or not in the correct category.`
)
.setColor([255, 97, 97])
]
})
return true
}
await interaction.channel.delete()
return true
}
export const handleSupportTicketsButtonCloseDelete = async (
interaction: ButtonInteraction<CacheType>
): Promise<boolean> => {
return await firstTruePromise([
handleSupportTicketsButtonClose(interaction),
handleSupportTicketsButtonDelete(interaction)
])
}

View File

@ -0,0 +1,97 @@
import {
GuildMember,
type CacheType,
type ModalSubmitInteraction,
Guild,
ChannelType,
userMention,
EmbedBuilder,
channelMention,
codeBlock,
ActionRowBuilder,
ButtonBuilder,
ButtonStyle
} from 'discord.js'
import type { SupportTicketId } from '#src/configuration/support-tickets.js'
import {
DISCORD_SUPPORT_TICKETS_OPEN_CATEGORY_ID,
SUPPORT_TICKETS_CATEGORIES,
SUPPORT_TICKETS_IMAGE_URL,
SUPPORT_TICKET_BUTTON_CLOSE_ID
} from '#src/configuration/support-tickets.js'
export const handleSupportTicketsModalSubmit = async (
interaction: ModalSubmitInteraction<CacheType>
): Promise<boolean> => {
if (interaction.replied) {
return false
}
if (!(interaction.member instanceof GuildMember)) {
return false
}
if (!(interaction.guild instanceof Guild)) {
return false
}
const category = SUPPORT_TICKETS_CATEGORIES.get(
interaction.customId as SupportTicketId
)
if (category == null) {
return false
}
const channelName = `${category.channelName}-${interaction.member.user.username}`
const channel = await interaction.guild.channels.create({
name: channelName,
type: ChannelType.GuildText,
parent: DISCORD_SUPPORT_TICKETS_OPEN_CATEGORY_ID
})
await channel.lockPermissions()
await channel.permissionOverwrites.edit(interaction.member.user.id, {
ViewChannel: true,
SendMessages: true
})
await channel.send({
content: userMention(interaction.member.id),
embeds: [
new EmbedBuilder()
.setTitle('ARK: Survival Ascended Community Discord Support')
.setDescription(
`
Support will be with you shortly.
To close this ticket react with the button below.
`
)
.setImage(SUPPORT_TICKETS_IMAGE_URL)
.setColor([56, 142, 60]),
new EmbedBuilder().setDescription(
category.textInputs
.map((textInput, index) => {
const label = textInput.data.label ?? `Field ${index}`
const value = interaction.fields.getTextInputValue(
textInput.data.custom_id ?? 'custom_id'
)
return `**${label}**\n${codeBlock(value)}`
})
.join('\n')
)
],
components: [
new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId(SUPPORT_TICKET_BUTTON_CLOSE_ID)
.setLabel('Close')
.setStyle(ButtonStyle.Danger)
)
]
})
await interaction.reply({
content: `✅ Ticket submitted successfully. Please check ${channelMention(
channel.id
)} for updates.`,
ephemeral: true
})
return true
}

View File

@ -0,0 +1,77 @@
import {
GuildMember,
ModalBuilder,
ActionRowBuilder,
Guild,
CategoryChannel,
channelMention
} from 'discord.js'
import type {
CacheType,
ModalActionRowComponentBuilder,
StringSelectMenuInteraction
} from 'discord.js'
import type { SupportTicketId } from '#src/configuration/support-tickets.js'
import {
DISCORD_SUPPORT_TICKETS_OPEN_CATEGORY_ID,
SUPPORT_TICKETS_CATEGORIES,
SUPPORT_TICKETS_SELECT_MENU_ID
} from '#src/configuration/support-tickets.js'
export const handleSupportTicketsSelectMenu = async (
interaction: StringSelectMenuInteraction<CacheType>
): Promise<boolean> => {
if (interaction.replied) {
return false
}
if (!(interaction.member instanceof GuildMember)) {
return false
}
if (!(interaction.guild instanceof Guild)) {
return false
}
if (interaction.customId !== SUPPORT_TICKETS_SELECT_MENU_ID) {
return false
}
const value = interaction.values[0]
const category = SUPPORT_TICKETS_CATEGORIES.get(value as SupportTicketId)
if (category == null) {
return false
}
const channelName = `${category.channelName}-${interaction.member.user.username}`
const openCategory = await interaction.guild.channels.fetch(
DISCORD_SUPPORT_TICKETS_OPEN_CATEGORY_ID
)
if (!(openCategory instanceof CategoryChannel)) {
return false
}
const openTicket = openCategory.children.cache.find((channel) => {
return channel.name === channelName
})
const hasAlreadyTicketOpen = openTicket != null
if (hasAlreadyTicketOpen) {
await interaction.reply({
content: `⚠️ You already have a ticket open in this category. Please check ${channelMention(
openTicket.id
)} for updates.`,
ephemeral: true
})
return true
}
await interaction.showModal(
new ModalBuilder()
.setCustomId(category.id)
.setTitle(category.title)
.addComponents(
...category.textInputs.map((textInput) => {
return new ActionRowBuilder<ModalActionRowComponentBuilder>().addComponents(
textInput
)
})
)
)
return true
}

View File

@ -0,0 +1,20 @@
import { Routes, EmbedBuilder } from 'discord.js'
// import { DISCORD_ADMIN_COMMANDS_CHANNEL_ID } from '#src/configuration/qotd.js'
import { DISCORD_QOTD_ANSWERS_CHANNEL_ID } from '#src/configuration/qotd.js'
import { discordRest } from '#src/services/discord/DiscordClient.js'
const QOTD_DAY_URL = 'https://i.imgur.com/JOTjHva.png'
// await discordRest.post(Routes.channelMessages(DISCORD_ADMIN_COMMANDS_CHANNEL_ID), {
await discordRest.post(
Routes.channelMessages(DISCORD_QOTD_ANSWERS_CHANNEL_ID),
{
body: {
embeds: [
new EmbedBuilder().setImage(QOTD_DAY_URL).setColor([255, 97, 97])
]
}
}
)
console.log('Successfully sent QOTD Day message.')

View File

@ -0,0 +1,54 @@
import {
Routes,
roleMention,
EmbedBuilder,
channelMention,
bold
} from 'discord.js'
// import { DISCORD_QOTD_ANSWERS_CHANNEL_ID, DISCORD_ADMIN_COMMANDS_CHANNEL_ID, DISCORD_QOTD_ROLE_ID } from '#src/configuration/qotd.js'
import {
DISCORD_QOTD_ANSWERS_CHANNEL_ID,
DISCORD_QOTD_QUESTIONS_CHANNEL_ID,
DISCORD_QOTD_ROLE_ID
} from '#src/configuration/qotd.js'
import { discordRest } from '#src/services/discord/DiscordClient.js'
const QOTD_QUESTION_URL = 'https://i.imgur.com/ist5BrW.jpg'
// await discordRest.post(Routes.channelMessages(DISCORD_ADMIN_COMMANDS_CHANNEL_ID), {
await discordRest.post(
Routes.channelMessages(DISCORD_QOTD_QUESTIONS_CHANNEL_ID),
{
body: {
content: `${roleMention(DISCORD_QOTD_ROLE_ID)}`,
embeds: [
new EmbedBuilder()
.setDescription(
`
## Question Of The Day #18
Following yesterday's question (day 17), about taming creatures.
Which ${bold(
'type of creature'
)} will be your favorite on ARK: Survival Ascended:
- 🦖 ${bold('Land')}
- 🐦 ${bold('Air')}
- 🐟 ${bold('Water')}
Discuss about it with other survivors in ${channelMention(
DISCORD_QOTD_ANSWERS_CHANNEL_ID
)}.
*(feel free to suggest questions ideas in ${channelMention(
'1082438215790772374'
)})*
`
)
.setImage(QOTD_QUESTION_URL)
.setColor([255, 97, 97])
]
}
}
)
console.log('Successfully sent QOTD Question message.')

View File

@ -0,0 +1,48 @@
import {
Routes,
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
roleMention,
EmbedBuilder
} from 'discord.js'
import {
QOTD_BUTTON_ID,
DISCORD_QOTD_REACT_CHANNEL_ID,
DISCORD_QOTD_ROLE_ID
} from '#src/configuration/qotd.js'
import { discordRest } from '#src/services/discord/DiscordClient.js'
const QOTD_URL = 'https://i.imgur.com/kB67d7N.png'
await discordRest.post(Routes.channelMessages(DISCORD_QOTD_REACT_CHANNEL_ID), {
body: {
embeds: [
new EmbedBuilder()
.setDescription(
`
## Question Of The Day
If you want to be notified for questions, polls, quizzes, please click on the button below to get the ${roleMention(
DISCORD_QOTD_ROLE_ID
)} role.
You can always unassign the role by clicking again on the button.
`
)
.setImage(QOTD_URL)
.setColor([255, 97, 97])
],
components: [
new ActionRowBuilder().addComponents(
new ButtonBuilder()
.setCustomId(QOTD_BUTTON_ID)
.setEmoji('<:QOTD:1155553559144841236>')
.setLabel('I want to be notified for questions.')
.setStyle(ButtonStyle.Primary)
)
]
}
})
console.log('Successfully sent QOTD message.')

View File

@ -0,0 +1,5 @@
import { DiscordClient } from '#src/services/discord/DiscordClient.js'
const discordClient = await DiscordClient.getInstance()
await discordClient.registerCommands()
console.log('Successfully reloaded application (/) commands.')

View File

@ -0,0 +1,136 @@
import { EmbedBuilder } from '@discordjs/builders'
import {
roleMention,
channelMention,
Routes,
bold,
ActionRowBuilder,
ButtonBuilder,
ButtonStyle
} from 'discord.js'
import {
DISCORD_ADMINISTRATOR_ROLE_ID,
DISCORD_BOT_COMMANDS_CHANNEL_ID,
DISCORD_CONTENT_CREATOR_ROLE_ID,
DISCORD_RULES_CHANNEL_ID,
DISCORD_SERVER_BOOSTER_ROLE_ID,
SURVIVOR_BUTTON_ID
} from '#src/configuration/global.js'
import { discordRest } from '#src/services/discord/DiscordClient.js'
const LOGO_URL = 'https://i.imgur.com/qfyWYkE.png'
const DISCORD_RULES_IMAGE_URL = 'https://imgur.com/HznoDDv.png'
await discordRest.post(Routes.channelMessages(DISCORD_RULES_CHANNEL_ID), {
body: {
content: 'https://discord.gg/playAscended',
embeds: [
new EmbedBuilder().setImage(LOGO_URL),
new EmbedBuilder()
.setTitle(
'Hello Survivors and welcome to the ARK Ascended Community Discord server!'
)
.setDescription(
`
Survive the past. Tame the future. Suddenly awakened on a primal world filled with dinosaurs and humans struggling for dominance, you must team-up with legendary heroes to confront powerful dark forces. Saddle up, and join the definitive next-gen survival experience!
This is your destination to discuss all things about ARK, stay up to date with the latest news, and find other players to play with and most importantly, have fun!
${bold('Not affiliated with Studio Wildcard. :bow_and_arrow:')}
`
)
.setFields(
{
name: bold('Roles'),
value: `
${roleMention(
DISCORD_SERVER_BOOSTER_ROLE_ID
)} - Boost the server to get this role and we're grateful for your support!
${roleMention(
DISCORD_CONTENT_CREATOR_ROLE_ID
)} - Use the \`/content-creator-role\` command to get this role.
Commands should be executed in the ${channelMention(
DISCORD_BOT_COMMANDS_CHANNEL_ID
)} channel.
`
},
{
name: 'Voice Channels',
value: `
${bold('•')} The voice channels are ${bold('self-moderated')}.
${bold('•')} You can create your ${bold(
'own voice channel'
)} by joining the \`Lobby\` voice channel.
You will be granted ownership of a new voice channel that you can rename and manage (only possible with 2FA enabled).
${bold(
'•'
)} Voice channels will automatically be deleted if the channel is empty.
`
},
{
name: bold('Links'),
value: `
${bold('•')} [ARKCountdown Twitch](https://www.twitch.tv/arkcountdown)
${bold('•')} [ARK Ascended News Twitter](https://twitter.com/ARKAscendedNews)
${bold(
'•'
)} [ARK Ascended News Instagram](https://www.instagram.com/arkascendednews)
${bold(
'•'
)} [ARK 2 Steam page](https://store.steampowered.com/app/2050420/ARK_2/)
`
}
),
new EmbedBuilder().setImage(DISCORD_RULES_IMAGE_URL),
new EmbedBuilder()
.setDescription(
`
We want everyone to enjoy their time in the community, so we've laid down a few rules to keep this server a safe and positive environment for all.
These rules will be enforced by our team of ${roleMention(
DISCORD_ADMINISTRATOR_ROLE_ID
)} volunteers, so please read them carefully and adhere to them at all times, and follow the direction of our moderation team. Failure to do so could result in a mute or ban.`
)
.setFields({
name: bold('General Rules:'),
value: `
${bold('•')} Use common sense.
${bold('•')} The primary language of this server is English.
${bold('•')} Do not publicly accuse other users/players of misconduct.
${bold('•')} Do not spam messages, images, emoji, commands, and @mentions.
${bold(
'•'
)} Trading, selling, begging, boosting and account sharing are not allowed.
${bold('•')} Aggressive or 'toxic' behaviour is forbidden.
${bold('•')} Multiple accounts are not permitted.
${bold(
'•'
)} No posting content related to piracy, cheats, cracks, exploits along with NSFW.
The following documents are also strictly enforced:
${bold('[Discord Community Guidelines](https://dis.gd/guidelines)')}
${bold('[Discord Terms of Service](https://dis.gd/terms)')}
`
}),
new EmbedBuilder()
.setColor([220, 148, 22])
.setDescription(
bold(
':warning: To access the community channels, after reading and agreeing to the rules, click on the button below to receive the Survivor role.'
)
)
],
components: [
new ActionRowBuilder().addComponents(
new ButtonBuilder()
.setCustomId(SURVIVOR_BUTTON_ID)
.setEmoji('✅')
.setLabel('I am a survivor and accept the rules!')
.setStyle(ButtonStyle.Primary)
)
]
}
})
console.log('Successfully sent rules message.')

View File

@ -0,0 +1,58 @@
import {
Routes,
bold,
EmbedBuilder,
StringSelectMenuBuilder,
StringSelectMenuOptionBuilder,
ActionRowBuilder
} from 'discord.js'
import {
DISCORD_SUPPORT_TICKETS_CHANNEL_ID,
SUPPORT_TICKETS_IMAGE_URL,
SUPPORT_TICKETS_SELECT_MENU_ID,
SUPPORT_TICKETS_CATEGORIES
} from '#src/configuration/support-tickets.js'
import { discordRest } from '#src/services/discord/DiscordClient.js'
await discordRest.post(
Routes.channelMessages(DISCORD_SUPPORT_TICKETS_CHANNEL_ID),
{
body: {
embeds: [
new EmbedBuilder()
.setTitle('ARK: Survival Ascended Community Discord Support')
.setDescription(
`
We appreciate you reaching out to our ${bold(
'ARK: Survival Ascended'
)} support channel.
To help us better understand you and provide you with the most appropriate assistance, please provide as much detail as possible in your support ticket.
Our team is dedicated to delivering timely and effective support and we will do our utmost to respond as quickly as possible.
`
)
.setImage(SUPPORT_TICKETS_IMAGE_URL)
.setColor([56, 142, 60])
],
components: [
new ActionRowBuilder().addComponents(
new StringSelectMenuBuilder()
.setCustomId(SUPPORT_TICKETS_SELECT_MENU_ID)
.setPlaceholder('Select an option to create a ticket')
.setMaxValues(1)
.addOptions(
[...SUPPORT_TICKETS_CATEGORIES.values()].map((category) => {
return new StringSelectMenuOptionBuilder()
.setLabel(category.title)
.setDescription(category.description)
.setValue(category.id)
.setEmoji(category.emoji)
})
)
)
]
}
}
)
console.log('Successfully sent Support Tickets message.')

View File

@ -0,0 +1,165 @@
import fs from 'node:fs'
import axios from 'axios'
import type { Invite, User } from 'discord.js'
import { Client, Collection, GatewayIntentBits, REST, Routes } from 'discord.js'
import type { DiscordEvent } from '#src/services/discord/DiscordEvent.js'
import type { DiscordCommand } from '#src/services/discord/DiscordCommand.js'
import {
DISCORD_CLIENT_ID,
DISCORD_GUILD_ID,
DISCORD_TOKEN,
DISCORD_TOKEN_USER
} from '#src/configuration/global.js'
const DISCORD_API_VERSION = '10'
export const discordAPI = axios.create({
baseURL: `https://discord.com/api/v${DISCORD_API_VERSION}/`,
headers: {
'Content-Type': 'application/json',
Authorization: DISCORD_TOKEN_USER
}
})
export const discordRest = new REST({ version: DISCORD_API_VERSION }).setToken(
DISCORD_TOKEN
)
const filesOnlyGetJavaScriptFiles = (files: string[]): string[] => {
return files.filter((file) => {
return file.endsWith('.js')
})
}
export class DiscordClient extends Client {
private static instance: DiscordClient | null = null
public static COMMANDS_URL = new URL('../../commands/', import.meta.url)
public static EVENTS_URL = new URL('../../events/', import.meta.url)
public commands: Collection<string, DiscordCommand> = new Collection()
private constructor() {
super({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMembers,
GatewayIntentBits.MessageContent,
GatewayIntentBits.GuildVoiceStates
]
})
this.token = DISCORD_TOKEN
}
private async saveCommands(): Promise<void> {
const commandsFiles = filesOnlyGetJavaScriptFiles(
await fs.promises.readdir(DiscordClient.COMMANDS_URL)
)
for (const commandFile of commandsFiles) {
const { default: command } = await import(
new URL(`./${commandFile}`, DiscordClient.COMMANDS_URL).href
)
this.commands.set(command.data.name, command)
}
}
public static async getInstance(): Promise<DiscordClient> {
if (DiscordClient.instance == null) {
DiscordClient.instance = new DiscordClient()
await DiscordClient.instance.saveCommands()
await DiscordClient.instance.loadEvents()
}
return DiscordClient.instance
}
public async loadEvents(): Promise<void> {
const eventsFiles = filesOnlyGetJavaScriptFiles(
await fs.promises.readdir(DiscordClient.EVENTS_URL)
)
for (const eventFile of eventsFiles) {
const { default: event } = (await import(
new URL(`./${eventFile}`, DiscordClient.EVENTS_URL).href
)) as { default: DiscordEvent<any> }
const eventExecute = async (...parameters: unknown[]): Promise<void> => {
try {
await event.execute(...parameters)
} catch (error) {
console.error(error)
}
}
if (event.once ?? false) {
this.once(event.name, eventExecute)
} else {
this.on(event.name, eventExecute)
}
}
}
public async registerCommands(): Promise<void> {
await discordRest.put(
Routes.applicationGuildCommands(DISCORD_CLIENT_ID, DISCORD_GUILD_ID),
{
body: this.commands.map((command) => {
return command.data
})
}
)
}
/**
*
* @param invitations
* @returns
*/
public async getUsersInvitations(
invitations?: Collection<string, Invite>
): Promise<UsersInvitations> {
if (invitations == null) {
throw new Error('Failed to fetch invitations')
}
const usersInvitations: UsersInvitations = new Map()
for (const invitation of invitations.values()) {
if (invitation.inviter == null) {
continue
}
const userInvitations = usersInvitations.get(invitation.inviter.id)
if (userInvitations == null) {
usersInvitations.set(invitation.inviter.id, {
invitationsCount: invitation.uses ?? 0,
inviter: invitation.inviter
})
} else {
userInvitations.invitationsCount += invitation.uses ?? 0
}
}
return usersInvitations
}
}
/**
* Key: User Id
*
* Value: Number of invitations
*/
export type UsersInvitations = Map<string, UserInvitations>
export interface UserInvitations {
invitationsCount: number
inviter: User
}
export interface ConnectedAccount {
type: 'github' | 'steam' | 'twitch' | 'twitter' | 'youtube'
id: string
name: string
verified: boolean
}
/**
* /users/{id}/profile
*/
export interface DiscordGetUserProfileResponse {
connected_accounts: ConnectedAccount[]
}

View File

@ -0,0 +1,12 @@
import type {
CacheType,
ChatInputCommandInteraction,
RESTPostAPIChatInputApplicationCommandsJSONBody
} from 'discord.js'
export interface DiscordCommand {
data: RESTPostAPIChatInputApplicationCommandsJSONBody
execute: (
interaction: ChatInputCommandInteraction<CacheType>
) => Promise<void>
}

View File

@ -0,0 +1,9 @@
import type { ClientEvents, Events } from 'discord.js'
export interface DiscordEvent<
K extends keyof ClientEvents = Events.ClientReady
> {
name: K
once?: boolean
execute: (..._arguments: ClientEvents[K]) => Promise<void>
}

View File

@ -0,0 +1,30 @@
import { youtube as youtubeAPI } from '@googleapis/youtube'
import { GOOGLE_API_KEY } from '#src/configuration/global.js'
import type { ConnectedAccount } from '#src/services/discord/DiscordClient.js'
export const youtube = youtubeAPI({
version: 'v3',
auth: GOOGLE_API_KEY
})
export const getYouTubeSubscriberCount = async (
account: ConnectedAccount
): Promise<number> => {
const channels = await youtube.channels.list({
part: ['statistics'],
id: [account.id]
})
if (channels.data.items == null || channels.data.items.length === 0) {
throw new Error('No YouTube channels found')
}
const channel = channels.data.items[0]
if (channel?.statistics == null) {
throw new Error('No YouTube channel statistics found')
}
const { subscriberCount } = channel.statistics
if (subscriberCount == null) {
throw new Error('No YouTube channel subscriber count found')
}
return Number.parseInt(subscriberCount, 10)
}

View File

@ -0,0 +1,52 @@
import axios from 'axios'
import { STEAM_API_KEY } from '#src/configuration/global.js'
/**
* Documentation: <https://partner.steamgames.com/doc/webapi>
*/
export const steamAPI = axios.create({
baseURL: 'https://api.steampowered.com/',
headers: {
'Content-Type': 'application/json'
}
})
export const ARK2_APP_ID = 2050420
export const ARK_SURVIVAL_EVOLVED_APP_ID = 346110
/**
* /ISteamUserStats/GetNumberOfCurrentPlayers/v1/
*/
export interface SteamGetNumberOfCurrentPlayersResponse {
response: {
player_count: number
}
}
/**
* /ISteamUser/GetPlayerSummaries/v2/
*/
export interface SteamGetPlayerSummaries {
response: {
players: Array<{
steamid: string
personaname: string
}>
}
}
export const steamCheckPlayerSteamId = async (
steamId: string
): Promise<boolean> => {
const { data } = await steamAPI.get<SteamGetPlayerSummaries>(
'/ISteamUser/GetPlayerSummaries/v2/',
{
params: {
key: STEAM_API_KEY,
steamids: steamId
}
}
)
return data.response.players.length === 1
}

View File

@ -0,0 +1,78 @@
import axios from 'axios'
import {
TWITCH_CLIENT_ID,
TWITCH_CLIENT_SECRET
} from '#src/configuration/global.js'
import type { ConnectedAccount } from '#src/services/discord/DiscordClient.js'
/**
* Documentation: <https://partner.steamgames.com/doc/webapi>
*/
export const twitchAPI = axios.create({
baseURL: 'https://api.twitch.tv/helix/',
headers: {
'Content-Type': 'application/json'
}
})
export const twitchOAuthAPI = axios.create({
baseURL: 'https://id.twitch.tv/oauth2/',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
})
/**
* /users/follows
*/
export interface TwitchGetUsersFollowsResponse {
total: number
}
/**
* /token
*/
export interface TwitchCredentials {
access_token: string
expires_in: number
token_type: 'bearer'
}
export interface TwitchCredendialsExtended extends TwitchCredentials {
access_token_age: number
}
let credentials: TwitchCredendialsExtended | null = null
export const getTwitchFollowerCount = async (
account: ConnectedAccount
): Promise<number> => {
const isValidCredentials =
credentials !== null &&
credentials.access_token_age + credentials.expires_in > Date.now()
if (credentials == null || !isValidCredentials) {
const parameters = new URLSearchParams()
parameters.append('client_id', TWITCH_CLIENT_ID)
parameters.append('client_secret', TWITCH_CLIENT_SECRET)
parameters.append('grant_type', 'client_credentials')
const { data: twitchCredentials } =
await twitchOAuthAPI.post<TwitchCredentials>('/token', parameters)
credentials = {
...twitchCredentials,
access_token_age: Date.now()
}
}
const { data: twitchData } =
await twitchAPI.get<TwitchGetUsersFollowsResponse>('/channels/followers', {
headers: {
Authorization: `Bearer ${credentials.access_token}`,
'Client-ID': TWITCH_CLIENT_ID
},
params: {
broadcaster_id: account.id
}
})
const followerCount = twitchData.total
return followerCount
}

View File

@ -0,0 +1,17 @@
{
"extends": "@ark-unity/tsconfig/tsconfig.json",
"compilerOptions": {
"target": "ESNext",
"lib": ["ESNext"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./build",
"rootDir": "./src",
"baseUrl": ".",
"paths": {
"#src/*": ["./src/*"]
},
"checkJs": false
},
"exclude": ["node_modules", "build"]
}

6707
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

29
package.json Normal file
View File

@ -0,0 +1,29 @@
{
"name": "ark-unity",
"version": "1.0.0",
"private": true,
"engines": {
"node": ">=20.0.0",
"npm": ">=10.0.0"
},
"workspaces": [
"apps/*",
"packages/*"
],
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev --parallel",
"test": "turbo run test",
"lint": "turbo run lint",
"lint:editorconfig": "editorconfig-checker",
"lint:prettier": "prettier . --check"
},
"devDependencies": {
"@ark-unity/eslint-config-custom": "*",
"editorconfig-checker": "5.1.1",
"prettier": "3.0.3",
"prettier-plugin-tailwindcss": "0.5.6",
"turbo": "1.10.15",
"typescript": "5.2.2"
}
}

View File

@ -0,0 +1,12 @@
{
"extends": ["conventions", "turbo", "prettier"],
"plugins": ["prettier"],
"parser": "@typescript-eslint/parser",
"env": {
"node": true,
"browser": true
},
"rules": {
"prettier/prettier": "error"
}
}

View File

@ -0,0 +1,22 @@
{
"name": "@ark-unity/eslint-config-custom",
"version": "1.0.0",
"private": true,
"main": ".eslintrc.json",
"files": [
".eslintrc.json"
],
"devDependencies": {
"@typescript-eslint/eslint-plugin": "6.8.0",
"@typescript-eslint/parser": "6.8.0",
"eslint": "8.51.0",
"eslint-config-conventions": "11.0.1",
"eslint-config-prettier": "9.0.0",
"eslint-config-turbo": "1.10.15",
"eslint-plugin-import": "2.28.1",
"eslint-plugin-prettier": "5.0.1",
"eslint-plugin-promise": "6.1.1",
"eslint-plugin-unicorn": "48.0.1",
"typescript": "5.2.2"
}
}

View File

@ -0,0 +1,8 @@
{
"name": "@ark-unity/tsconfig",
"version": "1.0.0",
"private": true,
"files": [
"tsconfig.json"
]
}

View File

@ -0,0 +1,19 @@
{
"compilerOptions": {
"strict": true,
"allowUnusedLabels": false,
"allowUnreachableCode": false,
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noPropertyAccessFromIndexSignature": true,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"checkJs": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"exclude": ["node_modules", "build"]
}

View File

@ -0,0 +1,10 @@
{
"root": true,
"extends": ["@ark-unity/eslint-config-custom"],
"parserOptions": {
"project": "./tsconfig.json"
},
"rules": {
"import/extensions": ["error", "always"]
}
}

13
packages/utils/.swcrc Normal file
View File

@ -0,0 +1,13 @@
{
"sourceMaps": true,
"jsc": {
"parser": {
"syntax": "typescript",
"dynamicImport": true
},
"target": "esnext"
},
"module": {
"type": "es6"
}
}

View File

@ -0,0 +1,35 @@
{
"name": "@ark-unity/utils",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "./build/index.js",
"types": "./build/index.d.ts",
"files": [
"build",
"!**/*.test.js",
"!**/*.test.d.ts",
"!**/*.map"
],
"scripts": {
"build": "rimraf ./build && swc ./src --out-dir ./build && tsc",
"dev:build": "swc ./src --out-dir ./build --watch",
"dev:tsc": "tsc --watch --preserveWatchOutput",
"dev": "concurrently --names \"swc,tsc\" \"npm run dev:build\" \"npm run dev:tsc\"",
"lint": "eslint ./src --max-warnings 0 --report-unused-disable-directives",
"test": "cross-env NODE_ENV=test node --enable-source-maps --test build/"
},
"devDependencies": {
"@ark-unity/eslint-config-custom": "*",
"@ark-unity/tsconfig": "*",
"@swc/cli": "0.1.62",
"@swc/core": "1.3.93",
"@types/node": "20.8.6",
"chokidar": "3.5.3",
"concurrently": "8.2.1",
"cross-env": "7.0.3",
"eslint": "8.51.0",
"rimraf": "5.0.5",
"typescript": "5.2.2"
}
}

View File

@ -0,0 +1,50 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { formatNumberOrdinals } from '../formatNumberOrdinals.js'
await test('utils/formatNumberOrdinals', async () => {
assert.strictEqual(formatNumberOrdinals(1), '1st')
assert.strictEqual(formatNumberOrdinals(2), '2nd')
assert.strictEqual(formatNumberOrdinals(3), '3rd')
assert.strictEqual(formatNumberOrdinals(4), '4th')
assert.strictEqual(formatNumberOrdinals(5), '5th')
assert.strictEqual(formatNumberOrdinals(6), '6th')
assert.strictEqual(formatNumberOrdinals(7), '7th')
assert.strictEqual(formatNumberOrdinals(8), '8th')
assert.strictEqual(formatNumberOrdinals(9), '9th')
assert.strictEqual(formatNumberOrdinals(10), '10th')
assert.strictEqual(formatNumberOrdinals(11), '11th')
assert.strictEqual(formatNumberOrdinals(12), '12th')
assert.strictEqual(formatNumberOrdinals(13), '13th')
assert.strictEqual(formatNumberOrdinals(14), '14th')
assert.strictEqual(formatNumberOrdinals(15), '15th')
assert.strictEqual(formatNumberOrdinals(16), '16th')
assert.strictEqual(formatNumberOrdinals(17), '17th')
assert.strictEqual(formatNumberOrdinals(18), '18th')
assert.strictEqual(formatNumberOrdinals(19), '19th')
assert.strictEqual(formatNumberOrdinals(20), '20th')
assert.strictEqual(formatNumberOrdinals(21), '21st')
assert.strictEqual(formatNumberOrdinals(22), '22nd')
assert.strictEqual(formatNumberOrdinals(23), '23rd')
assert.strictEqual(formatNumberOrdinals(24), '24th')
assert.strictEqual(formatNumberOrdinals(25), '25th')
assert.strictEqual(formatNumberOrdinals(26), '26th')
assert.strictEqual(formatNumberOrdinals(27), '27th')
assert.strictEqual(formatNumberOrdinals(28), '28th')
assert.strictEqual(formatNumberOrdinals(29), '29th')
assert.strictEqual(formatNumberOrdinals(30), '30th')
assert.strictEqual(formatNumberOrdinals(31), '31st')
assert.strictEqual(formatNumberOrdinals(32), '32nd')
assert.strictEqual(formatNumberOrdinals(33), '33rd')
assert.strictEqual(formatNumberOrdinals(34), '34th')
assert.strictEqual(formatNumberOrdinals(35), '35th')
assert.strictEqual(formatNumberOrdinals(501), '501st')
assert.strictEqual(formatNumberOrdinals(502), '502nd')
assert.strictEqual(formatNumberOrdinals(503), '503rd')
assert.strictEqual(formatNumberOrdinals(504), '504th')
assert.strictEqual(formatNumberOrdinals(571), '571st')
assert.strictEqual(formatNumberOrdinals(572), '572nd')
assert.strictEqual(formatNumberOrdinals(573), '573rd')
assert.strictEqual(formatNumberOrdinals(574), '574th')
})

View File

@ -0,0 +1,13 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { pluralizeWord } from '../pluralizeWord.js'
await test('utils/pluralizeWord', async () => {
assert.strictEqual(pluralizeWord('word', 0), 'word')
assert.strictEqual(pluralizeWord('word', 1), 'word')
assert.strictEqual(pluralizeWord('word', 2), 'words')
assert.strictEqual(pluralizeWord('word', 3), 'words')
assert.strictEqual(pluralizeWord('member', 1), 'member')
assert.strictEqual(pluralizeWord('member', 571), 'members')
})

View File

@ -0,0 +1,11 @@
export const firstTruePromise = async (
promises: Array<Promise<boolean>>
): Promise<boolean> => {
for (const promise of promises) {
const result = await promise
if (result) {
return true
}
}
return false
}

View File

@ -0,0 +1,18 @@
export const LOCALE = 'en-US' as const
const pluralRules = new Intl.PluralRules(LOCALE, { type: 'ordinal' })
const suffixes = {
zero: 'th',
one: 'st',
two: 'nd',
few: 'rd',
many: 'th',
other: 'th'
} as const
export const formatNumberOrdinals = (number: number): string => {
const rule = pluralRules.select(number)
const suffix = suffixes[rule]
return `${number.toLocaleString(LOCALE)}${suffix}`
}

View File

@ -0,0 +1,9 @@
/**
* Get a random number between a minimum (inclusive) and maximum (inclusive) value.
* @param minimum
* @param maximum
* @returns
*/
export const getRandomNumber = (minimum: number, maximum: number): number => {
return Math.floor(Math.random() * (maximum - minimum + 1)) + minimum
}

View File

@ -0,0 +1,4 @@
export * from './firstTruePromise.js'
export * from './formatNumberOrdinals.js'
export * from './getRandomNumber.js'
export * from './pluralizeWord.js'

View File

@ -0,0 +1,6 @@
export const pluralizeWord = (word: string, count: number): string => {
if (count <= 1) {
return word
}
return `${word}s`
}

View File

@ -0,0 +1,14 @@
{
"extends": "@ark-unity/tsconfig/tsconfig.json",
"compilerOptions": {
"target": "ESNext",
"lib": ["ESNext"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./build",
"rootDir": "./src",
"emitDeclarationOnly": true,
"declaration": true
},
"exclude": ["node_modules", "build"]
}

25
turbo.json Normal file
View File

@ -0,0 +1,25 @@
{
"$schema": "https://turbo.build/schema.json",
"globalEnv": ["NODE_ENV"],
"globalDependencies": ["**/.env"],
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", "build/**", ".next/**", "!.next/cache/**"],
"inputs": ["**/*.tsx", "**/*.ts"]
},
"test": {
"dependsOn": ["build"],
"inputs": ["**/*.tsx", "**/*.ts"]
},
"lint": {
"dependsOn": ["build"],
"outputs": [],
"inputs": ["**/*.tsx", "**/*.ts"]
},
"dev": {
"cache": false,
"persistent": true
}
}
}