diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index a5dfb0d..5bcdcaa 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,20 +1,18 @@ { - "name": "Codercord", - "image": "oven/bun:debian", - "features": { - "ghcr.io/devcontainers/features/git:1": {}, - "ghcr.io/devcontainers/features/github-cli:1": {}, - "ghcr.io/devcontainers/features/common-utils:2": {} - }, - "customizations": { - "vscode": { - "extensions": ["oven.bun-vscode", "biomejs.biome"], - "settings": { - "editor.defaultFormatter": "biomejs.biome", - "editor.formatOnSave": true - } - } - }, - "postCreateCommand": "bun install", - "remoteUser": "codercord" + "name": "Python 3", + "image": "mcr.microsoft.com/devcontainers/python:1-3.12-bullseye", + "postCreateCommand": "pip3 install -U py-cord", + + // Configure tool-specific properties. + "customizations": { + "vscode": { + "extensions": [ + "ms-python.python", + "ms-python.vscode-pylance", + "ms-python.black-formatter", + "charliermarsh.ruff", + "GitHub.copilot" + ] + } + } } diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 99f7c1c..0000000 --- a/.dockerignore +++ /dev/null @@ -1,11 +0,0 @@ -# Dependencies -node_modules/ - -# Configuration files -config.json -*.env - -# Other -.devcontainer/ -.github/ -.vscode/ \ No newline at end of file diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml deleted file mode 100644 index 5b955ba..0000000 --- a/.github/dependabot.yaml +++ /dev/null @@ -1,9 +0,0 @@ -version: 2 -updates: - - package-ecosystem: "npm" - directory: "/" - schedule: - interval: "weekly" - open-pull-requests-limit: 10 - commit-message: - prefix: "chore:" diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml deleted file mode 100644 index 02704a7..0000000 --- a/.github/workflows/ci.yaml +++ /dev/null @@ -1,20 +0,0 @@ -name: CI - -on: - push: - branches: ["main"] - pull_request: - -jobs: - check: - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - uses: actions/checkout@v4 - - uses: oven-sh/setup-bun@v1 - - name: Format and Lint - run: | - bun install --frozen-lockfile - bun format:ci - bun lint:ci \ No newline at end of file diff --git a/.github/workflows/deploy-docker.yaml b/.github/workflows/deploy-docker.yaml deleted file mode 100644 index f7dd048..0000000 --- a/.github/workflows/deploy-docker.yaml +++ /dev/null @@ -1,58 +0,0 @@ -name: Publish Docker image - -on: - push: - branches: ['main'] - -# Defines two custom environment variables for the workflow. These are used for the Container registry domain, and a name for the Docker image that this workflow builds. -env: - REGISTRY: ghcr.io - IMAGE_NAME: ${{ github.repository }} - -# There is a single job in this workflow. It's configured to run on the latest available version of Ubuntu. -jobs: - build-and-push-image: - runs-on: ubuntu-latest - # Sets the permissions granted to the `GITHUB_TOKEN` for the actions in this job. - permissions: - contents: read - packages: write - attestations: write - id-token: write - # - steps: - - name: Checkout repository - uses: actions/checkout@v4 - # Uses the `docker/login-action` action to log in to the Container registry registry using the account and password that will publish the packages. Once published, the packages are scoped to the account defined here. - - name: Log in to the Container registry - uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - # This step uses [docker/metadata-action](https://github.com/docker/metadata-action#about) to extract tags and labels that will be applied to the specified image. The `id` "meta" allows the output of this step to be referenced in a subsequent step. The `images` value provides the base name for the tags and labels. - - name: Extract metadata (tags, labels) for Docker - id: meta - uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - # This step uses the `docker/build-push-action` action to build the image, based on your repository's `Dockerfile`. If the build succeeds, it pushes the image to GitHub Packages. - # It uses the `context` parameter to define the build's context as the set of files located in the specified path. For more information, see "[Usage](https://github.com/docker/build-push-action#usage)" in the README of the `docker/build-push-action` repository. - # It uses the `tags` and `labels` parameters to tag and label the image with the output from the "meta" step. - - name: Build and push Docker image - id: push - uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4 - with: - context: . - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - - # This step generates an artifact attestation for the image, which is an unforgeable statement about where and how it was built. It increases supply chain security for people who consume the image. For more information, see "[AUTOTITLE](/actions/security-guides/using-artifact-attestations-to-establish-provenance-for-builds)." - - name: Generate artifact attestation - uses: actions/attest-build-provenance@v1 - with: - subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}} - subject-digest: ${{ steps.push.outputs.digest }} - push-to-registry: true - diff --git a/.gitignore b/.gitignore deleted file mode 100644 index f2302cd..0000000 --- a/.gitignore +++ /dev/null @@ -1,6 +0,0 @@ -# Dependencies -node_modules/ - -# Configuration files -config.json -*.env \ No newline at end of file diff --git a/.vscode/extensions.json b/.vscode/extensions.json deleted file mode 100644 index ac23b14..0000000 --- a/.vscode/extensions.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "recommendations": ["oven.bun-vscode", "biomejs.biome"] -} diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 462a5c0..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "editor.defaultFormatter": "biomejs.biome" -} diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 5bda9a2..0000000 --- a/Dockerfile +++ /dev/null @@ -1,11 +0,0 @@ -FROM oven/bun:1 AS base - -WORKDIR /usr/src/app -COPY . . - -ENV NODE_ENV=production -RUN bun install --frozen-lockfile --production - -# run the app -USER bun -ENTRYPOINT [ "bun", "start" ] \ No newline at end of file diff --git a/README.md b/README.md deleted file mode 100644 index dc2df4c..0000000 --- a/README.md +++ /dev/null @@ -1,34 +0,0 @@ -# Codercord - -A Discord bot for the Coder community server written in TypeScript. - -## How to run - -1. Clone the repository - - ```sh - git clone git@github.com:coder/codercord.git - cd codercord - ``` - -2. Configure the project - -3. Run the project - - ``` - bun start - ``` - -## Configuration - -### Environment variables (case sensitive) - -- `Codercord_token` : The Discord bot's token - -### Configuration file - -Example `config.json` provided [here](https://github.com/coder/codercord/blob/typescript/config.json.example) - -## Contributing - -Use the `.devcontainer` to develop in a containerized environment. diff --git a/assets/tags.json b/assets/tags.json deleted file mode 100644 index d62470c..0000000 --- a/assets/tags.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "no_programming": "This is is **NOT** a programming help server !\nYou are looking at the discord server, which is a product that lets you use remote machines as development environments.\n\nyou can ask programming-related questions at or ", - "post_status": "You can close posts by running ``/close`` or reopen them by runing ``/reopen``" -} diff --git a/biome.json b/biome.json deleted file mode 100644 index 24d2417..0000000 --- a/biome.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "$schema": "https://biomejs.dev/schemas/1.8.2/schema.json", - "organizeImports": { - "enabled": true - }, - - "files": { - "ignore": ["package.json", "bun.lockb", "*.md"] - }, - - "formatter": { - "indentWidth": 2, - "indentStyle": "space", - "lineWidth": 80 - }, - - "linter": { - "enabled": true, - "rules": { - "recommended": true, - - "style": { - "noUselessElse": "off" - } - } - }, - - "vcs": { - "enabled": true, - "clientKind": "git", - "useIgnoreFile": true - } -} diff --git a/bun.lockb b/bun.lockb deleted file mode 100755 index 0989ef0..0000000 Binary files a/bun.lockb and /dev/null differ diff --git a/config.json.example b/config.json.example deleted file mode 100644 index 3d876a4..0000000 --- a/config.json.example +++ /dev/null @@ -1,17 +0,0 @@ -{ - "serverId": "747933592273027093", - - "helpChannel": { - "closedTag": "1006926031434813500", - "id": "1006346052317753414", - "openedTag": "1063924583847170098" - }, - - "emojis": { - "coder": "971867583156469840", - "linux": "1078434842309566575", - "macos": "1078432543696748634", - "windows": "1078432538940416030", - "vscode": "1078432889995268248" - } -} diff --git a/package.json b/package.json deleted file mode 100644 index cc3ee23..0000000 --- a/package.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "codercord", - "version": "2.0.0", - "description": "A Discord bot for our community server", - "main": "src/index.ts", - "type": "module", - "scripts": { - "start": "bun .", - "watch": "bun --watch .", - "format": "biome format --write", - "format:ci": "biome format --reporter=github --verbose", - "lint": "biome lint --write", - "lint:ci": "biome lint --reporter=github --verbose" - }, - "keywords": [], - "author": "github.com/coder", - "license": "CC0-1.0", - "devDependencies": { - "@biomejs/biome": "^1.8.3", - "typescript": "^5.5.4" - }, - "dependencies": { - "@uwu/configmasher": "latest", - "discord.js": "^14.15.3", - "ofetch": "^1.4.1", - "throttle-debounce": "^5.0.2" - }, - "trustedDependencies": [ - "@biomejs/biome" - ] -} diff --git a/src/commands/index.ts b/src/commands/index.ts deleted file mode 100644 index 9077a7b..0000000 --- a/src/commands/index.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { - SlashCommandBuilder, - ChatInputCommandInteraction, - ContextMenuCommandBuilder, - ContextMenuCommandInteraction, - SlashCommandOptionsOnlyBuilder, -} from "discord.js"; - -import { default as product_notes } from "./product/notes.js"; - -import { default as close } from "./util/close.js"; -import { default as reopen } from "./util/reopen.js"; -import { default as walkthrough } from "./util/walkthrough.js"; - -type AnyCommandBuilder = - | SlashCommandBuilder - | SlashCommandOptionsOnlyBuilder - | ContextMenuCommandBuilder; -type AnyInteraction = - | ChatInputCommandInteraction - | ContextMenuCommandInteraction; - -const commandObject: { - [key: string]: { - data: AnyCommandBuilder; - execute: (interaction: AnyInteraction) => unknown; - }; -} = {}; - -for (const command of [product_notes, close, reopen, walkthrough]) { - commandObject[command.data.name] = command; -} - -export default commandObject; diff --git a/src/commands/product/notes.ts b/src/commands/product/notes.ts deleted file mode 100644 index b1dfd9a..0000000 --- a/src/commands/product/notes.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { config } from "@lib/config.js"; -import { makeCodeBlock } from "@lib/discord/messages.js"; - -import { ofetch } from "ofetch"; - -import { - ContextMenuCommandBuilder, - ApplicationCommandType, - type MessageContextMenuCommandInteraction, - MessageFlags, - ActionRowBuilder, - ButtonStyle, - ButtonBuilder, -} from "discord.js"; - -// TODO: try to make the official API package work -async function createNote(body) { - return ofetch("https://api.productboard.com/notes", { - method: "POST", - headers: { - Authorization: `Bearer ${config.productBoard.token}`, - }, - body, - }); -} - -export default { - data: new ContextMenuCommandBuilder() - .setName("Add to product notes") - .setType(ApplicationCommandType.Message), - - execute: async (interaction: MessageContextMenuCommandInteraction) => { - const data = await createNote({ - title: `Discord message from ${interaction.targetMessage.author.displayName} (in '${interaction.channel.name}')`, // this will only work for threads - display_url: interaction.targetMessage.url, - content: interaction.targetMessage.content, - - company: { id: config.productBoard.companyId }, - user: { external_id: `discord:${interaction.targetMessage.author.id}` }, - - source: { origin: "discord", record_id: interaction.targetId }, - tags: ["discord"], - }); - - const replyComponents = []; - - if (data.links?.html) { - const button = new ButtonBuilder() - .setLabel("Open in ProductBoard") - .setStyle(ButtonStyle.Link) - .setURL(data.links.html); - - replyComponents.push( - new ActionRowBuilder().addComponents(button), - ); - } - - await interaction.reply({ - content: makeCodeBlock(JSON.stringify(data), "json"), - - components: replyComponents, - - flags: MessageFlags.Ephemeral, - }); - }, -}; diff --git a/src/commands/util/close.ts b/src/commands/util/close.ts deleted file mode 100644 index 89a1257..0000000 --- a/src/commands/util/close.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { config } from "@lib/config.js"; - -import { - canMemberInteractWithThread, - getChannelFromInteraction, - isHelpPost, -} from "@lib/discord/channels.js"; - -import { - type ThreadChannel, - MessageFlags, - SlashCommandBuilder, - type ChatInputCommandInteraction, -} from "discord.js"; - -// TODO: find a better way to do this -const getStateWord = (close) => (close ? "closed" : "reopened"); -const getStateVerb = (close) => (close ? "close" : "reopen"); - -export function getTagsForCloseState(close: boolean) { - return { - tagToAdd: close - ? config.helpChannel.closedTag - : config.helpChannel.openedTag, - tagToRemove: close - ? config.helpChannel.openedTag - : config.helpChannel.closedTag, - }; -} - -export async function handleIssueState( - interaction: ChatInputCommandInteraction, - close = true, - lock = false, -) { - const threadChannel = (await getChannelFromInteraction( - interaction, - )) as ThreadChannel; - - const stateWord = getStateWord(close); - const stateVerb = getStateVerb(close); - - const { tagToAdd, tagToRemove } = getTagsForCloseState(close); - - const postTags = threadChannel.appliedTags; - - try { - // Update tags - if (!postTags.includes(tagToAdd)) { - postTags.push(tagToAdd); - } - - if (postTags.includes(tagToRemove)) { - postTags.splice(postTags.indexOf(tagToRemove), 1); - } - - await threadChannel.setAppliedTags(postTags, "Thread lifecycle"); - - await interaction.reply({ - content: `${interaction.user.toString()} ${stateWord} ${lock ? "and locked " : ""}the thread.`, - flags: [MessageFlags.SuppressNotifications], - }); - - // Archive/lock the thread if necessary (it seems we can't lock a thread if it's already been archived) - if (close && !threadChannel.archived) { - try { - if (lock) { - await threadChannel.setLocked(lock); - } else { - await threadChannel.setArchived(true); - } - } catch (err) { - console.error("Error archiving thread:", err); - } - } - } catch (e) { - await interaction.reply({ - content: `Could not ${stateVerb} the thread because of an unexpected error.`, - ephemeral: true, - }); - } -} - -export async function handleIssueStateCommand( - interaction: ChatInputCommandInteraction, - close: boolean, - lock = false, -) { - const interactionChannel = await getChannelFromInteraction(interaction); - const stateVerb = getStateVerb(close); - - // Check if thread is a help post and if user can interact - if (await isHelpPost(interactionChannel)) { - const member = await interaction.guild.members.fetch(interaction.user.id); - - if ( - await canMemberInteractWithThread( - interaction.channel as ThreadChannel, - member, - ) - ) { - return handleIssueState(interaction, close, lock); - } else { - await interaction.reply({ - content: `You cannot ${stateVerb} this thread since you are not the OP.`, - ephemeral: true, - }); - } - } else { - await interaction.reply({ - content: `You can only run this command in a <#${config.helpChannel.id}> post.`, - ephemeral: true, - }); - } -} - -export default { - data: new SlashCommandBuilder() - .setName("close") - .setDescription("Closes your post") - .addBooleanOption((option) => - option.setName("lock").setDescription("Whether to lock the post or not"), - ), - - execute: (interaction: ChatInputCommandInteraction) => - handleIssueStateCommand( - interaction, - true, - interaction.options.getBoolean("lock"), - ), -}; diff --git a/src/commands/util/reopen.ts b/src/commands/util/reopen.ts deleted file mode 100644 index 562fa15..0000000 --- a/src/commands/util/reopen.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { handleIssueStateCommand } from "./close.js"; - -import { - SlashCommandBuilder, - type ChatInputCommandInteraction, -} from "discord.js"; - -export default { - data: new SlashCommandBuilder() - .setName("reopen") - .setDescription("Reopens your post"), - - execute: (interaction: ChatInputCommandInteraction) => - handleIssueStateCommand(interaction, false, false), -}; diff --git a/src/commands/util/walkthrough.ts b/src/commands/util/walkthrough.ts deleted file mode 100644 index eac94bb..0000000 --- a/src/commands/util/walkthrough.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { config } from "@lib/config.js"; - -import { isHelpPost as isHelpThread } from "@lib/discord/channels.js"; -import issueCategorySelector from "@components/issueCategorySelector.js"; - -import { - type ChatInputCommandInteraction, - SlashCommandBuilder, - ActionRowBuilder, - type StringSelectMenuBuilder, - EmbedBuilder, - type Embed, - Colors, - type PublicThreadChannel, - type GuildTextBasedChannel, - ButtonBuilder, - ButtonStyle, - ContainerBuilder, - MessageFlags, - SectionBuilder, - SeparatorBuilder, - TextDisplayBuilder, - type MessageCreateOptions, - type InteractionReplyOptions, -} from "discord.js"; - -const resourcesMessage = { - flags: MessageFlags.IsComponentsV2, - - components: [ - new ContainerBuilder().addSectionComponents([ - new SectionBuilder() - .addTextDisplayComponents( - new TextDisplayBuilder({ content: "Where to find logs" }), - ) - .setButtonAccessory( - new ButtonBuilder() - .setStyle(ButtonStyle.Link) - .setLabel("Docs") - .setURL("https://coder.com/docs/admin/monitoring/logs"), - ), - - new SectionBuilder() - .addTextDisplayComponents( - new TextDisplayBuilder({ - content: "Troubleshooting templates", - }), - ) - .setButtonAccessory( - new ButtonBuilder() - .setStyle(ButtonStyle.Link) - .setLabel("Docs") - .setURL("https://coder.com/docs/admin/templates/troubleshooting"), - ), - - new SectionBuilder() - .addTextDisplayComponents( - new TextDisplayBuilder({ - content: "Troubleshooting networking", - }), - ) - .setButtonAccessory( - new ButtonBuilder() - .setStyle(ButtonStyle.Link) - .setLabel("Docs") - .setURL("https://coder.com/docs/admin/networking/troubleshooting"), - ), - ]), - - new SeparatorBuilder(), - ], -}; - -export function generateQuestion( - question: string, - component: StringSelectMenuBuilder, - embeds: (EmbedBuilder | Embed)[] = [], -) { - return { - embeds: [ - ...embeds, - new EmbedBuilder().setColor(Colors.White).setDescription(question), - ], - components: [ - new ActionRowBuilder().addComponents(component), - ], - }; -} - -export async function doWalkthrough( - channel: GuildTextBasedChannel, - interaction?: ChatInputCommandInteraction, -) { - if (await isHelpThread(channel)) { - const threadChannel = channel as PublicThreadChannel; // necessary type cast, isHelpThread does the check already - - // Check for tags in the forum post - const appliedTags = threadChannel.appliedTags ?? []; - if (!appliedTags.includes(config.helpChannel.openedTag)) { - appliedTags.push(config.helpChannel.openedTag); - threadChannel.setAppliedTags(appliedTags); - } - - // Send the resources message (or reply to the user if they're running the command) - if (interaction) { - // TODO: also check for components V2, but wait until revamp - // If the bot has sent a message that contains an embed in the first 30 messages, then we assume it's the walkthrough message - const firstMessage = await threadChannel.fetchStarterMessage(); - const walkthroughMessage = await threadChannel.messages - .fetch({ around: firstMessage.id, limit: 30 }) - .then((messages) => - messages - .filter( - (message) => - message.author.id === interaction.client.user.id && - message.embeds.length > 0, - ) - .at(0), - ); - - if (walkthroughMessage) { - await interaction.reply({ - content: `You cannot run the walkthrough command because a walkthrough already exists in this channel.\n(${walkthroughMessage.url})`, - ephemeral: true, - }); - } else { - // TODO: fix the fact that it looks weird when the resources message is sent as a reply - await interaction.reply(resourcesMessage as InteractionReplyOptions); - } - } else { - await channel.send(resourcesMessage as MessageCreateOptions); - } - - // Generate the walkthrough message asking the user what they're creating this issue for - const message = generateQuestion( - "What are you creating this issue for?", - issueCategorySelector, - ); - - return channel.send(message); - } -} - -export default { - data: new SlashCommandBuilder() - .setName("walkthrough") - .setDescription( - "Sends the walkthrough message in case the bot didn't automatically send it.", - ), - - async execute(interaction: ChatInputCommandInteraction) { - const interactionChannel = (await interaction.client.channels.fetch( - interaction.channelId, - )) as GuildTextBasedChannel; - - return doWalkthrough(interactionChannel, interaction); - }, -}; diff --git a/src/deploy-commands.ts b/src/deploy-commands.ts deleted file mode 100644 index 937f7f6..0000000 --- a/src/deploy-commands.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { config } from "@lib/config.js"; -import { getClientIDFromToken } from "@lib/discord/users.js"; - -import commands from "@commands/index.js"; - -import { REST, Routes } from "discord.js"; - -// Construct and prepare an instance of the REST module -const rest = new REST().setToken(config.token); - -const commandData = Object.values(commands).map((command) => - command.data.toJSON(), -); - -console.log( - `Started refreshing ${commandData.length} application (/) commands.`, -); - -// The put method is used to fully refresh all commands in the guild with the current set -// biome-ignore lint/suspicious/noExplicitAny: TODO: need to figure out the proper type -const data: any = await rest.put( - Routes.applicationGuildCommands( - getClientIDFromToken(config.token), - config.serverId, - ), - { body: commandData }, -); - -console.log(`Successfully reloaded ${data.length} application (/) commands.`); diff --git a/src/events/channels.ts b/src/events/channels.ts deleted file mode 100644 index f1e2e1b..0000000 --- a/src/events/channels.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { config } from "../lib/config.js"; -import { getTagsForCloseState } from "../commands/util/close.js"; -import { isHelpPost } from "../lib/discord/channels.js"; - -import { debounce } from "throttle-debounce"; - -import { type Client, Events, type ThreadChannel } from "discord.js"; - -// Map to store initial thread states -const threadUpdateMap = new Map(); - -// Create a debounced handler for processing thread updates -const handleEvent = debounce( - 1000, - async (threadId: string, newThread: ThreadChannel) => { - const initialThread = threadUpdateMap.get(threadId); - if (!initialThread) return; - - // Remove from map - threadUpdateMap.delete(threadId); - - // Handle tag additions - const addedTags = newThread.appliedTags.filter( - (t) => !initialThread.appliedTags.includes(t), - ); - if (addedTags.length > 0) { - for (const tag of addedTags) { - // If closed/opened tag is added, remove the opposite tag - if ( - tag === config.helpChannel.closedTag || - tag === config.helpChannel.openedTag - ) { - const isClose = tag === config.helpChannel.closedTag; - const { tagToRemove } = getTagsForCloseState(isClose); - if (newThread.appliedTags.includes(tagToRemove)) { - await newThread.setAppliedTags( - newThread.appliedTags.filter((t) => t !== tagToRemove), - ); - } - } - } - } - - // Handle tag removals - const removedTags = initialThread.appliedTags.filter( - (t) => !newThread.appliedTags.includes(t), - ); - if (removedTags.length > 0) { - for (const tag of removedTags) { - // If closed or opened tag is removed, add it back only if its opposite isn't present - if ( - tag === config.helpChannel.closedTag || - tag === config.helpChannel.openedTag - ) { - const isClose = tag === config.helpChannel.closedTag; - const { tagToRemove } = getTagsForCloseState(isClose); - if (!newThread.appliedTags.includes(tagToRemove)) { - await newThread.setAppliedTags([...newThread.appliedTags, tag]); - } - } - } - } - }, -); - -export default function registerEvents(client: Client) { - client.on(Events.ThreadUpdate, async (oldThread, newThread) => { - if (!(await isHelpPost(newThread))) { - return; - } - - // Store the initial state if this is the first update - if (!threadUpdateMap.has(newThread.id)) { - threadUpdateMap.set(newThread.id, oldThread); - } - - // Trigger the debounced handler - handleEvent(newThread.id, newThread); - }); -} diff --git a/src/events/commands.ts b/src/events/commands.ts deleted file mode 100644 index 1a3f9f5..0000000 --- a/src/events/commands.ts +++ /dev/null @@ -1,41 +0,0 @@ -import commands from "@commands/index.js"; - -import { type Client, Events } from "discord.js"; - -export default function registerEvents(client: Client) { - return client.on(Events.InteractionCreate, async (interaction) => { - if ( - interaction.isChatInputCommand() || - interaction.isMessageContextMenuCommand() - ) { - const command = commands[interaction.commandName]; - - if (!command) { - console.error( - `No command matching "${interaction.commandName}" was found.`, - ); - return; - } - - try { - await command.execute(interaction); - } catch (error) { - console.error(error); - - // TODO: make generic replyOrFollowUp method - // TODO: log error if the user is admin - if (interaction.replied || interaction.deferred) { - await interaction.followUp({ - content: "There was an error while executing this command!", - ephemeral: true, - }); - } else { - await interaction.reply({ - content: "There was an error while executing this command!", - ephemeral: true, - }); - } - } - } - }); -} diff --git a/src/events/messages.ts b/src/events/messages.ts deleted file mode 100644 index cc08fb1..0000000 --- a/src/events/messages.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { type Client, Events, MessageType } from "discord.js"; - -export default function registerEvents(client: Client) { - return client.on(Events.MessageCreate, async (message) => { - // If the bot pins a message, then we delete the automatic announcement message - if ( - message.type === MessageType.ChannelPinnedMessage && - message.author.id === client.user.id - ) { - await message.delete(); - } - }); -} diff --git a/src/events/walkthrough.ts b/src/events/walkthrough.ts deleted file mode 100644 index e4f9368..0000000 --- a/src/events/walkthrough.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { doWalkthrough, generateQuestion } from "@commands/util/walkthrough.js"; - -import issueCategorySelector from "@components/issueCategorySelector.js"; -import productSelector from "@components/productSelector.js"; -import operatingSystemFamilySelector from "@components/operatingSystemFamilySelector.js"; - -import { - type Client, - EmbedBuilder, - Events, - type InteractionUpdateOptions, -} from "discord.js"; - -// This has to follow the order of the walkthrough steps -const selectors = [ - issueCategorySelector, - productSelector, - operatingSystemFamilySelector, -]; - -function getLabelFromValue(value, selector: (typeof selectors)[number]) { - return selector.options.filter((option) => option.data.value === value)[0] - .data.label; -} - -// TODO: make this readable -export default function registerEvents(client: Client) { - // Do walkthrough whenever a thread is opened - client.on(Events.ThreadCreate, async (channel) => doWalkthrough(channel)); - - // Register events for the actual walkthrough steps - client.on(Events.InteractionCreate, async (interaction) => { - if (interaction.isStringSelectMenu()) { - let messageData: InteractionUpdateOptions; - - const selector = selectors.filter( - (element) => element.data.custom_id === interaction.customId, - )[0]; - const index = selectors.indexOf(selector); - - const lastStep = index + 1 === selectors.length; - - if (index === 0) { - const dataEmbed = new EmbedBuilder() - .setTitle(`<#${interaction.channelId}>`) - .addFields([ - { - name: "Category", - value: getLabelFromValue(interaction.values[0], selector), - inline: true, - }, - { name: "Product", value: "N/A", inline: true }, - { name: "Platform", value: "N/A", inline: true }, - { - name: "Logs", - value: "Please post any relevant logs/error messages.", - }, - ]); - - messageData = generateQuestion( - "What product are you using?", - productSelector, - [dataEmbed], - ); - } else { - // Grab the embed from the last message and edit the corresponding field with the human-readable field (instead of the ID) - const dataEmbed = interaction.message.embeds[0]; - dataEmbed.fields[index].value = getLabelFromValue( - interaction.values[0], - selector, - ); - - // TODO : make this part more generic once we have more questions - if (selector === productSelector) { - messageData = generateQuestion( - `What operating system are you running ${dataEmbed.fields[index].value} on?`, - selectors[index + 1], // next selector - [dataEmbed], - ); - } else if (lastStep) { - // This is the last step of the walkthrough, so we generate an empty message with just the data embed - messageData = { components: [], embeds: [dataEmbed] }; - } else { - throw new Error("No case matches this walkthrough step"); - } - } - - await interaction.update(messageData); - - // If this is the last step of the walkthrough, we pin the message - if (lastStep) { - await interaction.message.pin(); - } - } - }); -} diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index 140f792..0000000 --- a/src/index.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { config } from "./lib/config.js"; - -import registerCommandEvents from "./events/commands.js"; -import registerWalkthroughEvents from "./events/walkthrough.js"; -import registerMessageEvents from "./events/messages.js"; -import registerChannelEvents from "./events/channels.js"; - -import { Client, Events, GatewayIntentBits, ActivityType } from "discord.js"; - -const client = new Client({ - intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages], -}); - -const presenceList = [ - { name: "with Coder", type: ActivityType.Playing }, - { name: "with code-server", type: ActivityType.Playing }, - { name: "with envbuilder", type: ActivityType.Playing }, - { name: "with wush", type: ActivityType.Playing }, - { name: "with Terraform", type: ActivityType.Playing }, - { name: "to your issues", type: ActivityType.Listening }, - { name: "over the Coder community", type: ActivityType.Watching }, -]; - -function shufflePresence() { - const randomPresence = - presenceList[Math.floor(Math.random() * presenceList.length)]; - - return client.user.setPresence({ - activities: [randomPresence], - - status: "online", - }); -} - -client.once(Events.ClientReady, () => { - console.log(`Logged in as ${client.user?.tag}!`); - - registerCommandEvents(client); - registerWalkthroughEvents(client); - registerMessageEvents(client); - registerChannelEvents(client); - - shufflePresence(); - setInterval(shufflePresence, config.presenceDelay); -}); - -client.login(config.token); diff --git a/src/lib/config.ts b/src/lib/config.ts deleted file mode 100644 index 1506ee5..0000000 --- a/src/lib/config.ts +++ /dev/null @@ -1,62 +0,0 @@ -import loadConfig from "@uwu/configmasher"; - -interface Config { - token: string; - - serverId: string; - - helpChannel: { - id: string; - - closedTag: string; - openedTag: string; - }; - - emojis: { - coder: string; - linux: string; - macos: string; - windows: string; - vscode: string; - }; - - productBoard: { - token: string; - companyId: string; - }; - - presenceDelay: number; -} - -export const { config, layers } = await loadConfig({ - name: "Codercord", - - environmentFile: true, - processEnvironment: true, - - caseInsensitive: false, - - configs: ["config.json"], - - defaults: { - presenceDelay: 10 * 60 * 1000, - }, - mandatory: [ - "token", - - "serverId", - - ["helpChannel", "id"], - ["helpChannel", "closedTag"], - ["helpChannel", "openedTag"], - - ["emojis", "coder"], - ["emojis", "linux"], - ["emojis", "macos"], - ["emojis", "windows"], - ["emojis", "vscode"], - - ["productBoard", "token"], - ["productBoard", "companyId"], - ], -}); diff --git a/src/lib/discord/channels.ts b/src/lib/discord/channels.ts deleted file mode 100644 index cf2abb2..0000000 --- a/src/lib/discord/channels.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { config } from "@lib/config.js"; - -import { - type ChatInputCommandInteraction, - ChannelType, - type ThreadChannel, - type GuildTextBasedChannel, - PermissionsBitField, - type GuildMember, -} from "discord.js"; - -export async function getChannelFromInteraction( - interaction: ChatInputCommandInteraction, -): Promise { - return ( - interaction.channel ?? - (interaction.client.channels.fetch( - interaction.channelId, - ) as Promise) - ); -} - -async function isForumPost(channel: GuildTextBasedChannel) { - // If the channel is a thread, then we check if its parent is a Forum channel, if it is, then we are in a forum post. - if (channel.isThread()) { - const parentChannel = await channel.client.channels.fetch(channel.parentId); - - return parentChannel.type === ChannelType.GuildForum; - } - - return false; -} - -export async function isHelpPost(channel: GuildTextBasedChannel) { - return ( - (await isForumPost(channel)) && channel.parent.id === config.helpChannel.id - ); -} - -export async function canMemberInteractWithThread( - channel: ThreadChannel, - member: GuildMember, -) { - if (member.permissions.has(PermissionsBitField.Flags.ManageChannels)) { - return true; - } else { - // Sometimes fetchOwner() will fail, so this is just a failsafe - const owner = - (await channel.fetchOwner())?.guildMember ?? - (await channel.fetchStarterMessage()).member; - - return member.id === owner.id; - } -} diff --git a/src/lib/discord/messages.ts b/src/lib/discord/messages.ts deleted file mode 100644 index 8531e1f..0000000 --- a/src/lib/discord/messages.ts +++ /dev/null @@ -1,5 +0,0 @@ -export function makeCodeBlock(text, language = "") { - return `\`\`\`${language} -${text} -\`\`\``; -} diff --git a/src/lib/discord/users.ts b/src/lib/discord/users.ts deleted file mode 100644 index 79e0da3..0000000 --- a/src/lib/discord/users.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function getClientIDFromToken(token: string): string { - return atob(token.split(".")[0]); -} diff --git a/src/ui/components/issueCategorySelector.ts b/src/ui/components/issueCategorySelector.ts deleted file mode 100644 index 397b209..0000000 --- a/src/ui/components/issueCategorySelector.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { - StringSelectMenuBuilder, - StringSelectMenuOptionBuilder, -} from "discord.js"; - -const options = [ - new StringSelectMenuOptionBuilder().setLabel("Help needed").setValue("help"), - - new StringSelectMenuOptionBuilder().setLabel("Bug report").setValue("bug"), - - new StringSelectMenuOptionBuilder() - .setLabel("Feature request") - .setValue("feature"), - - new StringSelectMenuOptionBuilder().setLabel("Other").setValue("other"), -]; - -export default new StringSelectMenuBuilder() - .setCustomId("issueCategorySelector") - .setPlaceholder("Choose an issue category") - .addOptions(options); diff --git a/src/ui/components/operatingSystemFamilySelector.ts b/src/ui/components/operatingSystemFamilySelector.ts deleted file mode 100644 index ef2b0de..0000000 --- a/src/ui/components/operatingSystemFamilySelector.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { config } from "@lib/config.js"; - -import { - StringSelectMenuBuilder, - StringSelectMenuOptionBuilder, -} from "discord.js"; - -const options = [ - new StringSelectMenuOptionBuilder() - .setLabel("Linux") - .setValue("linux") - .setEmoji(config.emojis.linux), - - new StringSelectMenuOptionBuilder() - .setLabel("Windows") - .setValue("windows") - .setEmoji(config.emojis.windows), - - new StringSelectMenuOptionBuilder() - .setLabel("macOS") - .setValue("macos") - .setEmoji(config.emojis.macos), -]; - -export default new StringSelectMenuBuilder() - .setCustomId("operatingSystemFamilySelector") - .setPlaceholder("Choose an operating system family") - .addOptions(options); diff --git a/src/ui/components/productSelector.ts b/src/ui/components/productSelector.ts deleted file mode 100644 index d788b18..0000000 --- a/src/ui/components/productSelector.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { config } from "@lib/config.js"; - -import { - StringSelectMenuBuilder, - StringSelectMenuOptionBuilder, -} from "discord.js"; - -const options = [ - new StringSelectMenuOptionBuilder() - .setLabel("Coder (v2)") - .setValue("coder") - .setEmoji(config.emojis.coder), - - new StringSelectMenuOptionBuilder() - .setLabel("code-server") - .setValue("code-server") - .setEmoji(config.emojis.vscode), -]; - -export default new StringSelectMenuBuilder() - .setCustomId("productSelector") - .setPlaceholder("Choose a product") - .addOptions(options); diff --git a/tsconfig.json b/tsconfig.json deleted file mode 100644 index 3d93445..0000000 --- a/tsconfig.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "compilerOptions": { - "target": "ESNext", - "module": "NodeNext", - - "declaration": false, - - "moduleResolution": "NodeNext", - "noImplicitAny": false, - - "allowSyntheticDefaultImports": true, - - "sourceMap": false, - "outDir": "dist", - - "rootDir": "src", - "paths": { - "@commands/*": ["./src/commands/*"], - "@events/*": ["./src/events/*"], - "@lib/*": ["./src/lib/*"], - - "@components/*": ["./src/ui/components/*"] - } - }, - - "include": ["src/**/*.ts"], - "exclude": ["dist"] -}