diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..a5dfb0d --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,20 @@ +{ + "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" +} diff --git a/.dockerignore b/.dockerignore index 20c17df..99f7c1c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,9 +1,11 @@ -# Files and directories created by pub. -.dart_tool/ -.packages +# Dependencies +node_modules/ -# Conventional directory for build output. -build/ +# Configuration files +config.json +*.env -# Config -*.toml \ No newline at end of file +# Other +.devcontainer/ +.github/ +.vscode/ \ No newline at end of file diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml new file mode 100644 index 0000000..5b955ba --- /dev/null +++ b/.github/dependabot.yaml @@ -0,0 +1,9 @@ +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 new file mode 100644 index 0000000..02704a7 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,20 @@ +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 new file mode 100644 index 0000000..f7dd048 --- /dev/null +++ b/.github/workflows/deploy-docker.yaml @@ -0,0 +1,58 @@ +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/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml deleted file mode 100644 index 48aacb2..0000000 --- a/.github/workflows/docker-publish.yml +++ /dev/null @@ -1,91 +0,0 @@ -name: Docker - -# This workflow uses actions that are not certified by GitHub. -# They are provided by a third-party and are governed by -# separate terms of service, privacy policy, and support -# documentation. - -on: - push: - branches: ["main"] - pull_request: - branches: ["main"] - -env: - # Use docker.io for Docker Hub if empty - REGISTRY: ghcr.io - # github.repository as / - IMAGE_NAME: ${{ github.repository }} - -jobs: - build: - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - # This is used to complete the identity challenge - # with sigstore/fulcio when running outside of PRs. - id-token: write - - steps: - - name: Checkout repository - uses: actions/checkout@v3 - - # Install the cosign tool except on PR - # https://github.com/sigstore/cosign-installer - - name: Install cosign - if: github.event_name != 'pull_request' - uses: sigstore/cosign-installer@f3c664df7af409cb4873aa5068053ba9d61a57b6 #v2.6.0 - with: - cosign-release: "v1.13.1" - - # Workaround: https://github.com/docker/build-push-action/issues/461 - - name: Setup Docker buildx - uses: docker/setup-buildx-action@79abd3f86f79a9d68a23c75a09a9a85889262adf - - # Login against a Docker registry except on PR - # https://github.com/docker/login-action - - name: Log into registry ${{ env.REGISTRY }} - if: github.event_name != 'pull_request' - uses: docker/login-action@28218f9b04b4f3f62068d7b6ce6ca5b26e35336c - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - # Extract metadata (tags, labels) for Docker - # https://github.com/docker/metadata-action - - name: Extract Docker metadata - id: meta - uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - tags: | - type=sha - - # Build and push Docker image with Buildx (don't push on PR) - # https://github.com/docker/build-push-action - - name: Build and push Docker image - if: github.event_name != 'pull_request' - id: build-and-push - uses: docker/build-push-action@ac9327eae2b366085ac7f6a2d02df8aa8ead720a - with: - context: . - push: ${{ github.event_name != 'pull_request' }} - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max - - # Sign the resulting Docker image digest except on PRs. - # This will only write to the public Rekor transparency log when the Docker - # repository is public to avoid leaking data. If you would like to publish - # transparency data even for private images, pass --force to cosign below. - # https://github.com/sigstore/cosign - - name: Sign the published Docker image - if: ${{ github.event_name != 'pull_request' }} - env: - COSIGN_EXPERIMENTAL: "true" - # This step uses the identity token to provision an ephemeral certificate - # against the sigstore community Fulcio instance. - run: echo "${{ steps.meta.outputs.tags }}" | xargs -I {} cosign sign {}@${{ steps.build-and-push.outputs.digest }} diff --git a/.gitignore b/.gitignore index 20c17df..f2302cd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,6 @@ -# Files and directories created by pub. -.dart_tool/ -.packages +# Dependencies +node_modules/ -# Conventional directory for build output. -build/ - -# Config -*.toml \ No newline at end of file +# Configuration files +config.json +*.env \ No newline at end of file diff --git a/.markdownlint.json b/.markdownlint.json deleted file mode 100644 index ee013b5..0000000 --- a/.markdownlint.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "no-hard-tabs": false -} \ No newline at end of file diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..ac23b14 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["oven.bun-vscode", "biomejs.biome"] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..462a5c0 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "editor.defaultFormatter": "biomejs.biome" +} diff --git a/Dockerfile b/Dockerfile index d6ee45b..5bda9a2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,26 +1,11 @@ -FROM dart:3.0.7 as builder +FROM oven/bun:1 AS base -ADD . /src -WORKDIR /src +WORKDIR /usr/src/app +COPY . . -RUN dart pub get +ENV NODE_ENV=production +RUN bun install --frozen-lockfile --production -# AOT Compilation allows to reduce overhead, rather than using dart run on each startup -RUN dart compile aot-snapshot -o /tmp/codercord.aot bin/codercord.dart - -FROM alpine:3.18 as runner - -RUN adduser --disabled-password -u 1337 codercord codercord - -# We copy the dart runtime manually because it is smaller than using the official dart images -# https://github.com/dart-lang/dart-docker/issues/71 -COPY --from=builder /runtime/ / -COPY --from=builder /usr/lib/dart/bin/dart /usr/bin/ -COPY --from=builder /usr/lib/dart/bin/dartaotruntime /usr/bin/ - -WORKDIR /opt -COPY --from=builder --chown=codercord:codercord /tmp/codercord.aot codercord.aot -COPY --from=builder --chown=codercord:codercord /src/tags.json tags.json - -USER codercord -ENTRYPOINT [ "dartaotruntime", "codercord.aot" ] \ No newline at end of file +# run the app +USER bun +ENTRYPOINT [ "bun", "start" ] \ No newline at end of file diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 0e259d4..0000000 --- a/LICENSE +++ /dev/null @@ -1,121 +0,0 @@ -Creative Commons Legal Code - -CC0 1.0 Universal - - CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE - LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN - ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS - INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES - REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS - PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM - THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED - HEREUNDER. - -Statement of Purpose - -The laws of most jurisdictions throughout the world automatically confer -exclusive Copyright and Related Rights (defined below) upon the creator -and subsequent owner(s) (each and all, an "owner") of an original work of -authorship and/or a database (each, a "Work"). - -Certain owners wish to permanently relinquish those rights to a Work for -the purpose of contributing to a commons of creative, cultural and -scientific works ("Commons") that the public can reliably and without fear -of later claims of infringement build upon, modify, incorporate in other -works, reuse and redistribute as freely as possible in any form whatsoever -and for any purposes, including without limitation commercial purposes. -These owners may contribute to the Commons to promote the ideal of a free -culture and the further production of creative, cultural and scientific -works, or to gain reputation or greater distribution for their Work in -part through the use and efforts of others. - -For these and/or other purposes and motivations, and without any -expectation of additional consideration or compensation, the person -associating CC0 with a Work (the "Affirmer"), to the extent that he or she -is an owner of Copyright and Related Rights in the Work, voluntarily -elects to apply CC0 to the Work and publicly distribute the Work under its -terms, with knowledge of his or her Copyright and Related Rights in the -Work and the meaning and intended legal effect of CC0 on those rights. - -1. Copyright and Related Rights. A Work made available under CC0 may be -protected by copyright and related or neighboring rights ("Copyright and -Related Rights"). Copyright and Related Rights include, but are not -limited to, the following: - - i. the right to reproduce, adapt, distribute, perform, display, - communicate, and translate a Work; - ii. moral rights retained by the original author(s) and/or performer(s); -iii. publicity and privacy rights pertaining to a person's image or - likeness depicted in a Work; - iv. rights protecting against unfair competition in regards to a Work, - subject to the limitations in paragraph 4(a), below; - v. rights protecting the extraction, dissemination, use and reuse of data - in a Work; - vi. database rights (such as those arising under Directive 96/9/EC of the - European Parliament and of the Council of 11 March 1996 on the legal - protection of databases, and under any national implementation - thereof, including any amended or successor version of such - directive); and -vii. other similar, equivalent or corresponding rights throughout the - world based on applicable law or treaty, and any national - implementations thereof. - -2. Waiver. To the greatest extent permitted by, but not in contravention -of, applicable law, Affirmer hereby overtly, fully, permanently, -irrevocably and unconditionally waives, abandons, and surrenders all of -Affirmer's Copyright and Related Rights and associated claims and causes -of action, whether now known or unknown (including existing as well as -future claims and causes of action), in the Work (i) in all territories -worldwide, (ii) for the maximum duration provided by applicable law or -treaty (including future time extensions), (iii) in any current or future -medium and for any number of copies, and (iv) for any purpose whatsoever, -including without limitation commercial, advertising or promotional -purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each -member of the public at large and to the detriment of Affirmer's heirs and -successors, fully intending that such Waiver shall not be subject to -revocation, rescission, cancellation, termination, or any other legal or -equitable action to disrupt the quiet enjoyment of the Work by the public -as contemplated by Affirmer's express Statement of Purpose. - -3. Public License Fallback. Should any part of the Waiver for any reason -be judged legally invalid or ineffective under applicable law, then the -Waiver shall be preserved to the maximum extent permitted taking into -account Affirmer's express Statement of Purpose. In addition, to the -extent the Waiver is so judged Affirmer hereby grants to each affected -person a royalty-free, non transferable, non sublicensable, non exclusive, -irrevocable and unconditional license to exercise Affirmer's Copyright and -Related Rights in the Work (i) in all territories worldwide, (ii) for the -maximum duration provided by applicable law or treaty (including future -time extensions), (iii) in any current or future medium and for any number -of copies, and (iv) for any purpose whatsoever, including without -limitation commercial, advertising or promotional purposes (the -"License"). The License shall be deemed effective as of the date CC0 was -applied by Affirmer to the Work. Should any part of the License for any -reason be judged legally invalid or ineffective under applicable law, such -partial invalidity or ineffectiveness shall not invalidate the remainder -of the License, and in such case Affirmer hereby affirms that he or she -will not (i) exercise any of his or her remaining Copyright and Related -Rights in the Work or (ii) assert any associated claims and causes of -action with respect to the Work, in either case contrary to Affirmer's -express Statement of Purpose. - -4. Limitations and Disclaimers. - - a. No trademark or patent rights held by Affirmer are waived, abandoned, - surrendered, licensed or otherwise affected by this document. - b. Affirmer offers the Work as-is and makes no representations or - warranties of any kind concerning the Work, express, implied, - statutory or otherwise, including without limitation warranties of - title, merchantability, fitness for a particular purpose, non - infringement, or the absence of latent or other defects, accuracy, or - the present or absence of errors, whether or not discoverable, all to - the greatest extent permissible under applicable law. - c. Affirmer disclaims responsibility for clearing rights of other persons - that may apply to the Work or any use thereof, including without - limitation any person's Copyright and Related Rights in the Work. - Further, Affirmer disclaims responsibility for obtaining any necessary - consents, permissions or other rights required for any use of the - Work. - d. Affirmer understands and acknowledges that Creative Commons is not a - party to this document and has no duty or obligation with respect to - this CC0 or use of the Work. diff --git a/README.md b/README.md index 60d0b07..dc2df4c 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,34 @@ # Codercord -A Discord bot for the Coder community server written in Dart. +A Discord bot for the Coder community server written in TypeScript. ## How to run -1. Get the [Dart SDK](https://dart.dev/get-dart) -2. Clone the repository +1. Clone the repository - ```sh - git clone git@github.com:coder/codercord.git - cd codercord - ``` + ```sh + git clone git@github.com:coder/codercord.git + cd codercord + ``` + +2. Configure the project 3. Run the project - ```sh - dart run - ``` + ``` + bun start + ``` + +## Configuration - You can also pre-compile the binary instead of using ``dart run`` everytime +### Environment variables (case sensitive) - ```sh - dart compile exe bin/codercord.dart -o codercord - ./codercord - ``` +- `Codercord_token` : The Discord bot's token -## Configuration +### Configuration file -Environment variables : +Example `config.json` provided [here](https://github.com/coder/codercord/blob/typescript/config.json.example) -* ``CODERCORD_TOKEN`` : The Discord bot's token -* ``CODERCORD_TOML_PATH`` : The path of the toml config file (default: config.toml) - (relative to process working directory if no absolute path is provided) +## Contributing -Example ``config.toml`` provided [here](https://github.com/coder/codercord/blob/main/config.toml.example) +Use the `.devcontainer` to develop in a containerized environment. diff --git a/analysis_options.yaml b/analysis_options.yaml deleted file mode 100644 index ea2c9e9..0000000 --- a/analysis_options.yaml +++ /dev/null @@ -1 +0,0 @@ -include: package:lints/recommended.yaml \ No newline at end of file diff --git a/assets/tags.json b/assets/tags.json new file mode 100644 index 0000000..d62470c --- /dev/null +++ b/assets/tags.json @@ -0,0 +1,4 @@ +{ + "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/bin/codercord.dart b/bin/codercord.dart deleted file mode 100644 index cce54fe..0000000 --- a/bin/codercord.dart +++ /dev/null @@ -1,33 +0,0 @@ -import "dart:io"; - -import "package:codercord/discord/client.dart"; -import "package:codercord/discord/utils.dart"; -import "package:codercord/config.dart"; - -import "package:nyxx/nyxx.dart"; - -void main() async { - try { - await loadConfig(); - } catch (e) { - print( - "[TOML] Could not load configuration from TOML file: $e", - ); - } - - if (await enforceRequiredEntries()) { - exit(2); - } - - final token = Platform.environment["CODERCORD_TOKEN"]!; - final client = Codercord( - token, - config["clientId"] == null - ? getIdFromToken(token) - : Snowflake( - config["clientId"], - ), - ); - - client.login(); -} diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..24d2417 --- /dev/null +++ b/biome.json @@ -0,0 +1,33 @@ +{ + "$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 new file mode 100755 index 0000000..a0fb330 Binary files /dev/null and b/bun.lockb differ diff --git a/config.json.example b/config.json.example new file mode 100644 index 0000000..abe4a1b --- /dev/null +++ b/config.json.example @@ -0,0 +1,25 @@ +{ + "serverId": "747933592273027093", + + "helpChannel": { + "closedTag": "1006926031434813500", + "id": "1006346052317753414", + "openedTag": "1063924583847170098" + }, + + "releaseAlertChannel": { + "id": "" + }, + + "releaseChannel": { + "id": "984553991545446411" + }, + + "emojis": { + "coder": "971867583156469840", + "linux": "1078434842309566575", + "macos": "1078432543696748634", + "windows": "1078432538940416030", + "vscode": "1078432889995268248" + } +} \ No newline at end of file diff --git a/config.toml.example b/config.toml.example deleted file mode 100644 index 135cd21..0000000 --- a/config.toml.example +++ /dev/null @@ -1,21 +0,0 @@ -[coderServer] -id = "747933592273027093" # Server ID - -[helpChannel] -id = "1006346052317753414" # ID of the #help forum channel -closedTag = "1006926031434813500" # ID of the post tag that corresponds to a closed post -openedTag = "1063924583847170098" # ID of the post tag that corresponds to an opened post - -[releaseChannel] -id = "984553991545446411" # ID of the #release channel - -[releaseAlertChannel] -id = "" # ID of the channel where the GitHub webhook is configured - -[emojis] -coder = "971867583156469840" -vscode = "1078432889995268248" - -linux = "1078434842309566575" -windows = "1078432538940416030" -macos = "1078432543696748634" \ No newline at end of file diff --git a/lib/codercord.dart b/lib/codercord.dart deleted file mode 100644 index cefe88d..0000000 --- a/lib/codercord.dart +++ /dev/null @@ -1,21 +0,0 @@ -List dynamicListToType(List list) { - return list.map((e) => e as T).toList(); -} - -// I know, I know. -// https://discord.com/channels/420324994703163402/825211448598331392/1008120044263321724 (/r/FlutterDev discord) -extension PathChecker on Map { - bool hasPath(List keyPath) { - dynamic map = this; - - for (final key in keyPath) { - if (map is Map) { - map = map[key]; - } else { - return false; - } - } - - return map != null; - } -} diff --git a/lib/config.dart b/lib/config.dart deleted file mode 100644 index a855a6a..0000000 --- a/lib/config.dart +++ /dev/null @@ -1,66 +0,0 @@ -import "dart:io"; - -import "package:codercord/codercord.dart"; - -import "package:toml/toml.dart"; - -late final Map config; - -class ConfigType { - String name; - List> required; - Map store; - - ConfigType(this.name, this.required, this.store); -} - -final List configTypes = [ - ConfigType( - "env", - [ - ["CODERCORD_TOKEN"] - ], - Platform.environment, - ), - ConfigType( - "toml", - [ - ["coderServer", "id"], - ["helpChannel", "id"], - ["helpChannel", "closedTag"], - ["helpChannel", "openedTag"], - ["releaseChannel", "id"], - ["releaseAlertChannel", "id"], - ["emojis", "coder"], - ["emojis", "vscode"], - ["emojis", "linux"], - ["emojis", "windows"], - ["emojis", "macos"], - ], - config, - ), -]; - -Future> loadConfig() async { - return config = await TomlDocument.load( - Platform.environment["CODERCORD_CONFIG_PATH"] ?? "config.toml", - ).then((doc) => doc.toMap()); -} - -Future enforceRequiredEntries() async { - bool missingKeys = false; - - for (final configType in configTypes) { - for (final path in configType.required) { - if (!configType.store.hasPath(path)) { - print( - "[${configType.name}] Please define the `${path.join(".")}` ${configType.name} variable!", - ); - - missingKeys = true; - } - } - } - - return missingKeys; -} diff --git a/lib/discord/client.dart b/lib/discord/client.dart deleted file mode 100644 index 18f2111..0000000 --- a/lib/discord/client.dart +++ /dev/null @@ -1,181 +0,0 @@ -import "dart:async"; -import "dart:io"; - -import "package:codercord/discord/utils.dart"; -import "package:codercord/values.dart"; -import "package:codercord/discord/components/category_multi_select.dart"; -import "package:codercord/discord/interactions/commands/commands.dart" - as commands; -import "package:codercord/discord/interactions/multiselects/multiselects.dart" - as multiselect; - -import "package:logging/logging.dart"; - -import "package:codercord/github/github.dart"; -import "package:github/github.dart"; -import "package:version/version.dart"; - -import "package:nyxx/nyxx.dart"; -import "package:nyxx_interactions/nyxx_interactions.dart"; - -final logger = Logger("Codercord"); - -RegExp linkText = RegExp("(?<=\\[)(.+)(?=\\])"); - -class Codercord { - final List presenceList = [ - PresenceBuilder.of(activity: ActivityBuilder.game("with Coder OSS")), - PresenceBuilder.of(activity: ActivityBuilder.game("with Coder v1")), - PresenceBuilder.of(activity: ActivityBuilder.game("with code-server")), - PresenceBuilder.of(activity: ActivityBuilder.game("with Terraform")), - PresenceBuilder.of(activity: ActivityBuilder.listening("to your issues")), - PresenceBuilder.of( - activity: ActivityBuilder.watching("over the Coder community"), - ) - ]; - - final String _token; - final Snowflake clientId; - - late INyxxWebsocket client; - late IInteractions interactions; - - Codercord(this._token, this.clientId) { - client = - NyxxFactory.createNyxxWebsocket(_token, GatewayIntents.allUnprivileged) - ..registerPlugin(Logging()) - ..registerPlugin(CliIntegration()) - ..registerPlugin(IgnoreExceptions()); - - interactions = IInteractions.create(WebsocketInteractionBackend(client)); - } - - void shufflePresence() { - client.setPresence( - (presenceList.toList()..shuffle()).first, - ); - } - - Future registerInteractionHandlers() async { - await commands.registerSlashCommands(interactions); - multiselect.registerInteractionHandlers(interactions); - - interactions.sync(); - } - - Future> triggerReleaseCheck(RepositorySlug slug) async { - Version? lastSentVersion; - - await for (final message in releaseChannel.downloadMessages(limit: 10)) { - if (message.author.id == client.self.id && message.embeds.isNotEmpty) { - IEmbedField versionField = message.embeds[0].fields.firstWhere( - (field) => field.name == "Version", - ); - - String? messageVersion = - linkText.stringMatch(versionField.content)?.replaceFirst("v", ""); - - try { - lastSentVersion = Version.parse(messageVersion!); - - break; - } catch (_) {} - } - } - - List releasesToSend; - if (lastSentVersion != null) { - releasesToSend = await getNewerReleases(slug, lastSentVersion).then( - (releases) => releases.reversed.toList(), - ); - } else { - releasesToSend = [await getNewestRelease(slug)]; - } - - for (final release in releasesToSend) { - ComponentMessageBuilder releaseMessage = await makeReleaseMessage( - slug, - release, - ); - - await releaseChannel.sendMessage(releaseMessage); - logger.info( - "Sent release announcement for ${slug.fullName} ${release.tagName!}", - ); - } - - return releasesToSend; - } - - void login() async { - logger.info("Codercord is loading.."); - - await client.connect(); - - client.eventsWs.onReady.listen((event) async { - logger.info("Loading config values.."); - try { - await loadValues(client); - } catch (error, trace) { - logger.log( - Level.SEVERE, - "Could not load configuration values.", - error, - trace, - ); - exit(1); - } - - logger.info("Codercord is ready !"); - - logger.info( - "Invite link: https://discord.com/oauth2/authorize?client_id=$clientId&scope=bot%20applications.commands&permissions=294205377552", - ); - - logger.info("Registering commands.."); - await registerInteractionHandlers(); - - logger.info("Checking for new releases.."); - await triggerReleaseCheck(coderRepo); - - client.eventsWs.onThreadCreated.listen((event) async { - if (event.newlyCreated && await event.thread.isHelpPost) { - event.thread.setPostTags([openedTagID]); - - try { - await event.thread.sendMessage(categoryMultiSelectMessage); - } catch (e) { - final retryIn = const Duration(milliseconds: 50); - - //print(e); - //print(e.toString().contains("40058")); - - logger.info( - "Couldn't send message because thread owner did not post message, retrying in ${retryIn.toString()}.", - ); - await Future.delayed(retryIn); - await event.thread.sendMessage(categoryMultiSelectMessage); - } - } - }); - - client.eventsWs.onMessageReceived.listen((event) async { - if (event.message.type == MessageType.channelPinnedMessage && - event.message.author.id == client.self.id) { - await event.message.delete( - auditReason: "Automatic deletion of channel pin announcements.", - ); - } else if (event.message.channel.id == releaseAlertChannel.id && - event.message.author.isInteractionWebhook) { - logger.info( - "A new message was sent in the release alert channel, checking for new releases..", - ); - await triggerReleaseCheck(coderRepo); - } - }); - - shufflePresence(); - Timer.periodic(const Duration(minutes: 10), (_) => shufflePresence()); - }); - } -} diff --git a/lib/discord/components/category_multi_select.dart b/lib/discord/components/category_multi_select.dart deleted file mode 100644 index eb1fc1e..0000000 --- a/lib/discord/components/category_multi_select.dart +++ /dev/null @@ -1,21 +0,0 @@ -import "package:nyxx_interactions/nyxx_interactions.dart"; - -final Map categoryOptions = { - "help": MultiselectOptionBuilder("Help needed", "help"), - "bug": MultiselectOptionBuilder("Bug report", "bug"), - "feature": MultiselectOptionBuilder("Feature request", "feature"), - "other": MultiselectOptionBuilder("Other", "other") -}; - -final MultiselectBuilder categoryMultiSelect = MultiselectBuilder( - "categoryMultiSelect", - categoryOptions.values, -); - -final ComponentRowBuilder categoryMultiSelectRow = ComponentRowBuilder() - ..addComponent(categoryMultiSelect); - -final ComponentMessageBuilder categoryMultiSelectMessage = - ComponentMessageBuilder() - ..addComponentRow(categoryMultiSelectRow) - ..content = "What are you creating this issue for?"; diff --git a/lib/discord/components/platform_multi_select.dart b/lib/discord/components/platform_multi_select.dart deleted file mode 100644 index 0916d69..0000000 --- a/lib/discord/components/platform_multi_select.dart +++ /dev/null @@ -1,22 +0,0 @@ -import "package:codercord/values.dart"; -import "package:nyxx_interactions/nyxx_interactions.dart"; - -final Map platformOptions = { - "linux": MultiselectOptionBuilder("Linux", "linux")..emoji = linuxEmoji, - "windows": MultiselectOptionBuilder("Windows", "windows") - ..emoji = windowsEmoji, - "macos": MultiselectOptionBuilder("macOS", "macos")..emoji = macosEmoji -}; - -final MultiselectBuilder platformMultiSelect = MultiselectBuilder( - "platformMultiSelect", - platformOptions.values, -); - -final ComponentRowBuilder platformMultiSelectRow = ComponentRowBuilder() - ..addComponent(platformMultiSelect); - -final ComponentMessageBuilder platformMultiSelectMessage = - ComponentMessageBuilder() - ..addComponentRow(platformMultiSelectRow) - ..content = "What platform are you using?"; diff --git a/lib/discord/components/product_multi_select.dart b/lib/discord/components/product_multi_select.dart deleted file mode 100644 index a16f871..0000000 --- a/lib/discord/components/product_multi_select.dart +++ /dev/null @@ -1,22 +0,0 @@ -import "package:codercord/values.dart"; -import "package:nyxx_interactions/nyxx_interactions.dart"; - -final Map productOptions = { - "coder-v2": MultiselectOptionBuilder("Coder OSS (v2)", "coder-v2") - ..emoji = coderEmoji, - "code-server": MultiselectOptionBuilder("code-server", "code-server") - ..emoji = vscodeEmoji -}; - -final MultiselectBuilder productMultiSelect = MultiselectBuilder( - "productMultiSelect", - productOptions.values, -); - -final ComponentRowBuilder productMultiSelectRow = ComponentRowBuilder() - ..addComponent(productMultiSelect); - -final ComponentMessageBuilder productMultiSelectMessage = - ComponentMessageBuilder() - ..addComponentRow(productMultiSelectRow) - ..content = "What product are you using?"; diff --git a/lib/discord/interactions/commands/close.dart b/lib/discord/interactions/commands/close.dart deleted file mode 100644 index bf5f19a..0000000 --- a/lib/discord/interactions/commands/close.dart +++ /dev/null @@ -1,128 +0,0 @@ -import "package:codercord/discord/utils.dart"; -import "package:codercord/values.dart"; - -import "package:nyxx/nyxx.dart"; -import "package:nyxx_interactions/nyxx_interactions.dart"; - -final stateVerbs = { - true: "close", - false: "reopen", -}; - -final stateWords = { - true: "closed", - false: "reopened", -}; - -Future handleIssueState( - IThreadChannel threadChannel, IUser closer, Function respond, bool close, - [bool lock = false]) async { - final stateWord = stateWords[close]; - final stateVerb = stateVerbs[close]; - - final tagToAdd = close == true ? closedTagID : openedTagID; - final tagToRemove = close == true ? openedTagID : closedTagID; - - final postTags = threadChannel.appliedTags; - - try { - if (!postTags.contains(tagToAdd)) { - postTags.add(tagToAdd); - } - - if (postTags.contains(tagToRemove)) { - postTags.remove(tagToRemove); - } - - await threadChannel.setPostTags(postTags); - - await respond( - MessageBuilder.content( - "${closer.mention} $stateWord ${lock == true ? "and locked " : ""}the thread.", - )..flags = (MessageFlagBuilder()..suppressNotifications = true), - ); - - if (close == true && threadChannel.archived == false) { - try { - await threadChannel.archive(true, lock); - } catch (_) {} - } - } catch (e) { - await respond( - MessageBuilder.content( - "Could not $stateVerb the thread because of an unexpected error.", - ), - hidden: true, - ); - } -} - -Future handleIssueStateCommand( - ISlashCommandInteractionEvent p0, bool close, - [bool lock = false]) async { - final interactionChannel = await p0.interaction.channel.download(); - - if (interactionChannel.channelType == ChannelType.guildPublicThread) { - final threadChannel = interactionChannel as IThreadChannel; - final stateVerb = stateVerbs[close]; - - if (await threadChannel.isHelpPost) { - if (canUserInteractWithThread(threadChannel.owner, p0.interaction)) { - return handleIssueState( - threadChannel, - p0.interaction.userAuthor!, - p0.respond, - close, - lock, - ); - } else { - await p0.respond( - MessageBuilder.content( - "You cannot $stateVerb this thread since you are not the OP.", - ), - hidden: true, - ); - } - } else { - await p0.respond( - MessageBuilder.content( - "You can only run this command in a <#${helpChannel.id}> post.", - ), - hidden: true, - ); - } - } else { - await p0.respond( - MessageBuilder.content( - "You can only run this command in a <#${helpChannel.id}> post.", - ), - hidden: true, - ); - } -} - -SlashCommandBuilder getCommand() { - return SlashCommandBuilder( - "close", - "Closes your post", - [ - CommandOptionBuilder( - CommandOptionType.boolean, - "lock", - "Whether to lock the post or not", - channelTypes: [ - ChannelType.guildPublicThread, - ChannelType.guildPrivateThread, - ], - ) - ], - guild: coderServer.id, - canBeUsedInDm: false, - )..registerHandler((p0) async { - await handleIssueStateCommand( - p0, - true, - p0.args.isNotEmpty ? p0.args[0].value : false, - ); - }); -} diff --git a/lib/discord/interactions/commands/commands.dart b/lib/discord/interactions/commands/commands.dart deleted file mode 100644 index 0815716..0000000 --- a/lib/discord/interactions/commands/commands.dart +++ /dev/null @@ -1,26 +0,0 @@ -import "package:codercord/discord/client.dart" show logger; - -import "tag.dart" as command_tag; -import "close.dart" as command_close; -import "reopen.dart" as command_reopen; -import "walkthrough.dart" as command_walkthrough; - -import "package:nyxx_interactions/nyxx_interactions.dart"; - -Future> getSlashCommands() async { - return [ - command_tag.getCommand(await command_tag.getTags()), - command_close.getCommand(), - command_reopen.getCommand(), - command_walkthrough.getCommand() - ]; -} - -Future registerSlashCommands(IInteractions interactions) async { - for (final command in await getSlashCommands()) { - logger.info("Registering command `${command.name}`"); - interactions.registerSlashCommand(command); - } - - logger.info("Registered commands"); -} diff --git a/lib/discord/interactions/commands/reopen.dart b/lib/discord/interactions/commands/reopen.dart deleted file mode 100644 index 2745826..0000000 --- a/lib/discord/interactions/commands/reopen.dart +++ /dev/null @@ -1,16 +0,0 @@ -import "package:codercord/discord/interactions/commands/close.dart"; -import "package:codercord/values.dart"; - -import "package:nyxx_interactions/nyxx_interactions.dart"; - -SlashCommandBuilder getCommand() { - return SlashCommandBuilder( - "reopen", - "Reopens your post", - [], - guild: coderServer.id, - canBeUsedInDm: false, - )..registerHandler((p0) async { - await handleIssueStateCommand(p0, false); - }); -} diff --git a/lib/discord/interactions/commands/tag.dart b/lib/discord/interactions/commands/tag.dart deleted file mode 100644 index bdf4f07..0000000 --- a/lib/discord/interactions/commands/tag.dart +++ /dev/null @@ -1,66 +0,0 @@ -import "dart:io"; -import "dart:convert"; - -import "package:codercord/values.dart"; - -import "package:nyxx/nyxx.dart"; -import "package:nyxx_interactions/nyxx_interactions.dart"; - -late Map tags; -Future> getTags() async { - final tagsFile = - File(Platform.environment["CODERCORD_TAGS_PATH"] ?? "tags.json"); - - tags = await tagsFile - .readAsString() - .then((str) => jsonDecode(str)) - .then((data) => data.cast()); - - return tags.entries.map((entry) { - return ArgChoiceBuilder(entry.key, entry.key); - }).toList(); -} - -SlashCommandBuilder getCommand(List choiceBuilders) { - return SlashCommandBuilder( - "tag", - "Sends the content of a tag", - [ - CommandOptionBuilder( - CommandOptionType.string, - "name", - "Name of the tag to get", - //defaultArg: true, - required: true, - choices: choiceBuilders, - ), - CommandOptionBuilder( - CommandOptionType.user, - "user", - "User to mention", - ) - ], - guild: coderServer.id, - canBeUsedInDm: false, - )..registerHandler((p0) async { - String tagName = p0.args[0].value; - String? tagText = tags[tagName]; - - if (tagText != null) { - if (p0.args.length > 1) { - Snowflake userId = Snowflake(p0.args[1].value); - - tagText += " (<@${userId.id}>)"; - } - - await p0.respond( - MessageBuilder.content(tagText), - ); - } else { - await p0.respond( - MessageBuilder.content("No tag found with name $tagName."), - hidden: true, - ); - } - }); -} diff --git a/lib/discord/interactions/commands/walkthrough.dart b/lib/discord/interactions/commands/walkthrough.dart deleted file mode 100644 index 6ace5fa..0000000 --- a/lib/discord/interactions/commands/walkthrough.dart +++ /dev/null @@ -1,50 +0,0 @@ -import "package:codercord/discord/components/category_multi_select.dart"; -import "package:codercord/discord/utils.dart"; -import "package:codercord/values.dart"; - -import "package:nyxx/nyxx.dart"; - -import "package:nyxx_interactions/nyxx_interactions.dart"; - -SlashCommandBuilder getCommand() { - return SlashCommandBuilder( - "walkthrough", - "Sends the walkthrough message in case the bot didn't send it", - [], - guild: coderServer.id, - canBeUsedInDm: false, - )..registerHandler((p0) async { - final interactionChannel = await p0.interaction.channel.getOrDownload(); - - if (interactionChannel.channelType == ChannelType.guildPublicThread) { - final threadChannel = interactionChannel as IThreadChannel; - - if (await threadChannel.isHelpPost) { - if (threadChannel.appliedTags.isEmpty) { - await threadChannel.setPostTags([openedTagID]); - } - - final channelMessages = threadChannel.downloadMessages( - limit: 10, - around: Snowflake.fromDateTime(threadChannel.createdAt), - ); - - try { - IMessage walkthroughMessage = await channelMessages.firstWhere( - (message) => - message.author.id == (p0.client as INyxxWebsocket).self.id && - (message.components.isNotEmpty || message.embeds.isNotEmpty), - ); - - await p0.respond( - MessageBuilder.content( - "You cannot run the walkthrough again because a walkthrough already exists in this channel\n(${walkthroughMessage.url})", - ), - hidden: true); - } on StateError catch (_) { - p0.respond(categoryMultiSelectMessage); - } - } - } - }); -} diff --git a/lib/discord/interactions/multiselects/multiselects.dart b/lib/discord/interactions/multiselects/multiselects.dart deleted file mode 100644 index 935c820..0000000 --- a/lib/discord/interactions/multiselects/multiselects.dart +++ /dev/null @@ -1,135 +0,0 @@ -import "package:codercord/discord/components/category_multi_select.dart" - show categoryOptions; -import "package:codercord/discord/components/platform_multi_select.dart" - show platformMultiSelectRow, platformOptions; -import "package:codercord/discord/components/product_multi_select.dart" - show productMultiSelectRow, productOptions; -import "package:codercord/discord/client.dart" show logger; -import 'package:codercord/discord/interactions/commands/close.dart'; -import "package:codercord/discord/utils.dart" show canUserInteractWithThread; - -import "package:nyxx/nyxx.dart"; -import "package:nyxx_interactions/nyxx_interactions.dart"; - -final String visualSeparator = "__${" " * 56}__"; -final String visualSeparatorWithPadding = "\n$visualSeparator\n\n"; - -String getMessageData(String content) { - return content.split(visualSeparatorWithPadding)[0]; -} - -Map> optionsByEvent = { - "categoryMultiSelect": categoryOptions, - "productMultiSelect": productOptions, - "platformMultiSelect": platformOptions -}; - -Map valueNamesByEvent = { - "categoryMultiSelect": "Category", - "productMultiSelect": "Product", - "platformMultiSelect": "platform" -}; - -Future handleEvent(IMultiselectInteractionEvent p0) async { - String customId = p0.interaction.customId; - - final threadChannel = - (await p0.interaction.channel.getOrDownload()) as IThreadChannel; - - if (canUserInteractWithThread(threadChannel.owner, p0.interaction)) { - Map options = optionsByEvent[customId]!; - - String valueName = valueNamesByEvent[customId]!; - String valueLabel = options[p0.interaction.values[0]]!.label; - String valueText = "**$valueName**: $valueLabel"; - - MessageBuilder? message; - bool pinMessage = false; - bool archiveThread = false; - - switch (customId) { - case "categoryMultiSelect": - message = ComponentMessageBuilder() - ..addComponentRow(productMultiSelectRow) - ..content = valueText; - - message.content += visualSeparatorWithPadding; - message.content += "What product are you using?"; - continue shared; - - case "productMultiSelect": - message = ComponentMessageBuilder() - ..addComponentRow(platformMultiSelectRow) - ..content = getMessageData(p0.interaction.message!.content); - - message.content += "\n$valueText"; - message.content += visualSeparatorWithPadding; - message.content += "What platform are you hosting $valueLabel on?"; - continue shared; - - case "platformMultiSelect": - List> fields = - getMessageData(p0.interaction.message!.content) - .split("\n") - .map((e) => e.split(":")) - .toList() - ..add(["**Platform**", valueLabel]); - - EmbedBuilder embed = EmbedBuilder() - ..title = "<#${p0.interaction.channel.id.id}>"; - - for (List message in fields) { - embed.addField( - name: message[0], - content: message[1], - inline: true, - ); - } - - embed.addField( - name: "Logs", - content: - "Please post any relevant logs/error messages.", // \n\nLogs for ${fields[1][1]} can be found at ``/var/lib/hello`` - ); - - message = ComponentMessageBuilder() - ..componentRows = [] - ..embeds = [embed]; - - pinMessage = true; - continue shared; - - shared: - default: - if (message != null) { - await p0.respond(message); - - if (pinMessage) { - await p0.interaction.message!.pinMessage(); - } - - // ignore: dead_code - if (archiveThread) { - await handleIssueState( - threadChannel, - (p0.interaction.client as INyxxWebsocket).self, - p0.sendFollowup, - true, - true, - ); - } - } else { - await p0.respond(p0.interaction.message!.toBuilder()); - } - break; - } - } -} - -void registerInteractionHandlers(IInteractions interactions) { - interactions.registerMultiselectHandler("categoryMultiSelect", handleEvent); - interactions.registerMultiselectHandler("productMultiSelect", handleEvent); - interactions.registerMultiselectHandler("platformMultiSelect", handleEvent); - - logger.info("Registered multiselect handlers"); -} diff --git a/lib/discord/utils.dart b/lib/discord/utils.dart deleted file mode 100644 index 7cee1a5..0000000 --- a/lib/discord/utils.dart +++ /dev/null @@ -1,85 +0,0 @@ -import "dart:convert"; - -import "package:codercord/values.dart" show helpChannel; - -import "package:nyxx/nyxx.dart"; -import "package:nyxx_interactions/nyxx_interactions.dart"; - -bool canUserInteractWithThread( - Cacheable owner, IInteraction interaction) { - return owner.id == interaction.userAuthor?.id || - interaction.memberAuthorPermissions!.manageThreads; -} - -Snowflake getIdFromToken(String token) { - return Snowflake( - utf8.decode( - base64.decode( - base64.normalize(token.split(".")[0]), - ), - ), - ); -} - -/* - Extension used to fill in the lack of API for Forums in nyxx as of now - - Same idea as defining methods on an instantiated class' prototype in JS. e.g: - - String.prototype.hello = 1337; - "".hello // is going to be 1337 -*/ - -final cache = SnowflakeCache(); - -extension ForumExtension on IThreadChannel { - Future archive([bool archived = true, bool locked = false]) { - ThreadBuilder threadBuilder = ThreadBuilder(name); - threadBuilder.archived = archived; - threadBuilder.locked = locked; - - return edit(threadBuilder); - } - - Future get isHelpPost async { - return await isForumPost && parentChannel!.id == helpChannel.id; - } - - Future get isForumPost async { - if (parentChannel != null) { - return (await parentChannel!.getOrDownload()).channelType == - ChannelType.forumChannel; - } else { - throw Exception("No parent channel found."); - } - } - - Future setPostTags(List tags) async { - Future res = client.httpEndpoints.sendRawRequest( - IHttpRoute() - ..channels( - id: id.id.toString(), - ), - "PATCH", - auth: true, - body: jsonEncode( - { - "applied_tags": tags - .map( - (e) => e.id.toString(), - ) - .toList() - }, - ), - headers: {"Content-Type": "application/json"}, - ); - - return res.then((value) { - if (value.statusCode != 200) { - throw Exception("Unsuccessful HTTP request"); - } - - return value; - }); - } -} diff --git a/lib/github/github.dart b/lib/github/github.dart deleted file mode 100644 index 13e6f76..0000000 --- a/lib/github/github.dart +++ /dev/null @@ -1,58 +0,0 @@ -import "package:version/version.dart"; -import "package:github/github.dart"; - -import "package:nyxx_interactions/nyxx_interactions.dart"; -import "package:nyxx/nyxx.dart"; - -final GitHub github = GitHub(); - -Future getNewestRelease(RepositorySlug slug) { - return github.repositories.listReleases(slug).first; -} - -Future> getNewerReleases( - RepositorySlug slug, - Version version, -) async { - Stream releaseStream = - github.repositories.listReleases(slug).takeWhile( - (release) { - Version releaseVersion = Version.parse( - release.tagName!.replaceFirst("v", ""), - ); - - return releaseVersion > version; - }, - ); - - return releaseStream.toList(); -} - -Future makeReleaseMessage( - RepositorySlug slug, - Release release, -) async { - EmbedBuilder embed = EmbedBuilder() - ..title = "New release published ! :tada:" - ..addField( - name: "Repository", - content: "[${slug.fullName}](https://github.com/${slug.fullName})", - inline: true, - ) - ..addField( - name: "Version", - content: "[${release.tagName!}](${release.htmlUrl!})", - inline: true, - ); - - ComponentMessageBuilder message = ComponentMessageBuilder() - ..componentRows = [ - ComponentRowBuilder() - ..addComponent( - LinkButtonBuilder("Changelog", release.htmlUrl!), - ) - ] - ..embeds = [embed]; - - return message; -} diff --git a/lib/values.dart b/lib/values.dart deleted file mode 100644 index 09e3981..0000000 --- a/lib/values.dart +++ /dev/null @@ -1,58 +0,0 @@ -import "package:codercord/config.dart"; - -import "package:github/github.dart"; -import "package:nyxx/nyxx.dart"; - -late final IGuild coderServer; -late final IGuild emojiServer; - -late final IForumChannel helpChannel; -late final ITextChannel releaseChannel; -late final ITextChannel releaseAlertChannel; - -late final Snowflake closedTagID; -late final Snowflake openedTagID; - -late final IBaseGuildEmoji coderEmoji; -late final IBaseGuildEmoji vscodeEmoji; - -late final IBaseGuildEmoji linuxEmoji; -late final IBaseGuildEmoji windowsEmoji; -late final IBaseGuildEmoji macosEmoji; - -final RepositorySlug coderRepo = RepositorySlug("coder", "coder"); - -Future loadValues(INyxxWebsocket client) async { - coderServer = await client.fetchGuild(Snowflake(config["coderServer"]["id"]), - withCounts: false); - - helpChannel = await client.fetchChannel( - Snowflake(config["helpChannel"]["id"]), - ); - - releaseChannel = await client.fetchChannel( - Snowflake(config["releaseChannel"]["id"]), - ); - - releaseAlertChannel = await client.fetchChannel( - Snowflake(config["releaseAlertChannel"]["id"]), - ); - - closedTagID = Snowflake(config["helpChannel"]["closedTag"]); - openedTagID = Snowflake(config["helpChannel"]["openedTag"]); - - if (config["emojis"]["server"] != null) { - emojiServer = await client.fetchGuild( - Snowflake(config["emojis"]["server"]), - ); - } else { - emojiServer = coderServer; - } - - coderEmoji = coderServer.emojis[Snowflake(config["emojis"]["coder"])]!; - vscodeEmoji = coderServer.emojis[Snowflake(config["emojis"]["vscode"])]!; - - linuxEmoji = coderServer.emojis[Snowflake(config["emojis"]["linux"])]!; - windowsEmoji = coderServer.emojis[Snowflake(config["emojis"]["windows"])]!; - macosEmoji = coderServer.emojis[Snowflake(config["emojis"]["macos"])]!; -} diff --git a/package.json b/package.json new file mode 100644 index 0000000..d604885 --- /dev/null +++ b/package.json @@ -0,0 +1,30 @@ +{ + "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", + "octokit": "^4.0.2" + }, + "trustedDependencies": [ + "@biomejs/biome" + ] +} diff --git a/pubspec.lock b/pubspec.lock deleted file mode 100644 index 268bef3..0000000 --- a/pubspec.lock +++ /dev/null @@ -1,453 +0,0 @@ -# Generated by pub -# See https://dart.dev/tools/pub/glossary#lockfile -packages: - _fe_analyzer_shared: - dependency: transitive - description: - name: _fe_analyzer_shared - sha256: "58826e40314219b223f4723dd4205845040161cdc2df3e6a1cdceed5d8165084" - url: "https://pub.dev" - source: hosted - version: "63.0.0" - analyzer: - dependency: transitive - description: - name: analyzer - sha256: f85566ec7b3d25cbea60f7dd4f157c5025f2f19233ca4feeed33b616c78a26a3 - url: "https://pub.dev" - source: hosted - version: "6.1.0" - args: - dependency: transitive - description: - name: args - sha256: eef6c46b622e0494a36c5a12d10d77fb4e855501a91c1b9ef9339326e58f0596 - url: "https://pub.dev" - source: hosted - version: "2.4.2" - async: - dependency: transitive - description: - name: async - sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" - url: "https://pub.dev" - source: hosted - version: "2.11.0" - boolean_selector: - dependency: transitive - description: - name: boolean_selector - sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" - url: "https://pub.dev" - source: hosted - version: "2.1.1" - collection: - dependency: transitive - description: - name: collection - sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a - url: "https://pub.dev" - source: hosted - version: "1.18.0" - convert: - dependency: transitive - description: - name: convert - sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592" - url: "https://pub.dev" - source: hosted - version: "3.1.1" - coverage: - dependency: transitive - description: - name: coverage - sha256: "2fb815080e44a09b85e0f2ca8a820b15053982b2e714b59267719e8a9ff17097" - url: "https://pub.dev" - source: hosted - version: "1.6.3" - crypto: - dependency: transitive - description: - name: crypto - sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab - url: "https://pub.dev" - source: hosted - version: "3.0.3" - eterl: - dependency: transitive - description: - name: eterl - sha256: "8669e5e766e6a1cb5593a0bee6ede7085c1a103056f1ea55cc227ee1ff32f6f7" - url: "https://pub.dev" - source: hosted - version: "1.0.4" - file: - dependency: transitive - description: - name: file - sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c" - url: "https://pub.dev" - source: hosted - version: "7.0.0" - frontend_server_client: - dependency: transitive - description: - name: frontend_server_client - sha256: "408e3ca148b31c20282ad6f37ebfa6f4bdc8fede5b74bc2f08d9d92b55db3612" - url: "https://pub.dev" - source: hosted - version: "3.2.0" - github: - dependency: "direct main" - description: - name: github - sha256: "9966bc13bf612342e916b0a343e95e5f046c88f602a14476440e9b75d2295411" - url: "https://pub.dev" - source: hosted - version: "9.17.0" - glob: - dependency: transitive - description: - name: glob - sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" - url: "https://pub.dev" - source: hosted - version: "2.1.2" - http: - dependency: transitive - description: - name: http - sha256: "5895291c13fa8a3bd82e76d5627f69e0d85ca6a30dcac95c4ea19a5d555879c2" - url: "https://pub.dev" - source: hosted - version: "0.13.6" - http_multi_server: - dependency: transitive - description: - name: http_multi_server - sha256: "97486f20f9c2f7be8f514851703d0119c3596d14ea63227af6f7a481ef2b2f8b" - url: "https://pub.dev" - source: hosted - version: "3.2.1" - http_parser: - dependency: transitive - description: - name: http_parser - sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" - url: "https://pub.dev" - source: hosted - version: "4.0.2" - io: - dependency: transitive - description: - name: io - sha256: "2ec25704aba361659e10e3e5f5d672068d332fc8ac516421d483a11e5cbd061e" - url: "https://pub.dev" - source: hosted - version: "1.0.4" - js: - dependency: transitive - description: - name: js - sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 - url: "https://pub.dev" - source: hosted - version: "0.6.7" - json_annotation: - dependency: transitive - description: - name: json_annotation - sha256: b10a7b2ff83d83c777edba3c6a0f97045ddadd56c944e1a23a3fdf43a1bf4467 - url: "https://pub.dev" - source: hosted - version: "4.8.1" - lints: - dependency: "direct dev" - description: - name: lints - sha256: "0a217c6c989d21039f1498c3ed9f3ed71b354e69873f13a8dfc3c9fe76f1b452" - url: "https://pub.dev" - source: hosted - version: "2.1.1" - logging: - dependency: "direct main" - description: - name: logging - sha256: "623a88c9594aa774443aa3eb2d41807a48486b5613e67599fb4c41c0ad47c340" - url: "https://pub.dev" - source: hosted - version: "1.2.0" - matcher: - dependency: transitive - description: - name: matcher - sha256: "1803e76e6653768d64ed8ff2e1e67bea3ad4b923eb5c56a295c3e634bad5960e" - url: "https://pub.dev" - source: hosted - version: "0.12.16" - meta: - dependency: transitive - description: - name: meta - sha256: "3c74dbf8763d36539f114c799d8a2d87343b5067e9d796ca22b5eb8437090ee3" - url: "https://pub.dev" - source: hosted - version: "1.9.1" - mime: - dependency: transitive - description: - name: mime - sha256: e4ff8e8564c03f255408decd16e7899da1733852a9110a58fe6d1b817684a63e - url: "https://pub.dev" - source: hosted - version: "1.0.4" - node_preamble: - dependency: transitive - description: - name: node_preamble - sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" - url: "https://pub.dev" - source: hosted - version: "2.0.2" - nyxx: - dependency: "direct main" - description: - name: nyxx - sha256: d57ae6fb98ec62df53c44d1d80c3048e9229a756329d8f5b9ceafcc30e0ccd25 - url: "https://pub.dev" - source: hosted - version: "5.1.1" - nyxx_interactions: - dependency: "direct main" - description: - name: nyxx_interactions - sha256: a1a07cfe4d504c50ba69b4e74160bd6aaafb855502254faa4f8b302323dd2666 - url: "https://pub.dev" - source: hosted - version: "4.6.0" - package_config: - dependency: transitive - description: - name: package_config - sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd" - url: "https://pub.dev" - source: hosted - version: "2.1.0" - path: - dependency: transitive - description: - name: path - sha256: "8829d8a55c13fc0e37127c29fedf290c102f4e40ae94ada574091fe0ff96c917" - url: "https://pub.dev" - source: hosted - version: "1.8.3" - petitparser: - dependency: transitive - description: - name: petitparser - sha256: cb3798bef7fc021ac45b308f4b51208a152792445cce0448c9a4ba5879dd8750 - url: "https://pub.dev" - source: hosted - version: "5.4.0" - pool: - dependency: transitive - description: - name: pool - sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" - url: "https://pub.dev" - source: hosted - version: "1.5.1" - pub_semver: - dependency: transitive - description: - name: pub_semver - sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c" - url: "https://pub.dev" - source: hosted - version: "2.1.4" - retry: - dependency: transitive - description: - name: retry - sha256: "822e118d5b3aafed083109c72d5f484c6dc66707885e07c0fbcb8b986bba7efc" - url: "https://pub.dev" - source: hosted - version: "3.1.2" - shelf: - dependency: transitive - description: - name: shelf - sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4 - url: "https://pub.dev" - source: hosted - version: "1.4.1" - shelf_packages_handler: - dependency: transitive - description: - name: shelf_packages_handler - sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" - url: "https://pub.dev" - source: hosted - version: "3.0.2" - shelf_static: - dependency: transitive - description: - name: shelf_static - sha256: a41d3f53c4adf0f57480578c1d61d90342cd617de7fc8077b1304643c2d85c1e - url: "https://pub.dev" - source: hosted - version: "1.1.2" - shelf_web_socket: - dependency: transitive - description: - name: shelf_web_socket - sha256: "9ca081be41c60190ebcb4766b2486a7d50261db7bd0f5d9615f2d653637a84c1" - url: "https://pub.dev" - source: hosted - version: "1.0.4" - source_map_stack_trace: - dependency: transitive - description: - name: source_map_stack_trace - sha256: "84cf769ad83aa6bb61e0aa5a18e53aea683395f196a6f39c4c881fb90ed4f7ae" - url: "https://pub.dev" - source: hosted - version: "2.1.1" - source_maps: - dependency: transitive - description: - name: source_maps - sha256: "708b3f6b97248e5781f493b765c3337db11c5d2c81c3094f10904bfa8004c703" - url: "https://pub.dev" - source: hosted - version: "0.10.12" - source_span: - dependency: transitive - description: - name: source_span - sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" - url: "https://pub.dev" - source: hosted - version: "1.10.0" - stack_trace: - dependency: transitive - description: - name: stack_trace - sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" - url: "https://pub.dev" - source: hosted - version: "1.11.1" - stream_channel: - dependency: transitive - description: - name: stream_channel - sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 - url: "https://pub.dev" - source: hosted - version: "2.1.2" - string_scanner: - dependency: transitive - description: - name: string_scanner - sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" - url: "https://pub.dev" - source: hosted - version: "1.2.0" - term_glyph: - dependency: transitive - description: - name: term_glyph - sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 - url: "https://pub.dev" - source: hosted - version: "1.2.1" - test: - dependency: "direct dev" - description: - name: test - sha256: "9b0dd8e36af4a5b1569029949d50a52cb2a2a2fdaa20cebb96e6603b9ae241f9" - url: "https://pub.dev" - source: hosted - version: "1.24.6" - test_api: - dependency: transitive - description: - name: test_api - sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b" - url: "https://pub.dev" - source: hosted - version: "0.6.1" - test_core: - dependency: transitive - description: - name: test_core - sha256: "4bef837e56375537055fdbbbf6dd458b1859881f4c7e6da936158f77d61ab265" - url: "https://pub.dev" - source: hosted - version: "0.5.6" - toml: - dependency: "direct main" - description: - name: toml - sha256: "157c5dca5160fced243f3ce984117f729c788bb5e475504f3dbcda881accee44" - url: "https://pub.dev" - source: hosted - version: "0.14.0" - typed_data: - dependency: transitive - description: - name: typed_data - sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c - url: "https://pub.dev" - source: hosted - version: "1.3.2" - version: - dependency: "direct main" - description: - name: version - sha256: "3d4140128e6ea10d83da32fef2fa4003fccbf6852217bb854845802f04191f94" - url: "https://pub.dev" - source: hosted - version: "3.0.2" - vm_service: - dependency: transitive - description: - name: vm_service - sha256: "0fae432c85c4ea880b33b497d32824b97795b04cdaa74d270219572a1f50268d" - url: "https://pub.dev" - source: hosted - version: "11.9.0" - watcher: - dependency: transitive - description: - name: watcher - sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8" - url: "https://pub.dev" - source: hosted - version: "1.1.0" - web_socket_channel: - dependency: transitive - description: - name: web_socket_channel - sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b - url: "https://pub.dev" - source: hosted - version: "2.4.0" - webkit_inspection_protocol: - dependency: transitive - description: - name: webkit_inspection_protocol - sha256: "67d3a8b6c79e1987d19d848b0892e582dbb0c66c57cc1fef58a177dd2aa2823d" - url: "https://pub.dev" - source: hosted - version: "1.2.0" - yaml: - dependency: transitive - description: - name: yaml - sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" - url: "https://pub.dev" - source: hosted - version: "3.1.2" -sdks: - dart: ">=3.0.0 <4.0.0" diff --git a/pubspec.yaml b/pubspec.yaml deleted file mode 100644 index 4715886..0000000 --- a/pubspec.yaml +++ /dev/null @@ -1,18 +0,0 @@ -name: codercord -description: A Discord bot for our community server. -version: 1.0.0 -homepage: https://github.com/coder/codercord -environment: - sdk: ">=3.0.0 <4.0.0" - -dependencies: - github: ^9.17.0 - logging: ^1.0.2 - nyxx: ^5.1.1 - nyxx_interactions: ^4.6.0 - toml: ^0.14.0 - version: ^3.0.2 - -dev_dependencies: - lints: ^2.0.0 - test: ^1.16.0 diff --git a/src/commands/index.ts b/src/commands/index.ts new file mode 100644 index 0000000..caf173f --- /dev/null +++ b/src/commands/index.ts @@ -0,0 +1,4 @@ +export { default as close } from "./util/close.js"; +export { default as reopen } from "./util/reopen.js"; + +export { default as walkthrough } from "./util/walkthrough.js"; diff --git a/src/commands/util/close.ts b/src/commands/util/close.ts new file mode 100644 index 0000000..929b0c2 --- /dev/null +++ b/src/commands/util/close.ts @@ -0,0 +1,125 @@ +import { config } from "@lib/config.js"; + +import { + canMemberInteractWithThread, + getChannelFromInteraction, + isHelpPost, +} from "@lib/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 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 = close + ? config.helpChannel.closedTag + : config.helpChannel.openedTag; + const tagToRemove = close + ? config.helpChannel.openedTag + : config.helpChannel.closedTag; + + 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 new file mode 100644 index 0000000..562fa15 --- /dev/null +++ b/src/commands/util/reopen.ts @@ -0,0 +1,15 @@ +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 new file mode 100644 index 0000000..4e0198d --- /dev/null +++ b/src/commands/util/walkthrough.ts @@ -0,0 +1,96 @@ +import { config } from "@lib/config.js"; + +import { isHelpPost as isHelpThread } from "@lib/channels.js"; +import issueCategorySelector from "@components/issueCategorySelector.js"; + +import { + type ChatInputCommandInteraction, + SlashCommandBuilder, + ActionRowBuilder, + type StringSelectMenuBuilder, + EmbedBuilder, + type Embed, + Colors, + type PublicThreadChannel, + type GuildTextBasedChannel, + FetchMessageOptions, +} from "discord.js"; + +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 + if (!threadChannel.appliedTags || threadChannel.appliedTags.length === 0) { + threadChannel.setAppliedTags([config.helpChannel.openedTag]); + } + + // Generate the message with the action row + const message = generateQuestion( + "What are you creating this issue for?", + issueCategorySelector, + ); + + if (interaction) { + // 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 { + return interaction.reply(message); + } + } else { + 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 new file mode 100644 index 0000000..4608917 --- /dev/null +++ b/src/deploy-commands.ts @@ -0,0 +1,22 @@ +import { config } from "@lib/config.js"; +import * as 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); + +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("1063886601165471814", config.serverId), // TODO: guess client ID from token + { body: commandData }, +); + +console.log(`Successfully reloaded ${data.length} application (/) commands.`); diff --git a/src/events/commands.ts b/src/events/commands.ts new file mode 100644 index 0000000..e1e1a22 --- /dev/null +++ b/src/events/commands.ts @@ -0,0 +1,37 @@ +import * as 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()) { + 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 + 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 new file mode 100644 index 0000000..cc08fb1 --- /dev/null +++ b/src/events/messages.ts @@ -0,0 +1,13 @@ +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 new file mode 100644 index 0000000..e4f9368 --- /dev/null +++ b/src/events/walkthrough.ts @@ -0,0 +1,96 @@ +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 new file mode 100644 index 0000000..8197d75 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,45 @@ +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 { 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); + + shufflePresence(); + setInterval(shufflePresence, config.presenceDelay); +}); + +client.login(config.token); diff --git a/src/lib/channels.ts b/src/lib/channels.ts new file mode 100644 index 0000000..cf2abb2 --- /dev/null +++ b/src/lib/channels.ts @@ -0,0 +1,54 @@ +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/config.ts b/src/lib/config.ts new file mode 100644 index 0000000..3535ea8 --- /dev/null +++ b/src/lib/config.ts @@ -0,0 +1,65 @@ +import loadConfig from "@uwu/configmasher"; + +interface Config { + token: string; + + serverId: string; + + helpChannel: { + id: string; + + closedTag: string; + openedTag: string; + }; + + releaseAlertChannel: { + id: string; + }; + + releaseChannel: { + id: string; + }; + + emojis: { + coder: string; + linux: string; + macos: string; + windows: string; + vscode: 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"], + + ["releaseAlertChannel", "id"], + ["releaseChannel", "id"], + + ["emojis", "coder"], + ["emojis", "linux"], + ["emojis", "macos"], + ["emojis", "windows"], + ["emojis", "vscode"], + ], +}); diff --git a/src/ui/components/issueCategorySelector.ts b/src/ui/components/issueCategorySelector.ts new file mode 100644 index 0000000..397b209 --- /dev/null +++ b/src/ui/components/issueCategorySelector.ts @@ -0,0 +1,21 @@ +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 new file mode 100644 index 0000000..ef2b0de --- /dev/null +++ b/src/ui/components/operatingSystemFamilySelector.ts @@ -0,0 +1,28 @@ +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 new file mode 100644 index 0000000..d788b18 --- /dev/null +++ b/src/ui/components/productSelector.ts @@ -0,0 +1,23 @@ +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/tags.json b/tags.json deleted file mode 100644 index 1c75905..0000000 --- a/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``" -} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..3d93445 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,28 @@ +{ + "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"] +}