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 4050f67..0000000 --- a/.gitignore +++ /dev/null @@ -1,7 +0,0 @@ -# Dependencies -node_modules/ - -# Configuration files -config.json -*.env -*.ignore \ 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/AGENTS.md b/AGENTS.md deleted file mode 100644 index 88ef0e2..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,91 +0,0 @@ -# Codercord Agent Guidelines - -Discord bot for the Coder community server. TypeScript on Bun, using -discord.js v14. - -## Stack - -- **Runtime**: [Bun](https://bun.sh) (ESM, `"type": "module"`). No build step - for local dev, Bun runs the TypeScript entrypoint directly. -- **Library**: [discord.js v14](https://discord.js.org/docs) (Gateway + - slash/context-menu commands, Components V2). For the underlying platform, - see the [Discord developer docs](https://discord.dev). -- **Tooling**: [Biome](https://biomejs.dev) for formatting and linting. -- **HTTP**: `ofetch` for outbound API calls (e.g. ProductBoard). -- **Config**: `@uwu/configmasher`. - -## Commands - -| Task | Command | Notes | -|-----------------|--------------------|----------------------------------------| -| Run | `bun start` | Starts the bot (`bun .`). | -| Watch | `bun watch` | Restarts on file change. | -| Format | `bun format` | Biome, writes changes. | -| Lint | `bun lint` | Biome, writes fixes. | -| Deploy commands | `bun src/deploy-commands.ts` | Registers guild slash commands. | - -CI runs `bun format:ci` and `bun lint:ci` (report-only, non-writing). Run -`bun format` and `bun lint` locally before pushing so CI passes. - -There is no test suite. - -## Project layout - -``` -src/ - index.ts Entrypoint: builds the Client, registers events, logs in. - deploy-commands.ts Registers slash/context commands with the guild. - commands/ - index.ts Aggregates every command into a name -> command map. - util/ close, reopen, walkthrough. - product/ notes (ProductBoard context-menu command). - events/ commands, messages, channels, walkthrough handlers. - lib/ - config.ts Typed config loader + mandatory field list. - discord/ channels, users, messages helpers. - ui/components/ StringSelectMenu builders for the walkthrough. -assets/tags.json Canned response text. -``` - -## Conventions - -- **Imports**: Use the `.js` extension on relative imports (ESM/NodeNext), - even though the source is `.ts`. Prefer the `tsconfig` path aliases: - `@lib/*`, `@commands/*`, `@events/*`, `@components/*`. -- **Formatting** (enforced by Biome): 2-space indent, 80-char line width. - Biome ignores `package.json`, `bun.lockb`, and `*.md`. -- **Adding a command**: create it under `src/commands/`, export a - `{ data, execute }` default, then add it to the array in - `src/commands/index.ts`. Run `bun src/deploy-commands.ts` to register it. -- **Adding an event handler**: export a `registerEvents(client)` default and - call it from `src/index.ts`. -- **Commits**: `type(scope): message` (e.g. `chore: update example config`). - -## Configuration - -`@uwu/configmasher` merges, in order: `defaults` -> `config.json` -> -environment file -> process environment. Keys are **case-sensitive**. - -- Copy `config.json.example` to `config.json` (gitignored) for local IDs. -- Secrets come from the environment, e.g. `Codercord_token` (the bot token). -- Mandatory fields are declared in `src/lib/config.ts`; the process exits if - any are missing. - -## Runtime notes - -- **Stay stateless**: the bot keeps no persistent store. All state lives on - Discord's servers (channels, tags, pinned messages) plus a few clever hacks, - so any change should ideally survive a restart. Avoid relying on in-memory - state that is lost when the process dies; if you must hold state in memory, - keep it ephemeral and reconstructable from Discord. -- Intents: `Guilds` and `GuildMessages` only. -- The bot targets a single guild (`serverId`); commands are registered - per-guild, not globally. -- The help forum flow (walkthrough, `/close`, `/reopen`, auto-tagging) keys - off `helpChannel` IDs and tag IDs from config. - -## Deployment - -Pushing to `main` triggers `.github/workflows/deploy-docker.yaml`, which -builds the `Dockerfile` (`oven/bun:1`) and publishes the image to GHCR with a -build-provenance attestation. diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 14f39ba..0000000 --- a/Dockerfile +++ /dev/null @@ -1,11 +0,0 @@ -FROM oven/bun:1-alpine 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 5edcc15..0000000 --- a/biome.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "$schema": "https://biomejs.dev/schemas/2.5.8/schema.json", - "assist": { "actions": { "source": { "organizeImports": "on" } } }, - - "files": { - "includes": ["**", "!**/package.json", "!**/bun.lockb", "!**/*.md"] - }, - - "formatter": { - "indentWidth": 2, - "indentStyle": "space", - "lineWidth": 80 - }, - - "linter": { - "enabled": true, - "rules": { - "preset": "recommended", - - "style": { - "noUselessElse": "off" - } - } - }, - - "vcs": { - "enabled": true, - "clientKind": "git", - "useIgnoreFile": true - } -} diff --git a/bun.lockb b/bun.lockb deleted file mode 100755 index b986e48..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 3fa52ac..0000000 --- a/config.json.example +++ /dev/null @@ -1,23 +0,0 @@ -{ - "serverId": "747933592273027093", - - "helpChannel": { - "closedTag": "1006926031434813500", - "id": "1006346052317753414", - "openedTag": "1409314109773582487", - "waitingForUserTag": "1382030514571186327", - "waitingForTeamTag": "1536933491315314868" - }, - - "teamRoleId": "776843361662271568", - - "startupCatchupLimit": 20, - - "emojis": { - "coder": "1387459034508034058", - "linux": "1078434842309566575", - "macos": "1078432543696748634", - "windows": "1078432538940416030", - "vscode": "1078432889995268248" - } -} diff --git a/package.json b/package.json deleted file mode 100644 index da64a9c..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": "^2.5.8", - "typescript": "^5.9.3" - }, - "dependencies": { - "@uwu/configmasher": "^2.0.2", - "discord.js": "^14.27.0", - "ofetch": "^1.5.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 cf00f77..0000000 --- a/src/commands/index.ts +++ /dev/null @@ -1,41 +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 updateThread } from "./util/update-thread.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, - updateThread, - 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 de90bc1..0000000 --- a/src/commands/util/close.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { config } from "@lib/config.js"; - -import { - canMemberInteractWithThread, - getChannelFromInteraction, - isHelpPost, -} from "@lib/discord/channels.js"; -import { getCommandMention } from "@lib/discord/commands.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"); - - const reopenHint = - close && !lock - ? `\nYou can reopen this issue by doing ${await getCommandMention( - interaction.client, - "reopen", - )}.` - : ""; - - await interaction.reply({ - content: `${interaction.user.toString()} ${stateWord} ${lock ? "and locked " : ""}the thread.${reopenHint}`, - 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 { - await interaction.reply({ - content: `Could not ${stateVerb} the thread because of an unexpected error.`, - flags: [MessageFlags.Ephemeral], - }); - } -} - -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.`, - flags: [MessageFlags.Ephemeral], - }); - } - } else { - await interaction.reply({ - content: `You can only run this command in a <#${config.helpChannel.id}> issue.`, - flags: [MessageFlags.Ephemeral], - }); - } -} - -export default { - data: new SlashCommandBuilder() - .setName("close") - .setDescription("Closes your issue") - .addBooleanOption((option) => - option.setName("lock").setDescription("Whether to lock the issue 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 259db1c..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 issue"), - - execute: (interaction: ChatInputCommandInteraction) => - handleIssueStateCommand(interaction, false, false), -}; diff --git a/src/commands/util/update-thread.ts b/src/commands/util/update-thread.ts deleted file mode 100644 index 622129b..0000000 --- a/src/commands/util/update-thread.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { config } from "@lib/config.js"; -import { - getChannelFromInteraction, - isHelpPost, -} from "@lib/discord/channels.js"; -import { reconcileThread } from "@lib/discord/help.js"; - -import { - type ChatInputCommandInteraction, - type ThreadChannel, - MessageFlags, - PermissionFlagsBits, - SlashCommandBuilder, -} from "discord.js"; - -export default { - data: new SlashCommandBuilder() - .setName("update-thread") - .setDescription( - "Re-sync this help post's waiting tag with the last message", - ) - .setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels), - - execute: async (interaction: ChatInputCommandInteraction) => { - const channel = await getChannelFromInteraction(interaction); - - if (!(await isHelpPost(channel))) { - await interaction.reply({ - content: `You can only run this command in a <#${config.helpChannel.id}> post.`, - flags: [MessageFlags.Ephemeral], - }); - return; - } - - const member = await interaction.guild.members.fetch(interaction.user.id); - if (!member.permissions.has(PermissionFlagsBits.ManageChannels)) { - await interaction.reply({ - content: "You do not have permission to run this command.", - flags: [MessageFlags.Ephemeral], - }); - return; - } - - await reconcileThread(channel as ThreadChannel); - - await interaction.reply({ - content: "Updated this post's waiting tag.", - flags: [MessageFlags.Ephemeral], - }); - }, -}; diff --git a/src/commands/util/walkthrough.ts b/src/commands/util/walkthrough.ts deleted file mode 100644 index 27f14cb..0000000 --- a/src/commands/util/walkthrough.ts +++ /dev/null @@ -1,272 +0,0 @@ -import { config } from "@lib/config.js"; - -import { isHelpPost as isHelpThread } from "@lib/discord/channels.js"; -import { getCommandMention } from "@lib/discord/commands.js"; -import issueCategorySelector from "@components/issueCategorySelector.js"; -import productSelector from "@components/productSelector.js"; -import operatingSystemFamilySelector from "@components/operatingSystemFamilySelector.js"; - -import { - ActionRowBuilder, - ButtonBuilder, - ButtonStyle, - type ButtonInteraction, - type ChatInputCommandInteraction, - type Client, - Colors, - ContainerBuilder, - type GuildTextBasedChannel, - type MessageActionRowComponentBuilder, - MessageFlags, - type PublicThreadChannel, - SectionBuilder, - SeparatorBuilder, - SlashCommandBuilder, - StringSelectMenuBuilder, - type StringSelectMenuInteraction, - TextDisplayBuilder, -} from "discord.js"; - -type ResourceLink = { label: string; url: string }; - -// Documentation resources keyed by product value. Products without an entry -// (for example code-server) simply get no resource links. -const productResources: Record = { - coder: [ - { - label: "Where to find logs", - url: "https://coder.com/docs/admin/monitoring/logs", - }, - { - label: "Troubleshooting templates", - url: "https://coder.com/docs/admin/templates/troubleshooting", - }, - { - label: "Troubleshooting networking", - url: "https://coder.com/docs/admin/networking/troubleshooting", - }, - ], -}; - -// The walkthrough asks one selector per field, in this order. -const steps = [ - { - field: "Category", - menu: issueCategorySelector, - prompt: () => "What are you creating this issue for?", - }, - { - field: "Product", - menu: productSelector, - prompt: () => "What product are you using?", - }, - { - field: "Platform", - menu: operatingSystemFamilySelector, - prompt: (product: string) => - `What operating system are you running ${product} on?`, - }, -] as const; - -// Answered values are carried between steps in the selector's custom id, so the -// walkthrough never has to read its state back out of the message. -const CUSTOM_ID = "walkthrough"; - -const text = (content: string) => new TextDisplayBuilder({ content }); - -const optionOf = (menu: StringSelectMenuBuilder, value?: string) => - menu.options.find((o) => o.data.value === value)?.data; - -const row = (...components: MessageActionRowComponentBuilder[]) => - new ActionRowBuilder().addComponents( - ...components, - ); - -// A field row: the field name with a disabled button showing the chosen option -// (label and emoji), or "N/A" until it is answered. -function fieldSection( - field: string, - menu: StringSelectMenuBuilder, - value?: string, -) { - const option = optionOf(menu, value); - - const button = new ButtonBuilder() - .setStyle(ButtonStyle.Secondary) - .setCustomId(`${CUSTOM_ID}:field:${field}`) - .setDisabled(!option) - .setLabel(option?.label ?? "N/A"); - - if (option?.emoji) { - button.setEmoji(option.emoji); - } - - return new SectionBuilder() - .addTextDisplayComponents(text(field)) - .setButtonAccessory(button); -} - -async function lifecycleText(client: Client) { - const close = await getCommandMention(client, "close"); - const reopen = await getCommandMention(client, "reopen"); - return `When your issue is resolved, use ${close} to close it.\nUse ${reopen} to reopen it if needed.`; -} - -// Builds the walkthrough message from the answered values so far: an info -// container with a field row per answer, the current question and selector while -// steps remain, and the selected product's documentation buttons at the bottom. -async function buildMessage( - client: Client, - channelId: string, - values: string[], -) { - const info = new ContainerBuilder() - .setAccentColor(Colors.Blurple) - .addTextDisplayComponents(text(`<#${channelId}>`)) - .addSeparatorComponents(new SeparatorBuilder()) - .addSectionComponents( - fieldSection("Category", issueCategorySelector, values[0]), - fieldSection("Product", productSelector, values[1]), - fieldSection("Platform", operatingSystemFamilySelector, values[2]), - ) - .addSeparatorComponents(new SeparatorBuilder()) - .addTextDisplayComponents(text(await lifecycleText(client))); - - const components: ( - | ContainerBuilder - | ActionRowBuilder - )[] = [info]; - - const step = steps[values.length]; - if (step) { - const product = optionOf(productSelector, values[1])?.label ?? "N/A"; - - components.push( - new ContainerBuilder() - .setAccentColor(Colors.Blurple) - .addTextDisplayComponents(text(step.prompt(product))), - row( - StringSelectMenuBuilder.from(step.menu).setCustomId( - [CUSTOM_ID, ...values].join(":"), - ), - ), - ); - } - - const docs = productResources[values[1]] ?? []; - if (docs.length > 0) { - components.push( - row( - ...docs.map((doc) => - new ButtonBuilder() - .setStyle(ButtonStyle.Link) - .setLabel(doc.label) - .setURL(doc.url), - ), - ), - ); - } - - return { flags: MessageFlags.IsComponentsV2 as const, components }; -} - -export async function doWalkthrough( - channel: GuildTextBasedChannel, - interaction?: ChatInputCommandInteraction, -) { - if (!(await isHelpThread(channel))) { - return; - } - - 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); - } - - const walkthroughMessage = await buildMessage(channel.client, channel.id, []); - - // Slash-command runs reply to the user; auto-runs post to the thread. - if (!interaction) { - await channel.send(walkthroughMessage); - return; - } - - // If the bot already posted a walkthrough (a message with components) near the - // start of the thread, don't post another one. - const firstMessage = await threadChannel.fetchStarterMessage(); - const existing = await threadChannel.messages - .fetch({ around: firstMessage.id, limit: 30 }) - .then((messages) => - messages - .filter( - (message) => - message.author.id === interaction.client.user.id && - message.components.length > 0, - ) - .at(0), - ); - - if (existing) { - await interaction.reply({ - content: `You cannot run the walkthrough command because a walkthrough already exists in this channel.\n(${existing.url})`, - flags: MessageFlags.Ephemeral, - }); - return; - } - - await interaction.reply(walkthroughMessage); -} - -// Advances the walkthrough one step by editing the same message with the newly -// answered value appended. -export async function handleSelection( - interaction: StringSelectMenuInteraction, -) { - if (!interaction.customId.startsWith(CUSTOM_ID)) { - return; - } - - const values = [ - ...interaction.customId.split(":").slice(1), - interaction.values[0], - ]; - - await interaction.update( - await buildMessage(interaction.client, interaction.channelId, values), - ); - - if (values.length === steps.length) { - await interaction.message.pin(); - } -} - -// The answer buttons only summarize the walkthrough answers, so a click just -// tells the user they can't be edited. -export async function handleFieldButton(interaction: ButtonInteraction) { - if (interaction.customId.startsWith(`${CUSTOM_ID}:field:`)) { - await interaction.reply({ - content: "This is just a summary of your answers, you can't edit it.", - flags: MessageFlags.Ephemeral, - }); - } -} - -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 005864a..0000000 --- a/src/events/channels.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { config } from "../lib/config.js"; -import { getTagsForCloseState } from "../commands/util/close.js"; -import { isHelpPost } from "../lib/discord/channels.js"; -import { applyWaitingTag } from "../lib/discord/help.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.ThreadCreate, async (thread) => { - if (!(await isHelpPost(thread))) { - return; - } - - // A new help post is waiting for the Coder team to respond. - await applyWaitingTag(thread, false); - }); - - 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 719a556..0000000 --- a/src/events/commands.ts +++ /dev/null @@ -1,41 +0,0 @@ -import commands from "@commands/index.js"; - -import { type Client, Events, MessageFlags } 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!", - flags: MessageFlags.Ephemeral, - }); - } else { - await interaction.reply({ - content: "There was an error while executing this command!", - flags: MessageFlags.Ephemeral, - }); - } - } - } - }); -} diff --git a/src/events/messages.ts b/src/events/messages.ts deleted file mode 100644 index 36e4a3c..0000000 --- a/src/events/messages.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { type Client, Events, MessageType } from "discord.js"; - -import { isHelpPost } from "@lib/discord/channels.js"; -import { reconcileFromMessage } from "@lib/discord/help.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(); - return; - } - - // Keep the help posts' waiting tag in sync with the latest interaction. - if (message.inGuild() && (await isHelpPost(message.channel))) { - await reconcileFromMessage(message); - } - }); -} diff --git a/src/events/walkthrough.ts b/src/events/walkthrough.ts deleted file mode 100644 index 41a3d31..0000000 --- a/src/events/walkthrough.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { - doWalkthrough, - handleFieldButton, - handleSelection, -} from "@commands/util/walkthrough.js"; - -import { type Client, Events } from "discord.js"; - -export default function registerEvents(client: Client) { - // Do walkthrough whenever a thread is opened - client.on(Events.ThreadCreate, async (channel) => doWalkthrough(channel)); - - // Each selection advances the single walkthrough message; the answer buttons - // are no-ops. - client.on(Events.InteractionCreate, async (interaction) => { - if (interaction.isStringSelectMenu()) { - return handleSelection(interaction); - } - if (interaction.isButton()) { - return handleFieldButton(interaction); - } - }); -} diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index b2eb80e..0000000 --- a/src/index.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { config } from "./lib/config.js"; -import { catchUpHelpPosts } from "./lib/discord/help.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 = [ - { type: ActivityType.Playing, name: "with Coder" }, - { type: ActivityType.Playing, name: "with code-server" }, - { type: ActivityType.Playing, name: "with wush" }, - { type: ActivityType.Playing, name: "with Mux (mux.coder.com)" }, - { type: ActivityType.Playing, name: "with Terraform" }, - { type: ActivityType.Listening, name: "to your issues" }, - { type: ActivityType.Watching, name: "over the Coder community" }, -]; - -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); - - catchUpHelpPosts(client).catch((err) => - console.error("Failed to catch up on help posts:", err), - ); -}); - -client.login(config.token); diff --git a/src/lib/config.ts b/src/lib/config.ts deleted file mode 100644 index 9b2cedc..0000000 --- a/src/lib/config.ts +++ /dev/null @@ -1,77 +0,0 @@ -import loadConfig from "@uwu/configmasher"; - -interface Config { - token: string; - - serverId: string; - - helpChannel: { - id: string; - - closedTag: string; - openedTag: string; - - waitingForUserTag: string; - waitingForTeamTag: string; - }; - - // Role that identifies Coder team members. Anyone without this role is - // treated as a community member. - teamRoleId: string; - - // Number of most recently active open help posts to reconcile on startup. - startupCatchupLimit: number; - - 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, - startupCatchupLimit: 20, - }, - mandatory: [ - "token", - - "serverId", - - ["helpChannel", "id"], - ["helpChannel", "closedTag"], - ["helpChannel", "openedTag"], - ["helpChannel", "waitingForUserTag"], - ["helpChannel", "waitingForTeamTag"], - - "teamRoleId", - - ["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/commands.ts b/src/lib/discord/commands.ts deleted file mode 100644 index 466f6a8..0000000 --- a/src/lib/discord/commands.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { config } from "@lib/config.js"; - -import type { Client } from "discord.js"; - -// Resolves a slash command mention () by looking the command ID up -// from the ones the bot registered in the guild. Falls back to plain text if -// the command cannot be found. -export async function getCommandMention( - client: Client, - name: string, -): Promise { - const commands = await client.application.commands.fetch({ - guildId: config.serverId, - }); - - const command = commands.find((cmd) => cmd.name === name); - - return command ? `` : `/${name}`; -} diff --git a/src/lib/discord/help.ts b/src/lib/discord/help.ts deleted file mode 100644 index 573e749..0000000 --- a/src/lib/discord/help.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { config } from "@lib/config.js"; -import { isTeamMember } from "@lib/discord/users.js"; - -import { - type Client, - type GuildMember, - type Message, - type ThreadChannel, - ChannelType, - MessageType, -} from "discord.js"; - -// Message types that represent an actual interaction from a person, as opposed -// to system notices (pins, joins, etc). -const humanMessageTypes = new Set([MessageType.Default, MessageType.Reply]); - -function isHumanMessage(message: Message): boolean { - return !message.author.bot && humanMessageTypes.has(message.type); -} - -// Picks the waiting tag for a help post based on who sent the last message. -// When the last interaction comes from a community member the team still needs -// to respond, so we apply waitingForTeamTag; when it comes from the Coder team -// we apply waitingForUserTag. Adding one always removes the other. -export async function applyWaitingTag( - thread: ThreadChannel, - lastFromTeam: boolean, -): Promise { - const { waitingForUserTag, waitingForTeamTag, closedTag } = - config.helpChannel; - - // Leave closed posts untouched. - if (thread.appliedTags.includes(closedTag)) return; - - const desired = lastFromTeam ? waitingForUserTag : waitingForTeamTag; - const opposite = lastFromTeam ? waitingForTeamTag : waitingForUserTag; - - const alreadyCorrect = - thread.appliedTags.includes(desired) && - !thread.appliedTags.includes(opposite); - if (alreadyCorrect) return; - - // Forum posts allow at most 5 tags. Keep the desired tag and drop the - // opposite one, trimming any overflow from the least recent tags. - const nextTags = [ - desired, - ...thread.appliedTags.filter((t) => t !== desired && t !== opposite), - ].slice(0, 5); - - await thread.setAppliedTags(nextTags, "Help post waiting state"); -} - -async function resolveMember(message: Message): Promise { - if (message.member) return message.member; - - try { - return await message.guild?.members.fetch(message.author.id); - } catch { - return null; - } -} - -// Applies the waiting tag for a help post based on who sent the given message. -async function applyWaitingTagFromMessage( - thread: ThreadChannel, - message: Message, -): Promise { - const member = await resolveMember(message); - await applyWaitingTag(thread, member ? isTeamMember(member) : false); -} - -// Reconciles a single help post from a freshly received message. -export async function reconcileFromMessage(message: Message): Promise { - if (!isHumanMessage(message)) return; - await applyWaitingTagFromMessage(message.channel as ThreadChannel, message); -} - -// Reconciles a help post by inspecting its most recent human message. -export async function reconcileThread(thread: ThreadChannel): Promise { - const messages = await thread.messages.fetch({ limit: 10 }); - const lastHuman = messages.find(isHumanMessage); - if (!lastHuman) return; - await applyWaitingTagFromMessage(thread, lastHuman); -} - -// On startup, reconcile the most recently active open help posts so their -// waiting tag reflects the last interaction even if messages were missed while -// the bot was offline. -export async function catchUpHelpPosts(client: Client): Promise { - const forum = await client.channels.fetch(config.helpChannel.id); - if (!forum || forum.type !== ChannelType.GuildForum) return; - - const { threads } = await forum.threads.fetchActive(); - - const openPosts = [...threads.values()] - .filter((t) => !t.appliedTags.includes(config.helpChannel.closedTag)) - .sort((a, b) => - (b.lastMessageId ?? "").localeCompare(a.lastMessageId ?? ""), - ) - .slice(0, config.startupCatchupLimit); - - for (const thread of openPosts) { - try { - await reconcileThread(thread); - } catch (err) { - console.error(`Failed to reconcile help post ${thread.id}:`, err); - } - } -} 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 7f43ac2..0000000 --- a/src/lib/discord/users.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { config } from "@lib/config.js"; - -import type { GuildMember } from "discord.js"; - -export function getClientIDFromToken(token: string): string { - return atob(token.split(".")[0]); -} - -// A member is part of the Coder team if they hold the configured team role. -// Everyone else is treated as a community member. -export function isTeamMember(member: GuildMember): boolean { - return member.roles.cache.has(config.teamRoleId); -} diff --git a/src/ui/components/issueCategorySelector.ts b/src/ui/components/issueCategorySelector.ts deleted file mode 100644 index 7f200db..0000000 --- a/src/ui/components/issueCategorySelector.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { - StringSelectMenuBuilder, - StringSelectMenuOptionBuilder, -} from "discord.js"; - -const options = [ - new StringSelectMenuOptionBuilder() - .setLabel("Help needed") - .setValue("help") - .setEmoji("🙋"), - - new StringSelectMenuOptionBuilder() - .setLabel("Bug report") - .setValue("bug") - .setEmoji("🧩"), - - new StringSelectMenuOptionBuilder() - .setLabel("Feature request") - .setValue("feature") - .setEmoji("✨"), - - new StringSelectMenuOptionBuilder() - .setLabel("Other") - .setValue("other") - .setEmoji("❓"), -]; - -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"] -}