diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..41bd5c6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,29 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: bug +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Desktop (please complete the following information):** + - OS: [e.g. Windows (with version), Linux (with kernel version)] + - Dart version [e.g. 2.14.0] + - Nyxx version [e.g. 3.0.0] + - nyxx_interactions version [e.g. 3.0.0] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..11fc491 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: enhancement +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..b5ba64c --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,28 @@ +# Description + +Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. + +Use smart commits here to manipulate issues (eg. Fixes #issue) + +## Connected issues & potential other potential problems + +If changes are connected to other issues or are affecting code in other parts of framework +(e.g. in main package or any subpackage) make sure to link and describe where and why problem could be present + +## Type of change + +Please delete options that are not relevant. + +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) + +# Checklist: + +- [ ] Ran `dart analyze` or `make analyze` and fixed all issues +- [ ] Ran `dart format --set-exit-if-changed -l 160 ./lib` or `make format` and fixed all issues +- [ ] I have performed a self-review of my own code +- [ ] I have commented my code, particularly in hard-to-understand areas +- [ ] I have made corresponding changes to the documentation +- [ ] I have added tests that prove my fix is effective or that my feature works +- [ ] I have checked my changes haven't lowered code coverage diff --git a/.github/workflows/deploy_docs.yml b/.github/workflows/deploy_docs.yml new file mode 100644 index 0000000..6cb92d2 --- /dev/null +++ b/.github/workflows/deploy_docs.yml @@ -0,0 +1,47 @@ +name: deploy dev docs + +on: + push: + branches: + - dev + - next + +jobs: + deploy-docs: + runs-on: ubuntu-latest + + steps: + - name: Setup Dart Action + uses: dart-lang/setup-dart@v1 + + - name: Checkout + uses: actions/checkout@v2.3.4 + + - name: Cache + uses: actions/cache@v2 + with: + path: ~/.pub-cache + key: ${{ runner.os }}-pubspec-${{ hashFiles('**/pubspec.lock') }} + restore-keys: | + ${{ runner.os }}-pubspec- + + - name: Install dependencies + run: dart pub get + + - name: Generate docs + run: dartdoc + + - name: Extract branch name + shell: bash + run: echo "##[set-output name=branch;]$(echo ${GITHUB_REF#refs/heads/})" + id: extract_branch + + - name: Deploy nyxx dev docs + uses: easingthemes/ssh-deploy@v2.1.5 + env: + SSH_PRIVATE_KEY: ${{ secrets.DEPLOY_SSH_SERVER_KEY }} + ARGS: "-rltDzvO" + SOURCE: "doc/api/" + REMOTE_HOST: ${{ secrets.DEPLOY_REMOTE_HOST }} + REMOTE_USER: ${{ secrets.DEPLOY_REMOTE_USER }} + TARGET: "${{ secrets.DEPLOY_REMOTE_TARGET }}/dartdocs/nyxx_interactions/${{ steps.extract_branch.outputs.branch }}/" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..a20fe92 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,37 @@ +name: publish + +on: + push: + branches: + - main + +jobs: + nyxx_publish: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Cache + uses: actions/cache@v2 + with: + path: ~/.pub-cache + key: ${{ runner.os }}-pubspec-${{ hashFiles('**/pubspec.lock') }} + restore-keys: | + ${{ runner.os }}-pubspec- + + - name: 'publish nyxx package to pub.dev' + id: publish + uses: k-paxian/dart-package-publisher@master + with: + skipTests: true + force: true + suppressBuildRunner: true + credentialJson: ${{ secrets.CREDENTIAL_JSON }} + + - name: 'Commit release tag' + if: steps.publish.outputs.success + uses: hole19/git-tag-action@master + env: + TAG: ${{steps.publish.outputs.package}}-${{steps.publish.outputs.localVersion}} + GITHUB_TOKEN: ${{ secrets.TAG_RELEASE_TOKEN }} diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml new file mode 100644 index 0000000..e2521a3 --- /dev/null +++ b/.github/workflows/unit_tests.yml @@ -0,0 +1,110 @@ +name: unit tests + +on: + push: + branches-ignore: + - main + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + env: + TEST_TOKEN: ${{ secrets.TEST_TOKEN }} + steps: + - name: Setup Dart Action + uses: dart-lang/setup-dart@v1 + + - name: Checkout + uses: actions/checkout@v2.3.4 + + - name: Cache + uses: actions/cache@v2 + with: + path: ~/.pub-cache + key: ${{ runner.os }}-pubspec-${{ hashFiles('**/pubspec.lock') }} + restore-keys: | + ${{ runner.os }}-pubspec- + + - name: Install dependencies + run: dart pub get + + - name: Analyze project source + run: dart analyze + + format: + name: Format + runs-on: ubuntu-latest + env: + TEST_TOKEN: ${{ secrets.TEST_TOKEN }} + steps: + - name: Setup Dart Action + uses: dart-lang/setup-dart@v1 + + - name: Checkout + uses: actions/checkout@v2.3.4 + + - name: Cache + uses: actions/cache@v2 + with: + path: ~/.pub-cache + key: ${{ runner.os }}-pubspec-${{ hashFiles('**/pubspec.lock') }} + restore-keys: | + ${{ runner.os }}-pubspec- + + - name: Install dependencies + run: dart pub get + + - name: Format + run: dart format --set-exit-if-changed -l 160 ./lib + + tests: + needs: [ format, analyze ] + name: Tests + runs-on: ubuntu-latest + env: + TEST_TOKEN: ${{ secrets.TEST_TOKEN }} + steps: + - name: Setup Dart Action + uses: dart-lang/setup-dart@v1 + + - name: Install lcov + run: sudo apt-get install -y lcov + + - name: Checkout + uses: actions/checkout@v2.3.4 + + - name: Cache + uses: actions/cache@v2 + with: + path: ~/.pub-cache + key: ${{ runner.os }}-pubspec-${{ hashFiles('**/pubspec.lock') }} + restore-keys: | + ${{ runner.os }}-pubspec- + + - name: Install dependencies + run: dart pub get + + - name: Unit tests + run: dart run test --coverage="coverage" test/unit/** + + - name: Format coverage + run: dart run coverage:format_coverage --lcov --in=coverage --out=coverage/coverage.lcov --packages=.packages --report-on=lib + + - name: Generate coverage + run: genhtml coverage/coverage.lcov -o coverage/coverage_gen + + - name: Extract branch name + shell: bash + run: echo "##[set-output name=branch;]$(echo ${GITHUB_REF#refs/heads/})" + id: extract_branch + + - name: Deploy code coverage + uses: easingthemes/ssh-deploy@v2.1.5 + env: + SSH_PRIVATE_KEY: ${{ secrets.DEPLOY_SSH_SERVER_KEY }} + ARGS: '-rltDzvO --rsync-path="mkdir -p ${{ secrets.DEPLOY_REMOTE_TARGET }}/coverage/nyxx_interactions/${{ steps.extract_branch.outputs.branch }}/ && rsync"' + SOURCE: "coverage/coverage_gen/" + REMOTE_HOST: ${{ secrets.DEPLOY_REMOTE_HOST }} + REMOTE_USER: ${{ secrets.DEPLOY_REMOTE_USER }} + TARGET: "${{ secrets.DEPLOY_REMOTE_TARGET }}/coverage/nyxx_interactions/${{ steps.extract_branch.outputs.branch }}/" diff --git a/.gitignore b/.gitignore index 11e6502..de985f7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,36 +1,7 @@ -local/ -.atom/ -.vscode/ -index.html -docs/ -.buildlog -.packages -.project -.pub -**/build -**/packages -*.dart.js -*.part.js -*.js.deps -*.js.map -*.info.json -doc/api/ -pubspec.lock -*.iml +**/coverage/** .idea -*~ -*# -.#* -.dart_tool/ -/README.html -/log.txt -/nyxx.wiki/ -/test/private.dart -/publish_docs.sh -/test/mirrors.dart -/private -private-*.dart -test-*.dart -[Rr]pc* -**/doc/api/** -old.* +.dart_tool +pubspec.lock +.packages +private_test.dart +.vscode/ diff --git a/.packages b/.packages deleted file mode 100644 index ebaf50b..0000000 --- a/.packages +++ /dev/null @@ -1,56 +0,0 @@ -# This file is deprecated. Tools should instead consume -# `.dart_tool/package_config.json`. -# -# For more info see: https://dart.dev/go/dot-packages-deprecation -# -# Generated by pub on 2021-10-04 16:59:03.827937. -_fe_analyzer_shared:file:///home/lusha/.pub-cache/hosted/pub.dartlang.org/_fe_analyzer_shared-27.0.0/lib/ -analyzer:file:///home/lusha/.pub-cache/hosted/pub.dartlang.org/analyzer-2.4.0/lib/ -args:file:///home/lusha/.pub-cache/hosted/pub.dartlang.org/args-2.3.0/lib/ -async:file:///home/lusha/.pub-cache/hosted/pub.dartlang.org/async-2.8.2/lib/ -boolean_selector:file:///home/lusha/.pub-cache/hosted/pub.dartlang.org/boolean_selector-2.1.0/lib/ -charcode:file:///home/lusha/.pub-cache/hosted/pub.dartlang.org/charcode-1.3.1/lib/ -cli_util:file:///home/lusha/.pub-cache/hosted/pub.dartlang.org/cli_util-0.3.4/lib/ -collection:file:///home/lusha/.pub-cache/hosted/pub.dartlang.org/collection-1.15.0/lib/ -convert:file:///home/lusha/.pub-cache/hosted/pub.dartlang.org/convert-3.0.1/lib/ -coverage:file:///home/lusha/.pub-cache/hosted/pub.dartlang.org/coverage-1.0.3/lib/ -crypto:file:///home/lusha/.pub-cache/hosted/pub.dartlang.org/crypto-3.0.1/lib/ -file:file:///home/lusha/.pub-cache/hosted/pub.dartlang.org/file-6.1.2/lib/ -frontend_server_client:file:///home/lusha/.pub-cache/hosted/pub.dartlang.org/frontend_server_client-2.1.2/lib/ -glob:file:///home/lusha/.pub-cache/hosted/pub.dartlang.org/glob-2.0.2/lib/ -http:file:///home/lusha/.pub-cache/hosted/pub.dartlang.org/http-0.13.4/lib/ -http_multi_server:file:///home/lusha/.pub-cache/hosted/pub.dartlang.org/http_multi_server-3.0.1/lib/ -http_parser:file:///home/lusha/.pub-cache/hosted/pub.dartlang.org/http_parser-4.0.0/lib/ -io:file:///home/lusha/.pub-cache/hosted/pub.dartlang.org/io-1.0.3/lib/ -js:file:///home/lusha/.pub-cache/hosted/pub.dartlang.org/js-0.6.3/lib/ -logging:file:///home/lusha/.pub-cache/hosted/pub.dartlang.org/logging-1.0.2/lib/ -matcher:file:///home/lusha/.pub-cache/hosted/pub.dartlang.org/matcher-0.12.11/lib/ -meta:file:///home/lusha/.pub-cache/hosted/pub.dartlang.org/meta-1.7.0/lib/ -mime:file:///home/lusha/.pub-cache/hosted/pub.dartlang.org/mime-1.0.0/lib/ -node_preamble:file:///home/lusha/.pub-cache/hosted/pub.dartlang.org/node_preamble-2.0.1/lib/ -nyxx:file:///home/lusha/.pub-cache/hosted/pub.dartlang.org/nyxx-2.0.2/lib/ -package_config:file:///home/lusha/.pub-cache/hosted/pub.dartlang.org/package_config-2.0.2/lib/ -path:file:///home/lusha/.pub-cache/hosted/pub.dartlang.org/path-1.8.0/lib/ -pool:file:///home/lusha/.pub-cache/hosted/pub.dartlang.org/pool-1.5.0/lib/ -pub_semver:file:///home/lusha/.pub-cache/hosted/pub.dartlang.org/pub_semver-2.1.0/lib/ -shelf:file:///home/lusha/.pub-cache/hosted/pub.dartlang.org/shelf-1.2.0/lib/ -shelf_packages_handler:file:///home/lusha/.pub-cache/hosted/pub.dartlang.org/shelf_packages_handler-3.0.0/lib/ -shelf_static:file:///home/lusha/.pub-cache/hosted/pub.dartlang.org/shelf_static-1.1.0/lib/ -shelf_web_socket:file:///home/lusha/.pub-cache/hosted/pub.dartlang.org/shelf_web_socket-1.0.1/lib/ -source_map_stack_trace:file:///home/lusha/.pub-cache/hosted/pub.dartlang.org/source_map_stack_trace-2.1.0/lib/ -source_maps:file:///home/lusha/.pub-cache/hosted/pub.dartlang.org/source_maps-0.10.10/lib/ -source_span:file:///home/lusha/.pub-cache/hosted/pub.dartlang.org/source_span-1.8.1/lib/ -stack_trace:file:///home/lusha/.pub-cache/hosted/pub.dartlang.org/stack_trace-1.10.0/lib/ -stream_channel:file:///home/lusha/.pub-cache/hosted/pub.dartlang.org/stream_channel-2.1.0/lib/ -string_scanner:file:///home/lusha/.pub-cache/hosted/pub.dartlang.org/string_scanner-1.1.0/lib/ -term_glyph:file:///home/lusha/.pub-cache/hosted/pub.dartlang.org/term_glyph-1.2.0/lib/ -test:file:///home/lusha/.pub-cache/hosted/pub.dartlang.org/test-1.18.2/lib/ -test_api:file:///home/lusha/.pub-cache/hosted/pub.dartlang.org/test_api-0.4.5/lib/ -test_core:file:///home/lusha/.pub-cache/hosted/pub.dartlang.org/test_core-0.4.5/lib/ -typed_data:file:///home/lusha/.pub-cache/hosted/pub.dartlang.org/typed_data-1.3.0/lib/ -vm_service:file:///home/lusha/.pub-cache/hosted/pub.dartlang.org/vm_service-7.3.0/lib/ -watcher:file:///home/lusha/.pub-cache/hosted/pub.dartlang.org/watcher-1.0.1/lib/ -web_socket_channel:file:///home/lusha/.pub-cache/hosted/pub.dartlang.org/web_socket_channel-2.1.0/lib/ -webkit_inspection_protocol:file:///home/lusha/.pub-cache/hosted/pub.dartlang.org/webkit_inspection_protocol-1.0.0/lib/ -yaml:file:///home/lusha/.pub-cache/hosted/pub.dartlang.org/yaml-3.1.0/lib/ -nyxx_interactions:lib/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fb3da3..f0b1f17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,55 @@ -## 2.0.2 -_02.11.2021_ +## 3.0.0 +__19.12.2021__ + +- Implemented new interface-based entity model. + > All concrete implementations of entities are now hidden behind interfaces which exports only behavior which is + > intended for end developer usage. For example: User is now not exported and its interface `IUser` is available for developers. + > This change shouldn't have impact of end developers. +- Improved handling autocomplete + > Autocomplete can be now registered in CommandOptionBuilder. This allows registering multiple autocomplete handler for options + > with same names. +- Fixed bugs with registering commands and command permissions. This feature should now work flawlessly. +- Fix critical bug preventing commands from being registered +- Fix #10 + +Other changes are initial implementation of unit and integration tests to assure correct behavior of internal framework +processes. Also added `Makefile` with common commands that are run during development. + +## 3.0.0-dev.2 +__10.12.2021__ + +- Fix #10 + +## 3.0.0-dev.1 +__04.12.2021__ + +- Fix critical bug preventing commands from being registered + +## 3.0.0-dev.0 +__24.11.2021__ + +- Implemented new interface-based entity model. + > All concrete implementations of entities are now hidden behind interfaces which exports only behavior which is + > intended for end developer usage. For example: User is now not exported and its interface `IUser` is available for developers. + > This change shouldn't have impact of end developers. +- Improved handling autocomplete + > Autocomplete can be now registered in CommandOptionBuilder. This allows registering multiple autocomplete handler for options + > with same names. +- Fixed bugs with registering commands and command permissions. This feature should now work flawlessly. + +Other changes are initial implementation of unit and integration tests to assure correct behavior of internal framework +processes. Also added `Makefile` with common commands that are run during development. + +## 2.0.3 +_03.11.2021_ - allow handlers on different nesting layers, closes nyxx#233 (664fd7cdab23ccbf037e4d29ead92178de7e7660) @abitofevrything +## 2.0.2 +_15.10.2021_ + +- Move to Apache 2 license + ## 2.0.1 _03.10.2021_ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..e2c6919 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,29 @@ +# Contributing +Nyxx is free and open-source project, and all contributions are welcome and highly appreciated. +However, please conform to the following guidelines when possible. + +## Branches + +Repo contains few main protected branches: +- `main` - for current stable version. Used for releasing new versions +- `dev` - for changes for next minor or patch version release +- `next` - for changes for next major version release + +## Development cycle + +All changes should be discussed beforehand either in issue or pull request on github +or in a discussion in our Discord channel with library regulars or other contributors. + +All issues marked with 'help-needed' badge are free to be picked up by any member of the community. + +### Pull Requests + +Pull requests should be descriptive about changes that are made. +If adding new functionality or modifying existing, documentation should be added/modified to reflect changes. + +## Coding style + +We attempt to conform [Effective Dart Coding Style](https://dart.dev/guides/language/effective-dart/style) where possible. +However, code style rules are not enforcement and code should be readable and easy to maintain. + +**One exception to rules above is line limit - we use 160 character line limit instead of 80 chars.** diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..50bc561 --- /dev/null +++ b/Makefile @@ -0,0 +1,36 @@ +.PHONY: help +help: ## Shows this + @fgrep -h "##" $(MAKEFILE_LIST) | sed -e 's/\(\:.*\#\#\)/\:\ /' | fgrep -v fgrep | sed -e 's/\\$$//' | sed -e 's/##//' + +.PHONY: app-check +app-check: format-check generate-coverage ## Run basic format checks and then generate code coverage + +.PHONY: format-check +format-check: format analyze ## Check basic format + +.PHONY: generate-coverage +generate-coverage: unit-tests coverage-format coverage-gen-html ## Run all test and generate html code coverage + +.PHONY: unit-tests +unit-tests: ## Run unit tests with coverage + (timeout 10s dart run test --coverage="coverage" --timeout=none test/unit/**; exit 0) + +.PHONY: coverage-format +coverage-format: ## Format dart coverage output to lcov + dart run coverage:format_coverage --lcov --in=coverage --out=coverage/coverage.lcov --packages=.packages --report-on=lib + +.PHONY: coverage-gen-html +coverage-gen-html: ## Generate html coverage from lcov data + genhtml coverage/coverage.lcov -o coverage/coverage_gen + +.PHONY: format +format: ## Run dart format + dart format --set-exit-if-changed -l 160 ./lib + +.PHONY: format-apply +format-apply: ## Run dart format + dart format --fix -l 160 ./lib + +.PHONY: analyze +analyze: ## Run dart analyze + dart analyze diff --git a/README.md b/README.md index 139a4fa..3e80ceb 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,8 @@ # nyxx_interactions -[![pub](https://img.shields.io/pub/v/nyxx.svg)](https://pub.dartlang.org/packages/nyxx) -[![documentation](https://img.shields.io/badge/Documentation-nyxx-yellow.svg)](https://www.dartdocs.org/documentation/nyxx/latest/) -[![documentation](https://img.shields.io/badge/Documentation-nyxx.commander-yellow.svg)](https://www.dartdocs.org/documentation/nyxx.commander/latest/) -[![documentation](https://img.shields.io/badge/Documentation-nyxx.interactions-yellow.svg)](https://www.dartdocs.org/documentation/nyxx.interactions/latest/) -[![documentation](https://img.shields.io/badge/Documentation-nyxx.extentions-yellow.svg)](https://www.dartdocs.org/documentation/nyxx.extensions/latest/) +[![Discord Shield](https://discordapp.com/api/guilds/846136758470443069/widget.png?style=shield)](https://discord.gg/nyxx) +[![pub](https://img.shields.io/pub/v/nyxx_interactions.svg)](https://pub.dartlang.org/packages/nyxx_interactions) +[![documentation](https://img.shields.io/badge/Documentation-nyxx_interactions-yellow.svg)](https://www.dartdocs.org/documentation/nyxx_interactions/latest/) Simple, robust framework for creating discord bots for Dart language. @@ -14,69 +12,55 @@ Simple, robust framework for creating discord bots for Dart language. - **Slash commands support**
Supports and provides easy API for creating and handling slash commands -- **Commands framework included**
- A fast way to create a bot with command support. Implementing the framework is simple - and everything is done automatically. -- **Cross Platform**
- Nyxx works on the command line, in the browser, and on mobile devices. -- **Fine Control**
- Nyxx allows you to control every outgoing HTTP request or WebSocket message. -- **Complete**
- Nyxx supports nearly all Discord API endpoints. +- **Buttons and dropdowns** +- **Autocomplete** +- **Context menus** ## Quick example -Basic usage: -```dart -void main() { - final bot = Nyxx("TOKEN", GatewayIntents.allUnprivileged); - - bot.onMessageReceived.listen((event) { - if (event.message.content == "!ping") { - event.message.channel.sendMessage(MessageBuilder.content("Pong!")); - } - }); -} -``` - Slash commands: ```dart void main() { - final bot = Nyxx("<%TOKEN%>", GatewayIntents.allUnprivileged); - final interactions = Interactions(bot); - - interactions - ..registerHandler("test", "This is test comamnd", [], handler: (event) async { + final bot = NyxxFactory.createNyxxWebsocket("", GatewayIntents.allUnprivileged) + ..registerPlugin(Logging()) // Default logging plugin + ..registerPlugin(CliIntegration()) // Cli integration for nyxx allows stopping application via SIGTERM and SIGKILl + ..registerPlugin(IgnoreExceptions()) // Plugin that handles uncaught exceptions that may occur + ..connect(); + + IInteractions.create(WebsocketInteractionBackend(bot)) + ..registerHandler("test", "This is test command", [], handler: (event) async { await event.reply(MessageBuilder.content("This is example message result")); - }); + }) + ..syncOnReady(); } ``` -Commands: -```dart -void main() { - final bot = Nyxx("TOKEN", GatewayIntents.allUnprivileged); +## Other nyxx packages - Commander(bot, prefix: "!!!") - ..registerCommand("ping", (context, message) => context.reply(MessageBuilder.content("Pong!"))); -} -``` +- [nyxx](https://github.com/nyxx-discord/nyxx) +- [nyxx_commander](https://github.com/nyxx-discord/nyxx_commander) +- [nyxx_extensions](https://github.com/nyxx-discord/nyxx_extensions) +- [nyxx_lavalink](https://github.com/nyxx-discord/nyxx_lavalink) +- [nyxx_pagination](https://github.com/nyxx-discord/nyxx_pagination) ## More examples -Nyxx examples can be found [here](https://github.com/l7ssha/nyxx/tree/dev/nyxx/example). - -Commander examples can be found [here](https://github.com/l7ssha/nyxx/tree/dev/nyxx_commander/example) - -Slash commands (interactions) examples can be found [here](https://github.com/l7ssha/nyxx/tree/dev/nyxx_interactions/example) +Nyxx examples can be found [here](https://github.com/nyxx-discord/nyxx_interactions/tree/dev/example). ### Example bots - [Running on Dart](https://github.com/l7ssha/running_on_dart) ## Documentation, help and examples -**Dartdoc documentation is hosted on [pub](https://www.dartdocs.org/documentation/nyxx/latest/). -This wiki just fills gap in docs with more descriptive guides and tutorials.** +**Dartdoc documentation for latest stable version is hosted on [pub](https://www.dartdocs.org/documentation/nyxx_interactions/latest/)** + +#### [Docs and wiki](https://nyxx.l7ssha.xyz) +You can read docs and wiki articles for latest stable version on my website. This website also hosts docs for latest +dev changes to framework (`dev` branch) + +#### [Official nyxx discord server](https://discord.gg/nyxx) +If you need assistance in developing bot using nyxx you can join official nyxx discord guild. #### [Discord API docs](https://discordapp.com/developers/docs/intro) Discord API documentation features rich descriptions about all topics that nyxx covers. @@ -84,18 +68,12 @@ Discord API documentation features rich descriptions about all topics that nyxx #### [Discord API Guild](https://discord.gg/discord-api) The unofficial guild for Discord Bot developers. To get help with nyxx check `#dart_nyxx` channel. -#### [Dartdocs](https://www.dartdocs.org/documentation/nyxx/latest/) +#### [Dartdocs](https://www.dartdocs.org/documentation/nyxx_interactions/latest/) The dartdocs page will always have the documentation for the latest release. -#### [Dev docs](https://nyxx.l7ssha.xyz) -You can read about upcoming changes in the library on my website. - -#### [Wiki](https://github.com/l7ssha/nyxx/wiki) -Wiki documentation are designed to match the latest Nyxx release. - ## Contributing to Nyxx -Read [contributing document](https://github.com/l7ssha/nyxx/blob/development/CONTRIBUTING.md) +Read [contributing document](https://github.com/l7ssha/nyxx_interactions/blob/development/CONTRIBUTING.md) ## Credits diff --git a/analysis_options.yaml b/analysis_options.yaml index eec1199..53ec678 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1,105 +1,13 @@ +include: package:lints/recommended.yaml + +linter: + rules: + unrelated_type_equality_checks: false + implementation_imports: false + analyzer: + exclude: [build/**, example/**] + language: + strict-raw-types: true strong-mode: implicit-casts: false -linter: - rules: - - avoid_empty_else - - comment_references - - control_flow_in_finally - - empty_statements - - hash_and_equals - - iterable_contains_unrelated_type - - list_remove_unrelated_type - - avoid_slow_async_io - - cancel_subscriptions - - test_types_in_equals - - throw_in_finally - - valid_regexps - - always_declare_return_types - - annotate_overrides - - avoid_init_to_null - - avoid_return_types_on_setters - - await_only_futures - - camel_case_types - - constant_identifier_names - - empty_constructor_bodies - - library_names - - library_prefixes - - non_constant_identifier_names - - only_throw_errors - - package_api_docs - - package_prefixed_library_names - - prefer_is_not_empty - - slash_for_doc_comments - - type_init_formals - - unnecessary_getters_setters - - package_names - - unnecessary_await_in_return - - use_function_type_syntax_for_parameters - - avoid_returning_null_for_future - - no_duplicate_case_values - - unnecessary_statements - - always_require_non_null_named_parameters - - always_put_required_named_parameters_first - - avoid_catches_without_on_clauses - - avoid_function_literals_in_foreach_calls - - avoid_redundant_argument_values - - avoid_returning_null - - avoid_returning_null_for_void - - avoid_returning_this - - camel_case_extensions - - curly_braces_in_flow_control_structures - - directives_ordering - - empty_catches - - join_return_with_assignment - - leading_newlines_in_multiline_strings - - missing_whitespace_between_adjacent_strings - - no_runtimeType_toString - - null_closures - - omit_local_variable_types - - one_member_abstracts - - prefer_adjacent_string_concatenation - - prefer_collection_literals - - prefer_conditional_assignment - - prefer_const_constructors - - prefer_const_constructors_in_immutables - - prefer_const_declarations - - prefer_const_literals_to_create_immutables - - prefer_constructors_over_static_methods - - prefer_contains - - prefer_equal_for_default_values - - prefer_expression_function_bodies - - prefer_final_fields - - prefer_final_in_for_each - - prefer_final_locals - - prefer_for_elements_to_map_fromIterable - - prefer_foreach - - prefer_function_declarations_over_variables - - prefer_generic_function_type_aliases - - prefer_if_elements_to_conditional_expressions - - prefer_if_null_operators - - prefer_initializing_formals - - prefer_inlined_adds - - prefer_int_literals - - prefer_interpolation_to_compose_strings - - prefer_is_empty - - provide_deprecation_message - - prefer_typing_uninitialized_variables - - public_member_api_docs - - unawaited_futures - - unnecessary_brace_in_string_interps - - unnecessary_lambdas - - unnecessary_null_in_if_null_operators - - unnecessary_parenthesis - - unnecessary_raw_strings - - unnecessary_string_escapes - - use_rethrow_when_possible - - use_string_buffers - - void_checks - - use_to_and_as_if_applicable - - sort_pub_dependencies - - prefer_is_not_operator - - prefer_iterable_whereType - - prefer_mixin - - prefer_null_aware_operators - - prefer_spread_collections diff --git a/example/basic.dart b/example/basic.dart index 2fee54e..62b1bdf 100644 --- a/example/basic.dart +++ b/example/basic.dart @@ -1,7 +1,7 @@ import "dart:math"; import "package:nyxx/nyxx.dart"; -import "package:nyxx_interactions/interactions.dart"; +import "package:nyxx_interactions/nyxx_interactions.dart"; // Creates instance of slash command builder with name, description and sub options. // Its used to synchronise commands with discord and also to be able to respond to them. @@ -35,8 +35,13 @@ final subCommandFlipGame = CommandOptionBuilder(CommandOptionType.subCommand, "c }); void main() { - final bot = Nyxx("", GatewayIntents.allUnprivileged); - Interactions(bot) + final bot = NyxxFactory.createNyxxWebsocket("", GatewayIntents.allUnprivileged) + ..registerPlugin(Logging()) // Default logging plugin + ..registerPlugin(CliIntegration()) // Cli integration for nyxx allows stopping application via SIGTERM and SIGKILl + ..registerPlugin(IgnoreExceptions()) // Plugin that handles uncaught exceptions that may occur + ..connect(); + + IInteractions.create(WebsocketInteractionBackend(bot)) ..registerSlashCommand(singleCommand) // Register created before slash command ..syncOnReady(); // This is needed if you want to sync commands on bot startup. } diff --git a/example/buttons-and-dropdowns.dart b/example/buttons_and_dropdowns.dart similarity index 82% rename from example/buttons-and-dropdowns.dart rename to example/buttons_and_dropdowns.dart index 8ace1a5..4865a2e 100644 --- a/example/buttons-and-dropdowns.dart +++ b/example/buttons_and_dropdowns.dart @@ -1,5 +1,5 @@ import "package:nyxx/nyxx.dart"; -import "package:nyxx_interactions/interactions.dart"; +import "package:nyxx_interactions/nyxx_interactions.dart"; final singleCommand = SlashCommandBuilder("help", "This is example help command", []) ..registerHandler((event) async { @@ -34,7 +34,7 @@ final singleCommand = SlashCommandBuilder("help", "This is example help command" // slash command it needs to acknowledged and/or responded. // If you know that command handler would take more that 3 second to complete // you would need to acknowledge and then respond later with proper result. -Future buttonHandler(ButtonInteractionEvent event) async { +Future buttonHandler(IButtonInteractionEvent event) async { await event.acknowledge(); // ack the interaction so we can send response later // Send followup to button click with id of button @@ -46,7 +46,7 @@ Future buttonHandler(ButtonInteractionEvent event) async { // Handling multiselect events is no different from handling button. // Only thing that changes is type of function argument -- it now passes information // about values selected with multiselect -Future multiselectHandlerHandler(MultiselectInteractionEvent event) async { +Future multiselectHandlerHandler(IMultiselectInteractionEvent event) async { await event.acknowledge(); // ack the interaction so we can send response later // Send followup to button click with id of button @@ -56,8 +56,13 @@ Future multiselectHandlerHandler(MultiselectInteractionEvent event) async } void main() { - final bot = Nyxx("", GatewayIntents.allUnprivileged); - Interactions(bot) + final bot = NyxxFactory.createNyxxWebsocket("", GatewayIntents.allUnprivileged) + ..registerPlugin(Logging()) // Default logging plugin + ..registerPlugin(CliIntegration()) // Cli integration for nyxx allows stopping application via SIGTERM and SIGKILl + ..registerPlugin(IgnoreExceptions()) // Plugin that handles uncaught exceptions that may occur + ..connect(); + + IInteractions.create(WebsocketInteractionBackend(bot)) ..registerSlashCommand(singleCommand) // Register created before slash command ..registerButtonHandler("thisisid", buttonHandler) // register handler for button with id: thisisid ..registerMultiselectHandler("customId", multiselectHandlerHandler) // register handler for multiselect with id: customId diff --git a/example/example.dart b/example/example.dart index f8d706a..cec1ae9 100644 --- a/example/example.dart +++ b/example/example.dart @@ -1,9 +1,14 @@ import "package:nyxx/nyxx.dart"; -import "package:nyxx_interactions/interactions.dart"; +import "package:nyxx_interactions/nyxx_interactions.dart"; void main() { - final bot = Nyxx("", GatewayIntents.allUnprivileged); - Interactions(bot) + final bot = NyxxFactory.createNyxxWebsocket("", GatewayIntents.allUnprivileged) + ..registerPlugin(Logging()) // Default logging plugin + ..registerPlugin(CliIntegration()) // Cli integration for nyxx allows stopping application via SIGTERM and SIGKILl + ..registerPlugin(IgnoreExceptions()) // Plugin that handles uncaught exceptions that may occur + ..connect(); + + IInteractions.create(WebsocketInteractionBackend(bot)) ..registerSlashCommand( SlashCommandBuilder("itest", "This is test command", [ CommandOptionBuilder(CommandOptionType.subCommand, "subtest", "This is sub test") diff --git a/example/private-test.dart b/example/private-test.dart deleted file mode 100644 index c00f808..0000000 --- a/example/private-test.dart +++ /dev/null @@ -1,98 +0,0 @@ -import "dart:io"; - -import "package:nyxx_interactions/interactions.dart"; -import "package:nyxx/nyxx.dart"; - -void main() { - final bot = Nyxx(Platform.environment["BOT_TOKEN"]!, GatewayIntents.allUnprivileged); - - Interactions(bot) - // ..registerSlashCommand(SlashCommandBuilder("test", "This is command group test", [ - // CommandOptionBuilder(CommandOptionType.string, "group1", "this is subcommand group", autoComplete: true) - // ], guild: 302360552993456135.toSnowflake())) - ..registerSlashCommand(SlashCommandBuilder("testfiles", "This is command group test", [], guild: 302360552993456135.toSnowflake()) - ..registerHandler((p0) async { - final builder = ComponentMessageBuilder() - ..content = "this is content" - ..addAttachment(AttachmentBuilder.path("/home/lusha/Pictures/dragraceundergambling.jpg")) - ..addComponentRow(ComponentRowBuilder() - ..addComponent(LinkButtonBuilder("This is discord link", "discord://-/channels/@me/")) - ); - await p0.respond(builder, hidden: true); - await p0.sendFollowup(builder); - }) - ) - // ..registerAutocompleteHandler("group1", (event) => event.respond([ArgChoiceBuilder("test", "test")])) - ..syncOnReady(); - -// Interactions(bot) -// ..registerSlashCommand( -// SlashCommandBuilder("itest", "This is test command", [ -// CommandOptionBuilder( -// CommandOptionType.subCommand, "testi", "This is testi", options: [CommandOptionBuilder(CommandOptionType.user, "user", "this is user")]) -// ..registerHandler((event) { -// event.acknowledge(); -// event.respond(MessageBuilder.content("This is respond")); -// }) -// // CommandOptionBuilder( -// // CommandOptionType.subCommand, "testii", "This is testi") -// // ..registerHandler((event) async { -// // await event.acknowledge(hidden: true); -// // -// // await event.respond(MessageBuilder.content("This is respond1")); -// // }) -// ], guild: 302360552993456135.toSnowflake()) -// )..registerSlashCommand( -// SlashCommandBuilder( -// "buttontest", "This is command for testing buttons", [], -// guild: 302360552993456135.toSnowflake()) -// ..registerHandler((event) async { -// await event.acknowledge(); -// -// final select = MultiselectBuilder("testid", [ -// MultiselectOptionBuilder("Example label", "123") -// ..emoji = UnicodeEmoji("💕"), -// MultiselectOptionBuilder("Example label 2", "1233"), -// ]); -// -// await event.respond(ComponentMessageBuilder() -// ..content = "Buttons" -// ..components = [ -// [ -// ButtonBuilder( -// "Disappearing button", "testowyu", ComponentStyle.danger) -// ], -// [select] -// ]); -// }) -// ) -// ..registerButtonHandler("testowyu", (event) async { -// // await event.respond(ButtonMessageBuilder()..buttons = []..content = "new content"); -// await event.acknowledge(); -// await event.respond(ComponentMessageBuilder() -// ..content = "This is test if it works" -// ..components = []); -// }) -// ..registerMultiselectHandler("testid", (event) async { -// await event.respond(MessageBuilder.content("Responded")); -// }) -// ..syncOnReady(); -// // ..registerHandler("reply", "This is test command", -// // [CommandOptionBuilder(CommandOptionType.subCommand, "test1", "This is description"), CommandOptionBuilder(CommandOptionType.subCommand, "test2", "this is test")], guild: 302360552993456135.toSnowflake(), -// // handler: (event) async { -// // await event.acknowledge(); -// // -// // final subCommand = event.subCommand; -// // -// // if (subCommand == null) { -// // return; -// // } -// // -// // Future.delayed(const Duration(seconds: 10), () async { -// // await event.respond(content: subCommand.name, hidden: true); -// // -// // await event.sendFollowup(content: "Test followup"); -// // }); -// // }); -// // } -} diff --git a/lib/interactions.dart b/lib/interactions.dart deleted file mode 100644 index 316a49d..0000000 --- a/lib/interactions.dart +++ /dev/null @@ -1,46 +0,0 @@ -library nyxx_interactions; - -import "dart:async"; -import "dart:collection"; -import "dart:convert"; -import "dart:io"; - -import "package:crypto/crypto.dart"; -import "package:logging/logging.dart"; -import "package:nyxx/nyxx.dart"; - -// Root -part "src/Interactions.dart"; -// Builders -part "src/builders/ArgChoiceBuilder.dart"; -part "src/builders/CommandOptionBuilder.dart"; -part "src/builders/CommandPermissionBuilder.dart"; -part "src/builders/ComponentBuilder.dart"; -part "src/builders/SlashCommandBuilder.dart"; -// Events -part "src/events/InteractionEvent.dart"; -part "src/exceptions/AlreadyResponded.dart"; -// Exceptions -part "src/exceptions/InteractionExpired.dart"; -part "src/exceptions/ResponseRequired.dart"; -// Internal -part "src/internal/_EventController.dart"; -part "src/internal/InteractionEndpoints.dart"; -// Sync -part "src/internal/sync/ICommandsSync.dart"; -part "src/internal/sync/LockFileCommandSync.dart"; -part "src/internal/sync/ManualCommandSync.dart"; -// Utils -part "src/internal/utils.dart"; -// Command Args -part "src/models/ArgChoice.dart"; -part "src/models/CommandOption.dart"; -part "src/models/Interaction.dart"; -part "src/models/InteractionDataResolved.dart"; -part "src/models/InteractionOption.dart"; -// Models -part "src/models/SlashCommand.dart"; -part "src/models/SlashCommandType.dart"; - -/// Typedef of api response -typedef RawApiMap = Map; diff --git a/lib/nyxx_interactions.dart b/lib/nyxx_interactions.dart new file mode 100644 index 0000000..2e9e89a --- /dev/null +++ b/lib/nyxx_interactions.dart @@ -0,0 +1,41 @@ +library nyxx_interactions; + +export 'src/builders/arg_choice_builder.dart' show ArgChoiceBuilder; +export 'src/builders/command_option_builder.dart' show CommandOptionBuilder; +export 'src/builders/command_permission_builder.dart' show CommandPermissionBuilderAbstract, RoleCommandPermissionBuilder, UserCommandPermissionBuilder; +export 'src/builders/component_builder.dart' + show ComponentMessageBuilder, ComponentRowBuilder, LinkButtonBuilder, ButtonBuilder, MultiselectBuilder, MultiselectOptionBuilder; +export 'src/builders/slash_command_builder.dart' show SlashCommandBuilder; +export 'src/events/interaction_event.dart' + show + IInteractionEventWithAcknowledge, + IInteractionEvent, + IAutocompleteInteractionEvent, + IButtonInteractionEvent, + IComponentInteractionEvent, + IMultiselectInteractionEvent, + InteractionEventAbstract, + InteractionEventWithAcknowledge, + ISlashCommandInteractionEvent; +export 'src/exceptions/already_responded.dart' show AlreadyRespondedError; +export 'src/exceptions/interaction_expired.dart' show InteractionExpiredError; +export 'src/exceptions/response_required.dart' show ResponseRequiredError; +export 'src/internal/sync/commands_sync.dart' show ICommandsSync; +export 'src/internal/sync/lock_file_command_sync.dart' show LockFileCommandSync; +export 'src/internal/sync/manual_command_sync.dart' show ManualCommandSync; +export 'src/internal/event_controller.dart' show IEventController; +export 'src/internal/interaction_endpoints.dart' show IInteractionsEndpoints; +export 'src/internal/utils.dart' show slashCommandNameRegex; +export 'src/models/arg_choice.dart' show IArgChoice; +export 'src/models/command_option.dart' show ICommandOption, CommandOptionType; +export 'src/models/interaction.dart' show IComponentInteraction, IInteraction, IButtonInteraction, IMultiselectInteraction, ISlashCommandInteraction; +export 'src/models/interaction_data_resolved.dart' show IInteractionDataResolved, IPartialChannel; +export 'src/models/interaction_option.dart' show IInteractionOption; +export 'src/models/slash_command.dart' show ISlashCommand; +export 'src/models/slash_command_type.dart' show SlashCommandType; + +export 'src/interactions.dart' show IInteractions; +export 'src/typedefs.dart' show AutocompleteInteractionHandler, ButtonInteractionHandler, MultiselectInteractionHandler, SlashCommandHandler; + +export 'src/backend/interaction_backend.dart' show InteractionBackend; +export 'src/backend/nyxx_backend.dart' show WebsocketInteractionBackend; diff --git a/lib/src/Interactions.dart b/lib/src/Interactions.dart deleted file mode 100644 index f405c3e..0000000 --- a/lib/src/Interactions.dart +++ /dev/null @@ -1,284 +0,0 @@ -part of nyxx_interactions; - -/// Function that will handle execution of slash command interaction event -typedef SlashCommandHandler = FutureOr Function(SlashCommandInteractionEvent); - -/// Function that will handle execution of button interaction event -typedef ButtonInteractionHandler = FutureOr Function(ButtonInteractionEvent); - -/// Function that will handle execution of dropdown event -typedef MultiselectInteractionHandler = FutureOr Function(MultiselectInteractionEvent); - -/// Function that will handle execution of button interaction event -typedef AutocompleteInteractionHandler = FutureOr Function(AutocompleteInteractionEvent); - -/// Interaction extension for Nyxx. Allows use of: Slash Commands. -class Interactions { - static const _interactionCreateCommand = "INTERACTION_CREATE"; - - late final _EventController _events; - final Logger _logger = Logger("Interactions"); - - /// Reference to client - final Nyxx _client; - - final _commandBuilders = []; - final _commands = []; - final _commandHandlers = {}; - final _buttonHandlers = {}; - final _autocompleteHandlers = {}; - final _multiselectHandlers = {}; - - /// Commands registered by bot - Iterable get commands => UnmodifiableListView(this._commands); - - /// Emitted when a slash command is sent. - late final Stream onSlashCommand; - - /// Emitted when a button interaction is received. - late final Stream onButtonEvent; - - /// Emitted when a dropdown interaction is received. - late final Stream onMultiselectEvent; - - /// Emitted when a slash command is created by the user. - late final Stream onSlashCommandCreated; - - /// Emitted when a slash command is created by the user. - late final Stream onAutocompleteEvent; - - /// All interaction endpoints that can be accessed. - late final IInteractionsEndpoints interactionsEndpoints; - - /// Create new instance of the interactions class. - Interactions(this._client) { - _events = _EventController(this); - _client.options.dispatchRawShardEvent = true; - this.interactionsEndpoints = _InteractionsEndpoints(_client); - - _logger.info("Interactions ready"); - - _client.onReady.listen((event) async { - _client.shardManager.rawEvent.listen((event) { - if (event.rawData["op"] == OPCodes.dispatch && event.rawData["t"] == _interactionCreateCommand) { - this._logger.fine("Received interaction event: [${event.rawData}]"); - - final type = event.rawData["d"]["type"] as int; - - switch (type) { - case 2: - _events.onSlashCommand.add(SlashCommandInteractionEvent._new(this, event.rawData["d"] as RawApiMap)); - break; - case 3: - final componentType = event.rawData["d"]["data"]["component_type"] as int; - - switch (componentType) { - case 2: - _events.onButtonEvent - .add(ButtonInteractionEvent._new(this, event.rawData["d"] as Map)); - break; - case 3: - _events.onMultiselectEvent - .add(MultiselectInteractionEvent._new(this, event.rawData["d"] as Map)); - break; - default: - this - ._logger - .warning("Unknown componentType type: [$componentType]; Payload: ${jsonEncode(event.rawData)}"); - } - break; - case 4: - _events.onAutocompleteEvent - .add(AutocompleteInteractionEvent._new(this, event.rawData["d"] as Map)); - break; - default: - this._logger.warning("Unknown interaction type: [$type]; Payload: ${jsonEncode(event.rawData)}"); - } - } - }); - }); - } - - /// Syncs commands builders with discord after client is ready. - void syncOnReady({ICommandsSync syncRule = const ManualCommandSync()}) { - this._client.onReady.listen((_) async { - await this.sync(syncRule: syncRule); - }); - } - - /// Syncs command builders with discord immediately. - /// Warning: Client could not be ready at the function execution. - /// Use [syncOnReady] for proper behavior - Future sync({ICommandsSync syncRule = const ManualCommandSync()}) async { - if (!await syncRule.shouldSync(this._commandBuilders)) { - return; - } - - final commandPartition = _partition(this._commandBuilders, (element) => element.guild == null); - final globalCommands = commandPartition.first; - final groupedGuildCommands = _groupSlashCommandBuilders(commandPartition.last); - - final globalCommandsResponse = await this.interactionsEndpoints - .bulkOverrideGlobalCommands(this._client.app.id, globalCommands) - .toList(); - - _extractCommandIds(globalCommandsResponse); - this._registerCommandHandlers(globalCommandsResponse, globalCommands); - - for(final entry in groupedGuildCommands.entries) { - final response = await this.interactionsEndpoints - .bulkOverrideGuildCommands(this._client.app.id, entry.key, entry.value) - .toList(); - - _extractCommandIds(response); - this._registerCommandHandlers(response, entry.value); - await this.interactionsEndpoints.bulkOverrideGuildCommandsPermissions(this._client.app.id, entry.key, entry.value); - } - - this._commandBuilders.clear(); // Cleanup after registering command since we don't need this anymore - this._logger.info("Finished bulk overriding slash commands and permissions"); - - if (this._commands.isNotEmpty) { - this.onSlashCommand.listen((event) async { - final commandHash = _determineInteractionCommandHandler(event.interaction); - - this._logger.info("Executing command with hash [$commandHash]"); - if (this._commandHandlers.containsKey(commandHash)) { - await this._commandHandlers[commandHash]!(event); - } - }); - - this._logger.info("Finished registering ${this._commandHandlers.length} commands!"); - } - - if (this._buttonHandlers.isNotEmpty) { - this.onButtonEvent.listen((event) { - if (this._buttonHandlers.containsKey(event.interaction.customId)) { - this._logger.info("Executing button with id [${event.interaction.customId}]"); - this._buttonHandlers[event.interaction.customId]!(event); - } else { - this._logger.warning("Received event for unknown button: ${event.interaction.customId}"); - } - }); - } - - if (this._multiselectHandlers.isNotEmpty) { - this.onMultiselectEvent.listen((event) { - if (this._multiselectHandlers.containsKey(event.interaction.customId)) { - this._logger.info("Executing multiselect with id [${event.interaction.customId}]"); - this._multiselectHandlers[event.interaction.customId]!(event); - } else { - this._logger.warning("Received event for unknown dropdown: ${event.interaction.customId}"); - } - }); - } - - if (this._autocompleteHandlers.isNotEmpty) { - this.onAutocompleteEvent.listen((event) { - final name = event.focusedOption.name; - - if (this._autocompleteHandlers.containsKey(name)) { - this._logger.info("Executing autocomplete with id [$name]"); - this._autocompleteHandlers[name]!(event); - } else { - this._logger.warning("Received event for unknown dropdown: $name"); - } - }); - } - } - - /// Registers callback for button event for given [id] - void registerAutocompleteHandler(String id, AutocompleteInteractionHandler handler) => this._autocompleteHandlers[id] = handler; - - /// Registers callback for button event for given [id] - void registerButtonHandler(String id, ButtonInteractionHandler handler) => this._buttonHandlers[id] = handler; - - /// Register callback for dropdown event for given [id] - void registerMultiselectHandler(String id, MultiselectInteractionHandler handler) => - this._multiselectHandlers[id] = handler; - - /// Allows to register new [SlashCommandBuilder] - void registerSlashCommand(SlashCommandBuilder slashCommandBuilder) => this._commandBuilders.add(slashCommandBuilder); - - /// Register callback for slash command event for given [id] - void registerSlashCommandHandler(String id, SlashCommandHandler handler) => - this._commandHandlers[id] = handler; - - /// Deletes global command - Future deleteGlobalCommand(Snowflake commandId) => - this.interactionsEndpoints.deleteGlobalCommand(this._client.app.id, commandId); - - /// Deletes guild command - Future deleteGuildCommand(Snowflake commandId, Snowflake guildId) => - this.interactionsEndpoints.deleteGuildCommand(this._client.app.id, commandId, guildId); - - /// Fetches all global bots command - Stream fetchGlobalCommands() => - this.interactionsEndpoints.fetchGlobalCommands(this._client.app.id); - - /// Fetches all guild commands for given guild - Stream fetchGuildCommands(Snowflake guildId) => - this.interactionsEndpoints.fetchGuildCommands(this._client.app.id, guildId); - - void _extractCommandIds(List commands) { - for (final slashCommand in commands) { - this._commandBuilders - .firstWhere((element) => element.name == slashCommand.name && element.guild == slashCommand.guild?.id) - ._setId(slashCommand.id); - } - } - - void _registerCommandHandlers(List registeredSlashCommands, Iterable builders) { - for(final registeredCommand in registeredSlashCommands) { - final matchingBuilder = builders.firstWhere((element) => element.name.toLowerCase() == registeredCommand.name); - this._assignCommandToHandler(matchingBuilder, registeredCommand); - - this._commands.add(registeredCommand); - } - } - - void _assignCommandToHandler(SlashCommandBuilder builder, SlashCommand command) { - final commandHashPrefix = "${command.id}|${command.name}"; - - var allowRootHandler = true; - - final subCommands = builder.options.where((element) => element.type == CommandOptionType.subCommand); - if (subCommands.isNotEmpty) { - for (final subCommand in subCommands) { - if (subCommand._handler == null) { - continue; - } - - this._commandHandlers["$commandHashPrefix|${subCommand.name}"] = subCommand._handler!; - } - - allowRootHandler = false; - } - - final subCommandGroups = builder.options.where((element) => element.type == CommandOptionType.subCommandGroup); - if (subCommandGroups.isNotEmpty) { - for (final subCommandGroup in subCommandGroups) { - final subCommands = - subCommandGroup.options?.where((element) => element.type == CommandOptionType.subCommand) ?? []; - - for (final subCommand in subCommands) { - if (subCommand._handler == null) { - continue; - } - - this._commandHandlers["$commandHashPrefix|${subCommandGroup.name}|${subCommand.name}"] = subCommand._handler!; - } - } - - allowRootHandler = false; - } - - if (!allowRootHandler) { - return; - } - - if (builder._handler != null) { - this._commandHandlers[commandHashPrefix] = builder._handler!; - } - } -} diff --git a/lib/src/backend/interaction_backend.dart b/lib/src/backend/interaction_backend.dart new file mode 100644 index 0000000..6fd7c47 --- /dev/null +++ b/lib/src/backend/interaction_backend.dart @@ -0,0 +1,16 @@ +import 'dart:async'; + +import 'package:nyxx/nyxx.dart'; + +typedef ApiData = Map; + +abstract class InteractionBackend { + INyxx get client; + + void setup(); + + Stream getStream(); + StreamController getStreamController(); + + Stream getReadyStream(); +} diff --git a/lib/src/backend/nyxx_backend.dart b/lib/src/backend/nyxx_backend.dart new file mode 100644 index 0000000..db9b7e8 --- /dev/null +++ b/lib/src/backend/nyxx_backend.dart @@ -0,0 +1,35 @@ +import 'dart:async'; + +import 'package:nyxx/nyxx.dart'; + +import 'interaction_backend.dart'; + +class WebsocketInteractionBackend implements InteractionBackend { + @override + INyxxWebsocket client; + + late final StreamController _streamController; + late final Stream _stream; + + WebsocketInteractionBackend(this.client); + + @override + void setup() { + _streamController = StreamController.broadcast(); + _stream = _streamController.stream; + + client.options.dispatchRawShardEvent = true; + getReadyStream().listen((_) { + client.shardManager.rawEvent.map((event) => event.rawData).pipe(_streamController); + }); + } + + @override + Stream getStream() => _stream; + + @override + StreamController getStreamController() => _streamController; + + @override + Stream getReadyStream() => client.eventsWs.onReady; +} diff --git a/lib/src/builders/ArgChoiceBuilder.dart b/lib/src/builders/arg_choice_builder.dart similarity index 83% rename from lib/src/builders/ArgChoiceBuilder.dart rename to lib/src/builders/arg_choice_builder.dart index a872a22..15fc058 100644 --- a/lib/src/builders/ArgChoiceBuilder.dart +++ b/lib/src/builders/arg_choice_builder.dart @@ -1,4 +1,4 @@ -part of nyxx_interactions; +import 'package:nyxx/nyxx.dart'; /// A specified choice for a slash command argument. class ArgChoiceBuilder extends Builder { @@ -16,5 +16,6 @@ class ArgChoiceBuilder extends Builder { } } - RawApiMap build() => { "name": this.name, "value": this.value }; + @override + RawApiMap build() => {"name": name, "value": value}; } diff --git a/lib/src/builders/CommandOptionBuilder.dart b/lib/src/builders/command_option_builder.dart similarity index 58% rename from lib/src/builders/CommandOptionBuilder.dart rename to lib/src/builders/command_option_builder.dart index 250b25e..bf2f23a 100644 --- a/lib/src/builders/CommandOptionBuilder.dart +++ b/lib/src/builders/command_option_builder.dart @@ -1,4 +1,8 @@ -part of nyxx_interactions; +import 'package:nyxx/nyxx.dart'; + +import 'package:nyxx_interactions/src/builders/arg_choice_builder.dart'; +import 'package:nyxx_interactions/src/models/command_option.dart'; +import 'package:nyxx_interactions/src/typedefs.dart'; /// An argument for a [SlashCommandBuilder]. class CommandOptionBuilder extends Builder { @@ -37,34 +41,44 @@ class CommandOptionBuilder extends Builder { List? channelTypes; /// Set to true if option should be autocompleted - bool? autoComplete; + bool autoComplete; + + SlashCommandHandler? handler; - SlashCommandHandler? _handler; + AutocompleteInteractionHandler? autocompleteHandler; /// Used to create an argument for a [SlashCommandBuilder]. CommandOptionBuilder(this.type, this.name, this.description, - {this.defaultArg = false, this.required = false, this.choices, this.options, - this.channelTypes, this.autoComplete}); + {this.defaultArg = false, this.required = false, this.choices, this.options, this.channelTypes, this.autoComplete = false}); /// Registers handler for subcommand void registerHandler(SlashCommandHandler handler) { - if (this.type != CommandOptionType.subCommand) { + if (type != CommandOptionType.subCommand) { throw StateError("Cannot register handler for command option with type other that subcommand"); } - this._handler = handler; + this.handler = handler; + } + + void registerAutocompleteHandler(AutocompleteInteractionHandler handler) { + if (choices != null || options != null) { + throw ArgumentError("Autocomplete cannot be set if choices or options are present"); + } + + autocompleteHandler = handler; + autoComplete = true; } + @override RawApiMap build() => { - "type": this.type.value, - "name": this.name, - "description": this.description, - "default": this.defaultArg, - "required": this.required, - if (this.choices != null) "choices": this.choices!.map((e) => e.build()).toList(), - if (this.options != null) "options": this.options!.map((e) => e.build()).toList(), - if (this.channelTypes != null && this.type == CommandOptionType.channel) - "channel_types": channelTypes!.map((e) => e.value).toList(), - if (this.autoComplete != null) "autocomplete": this.autoComplete, - }; + "type": type.value, + "name": name, + "description": description, + "default": defaultArg, + "required": required, + if (choices != null) "choices": choices!.map((e) => e.build()).toList(), + if (options != null) "options": options!.map((e) => e.build()).toList(), + if (channelTypes != null && type == CommandOptionType.channel) "channel_types": channelTypes!.map((e) => e.value).toList(), + "autocomplete": autoComplete, + }; } diff --git a/lib/src/builders/CommandPermissionBuilder.dart b/lib/src/builders/command_permission_builder.dart similarity index 52% rename from lib/src/builders/CommandPermissionBuilder.dart rename to lib/src/builders/command_permission_builder.dart index 91c59dd..ee514a1 100644 --- a/lib/src/builders/CommandPermissionBuilder.dart +++ b/lib/src/builders/command_permission_builder.dart @@ -1,8 +1,8 @@ -part of nyxx_interactions; +import 'package:nyxx/nyxx.dart'; /// Used to define permissions for a particular command. -abstract class ICommandPermissionBuilder extends Builder { - late final int _type; +abstract class CommandPermissionBuilderAbstract extends Builder { + int get type; /// The ID of the Role or User to give permissions too final Snowflake id; @@ -10,37 +10,35 @@ abstract class ICommandPermissionBuilder extends Builder { /// Does the role have permission to use the command final bool hasPermission; - ICommandPermissionBuilder(this.id, {this.hasPermission = true}); + CommandPermissionBuilderAbstract(this.id, {this.hasPermission = true}); /// A permission for a single user that can be used in [SlashCommandBuilder] - factory ICommandPermissionBuilder.user(Snowflake id, {bool hasPermission = true}) => - UserCommandPermissionBuilder(id, hasPermission: hasPermission); + factory CommandPermissionBuilderAbstract.user(Snowflake id, {bool hasPermission = true}) => UserCommandPermissionBuilder(id, hasPermission: hasPermission); /// A permission for a single role that can be used in [SlashCommandBuilder] - factory ICommandPermissionBuilder.role(Snowflake id, {bool hasPermission = true}) => - RoleCommandPermissionBuilder(id, hasPermission: hasPermission); + factory CommandPermissionBuilderAbstract.role(Snowflake id, {bool hasPermission = true}) => RoleCommandPermissionBuilder(id, hasPermission: hasPermission); } /// A permission for a single role that can be used in [SlashCommandBuilder] -class RoleCommandPermissionBuilder extends ICommandPermissionBuilder { +class RoleCommandPermissionBuilder extends CommandPermissionBuilderAbstract { @override - late final int _type = 1; + late final int type = 1; /// A permission for a single role that can be used in [SlashCommandBuilder] RoleCommandPermissionBuilder(Snowflake id, {bool hasPermission = true}) : super(id, hasPermission: hasPermission); @override - RawApiMap build() => {"id": this.id.toString(), "type": this._type, "permission": this.hasPermission}; + RawApiMap build() => {"id": id.toString(), "type": type, "permission": hasPermission}; } /// A permission for a single user that can be used in [SlashCommandBuilder] -class UserCommandPermissionBuilder extends ICommandPermissionBuilder { +class UserCommandPermissionBuilder extends CommandPermissionBuilderAbstract { @override - late final int _type = 2; + late final int type = 2; /// A permission for a single user that can be used in [SlashCommandBuilder] UserCommandPermissionBuilder(Snowflake id, {bool hasPermission = true}) : super(id, hasPermission: hasPermission); @override - RawApiMap build() => {"id": this.id.toString(), "type": this._type, "permission": this.hasPermission}; + RawApiMap build() => {"id": id.toString(), "type": type, "permission": hasPermission}; } diff --git a/lib/src/builders/ComponentBuilder.dart b/lib/src/builders/component_builder.dart similarity index 52% rename from lib/src/builders/ComponentBuilder.dart rename to lib/src/builders/component_builder.dart index 5565ac6..3819324 100644 --- a/lib/src/builders/ComponentBuilder.dart +++ b/lib/src/builders/component_builder.dart @@ -1,14 +1,14 @@ -part of nyxx_interactions; +import 'package:nyxx/nyxx.dart'; /// Allows to create components -abstract class IComponentBuilder extends Builder { +abstract class ComponentBuilderAbstract extends Builder { /// Type of component ComponentType get type; @override Map build() => { - "type": this.type.value, - }; + "type": type.value, + }; } /// Allows to create multi select option for [MultiselectBuilder] @@ -33,20 +33,21 @@ class MultiselectOptionBuilder extends Builder { @override RawApiMap build() => { - "label": this.label, - "value": this.value, - "default": this.isDefault, - if (this.emoji != null) "emoji": { - if (this.emoji is IGuildEmoji) "id": (this.emoji as IGuildEmoji).id, - if (this.emoji is UnicodeEmoji) "name": (this.emoji as UnicodeEmoji).code, - if (this.emoji is GuildEmoji) "animated": (this.emoji as GuildEmoji).animated, - }, - if (description != null) "description": this.description, - }; + "label": label, + "value": value, + "default": isDefault, + if (emoji != null) + "emoji": { + if (emoji is IBaseGuildEmoji) "id": (emoji as IBaseGuildEmoji).id.toString(), + if (emoji is UnicodeEmoji) "name": (emoji as UnicodeEmoji).code, + if (emoji is IGuildEmoji) "animated": (emoji as IGuildEmoji).animated, + }, + if (description != null) "description": description, + }; } /// Allows to create multi select interactive components. -class MultiselectBuilder extends IComponentBuilder { +class MultiselectBuilder extends ComponentBuilderAbstract { @override ComponentType get type => ComponentType.select; @@ -69,7 +70,7 @@ class MultiselectBuilder extends IComponentBuilder { /// Creates instance of [MultiselectBuilder] MultiselectBuilder(this.customId, [Iterable? options]) { - if (this.customId.length > 100) { + if (customId.length > 100) { throw ArgumentError("Custom Id for Select cannot have more than 100 characters"); } @@ -79,24 +80,21 @@ class MultiselectBuilder extends IComponentBuilder { } /// Adds option to dropdown - void addOption(MultiselectOptionBuilder builder) => this.options.add(builder); + void addOption(MultiselectOptionBuilder builder) => options.add(builder); @override Map build() => { - ...super.build(), - "custom_id": this.customId, - "options": [ - for (final optionBuilder in this.options) - optionBuilder.build() - ], - if (placeholder != null) "placeholder": this.placeholder, - if (minValues != null) "min_values": this.minValues, - if (maxValues != null) "max_values": this.maxValues, - }; + ...super.build(), + "custom_id": customId, + "options": [for (final optionBuilder in options) optionBuilder.build()], + if (placeholder != null) "placeholder": placeholder, + if (minValues != null) "min_values": minValues, + if (maxValues != null) "max_values": maxValues, + }; } /// Allows to build button. Generic interface for all types of buttons -abstract class IButtonBuilder extends IComponentBuilder { +abstract class ButtonBuilderAbstract extends ComponentBuilderAbstract { @override ComponentType get type => ComponentType.button; @@ -112,120 +110,99 @@ abstract class IButtonBuilder extends IComponentBuilder { /// Additional emoji for button IEmoji? emoji; - /// Creates instance of [IButtonBuilder] - IButtonBuilder(this.label, this.style, {this.disabled = false, this.emoji}) { - if (this.label.length > 80) { + /// Creates instance of [ButtonBuilderAbstract] + ButtonBuilderAbstract(this.label, this.style, {this.disabled = false, this.emoji}) { + if (label.length > 80) { throw ArgumentError("Label for Button cannot have more than 80 characters"); } } @override Map build() => { - ...super.build(), - "label": this.label, - "style": this.style.value, - if (this.disabled) "disabled": true, - if (this.emoji != null) "emoji": { - if (this.emoji is IGuildEmoji) "id": (this.emoji as IGuildEmoji).id, - if (this.emoji is UnicodeEmoji) "name": (this.emoji as UnicodeEmoji).code, - if (this.emoji is GuildEmoji) "animated": (this.emoji as GuildEmoji).animated, - } - }; + ...super.build(), + "label": label, + "style": style.value, + if (disabled) "disabled": true, + if (emoji != null) + "emoji": { + if (emoji is IGuildEmoji) "id": (emoji as IGuildEmoji).id, + if (emoji is UnicodeEmoji) "name": (emoji as UnicodeEmoji).code, + if (emoji is IGuildEmoji) "animated": (emoji as IGuildEmoji).animated, + } + }; } /// Allows to create a button with link -class LinkButtonBuilder extends IButtonBuilder { +class LinkButtonBuilder extends ButtonBuilderAbstract { /// Url where his button should redirect final String url; /// Creates instance of [LinkButtonBuilder] - LinkButtonBuilder( - String label, - this.url, - {bool disabled = false, - IEmoji? emoji - }): super(label, ComponentStyle.link, disabled: disabled, emoji: emoji - ) { - if (this.url.length > 512) { + LinkButtonBuilder(String label, this.url, {bool disabled = false, IEmoji? emoji}) : super(label, ComponentStyle.link, disabled: disabled, emoji: emoji) { + if (url.length > 512) { throw ArgumentError("Url for button cannot have more than 512 characters"); } } @override - RawApiMap build() => { - ...super.build(), - "url": url - }; + RawApiMap build() => {...super.build(), "url": url}; } /// Button which will generate event when clicked. -class ButtonBuilder extends IButtonBuilder { +class ButtonBuilder extends ButtonBuilderAbstract { /// Id with optional additional metadata for button. String customId; /// Creates instance of [ButtonBuilder] - ButtonBuilder( - String label, - this.customId, - ComponentStyle style, - {bool disabled = false, - IEmoji? emoji - }) : super(label, style, disabled: disabled, emoji: emoji - ) { - if (this.label.length > 100) { + ButtonBuilder(String label, this.customId, ComponentStyle style, {bool disabled = false, IEmoji? emoji}) + : super(label, style, disabled: disabled, emoji: emoji) { + if (customId.length > 100) { throw ArgumentError("customId for button cannot have more than 100 characters"); } } @override - RawApiMap build() => { - ...super.build(), - "custom_id": customId - }; + RawApiMap build() => {...super.build(), "custom_id": customId}; } /// Helper builder to provide fluid api for building component rows class ComponentRowBuilder { - final List _components = []; + final List _components = []; /// Adds component to row - void addComponent(IComponentBuilder componentBuilder) => this._components.add(componentBuilder); + void addComponent(ComponentBuilderAbstract componentBuilder) => _components.add(componentBuilder); } /// Extended [MessageBuilder] with support for buttons class ComponentMessageBuilder extends MessageBuilder { /// Set of buttons to attach to message. Message can only have 5 rows with 5 buttons each. - List>? components; + List>? componentRows; /// Allows to add void addComponentRow(ComponentRowBuilder componentRowBuilder) { - if (this.components == null) { - this.components = []; - } + componentRows ??= []; if (componentRowBuilder._components.length > 5 || componentRowBuilder._components.isEmpty) { throw ArgumentError("Component row cannot be empty or have more than 5 components"); } - if (this.components!.length == 5) { - throw ArgumentError("There cannot be more that 5 rows of components"); + if (componentRows!.length == 5) { + throw ArgumentError("Maximum number of component rows is 5"); } - this.components!.add(componentRowBuilder._components); + componentRows!.add(componentRowBuilder._components); } @override - RawApiMap build(INyxx client) => { - ...super.build(client), - if (this.components != null) "components": [ - for (final row in this.components!) - { - "type": ComponentType.row.value, + RawApiMap build([AllowedMentions? defaultAllowedMentions]) => { + ...super.build(allowedMentions), + if (componentRows != null) "components": [ - for (final component in row) - component.build() + for (final row in componentRows!) + { + "type": ComponentType.row.value, + "components": [for (final component in row) component.build()] + } ] - } - ] - }; + }; } diff --git a/lib/src/builders/SlashCommandBuilder.dart b/lib/src/builders/slash_command_builder.dart similarity index 54% rename from lib/src/builders/SlashCommandBuilder.dart rename to lib/src/builders/slash_command_builder.dart index 46bdc73..915cdb9 100644 --- a/lib/src/builders/SlashCommandBuilder.dart +++ b/lib/src/builders/slash_command_builder.dart @@ -1,4 +1,13 @@ -part of nyxx_interactions; +import 'package:nyxx/nyxx.dart'; + +import 'package:nyxx_interactions/src/builders/command_option_builder.dart'; +import 'package:nyxx_interactions/src/builders/command_permission_builder.dart'; + +import 'package:nyxx_interactions/src/models/slash_command_type.dart'; +import 'package:nyxx_interactions/src/models/command_option.dart'; +import 'package:nyxx_interactions/src/interactions.dart'; +import 'package:nyxx_interactions/src/internal/utils.dart'; +import 'package:nyxx_interactions/src/typedefs.dart'; /// A slash command, can only be instantiated through a method on [Interactions] class SlashCommandBuilder extends Builder { @@ -21,58 +30,57 @@ class SlashCommandBuilder extends Builder { List options; /// Permission overrides for the command - List? permissions; + List? permissions; /// Target of slash command if different that SlashCommandTarget.chat - slash command will /// become context menu in appropriate context SlashCommandType type; /// Handler for SlashCommandBuilder - SlashCommandHandler? _handler; + SlashCommandHandler? handler; /// A slash command, can only be instantiated through a method on [Interactions] SlashCommandBuilder(this.name, this.description, this.options, {this.defaultPermissions = true, this.permissions, this.guild, this.type = SlashCommandType.chat}) { - if (!slashCommandNameRegex.hasMatch(this.name)) { + if (!slashCommandNameRegex.hasMatch(name)) { throw ArgumentError("Command name has to match regex: ${slashCommandNameRegex.pattern}"); } - if (this.description == null && this.type == SlashCommandType.chat) { + if (description == null && type == SlashCommandType.chat) { throw ArgumentError("Normal slash command needs to have description"); } - if (this.description != null && this.type != SlashCommandType.chat) { + if (description != null && type != SlashCommandType.chat) { throw ArgumentError("Context menus cannot have description"); } } @override RawApiMap build() => { - "name": this.name, - if (this.type == SlashCommandType.chat) "description": this.description, - "default_permission": this.defaultPermissions, - if (this.options.isNotEmpty) "options": this.options.map((e) => e.build()).toList(), - "type": this.type.value, + "name": name, + if (type == SlashCommandType.chat) "description": description, + "default_permission": defaultPermissions, + if (options.isNotEmpty) "options": options.map((e) => e.build()).toList(), + "type": type.value, }; - void _setId(Snowflake id) => this._id = id; + void setId(Snowflake id) => _id = id; + + Snowflake get id => _id; /// Register a permission - void addPermission(ICommandPermissionBuilder permission) { - if (this.permissions == null) { - this.permissions = []; - } - this.permissions!.add(permission); + void addPermission(CommandPermissionBuilderAbstract permission) { + permissions ??= []; + + permissions!.add(permission); } /// Registers handler for command. Note command cannot have handler if there are options present void registerHandler(SlashCommandHandler handler) { - if (this.options.any((element) => - element.type == CommandOptionType.subCommand || element.type == CommandOptionType.subCommandGroup)) { - throw new ArgumentError( - "Cannot register handler for slash command if command have subcommand or subcommandgroup"); + if (options.any((element) => element.type == CommandOptionType.subCommand || element.type == CommandOptionType.subCommandGroup)) { + throw ArgumentError("Cannot register handler for slash command if command have subcommand or subcommandgroup"); } - this._handler = handler; + this.handler = handler; } } diff --git a/lib/src/events/InteractionEvent.dart b/lib/src/events/InteractionEvent.dart deleted file mode 100644 index 0013888..0000000 --- a/lib/src/events/InteractionEvent.dart +++ /dev/null @@ -1,209 +0,0 @@ -part of nyxx_interactions; - -abstract class InteractionEvent { - /// Reference to [Nyxx] - Nyxx get client => interactions._client; - - /// Reference to [Interactions] - late final Interactions interactions; - - /// The interaction data, includes the args, name, guild, channel, etc. - T get interaction; - - /// The DateTime the interaction was received by the Nyxx Client. - final DateTime receivedAt = DateTime.now(); - - final Logger _logger = Logger("Interaction Event"); - - InteractionEvent._new(this.interactions); -} - -class AutocompleteInteractionEvent extends InteractionEvent { - @override - late final SlashCommandInteraction interaction; - - AutocompleteInteractionEvent._new(Interactions interactions, RawApiMap raw): super._new(interactions) { - this.interaction = SlashCommandInteraction._new(client, raw); - } - - /// Returns focused option of autocomplete - InteractionOption get focusedOption => _extractArgs(this.interaction.options) - .firstWhere((element) => element.isFocused); - - /// Responds to interaction - Future respond(List builders) async { - if (DateTime.now().difference(this.receivedAt).inSeconds > 3) { - throw new InteractionExpiredError._3secs(); - } - - return this.interactions.interactionsEndpoints.respondToAutocomplete(this.interaction.id, this.interaction.token, builders); - } -} - -abstract class InteractionEventWithAcknowledge extends InteractionEvent { - /// If the Client has sent a response to the Discord API. Once the API was received a response you cannot send another. - bool _hasAcked = false; - - /// Opcode for acknowledging interaction - int get _acknowledgeOpCode; - - /// Opcode for responding to interaction - int get _respondOpcode; - - InteractionEventWithAcknowledge._new(Interactions interactions): super._new(interactions); - - /// Create a followup message for an Interaction - Future sendFollowup(MessageBuilder builder) async { - if (!_hasAcked) { - return Future.error(ResponseRequiredError()); - } - this._logger.fine("Sending followup for for interaction: ${this.interaction.id}"); - - return this.interactions.interactionsEndpoints.sendFollowup( - this.interaction.token, - this.client.app.id, - builder - ); - } - - /// Edits followup message - Future editFollowup(Snowflake messageId, MessageBuilder builder) => - this.interactions.interactionsEndpoints.editFollowup(this.interaction.token, this.client.app.id, messageId, builder); - - /// Deletes followup message with given id - Future deleteFollowup(Snowflake messageId) => - this.interactions.interactionsEndpoints.deleteFollowup(this.interaction.token, this.client.app.id, messageId); - - /// Deletes original response - Future deleteOriginalResponse() => - this.interactions.interactionsEndpoints.deleteOriginalResponse(this.interaction.token, this.client.app.id, this.interaction.id.toString()); - - /// Fetch followup message - Future fetchFollowup(Snowflake messageId) async => - this.interactions.interactionsEndpoints.fetchFollowup(this.interaction.token, this.client.app.id, messageId); - - /// Used to acknowledge a Interaction but not send any response yet. - /// Once this is sent you can then only send ChannelMessages. - /// You can also set showSource to also print out the command the user entered. - Future acknowledge({bool hidden = false}) async { - if (_hasAcked) { - return Future.error(AlreadyRespondedError()); - } - - if (DateTime.now().isAfter(this.receivedAt.add(const Duration(seconds: 3)))) { - return Future.error(InteractionExpiredError._3secs()); - } - - await this.interactions.interactionsEndpoints.acknowledge( - this.interaction.token, - this.interaction.id.toString(), - hidden, - this._acknowledgeOpCode - ); - - this._logger.fine("Sending acknowledge for for interaction: ${this.interaction.id}"); - - _hasAcked = true; - } - - /// Used to acknowledge a Interaction and send a response. - /// Once this is sent you can then only send ChannelMessages. - Future respond(MessageBuilder builder, {bool hidden = false}) async { - final now = DateTime.now(); - if (_hasAcked && now.isAfter(this.receivedAt.add(const Duration(minutes: 15)))) { - return Future.error(InteractionExpiredError._15mins()); - } else if (now.isAfter(this.receivedAt.add(const Duration(seconds: 3)))) { - return Future.error(InteractionExpiredError._3secs()); - } - - this._logger.fine("Sending respond for for interaction: ${this.interaction.id}"); - if (_hasAcked) { - await this.interactions.interactionsEndpoints.respondEditOriginal( - this.interaction.token, - this.client.app.id, - builder, - hidden - ); - } else { - if (!builder.canBeUsedAsNewMessage()) { - return Future.error(ArgumentError("Cannot sent message when MessageBuilder doesn't have set either content, embed or files")); - } - - await this.interactions.interactionsEndpoints.respondCreateResponse( - this.interaction.token, - this.interaction.id.toString(), - builder, - hidden, - _respondOpcode - ); - } - - _hasAcked = true; - } - - /// Returns [Message] object of original interaction response - Future getOriginalResponse() async => - this.interactions.interactionsEndpoints.fetchOriginalResponse(this.interaction.token, this.client.app.id, this.interaction.id.toString()); - - /// Edits original message response - Future editOriginalResponse(MessageBuilder builder) => - this.interactions.interactionsEndpoints.editOriginalResponse(this.interaction.token, this.client.app.id, builder); -} - -/// Event for slash commands -class SlashCommandInteractionEvent extends InteractionEventWithAcknowledge { - /// Interaction data for slash command - @override - late final SlashCommandInteraction interaction; - - @override - int get _acknowledgeOpCode => 5; - - @override - int get _respondOpcode => 4; - - /// Returns args of interaction - List get args => UnmodifiableListView(_extractArgs(this.interaction.options)); - - /// Searches for arg with [name] in this interaction - InteractionOption getArg(String name) => args.firstWhere((element) => element.name == name); - - SlashCommandInteractionEvent._new(Interactions interactions, RawApiMap raw) : super._new(interactions) { - this.interaction = SlashCommandInteraction._new(client, raw); - } -} - -/// Generic event for component interactions -abstract class ComponentInteractionEvent extends InteractionEventWithAcknowledge { - /// Interaction data for slash command - @override - late final T interaction; - - @override - int get _acknowledgeOpCode => 6; - - @override - int get _respondOpcode => 7; - - ComponentInteractionEvent._new(Interactions interactions, RawApiMap raw) : super._new(interactions); -} - -/// Interaction event for button events -class ButtonInteractionEvent extends ComponentInteractionEvent { - @override - late final ButtonInteraction interaction; - - ButtonInteractionEvent._new(Interactions interactions, RawApiMap raw) : super._new(interactions, raw) { - this.interaction = ButtonInteraction._new(client, raw); - } -} - -/// Interaction event for dropdown events -class MultiselectInteractionEvent extends ComponentInteractionEvent { - @override - late final MultiselectInteraction interaction; - - MultiselectInteractionEvent._new(Interactions interactions, RawApiMap raw) : super._new(interactions, raw) { - this.interaction = MultiselectInteraction._new(client, raw); - } -} diff --git a/lib/src/events/interaction_event.dart b/lib/src/events/interaction_event.dart new file mode 100644 index 0000000..5407ec7 --- /dev/null +++ b/lib/src/events/interaction_event.dart @@ -0,0 +1,290 @@ +import 'dart:collection'; + +import 'package:logging/logging.dart'; +import 'package:nyxx_interactions/src/models/interaction.dart'; +import 'package:nyxx_interactions/src/interactions.dart'; +import 'package:nyxx_interactions/src/internal/utils.dart'; +import 'package:nyxx_interactions/src/models/interaction_option.dart'; +import 'package:nyxx_interactions/src/builders/arg_choice_builder.dart'; +import 'package:nyxx_interactions/src/exceptions/interaction_expired.dart'; +import 'package:nyxx_interactions/src/exceptions/response_required.dart'; +import 'package:nyxx_interactions/src/exceptions/already_responded.dart'; +import 'package:nyxx/nyxx.dart'; + +abstract class IInteractionEvent { + /// Reference to [Nyxx] + INyxx get client; + + /// Reference to [Interactions] + Interactions get interactions; + + /// The interaction data, includes the args, name, guild, channel, etc. + T get interaction; + + /// The DateTime the interaction was received by the Nyxx Client. + DateTime get receivedAt; +} + +abstract class InteractionEventAbstract implements IInteractionEvent { + /// Reference to [Nyxx] + @override + INyxx get client => interactions.client; + + /// Reference to [Interactions] + @override + late final Interactions interactions; + + /// The interaction data, includes the args, name, guild, channel, etc. + @override + T get interaction; + + /// The DateTime the interaction was received by the Nyxx Client. + @override + final DateTime receivedAt = DateTime.now(); + + final Logger logger = Logger("Interaction Event"); + + InteractionEventAbstract(this.interactions); +} + +abstract class IAutocompleteInteractionEvent implements InteractionEventAbstract { + @override + late final ISlashCommandInteraction interaction; + + /// Returns focused option of autocomplete + IInteractionOption get focusedOption; + + /// List of autocomplete options + late final Iterable options; + + /// Responds to interaction + Future respond(List builders); +} + +class AutocompleteInteractionEvent extends InteractionEventAbstract implements IAutocompleteInteractionEvent { + @override + late final ISlashCommandInteraction interaction; + + @override + IInteractionOption get focusedOption => options.firstWhere((element) => element.isFocused); + + @override + late final Iterable options; + + AutocompleteInteractionEvent(Interactions interactions, RawApiMap raw) : super(interactions) { + interaction = SlashCommandInteraction(client, raw); + options = extractArgs(interaction.options); + } + + @override + Future respond(List builders) async { + if (DateTime.now().difference(receivedAt).inSeconds > 3) { + throw InteractionExpiredError.threeSecs(); + } + + return interactions.interactionsEndpoints.respondToAutocomplete(interaction.id, interaction.token, builders); + } +} + +abstract class IInteractionEventWithAcknowledge implements IInteractionEvent { + /// Create a followup message for an Interaction + Future sendFollowup(MessageBuilder builder, {bool hidden = false}); + + /// Edits followup message + Future editFollowup(Snowflake messageId, MessageBuilder builder); + + /// Deletes followup message with given id + Future deleteFollowup(Snowflake messageId); + + /// Deletes original response + Future deleteOriginalResponse(); + + /// Fetch followup message + Future fetchFollowup(Snowflake messageId); + + /// Used to acknowledge a Interaction but not send any response yet. + /// Once this is sent you can then only send ChannelMessages. + /// You can also set showSource to also print out the command the user entered. + Future acknowledge({bool hidden = false}); + + /// Used to acknowledge a Interaction and send a response. + /// Once this is sent you can then only send ChannelMessages. + Future respond(MessageBuilder builder, {bool hidden = false}); + + /// Returns [Message] object of original interaction response + Future getOriginalResponse(); + + /// Edits original message response + Future editOriginalResponse(MessageBuilder builder); +} + +abstract class InteractionEventWithAcknowledge extends InteractionEventAbstract implements IInteractionEventWithAcknowledge { + /// If the Client has sent a response to the Discord API. Once the API was received a response you cannot send another. + bool _hasAcked = false; + + /// Opcode for acknowledging interaction + int get _acknowledgeOpCode; + + /// Opcode for responding to interaction + int get _respondOpcode; + + InteractionEventWithAcknowledge(Interactions interactions) : super(interactions); + + /// Create a followup message for an Interaction + @override + Future sendFollowup(MessageBuilder builder, {bool hidden = false}) async { + if (!_hasAcked) { + return Future.error(ResponseRequiredError()); + } + logger.fine("Sending followup for for interaction: ${interaction.id}"); + + return interactions.interactionsEndpoints.sendFollowup(interaction.token, client.appId, builder, hidden: hidden); + } + + /// Edits followup message + @override + Future editFollowup(Snowflake messageId, MessageBuilder builder) => + interactions.interactionsEndpoints.editFollowup(interaction.token, client.appId, messageId, builder); + + /// Deletes followup message with given id + @override + Future deleteFollowup(Snowflake messageId) => interactions.interactionsEndpoints.deleteFollowup(interaction.token, client.appId, messageId); + + /// Deletes original response + @override + Future deleteOriginalResponse() => + interactions.interactionsEndpoints.deleteOriginalResponse(interaction.token, client.appId, interaction.id.toString()); + + /// Fetch followup message + @override + Future fetchFollowup(Snowflake messageId) async => interactions.interactionsEndpoints.fetchFollowup(interaction.token, client.appId, messageId); + + /// Used to acknowledge a Interaction but not send any response yet. + /// Once this is sent you can then only send ChannelMessages. + /// You can also set showSource to also print out the command the user entered. + @override + Future acknowledge({bool hidden = false}) async { + if (_hasAcked) { + return Future.error(AlreadyRespondedError()); + } + + if (DateTime.now().isAfter(receivedAt.add(const Duration(seconds: 3)))) { + return Future.error(InteractionExpiredError.threeSecs()); + } + + await interactions.interactionsEndpoints.acknowledge(interaction.token, interaction.id.toString(), hidden, _acknowledgeOpCode); + + logger.fine("Sending acknowledge for for interaction: ${interaction.id}"); + + _hasAcked = true; + } + + /// Used to acknowledge a Interaction and send a response. + /// Once this is sent you can then only send ChannelMessages. + @override + Future respond(MessageBuilder builder, {bool hidden = false}) async { + final now = DateTime.now(); + if (_hasAcked && now.isAfter(receivedAt.add(const Duration(minutes: 15)))) { + return Future.error(InteractionExpiredError.fifteenMins()); + } else if (now.isAfter(receivedAt.add(const Duration(seconds: 3)))) { + return Future.error(InteractionExpiredError.threeSecs()); + } + + logger.fine("Sending respond for for interaction: ${interaction.id}"); + if (_hasAcked) { + await interactions.interactionsEndpoints.respondEditOriginal(interaction.token, client.appId, builder, hidden); + } else { + if (!builder.canBeUsedAsNewMessage()) { + return Future.error(ArgumentError("Cannot sent message when MessageBuilder doesn't have set either content, embed or files")); + } + + await interactions.interactionsEndpoints.respondCreateResponse(interaction.token, interaction.id.toString(), builder, hidden, _respondOpcode); + } + + _hasAcked = true; + } + + /// Returns [Message] object of original interaction response + @override + Future getOriginalResponse() async => + interactions.interactionsEndpoints.fetchOriginalResponse(interaction.token, client.appId, interaction.id.toString()); + + /// Edits original message response + @override + Future editOriginalResponse(MessageBuilder builder) => + interactions.interactionsEndpoints.editOriginalResponse(interaction.token, client.appId, builder); +} + +abstract class ISlashCommandInteractionEvent implements InteractionEventWithAcknowledge { + /// Returns args of interaction + List get args; + + /// Searches for arg with [name] in this interaction + IInteractionOption getArg(String name); +} + +/// Event for slash commands +class SlashCommandInteractionEvent extends InteractionEventWithAcknowledge implements ISlashCommandInteractionEvent { + /// Interaction data for slash command + @override + late final SlashCommandInteraction interaction; + + @override + int get _acknowledgeOpCode => 5; + + @override + int get _respondOpcode => 4; + + /// Returns args of interaction + @override + List get args => UnmodifiableListView(extractArgs(interaction.options)); + + /// Searches for arg with [name] in this interaction + @override + IInteractionOption getArg(String name) => args.firstWhere((element) => element.name == name); + + SlashCommandInteractionEvent(Interactions interactions, RawApiMap raw) : super(interactions) { + interaction = SlashCommandInteraction(client, raw); + } +} + +abstract class IComponentInteractionEvent implements IInteractionEventWithAcknowledge {} + +/// Generic event for component interactions +abstract class ComponentInteractionEvent extends InteractionEventWithAcknowledge implements IComponentInteractionEvent { + /// Interaction data for slash command + @override + T get interaction; + + @override + int get _acknowledgeOpCode => 6; + + @override + int get _respondOpcode => 7; + + ComponentInteractionEvent(Interactions interactions, RawApiMap raw) : super(interactions); +} + +abstract class IButtonInteractionEvent implements IComponentInteractionEvent {} + +/// Interaction event for button events +class ButtonInteractionEvent extends ComponentInteractionEvent implements IButtonInteractionEvent { + @override + late final IButtonInteraction interaction; + + ButtonInteractionEvent(Interactions interactions, RawApiMap raw) : super(interactions, raw) { + interaction = ButtonInteraction(client, raw); + } +} + +abstract class IMultiselectInteractionEvent implements ComponentInteractionEvent {} + +/// Interaction event for dropdown events +class MultiselectInteractionEvent extends ComponentInteractionEvent implements IMultiselectInteractionEvent { + @override + late final IMultiselectInteraction interaction; + + MultiselectInteractionEvent(Interactions interactions, RawApiMap raw) : super(interactions, raw) { + interaction = MultiselectInteraction(client, raw); + } +} diff --git a/lib/src/exceptions/InteractionExpired.dart b/lib/src/exceptions/InteractionExpired.dart deleted file mode 100644 index 0d34a9c..0000000 --- a/lib/src/exceptions/InteractionExpired.dart +++ /dev/null @@ -1,21 +0,0 @@ -part of nyxx_interactions; - -/// Thrown when 15 minutes has passed since an interaction was called. -class InteractionExpiredError implements Error { - late final String _timeFrameString; - - InteractionExpiredError._15mins() { - this._timeFrameString = "15mins"; - } - - InteractionExpiredError._3secs() { - this._timeFrameString = "3secs"; - } - - /// Returns a string representation of this object. - @override - String toString() => "InteractionExpiredError: Interaction tokens are only valid for $_timeFrameString. It has been over $_timeFrameString and the token is now invalid."; - - @override - StackTrace? get stackTrace => StackTrace.empty; -} diff --git a/lib/src/exceptions/AlreadyResponded.dart b/lib/src/exceptions/already_responded.dart similarity index 93% rename from lib/src/exceptions/AlreadyResponded.dart rename to lib/src/exceptions/already_responded.dart index 9e84b48..58edec6 100644 --- a/lib/src/exceptions/AlreadyResponded.dart +++ b/lib/src/exceptions/already_responded.dart @@ -1,5 +1,3 @@ -part of nyxx_interactions; - /// Thrown when you have already responded to an interaction class AlreadyRespondedError implements Error { /// Returns a string representation of this object. diff --git a/lib/src/exceptions/interaction_expired.dart b/lib/src/exceptions/interaction_expired.dart new file mode 100644 index 0000000..4a838e4 --- /dev/null +++ b/lib/src/exceptions/interaction_expired.dart @@ -0,0 +1,20 @@ +/// Thrown when 15 minutes has passed since an interaction was called. +class InteractionExpiredError implements Error { + late final String _timeFrameString; + + InteractionExpiredError.fifteenMins() { + _timeFrameString = "15mins"; + } + + InteractionExpiredError.threeSecs() { + _timeFrameString = "3secs"; + } + + /// Returns a string representation of this object. + @override + String toString() => + "InteractionExpiredError: Interaction tokens are only valid for $_timeFrameString. It has been over $_timeFrameString and the token is now invalid."; + + @override + StackTrace? get stackTrace => StackTrace.empty; +} diff --git a/lib/src/exceptions/ResponseRequired.dart b/lib/src/exceptions/response_required.dart similarity index 92% rename from lib/src/exceptions/ResponseRequired.dart rename to lib/src/exceptions/response_required.dart index ff12c92..45c99fe 100644 --- a/lib/src/exceptions/ResponseRequired.dart +++ b/lib/src/exceptions/response_required.dart @@ -1,5 +1,3 @@ -part of nyxx_interactions; - /// Thrown when you haven't sent a response yet class ResponseRequiredError implements Error { /// Returns a string representation of this object. diff --git a/lib/src/interactions.dart b/lib/src/interactions.dart new file mode 100644 index 0000000..0183048 --- /dev/null +++ b/lib/src/interactions.dart @@ -0,0 +1,358 @@ +import 'dart:async'; +import 'dart:collection'; +import 'dart:convert'; + +import 'package:logging/logging.dart'; +import 'package:nyxx/nyxx.dart'; +import 'package:nyxx_interactions/src/backend/interaction_backend.dart'; + +import 'package:nyxx_interactions/src/builders/slash_command_builder.dart'; +import 'package:nyxx_interactions/src/internal/interaction_endpoints.dart'; +import 'package:nyxx_interactions/src/internal/event_controller.dart'; +import 'package:nyxx_interactions/src/models/slash_command.dart'; +import 'package:nyxx_interactions/src/internal/sync/commands_sync.dart'; +import 'package:nyxx_interactions/src/internal/sync/manual_command_sync.dart'; +import 'package:nyxx_interactions/src/internal/utils.dart'; +import 'package:nyxx_interactions/src/models/command_option.dart'; +import 'package:nyxx_interactions/src/typedefs.dart'; +import 'package:nyxx_interactions/src/events/interaction_event.dart'; +import 'package:nyxx_interactions/src/builders/command_option_builder.dart'; + +abstract class IInteractions { + IEventController get events; + + /// Reference to client + INyxx get client; + + InteractionBackend get backend; + + /// Commands registered by bot + Iterable get commands; + + /// All interaction endpoints that can be accessed. + IInteractionsEndpoints get interactionsEndpoints; + + /// Syncs commands builders with discord after client is ready. + void syncOnReady({ICommandsSync syncRule = const ManualCommandSync()}); + + /// Syncs command builders with discord immediately. + /// Warning: Client could not be ready at the function execution. + /// Use [syncOnReady] for proper behavior + Future sync({ICommandsSync syncRule = const ManualCommandSync()}); + + /// Registers callback for button event for given [id] + void registerButtonHandler(String id, ButtonInteractionHandler handler); + + /// Register callback for dropdown event for given [id] + void registerMultiselectHandler(String id, MultiselectInteractionHandler handler); + + /// Allows to register new [SlashCommandBuilder] + void registerSlashCommand(SlashCommandBuilder slashCommandBuilder); + + /// Register callback for slash command event for given [id] + void registerSlashCommandHandler(String id, SlashCommandHandler handler); + + /// Deletes global command + Future deleteGlobalCommand(Snowflake commandId); + + /// Deletes all global commands + Future deleteGlobalCommands(); + + /// Deletes guild command + Future deleteGuildCommand(Snowflake commandId, Snowflake guildId); + + /// Deletes all guild commands for the specified guilds + Future deleteGuildCommands(List guildIds); + + /// Fetches all global bots command + Stream fetchGlobalCommands(); + + /// Fetches all guild commands for given guild + Stream fetchGuildCommands(Snowflake guildId); + + static IInteractions create(InteractionBackend backend) => Interactions(backend); +} + +/// Interaction extension for Nyxx. Allows use of: Slash Commands. +class Interactions implements IInteractions { + static const _interactionCreateCommand = "INTERACTION_CREATE"; + + final Logger _logger = Logger("Interactions"); + final _commandBuilders = []; + final _commands = []; + final _commandHandlers = {}; + final _buttonHandlers = {}; + final _autocompleteHandlers = {}; + final _multiselectHandlers = {}; + + @override + late final IEventController events; + + /// Reference to client + @override + INyxx get client => backend.client; + + @override + final InteractionBackend backend; + + /// Commands registered by bot + @override + Iterable get commands => UnmodifiableListView(_commands); + + /// All interaction endpoints that can be accessed. + @override + late final IInteractionsEndpoints interactionsEndpoints; + + /// Create new instance of the interactions class. + Interactions(this.backend) { + events = EventController(); + interactionsEndpoints = InteractionsEndpoints(client); + + backend.setup(); + + _logger.info("Interactions ready"); + + backend.getStream().listen((rawData) { + if (rawData["op"] == 0 && rawData["t"] == _interactionCreateCommand) { + _logger.fine("Received interaction event: [$rawData]"); + + final type = rawData["d"]["type"] as int; + + switch (type) { + case 2: + (events as EventController).onSlashCommandController.add(SlashCommandInteractionEvent(this, rawData["d"] as RawApiMap)); + break; + case 3: + final componentType = rawData["d"]["data"]["component_type"] as int; + + switch (componentType) { + case 2: + (events as EventController).onButtonEventController.add(ButtonInteractionEvent(this, rawData["d"] as Map)); + break; + case 3: + (events as EventController).onMultiselectEventController.add(MultiselectInteractionEvent(this, rawData["d"] as Map)); + break; + default: + _logger.warning("Unknown componentType type: [$componentType]; Payload: ${jsonEncode(rawData)}"); + } + break; + case 4: + (events as EventController).onAutocompleteEventController.add(AutocompleteInteractionEvent(this, rawData["d"] as Map)); + break; + default: + _logger.warning("Unknown interaction type: [$type]; Payload: ${jsonEncode(rawData)}"); + } + } + }); + } + + /// Syncs commands builders with discord after client is ready. + @override + void syncOnReady({ICommandsSync syncRule = const ManualCommandSync()}) { + backend.getReadyStream().listen((_) async { + await sync(syncRule: syncRule); + }); + } + + /// Syncs command builders with discord immediately. + /// Warning: Client could not be ready at the function execution. + /// Use [syncOnReady] for proper behavior + @override + Future sync({ICommandsSync syncRule = const ManualCommandSync()}) async { + final shouldSync = await syncRule.shouldSync(_commandBuilders); + + final commandPartition = partition(_commandBuilders, (element) => element.guild == null); + final globalCommands = commandPartition.first; + final groupedGuildCommands = groupSlashCommandBuilders(commandPartition.last); + + if (shouldSync) { + final globalCommandsResponse = await interactionsEndpoints.bulkOverrideGlobalCommands(client.appId, globalCommands).toList(); + _extractCommandIds(globalCommandsResponse); + + for (final command in globalCommandsResponse) { + _commands.add(command); + } + } + + for (final globalCommandBuilder in globalCommands) { + _assignCommandToHandler(globalCommandBuilder); + } + + for (final entry in groupedGuildCommands.entries) { + if (shouldSync) { + final response = await interactionsEndpoints.bulkOverrideGuildCommands(client.appId, entry.key, entry.value).toList(); + _extractCommandIds(response); + + for (final command in response) { + _commands.add(command); + } + } + + for (final globalCommandBuilder in entry.value) { + _assignCommandToHandler(globalCommandBuilder); + } + + await interactionsEndpoints.bulkOverrideGuildCommandsPermissions(client.appId, entry.key, entry.value); + } + + if (_commandHandlers.isNotEmpty) { + events.onSlashCommand.listen((event) async { + final commandHash = determineInteractionCommandHandler(event.interaction); + + _logger.info("Executing command with hash [$commandHash]"); + if (_commandHandlers.containsKey(commandHash)) { + await _commandHandlers[commandHash]!(event); + } + }); + + _logger.info("Finished registering ${_commandHandlers.length} commands!"); + } + + if (_buttonHandlers.isNotEmpty) { + events.onButtonEvent.listen((event) { + if (_buttonHandlers.containsKey(event.interaction.customId)) { + _logger.info("Executing button with id [${event.interaction.customId}]"); + _buttonHandlers[event.interaction.customId]!(event); + } else { + _logger.warning("Received event for unknown button: ${event.interaction.customId}"); + } + }); + } + + if (_multiselectHandlers.isNotEmpty) { + events.onMultiselectEvent.listen((event) { + if (_multiselectHandlers.containsKey(event.interaction.customId)) { + _logger.info("Executing multiselect with id [${event.interaction.customId}]"); + _multiselectHandlers[event.interaction.customId]!(event); + } else { + _logger.warning("Received event for unknown dropdown: ${event.interaction.customId}"); + } + }); + } + + if (_autocompleteHandlers.isNotEmpty) { + events.onAutocompleteEvent.listen((event) { + final name = event.focusedOption.name; + final commandHash = determineInteractionCommandHandler(event.interaction); + final autocompleteHash = "$commandHash$name"; + + if (_autocompleteHandlers.containsKey(autocompleteHash)) { + _logger.info("Executing autocomplete with id [$autocompleteHash]"); + _autocompleteHandlers[autocompleteHash]!(event); + } else { + _logger.warning("Received event for unknown dropdown: $autocompleteHash"); + } + }); + } + + _commandBuilders.clear(); // Cleanup after registering command since we don't need this anymore + _logger.info("Finished registering commands"); + } + + /// Registers callback for button event for given [id] + @override + void registerButtonHandler(String id, ButtonInteractionHandler handler) => _buttonHandlers[id] = handler; + + /// Register callback for dropdown event for given [id] + @override + void registerMultiselectHandler(String id, MultiselectInteractionHandler handler) => _multiselectHandlers[id] = handler; + + /// Allows to register new [SlashCommandBuilder] + @override + void registerSlashCommand(SlashCommandBuilder slashCommandBuilder) => _commandBuilders.add(slashCommandBuilder); + + /// Register callback for slash command event for given [id] + @override + void registerSlashCommandHandler(String id, SlashCommandHandler handler) => _commandHandlers[id] = handler; + + /// Deletes global command + @override + Future deleteGlobalCommand(Snowflake commandId) => interactionsEndpoints.deleteGlobalCommand(client.appId, commandId); + + /// Deletes all global commands + @override + Future deleteGlobalCommands() async => interactionsEndpoints.bulkOverrideGlobalCommands(client.appId, []); + + /// Deletes guild command + @override + Future deleteGuildCommand(Snowflake commandId, Snowflake guildId) => interactionsEndpoints.deleteGuildCommand(client.appId, commandId, guildId); + + /// Deletes all guild commands for the specified guilds + @override + Future deleteGuildCommands(List guildIds) async { + await Future.wait(guildIds.map((guildId) => interactionsEndpoints.bulkOverrideGuildCommands(client.appId, guildId, []).toList())); + } + + /// Fetches all global bots command + @override + Stream fetchGlobalCommands() => interactionsEndpoints.fetchGlobalCommands(client.appId); + + /// Fetches all guild commands for given guild + @override + Stream fetchGuildCommands(Snowflake guildId) => interactionsEndpoints.fetchGuildCommands(client.appId, guildId); + + void _extractCommandIds(List commands) { + for (final slashCommand in commands) { + _commandBuilders.firstWhere((element) => element.name == slashCommand.name && element.guild == slashCommand.guild?.id).setId(slashCommand.id); + } + } + + void _assignAutoCompleteHandler(String commandHash, Iterable options) { + for (final option in options) { + if (!option.autoComplete || option.autocompleteHandler == null) { + continue; + } + + _autocompleteHandlers['$commandHash${option.name}'] = option.autocompleteHandler!; + } + } + + void _assignCommandToHandler(SlashCommandBuilder builder) { + final commandHashPrefix = builder.name; + + var allowRootHandler = true; + + final subCommands = builder.options.where((element) => element.type == CommandOptionType.subCommand); + if (subCommands.isNotEmpty) { + for (final subCommand in subCommands) { + if (subCommand.handler == null) { + continue; + } + + final hash = "$commandHashPrefix|${subCommand.name}"; + _commandHandlers[hash] = subCommand.handler!; + _assignAutoCompleteHandler(hash, subCommand.options ?? []); + } + + allowRootHandler = false; + } + + final subCommandGroups = builder.options.where((element) => element.type == CommandOptionType.subCommandGroup); + if (subCommandGroups.isNotEmpty) { + for (final subCommandGroup in subCommandGroups) { + final subCommands = subCommandGroup.options?.where((element) => element.type == CommandOptionType.subCommand) ?? []; + + for (final subCommand in subCommands) { + if (subCommand.handler == null) { + continue; + } + + final hash = "$commandHashPrefix|${subCommandGroup.name}|${subCommand.name}"; + _commandHandlers[hash] = subCommand.handler!; + _assignAutoCompleteHandler(hash, subCommand.options ?? []); + } + } + + allowRootHandler = false; + } + + if (!allowRootHandler) { + return; + } + + if (builder.handler != null) { + _commandHandlers[commandHashPrefix] = builder.handler!; + _assignAutoCompleteHandler(commandHashPrefix, builder.options); + } + } +} diff --git a/lib/src/internal/InteractionEndpoints.dart b/lib/src/internal/InteractionEndpoints.dart deleted file mode 100644 index 97c2bc9..0000000 --- a/lib/src/internal/InteractionEndpoints.dart +++ /dev/null @@ -1,423 +0,0 @@ -part of nyxx_interactions; - -abstract class IInteractionsEndpoints { - /// Sends followup for interaction with given [token]. Message will be created with [builder] - Future sendFollowup(String token, Snowflake applicationId, MessageBuilder builder); - - /// Fetches followup messagge from API - Future fetchFollowup(String token, Snowflake applicationId, Snowflake messageId); - - /// Acknowledges interaction that response can be sent within next 15 mins. - /// Response will be ephemeral if [hidden] is set to true. To response to different interaction types - /// (slash command, button...) [opCode] is used. - Future acknowledge(String token, String interactionId, bool hidden, int opCode); - - /// Response to interaction by editing original response. Used when interaction was acked before. - Future respondEditOriginal(String token, Snowflake applicationId, MessageBuilder builder, bool hidden); - - /// Response to interaction by creating response. Used when interaction wasn't acked before. - Future respondCreateResponse(String token, String interactionId, MessageBuilder builder, bool hidden, int respondOpCode); - - /// Fetch original interaction response. - Future fetchOriginalResponse(String token, Snowflake applicationId, String interactionId); - - /// Edits original interaction response using [builder] - Future editOriginalResponse(String token, Snowflake applicationId, MessageBuilder builder); - - /// Deletes original interaction response - Future deleteOriginalResponse(String token, Snowflake applicationId, String interactionId); - - /// Deletes followup message with given id - Future deleteFollowup(String token, Snowflake applicationId, Snowflake messageId); - - /// Edits followup message with given [messageId] - Future editFollowup(String token, Snowflake applicationId, Snowflake messageId, MessageBuilder builder); - - /// Fetches global commands of application - Stream fetchGlobalCommands(Snowflake applicationId); - - /// Fetches global command with given [commandId] - Future fetchGlobalCommand(Snowflake applicationId, Snowflake commandId); - - /// Edits global command with given [commandId] using [builder] - Future editGlobalCommand(Snowflake applicationId, Snowflake commandId, SlashCommandBuilder builder); - - /// Deletes global command with given [commandId] - Future deleteGlobalCommand(Snowflake applicationId, Snowflake commandId); - - /// Bulk overrides global commands. To delete all apps global commands pass empty list to [builders] - Stream bulkOverrideGlobalCommands(Snowflake applicationId, Iterable builders); - - /// Fetches all commands for given [guildId] - Stream fetchGuildCommands(Snowflake applicationId, Snowflake guildId); - - /// Fetches single guild command with given [commandId] - Future fetchGuildCommand(Snowflake applicationId, Snowflake commandId, Snowflake guildId); - - /// Edits single guild command with given [commandId] - Future editGuildCommand(Snowflake applicationId, Snowflake commandId, Snowflake guildId, SlashCommandBuilder builder); - - /// Deletes guild command with given commandId] - Future deleteGuildCommand(Snowflake applicationId, Snowflake commandId, Snowflake guildId); - - /// Bulk overrides global commands. To delete all apps global commands pass empty list to [builders] - Stream bulkOverrideGuildCommands(Snowflake applicationId, Snowflake guildId, Iterable builders); - - /// Overrides permissions for guild commands - Future bulkOverrideGuildCommandsPermissions(Snowflake applicationId, Snowflake guildId, Iterable builders); - - /// Responds to autocomplete interaction - Future respondToAutocomplete(Snowflake interactionId, String token, List builders); -} - -class _InteractionsEndpoints implements IInteractionsEndpoints { - final Nyxx _client; - - _InteractionsEndpoints(this._client); - - @override - Future acknowledge(String token, String interactionId, bool hidden, int opCode) async { - final url = "/interactions/$interactionId/$token/callback"; - final response = await this._client.httpEndpoints.sendRawRequest(url, "POST", body: { - "type": opCode, - "data": { - if (hidden) "flags": 1 << 6, - } - }); - - if (response is HttpResponseError) { - return Future.error(response); - } - } - - @override - Future deleteFollowup(String token, Snowflake applicationId, Snowflake messageId) => - this._client.httpEndpoints.sendRawRequest( - "webhooks/$applicationId/$token/messages/$messageId", - "DELETE" - ); - - @override - Future deleteOriginalResponse(String token, Snowflake applicationId, String interactionId) async { - final url = "/webhooks/$applicationId/$token/messages/@original"; - const method = "DELETE"; - - final response = await this._client.httpEndpoints.sendRawRequest(url, method); - if (response is HttpResponseError) { - return Future.error(response); - } - } - - @override - Future editFollowup(String token, Snowflake applicationId, Snowflake messageId, MessageBuilder builder) async { - final url = "/webhooks/$applicationId/$token/messages/$messageId"; - final body = BuilderUtility.buildWithClient(builder, _client); - - final response = await this._client.httpEndpoints.sendRawRequest(url, "PATCH", body: body); - if (response is HttpResponseError) { - return Future.error(response); - } - - return EntityUtility.createMessage(this._client, (response as HttpResponseSuccess).jsonBody as RawApiMap); - } - - @override - Future editOriginalResponse(String token, Snowflake applicationId, MessageBuilder builder) async { - final response = await this._client.httpEndpoints.sendRawRequest( - "/webhooks/$applicationId/$token/messages/@original", - "PATCH", - body: builder.build(this._client) - ); - - if (response is HttpResponseError) { - return Future.error(response); - } - - return EntityUtility.createMessage(this._client, (response as HttpResponseSuccess).jsonBody as RawApiMap); - } - - @override - Future fetchOriginalResponse(String token, Snowflake applicationId, String interactionId) async { - final response = await this._client.httpEndpoints.sendRawRequest( - "/webhooks/$applicationId/$token/messages/@original", - "GET" - ); - - if (response is HttpResponseError) { - return Future.error(response); - } - - return EntityUtility.createMessage(this._client, (response as HttpResponseSuccess).jsonBody as RawApiMap); - } - - @override - Future respondEditOriginal(String token, Snowflake applicationId, MessageBuilder builder, bool hidden) async { - final response = await this._client.httpEndpoints.sendRawRequest( - "/webhooks/$applicationId/$token/messages/@original", - "PATCH", - body: { - if (hidden) "flags": 1 << 6, - ...BuilderUtility.buildWithClient(builder, _client) - }, - files: builder.files ?? [] - ); - - if (response is HttpResponseError) { - return Future.error(response); - } - } - - @override - Future respondCreateResponse(String token, String interactionId, MessageBuilder builder, bool hidden, int respondOpCode) async { - final response = await this._client.httpEndpoints.sendRawRequest( - "/interactions/${interactionId.toString()}/$token/callback", - "POST", - body: { - "type": respondOpCode, - "data": { - if (hidden) "flags": 1 << 6, - ...BuilderUtility.buildWithClient(builder, _client) - }, - }, - files: builder.files ?? [] - ); - - if (response is HttpResponseError) { - return Future.error(response); - } - } - - @override - Future sendFollowup(String token, Snowflake applicationId, MessageBuilder builder) async { - final response = await this._client.httpEndpoints.sendRawRequest( - "/webhooks/$applicationId/$token", - "POST", - body: BuilderUtility.buildWithClient(builder, _client), - files: builder.files ?? [] - ); - - if (response is HttpResponseError) { - return Future.error(response); - } - - return EntityUtility.createMessage(this._client, (response as HttpResponseSuccess).jsonBody as RawApiMap); - } - - @override - Stream bulkOverrideGlobalCommands(Snowflake applicationId, Iterable builders) async* { - final response = await this._client.httpEndpoints.sendRawRequest( - "/applications/$applicationId/commands", - "PUT", - body: [ - for(final builder in builders) - builder.build() - ] - ); - - if (response is HttpResponseError) { - yield* Stream.error(response); - } - - for (final rawRes in (response as HttpResponseSuccess).jsonBody as List) { - yield SlashCommand._new(rawRes as RawApiMap, this._client); - } - } - - @override - Stream bulkOverrideGuildCommands(Snowflake applicationId, Snowflake guildId, Iterable builders) async* { - final response = await this._client.httpEndpoints.sendRawRequest( - "/applications/${this._client.app.id}/guilds/$guildId/commands", - "PUT", - body: [ - for(final builder in builders) - builder.build() - ] - ); - - if (response is HttpResponseError) { - yield* Stream.error(response); - } - - for (final rawRes in (response as HttpResponseSuccess).jsonBody as List) { - yield SlashCommand._new(rawRes as RawApiMap, this._client); - } - } - - @override - Future deleteGlobalCommand(Snowflake applicationId, Snowflake commandId) async { - final response = await this._client.httpEndpoints.sendRawRequest( - "/applications/$applicationId/commands/$commandId", - "DELETE" - ); - - if (response is HttpResponseSuccess) { - return Future.error(response); - } - } - - @override - Future deleteGuildCommand(Snowflake applicationId, Snowflake commandId, Snowflake guildId) async { - final response = await this._client.httpEndpoints.sendRawRequest( - "/applications/$applicationId/guilds/$guildId/commands/$commandId", - "DELETE" - ); - - if (response is HttpResponseSuccess) { - return Future.error(response); - } - } - - @override - Future editGlobalCommand(Snowflake applicationId, Snowflake commandId, SlashCommandBuilder builder) async { - final response = await this._client.httpEndpoints.sendRawRequest( - "/applications/$applicationId/commands/$commandId", - "PATCH", - body: builder.build() - ); - - if (response is HttpResponseSuccess) { - return Future.error(response); - } - - return SlashCommand._new((response as HttpResponseSuccess).jsonBody as RawApiMap, _client); - } - - @override - Future editGuildCommand(Snowflake applicationId, Snowflake commandId, Snowflake guildId, SlashCommandBuilder builder) async { - final response = await this._client.httpEndpoints.sendRawRequest( - "/applications/$applicationId/guilds/$guildId/commands/$commandId", - "GET", - body: builder.build() - ); - - if (response is HttpResponseSuccess) { - return Future.error(response); - } - - return SlashCommand._new((response as HttpResponseSuccess).jsonBody as RawApiMap, _client); - } - - @override - Future fetchGlobalCommand(Snowflake applicationId, Snowflake commandId) async { - final response = await this._client.httpEndpoints.sendRawRequest( - "/applications/$applicationId/commands/$commandId", - "GET" - ); - - if (response is HttpResponseSuccess) { - return Future.error(response); - } - - return SlashCommand._new((response as HttpResponseSuccess).jsonBody as RawApiMap, _client); - } - - @override - Stream fetchGlobalCommands(Snowflake applicationId) async* { - final response = await this._client.httpEndpoints.sendRawRequest( - "/applications/$applicationId/commands", - "GET" - ); - - if (response is HttpResponseError) { - yield* Stream.error(response); - } - - for (final commandSlash in (response as HttpResponseSuccess).jsonBody as List) { - yield SlashCommand._new(commandSlash as RawApiMap, _client); - } - } - - @override - Future fetchGuildCommand(Snowflake applicationId, Snowflake commandId, Snowflake guildId) async { - final response = await this._client.httpEndpoints.sendRawRequest( - "/applications/$applicationId/guilds/$guildId/commands/$commandId", - "GET" - ); - - if (response is HttpResponseSuccess) { - return Future.error(response); - } - - return SlashCommand._new((response as HttpResponseSuccess).jsonBody as RawApiMap, _client); - } - - @override - Stream fetchGuildCommands(Snowflake applicationId, Snowflake guildId) async* { - final response = await this._client.httpEndpoints.sendRawRequest( - "/applications/$applicationId/guilds/$guildId/commands", - "GET" - ); - - if (response is HttpResponseError) { - yield* Stream.error(response); - } - - for (final commandSlash in (response as HttpResponseSuccess).jsonBody as List) { - yield SlashCommand._new(commandSlash as RawApiMap, _client); - } - } - - @override - Future bulkOverrideGlobalCommandsPermissions(Snowflake applicationId, Iterable builders) async { - final globalBody = builders - .where((builder) => builder.permissions != null && builder.permissions!.isNotEmpty) - .map((builder) => { - "id": builder._id.toString(), - "permissions": [for (final permsBuilder in builder.permissions!) permsBuilder.build()] - }) - .toList(); - - await this._client.httpEndpoints - .sendRawRequest("/applications/$applicationId/commands/permissions", "PUT", body: globalBody); - } - - @override - Future bulkOverrideGuildCommandsPermissions(Snowflake applicationId, Snowflake guildId, Iterable builders) async { - final guildBody = builders - .where((b) => b.permissions != null && b.permissions!.isNotEmpty) - .map((builder) => { - "id": builder._id.toString(), - "permissions": [for (final permsBuilder in builder.permissions!) permsBuilder.build()] - }) - .toList(); - - await this._client.httpEndpoints - .sendRawRequest("/applications/$applicationId/guilds/$guildId/commands/permissions", "PUT", body: guildBody); - - } - - @override - Future fetchFollowup(String token, Snowflake applicationId, Snowflake messageId) async { - final result = await this._client.httpEndpoints.sendRawRequest( - "/webhooks/$applicationId/$token/messages/${messageId.toString()}", - "GET" - ); - - if (result is HttpResponseError) { - return Future.error(result); - } - - return EntityUtility.createMessage(_client, (result as HttpResponseSuccess).jsonBody as RawApiMap); - } - - @override - Future respondToAutocomplete(Snowflake interactionId, String token, List builders) async { - final result = await this._client.httpEndpoints.sendRawRequest( - "/interactions/${interactionId.toString()}/$token/callback", - "POST", - body: { - "type": 8, - "data": { - "choices": [ - for (final builder in builders) - builder.build() - ] - } - } - ); - - if (result is HttpResponseError) { - return Future.error(result); - } - } -} diff --git a/lib/src/internal/_EventController.dart b/lib/src/internal/_EventController.dart deleted file mode 100644 index 0b6c2b1..0000000 --- a/lib/src/internal/_EventController.dart +++ /dev/null @@ -1,41 +0,0 @@ -part of nyxx_interactions; - -class _EventController implements Disposable { - /// Emitted when a a slash command is sent. - late final StreamController onSlashCommand; - - /// Emitted when a a slash command is sent. - late final StreamController onSlashCommandCreated; - - /// Emitted when button event is sent - late final StreamController onButtonEvent; - - /// Emitted when dropdown event is sent - late final StreamController onMultiselectEvent; - - /// Emitted when autocomplete interaction event is sent - late final StreamController onAutocompleteEvent; - - _EventController(Interactions _client) { - this.onSlashCommand = StreamController.broadcast(); - _client.onSlashCommand = this.onSlashCommand.stream; - - this.onSlashCommandCreated = StreamController.broadcast(); - _client.onSlashCommandCreated = this.onSlashCommandCreated.stream; - - this.onButtonEvent = StreamController.broadcast(); - _client.onButtonEvent = this.onButtonEvent.stream; - - this.onMultiselectEvent = StreamController.broadcast(); - _client.onMultiselectEvent = this.onMultiselectEvent.stream; - - this.onAutocompleteEvent = StreamController.broadcast(); - _client.onAutocompleteEvent = this.onAutocompleteEvent.stream; - } - - @override - Future dispose() async { - await this.onSlashCommand.close(); - await this.onSlashCommandCreated.close(); - } -} diff --git a/lib/src/internal/event_controller.dart b/lib/src/internal/event_controller.dart new file mode 100644 index 0000000..9b37adb --- /dev/null +++ b/lib/src/internal/event_controller.dart @@ -0,0 +1,87 @@ +import 'dart:async'; + +import 'package:nyxx/nyxx.dart'; + +import 'package:nyxx_interactions/src/events/interaction_event.dart'; +import 'package:nyxx_interactions/src/models/slash_command.dart'; + +abstract class IEventController implements Disposable { + /// Emitted when a slash command is sent. + Stream get onSlashCommand; + + /// Emitted when a button interaction is received. + Stream get onButtonEvent; + + /// Emitted when a dropdown interaction is received. + Stream get onMultiselectEvent; + + /// Emitted when a slash command is created by the user. + Stream get onSlashCommandCreated; + + /// Emitted when a slash command is created by the user. + Stream get onAutocompleteEvent; +} + +class EventController implements IEventController { + /// Emitted when a slash command is sent. + @override + late final Stream onSlashCommand; + + /// Emitted when a button interaction is received. + @override + late final Stream onButtonEvent; + + /// Emitted when a dropdown interaction is received. + @override + late final Stream onMultiselectEvent; + + /// Emitted when a slash command is created by the user. + @override + late final Stream onSlashCommandCreated; + + /// Emitted when a slash command is created by the user. + @override + late final Stream onAutocompleteEvent; + + /// Emitted when a a slash command is sent. + late final StreamController onSlashCommandController; + + /// Emitted when a a slash command is sent. + late final StreamController onSlashCommandCreatedController; + + /// Emitted when button event is sent + late final StreamController onButtonEventController; + + /// Emitted when dropdown event is sent + late final StreamController onMultiselectEventController; + + /// Emitted when autocomplete interaction event is sent + late final StreamController onAutocompleteEventController; + + /// Creates na instance of [EventController] + EventController() { + onSlashCommandController = StreamController.broadcast(); + onSlashCommand = onSlashCommandController.stream; + + onSlashCommandCreatedController = StreamController.broadcast(); + onSlashCommandCreated = onSlashCommandCreatedController.stream; + + onButtonEventController = StreamController.broadcast(); + onButtonEvent = onButtonEventController.stream; + + onMultiselectEventController = StreamController.broadcast(); + onMultiselectEvent = onMultiselectEventController.stream; + + onAutocompleteEventController = StreamController.broadcast(); + onAutocompleteEvent = onAutocompleteEventController.stream; + } + + @override + Future dispose() async { + await onSlashCommandController.close(); + await onSlashCommandCreatedController.close(); + await onButtonEventController.close(); + await onMultiselectEventController.close(); + await onAutocompleteEventController.close(); + } +} diff --git a/lib/src/internal/interaction_endpoints.dart b/lib/src/internal/interaction_endpoints.dart new file mode 100644 index 0000000..6fdf65a --- /dev/null +++ b/lib/src/internal/interaction_endpoints.dart @@ -0,0 +1,354 @@ +import 'package:nyxx/nyxx.dart'; +import 'package:nyxx/src/core/message/message.dart'; + +import 'package:nyxx_interactions/src/models/slash_command.dart'; +import 'package:nyxx_interactions/src/builders/slash_command_builder.dart'; +import 'package:nyxx_interactions/src/builders/arg_choice_builder.dart'; + +abstract class IInteractionsEndpoints { + /// Sends followup for interaction with given [token]. IMessage will be created with [builder] + Future sendFollowup(String token, Snowflake applicationId, MessageBuilder builder, {bool hidden = false}); + + /// Fetches followup message from API + Future fetchFollowup(String token, Snowflake applicationId, Snowflake messageId); + + /// Acknowledges interaction that response can be sent within next 15 mins. + /// Response will be ephemeral if [hidden] is set to true. To response to different interaction types + /// (slash command, button...) [opCode] is used. + Future acknowledge(String token, String interactionId, bool hidden, int opCode); + + /// Response to interaction by editing original response. Used when interaction was acked before. + Future respondEditOriginal(String token, Snowflake applicationId, MessageBuilder builder, bool hidden); + + /// Response to interaction by creating response. Used when interaction wasn't acked before. + Future respondCreateResponse(String token, String interactionId, MessageBuilder builder, bool hidden, int respondOpCode); + + /// Fetch original interaction response. + Future fetchOriginalResponse(String token, Snowflake applicationId, String interactionId); + + /// Edits original interaction response using [builder] + Future editOriginalResponse(String token, Snowflake applicationId, MessageBuilder builder); + + /// Deletes original interaction response + Future deleteOriginalResponse(String token, Snowflake applicationId, String interactionId); + + /// Deletes followup IMessage with given id + Future deleteFollowup(String token, Snowflake applicationId, Snowflake messageId); + + /// Edits followup IMessage with given [messageId] + Future editFollowup(String token, Snowflake applicationId, Snowflake messageId, MessageBuilder builder); + + /// Fetches global commands of application + Stream fetchGlobalCommands(Snowflake applicationId); + + /// Fetches global command with given [commandId] + Future fetchGlobalCommand(Snowflake applicationId, Snowflake commandId); + + /// Edits global command with given [commandId] using [builder] + Future editGlobalCommand(Snowflake applicationId, Snowflake commandId, SlashCommandBuilder builder); + + /// Deletes global command with given [commandId] + Future deleteGlobalCommand(Snowflake applicationId, Snowflake commandId); + + /// Bulk overrides global commands. To delete all apps global commands pass empty list to [builders] + Stream bulkOverrideGlobalCommands(Snowflake applicationId, Iterable builders); + + /// Fetches all commands for given [guildId] + Stream fetchGuildCommands(Snowflake applicationId, Snowflake guildId); + + /// Fetches single guild command with given [commandId] + Future fetchGuildCommand(Snowflake applicationId, Snowflake commandId, Snowflake guildId); + + /// Edits single guild command with given [commandId] + Future editGuildCommand(Snowflake applicationId, Snowflake commandId, Snowflake guildId, SlashCommandBuilder builder); + + /// Deletes guild command with given commandId] + Future deleteGuildCommand(Snowflake applicationId, Snowflake commandId, Snowflake guildId); + + /// Bulk overrides global commands. To delete all apps global commands pass empty list to [builders] + Stream bulkOverrideGuildCommands(Snowflake applicationId, Snowflake guildId, Iterable builders); + + /// Overrides permissions for guild commands + Future bulkOverrideGuildCommandsPermissions(Snowflake applicationId, Snowflake guildId, Iterable builders); + + /// Responds to autocomplete interaction + Future respondToAutocomplete(Snowflake interactionId, String token, List builders); +} + +class InteractionsEndpoints implements IInteractionsEndpoints { + final INyxx _client; + + InteractionsEndpoints(this._client); + + @override + Future acknowledge(String token, String interactionId, bool hidden, int opCode) async { + final url = "/interactions/$interactionId/$token/callback"; + final response = await _client.httpEndpoints.sendRawRequest(url, "POST", body: { + "type": opCode, + "data": { + if (hidden) "flags": 1 << 6, + } + }); + + if (response is IHttpResponseError) { + return Future.error(response); + } + } + + @override + Future deleteFollowup(String token, Snowflake applicationId, Snowflake messageId) => + _client.httpEndpoints.sendRawRequest("webhooks/$applicationId/$token/messages/$messageId", "DELETE"); + + @override + Future deleteOriginalResponse(String token, Snowflake applicationId, String interactionId) async { + final url = "/webhooks/$applicationId/$token/messages/@original"; + const method = "DELETE"; + + final response = await _client.httpEndpoints.sendRawRequest(url, method); + if (response is IHttpResponseError) { + return Future.error(response); + } + } + + @override + Future editFollowup(String token, Snowflake applicationId, Snowflake messageId, MessageBuilder builder) async { + final url = "/webhooks/$applicationId/$token/messages/$messageId"; + final body = builder.build(_client.options.allowedMentions); + + final response = await _client.httpEndpoints.sendRawRequest(url, "PATCH", body: body); + if (response is IHttpResponseError) { + return Future.error(response); + } + + return Message(_client, (response as IHttpResponseSucess).jsonBody as RawApiMap); + } + + @override + Future editOriginalResponse(String token, Snowflake applicationId, MessageBuilder builder) async { + final response = await _client.httpEndpoints + .sendRawRequest("/webhooks/$applicationId/$token/messages/@original", "PATCH", body: builder.build(_client.options.allowedMentions)); + + if (response is IHttpResponseError) { + return Future.error(response); + } + + return Message(_client, (response as IHttpResponseSucess).jsonBody as RawApiMap); + } + + @override + Future fetchOriginalResponse(String token, Snowflake applicationId, String interactionId) async { + final response = await _client.httpEndpoints.sendRawRequest("/webhooks/$applicationId/$token/messages/@original", "GET"); + + if (response is IHttpResponseError) { + return Future.error(response); + } + + return Message(_client, (response as IHttpResponseSucess).jsonBody as RawApiMap); + } + + @override + Future respondEditOriginal(String token, Snowflake applicationId, MessageBuilder builder, bool hidden) async { + final response = await _client.httpEndpoints.sendRawRequest("/webhooks/$applicationId/$token/messages/@original", "PATCH", + body: {if (hidden) "flags": 1 << 6, ...builder.build(_client.options.allowedMentions)}, files: builder.files ?? []); + + if (response is IHttpResponseError) { + return Future.error(response); + } + } + + @override + Future respondCreateResponse(String token, String interactionId, MessageBuilder builder, bool hidden, int respondOpCode) async { + final response = await _client.httpEndpoints.sendRawRequest("/interactions/${interactionId.toString()}/$token/callback", "POST", + body: { + "type": respondOpCode, + "data": {if (hidden) "flags": 1 << 6, ...builder.build(_client.options.allowedMentions)}, + }, + files: builder.files ?? []); + + if (response is IHttpResponseError) { + return Future.error(response); + } + } + + @override + Future sendFollowup(String token, Snowflake applicationId, MessageBuilder builder, {bool hidden = false}) async { + final response = await _client.httpEndpoints.sendRawRequest("/webhooks/$applicationId/$token", "POST", + body: { + ...builder.build(_client.options.allowedMentions), + if (hidden) 'flags': 1 << 6, + }, + files: builder.files ?? []); + + if (response is IHttpResponseError) { + return Future.error(response); + } + + return Message(_client, (response as IHttpResponseSucess).jsonBody as RawApiMap); + } + + @override + Stream bulkOverrideGlobalCommands(Snowflake applicationId, Iterable builders) async* { + final response = await _client.httpEndpoints + .sendRawRequest("/applications/$applicationId/commands", "PUT", body: [for (final builder in builders) builder.build()], auth: true); + + if (response is IHttpResponseError) { + yield* Stream.error(response); + } + + for (final rawRes in (response as IHttpResponseSucess).jsonBody as List) { + yield SlashCommand(rawRes as RawApiMap, _client); + } + } + + @override + Stream bulkOverrideGuildCommands(Snowflake applicationId, Snowflake guildId, Iterable builders) async* { + final response = await _client.httpEndpoints + .sendRawRequest("/applications/$applicationId/guilds/$guildId/commands", "PUT", body: [for (final builder in builders) builder.build()], auth: true); + if (response is IHttpResponseError) { + yield* Stream.error(response); + } + + for (final rawRes in (response as IHttpResponseSucess).jsonBody as List) { + yield SlashCommand(rawRes as RawApiMap, _client); + } + } + + @override + Future deleteGlobalCommand(Snowflake applicationId, Snowflake commandId) async { + final response = await _client.httpEndpoints.sendRawRequest("/applications/$applicationId/commands/$commandId", "DELETE", auth: true); + + if (response is IHttpResponseSucess) { + return Future.error(response); + } + } + + @override + Future deleteGuildCommand(Snowflake applicationId, Snowflake commandId, Snowflake guildId) async { + final response = await _client.httpEndpoints.sendRawRequest("/applications/$applicationId/guilds/$guildId/commands/$commandId", "DELETE", auth: true); + + if (response is IHttpResponseSucess) { + return Future.error(response); + } + } + + @override + Future editGlobalCommand(Snowflake applicationId, Snowflake commandId, SlashCommandBuilder builder) async { + final response = await _client.httpEndpoints.sendRawRequest("/applications/$applicationId/commands/$commandId", "PATCH", body: builder.build(), auth: true); + + if (response is IHttpResponseSucess) { + return Future.error(response); + } + + return SlashCommand((response as IHttpResponseSucess).jsonBody as RawApiMap, _client); + } + + @override + Future editGuildCommand(Snowflake applicationId, Snowflake commandId, Snowflake guildId, SlashCommandBuilder builder) async { + final response = await _client.httpEndpoints + .sendRawRequest("/applications/$applicationId/guilds/$guildId/commands/$commandId", "GET", body: builder.build(), auth: true); + + if (response is IHttpResponseSucess) { + return Future.error(response); + } + + return SlashCommand((response as IHttpResponseSucess).jsonBody as RawApiMap, _client); + } + + @override + Future fetchGlobalCommand(Snowflake applicationId, Snowflake commandId) async { + final response = await _client.httpEndpoints.sendRawRequest("/applications/$applicationId/commands/$commandId", "GET", auth: true); + + if (response is IHttpResponseSucess) { + return Future.error(response); + } + + return SlashCommand((response as IHttpResponseSucess).jsonBody as RawApiMap, _client); + } + + @override + Stream fetchGlobalCommands(Snowflake applicationId) async* { + final response = await _client.httpEndpoints.sendRawRequest("/applications/$applicationId/commands", "GET", auth: true); + + if (response is IHttpResponseError) { + yield* Stream.error(response); + } + + for (final commandSlash in (response as IHttpResponseSucess).jsonBody as List) { + yield SlashCommand(commandSlash as RawApiMap, _client); + } + } + + @override + Future fetchGuildCommand(Snowflake applicationId, Snowflake commandId, Snowflake guildId) async { + final response = await _client.httpEndpoints.sendRawRequest("/applications/$applicationId/guilds/$guildId/commands/$commandId", "GET", auth: true); + + if (response is IHttpResponseSucess) { + return Future.error(response); + } + + return SlashCommand((response as IHttpResponseSucess).jsonBody as RawApiMap, _client); + } + + @override + Stream fetchGuildCommands(Snowflake applicationId, Snowflake guildId) async* { + final response = await _client.httpEndpoints.sendRawRequest("/applications/$applicationId/guilds/$guildId/commands", "GET", auth: true); + + if (response is IHttpResponseError) { + yield* Stream.error(response); + } + + for (final commandSlash in (response as IHttpResponseSucess).jsonBody as List) { + yield SlashCommand(commandSlash as RawApiMap, _client); + } + } + + Future bulkOverrideGlobalCommandsPermissions(Snowflake applicationId, Iterable builders) async { + final globalBody = builders + .where((builder) => builder.permissions != null && builder.permissions!.isNotEmpty) + .map((builder) => { + "id": builder.id.toString(), + "permissions": [for (final permsBuilder in builder.permissions!) permsBuilder.build()] + }) + .toList(); + + await _client.httpEndpoints.sendRawRequest("/applications/$applicationId/commands/permissions", "PUT", body: globalBody, auth: true); + } + + @override + Future bulkOverrideGuildCommandsPermissions(Snowflake applicationId, Snowflake guildId, Iterable builders) async { + final guildBody = builders + .where((b) => b.permissions != null && b.permissions!.isNotEmpty) + .map((builder) => { + "id": builder.id.toString(), + "permissions": [for (final permsBuilder in builder.permissions!) permsBuilder.build()] + }) + .toList(); + + await _client.httpEndpoints.sendRawRequest("/applications/$applicationId/guilds/$guildId/commands/permissions", "PUT", body: guildBody, auth: true); + } + + @override + Future fetchFollowup(String token, Snowflake applicationId, Snowflake messageId) async { + final result = await _client.httpEndpoints.sendRawRequest("/webhooks/$applicationId/$token/messages/${messageId.toString()}", "GET", auth: true); + + if (result is IHttpResponseError) { + return Future.error(result); + } + + return Message(_client, (result as IHttpResponseSucess).jsonBody as RawApiMap); + } + + @override + Future respondToAutocomplete(Snowflake interactionId, String token, List builders) async { + final result = await _client.httpEndpoints.sendRawRequest("/interactions/${interactionId.toString()}/$token/callback", "POST", body: { + "type": 8, + "data": { + "choices": [for (final builder in builders) builder.build()] + } + }); + + if (result is IHttpResponseError) { + return Future.error(result); + } + } +} diff --git a/lib/src/internal/sync/ICommandsSync.dart b/lib/src/internal/sync/commands_sync.dart similarity index 71% rename from lib/src/internal/sync/ICommandsSync.dart rename to lib/src/internal/sync/commands_sync.dart index 7947ca5..23ab4d0 100644 --- a/lib/src/internal/sync/ICommandsSync.dart +++ b/lib/src/internal/sync/commands_sync.dart @@ -1,4 +1,6 @@ -part of nyxx_interactions; +import 'dart:async'; + +import 'package:nyxx_interactions/src/builders/slash_command_builder.dart'; /// Used to make multiple methods of checking if the slash commands have been edited since last update abstract class ICommandsSync { diff --git a/lib/src/internal/sync/LockFileCommandSync.dart b/lib/src/internal/sync/lock_file_command_sync.dart similarity index 62% rename from lib/src/internal/sync/LockFileCommandSync.dart rename to lib/src/internal/sync/lock_file_command_sync.dart index aee4ffa..8810265 100644 --- a/lib/src/internal/sync/LockFileCommandSync.dart +++ b/lib/src/internal/sync/lock_file_command_sync.dart @@ -1,4 +1,12 @@ -part of nyxx_interactions; +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:crypto/crypto.dart'; +import 'package:nyxx/nyxx.dart'; +import 'package:nyxx_interactions/src/builders/slash_command_builder.dart'; +import 'package:nyxx_interactions/src/builders/command_option_builder.dart'; +import 'package:nyxx_interactions/src/internal/sync/commands_sync.dart'; /// Manually define command syncing rules class LockFileCommandSync implements ICommandsSync { @@ -10,13 +18,13 @@ class LockFileCommandSync implements ICommandsSync { final lockFileMapData = {}; for (final c in commands) { - lockFileMapData[c.name] = new _LockfileCommand( + lockFileMapData[c.name] = _LockfileCommand( c.name, c.description, c.guild, c.defaultPermissions, - c.permissions?.map((p) => new _LockfilePermission(p._type, p.id, p.hasPermission)) ?? [], - c.options.map((o) => new _LockfileOption(o.type.value, o.name, o.description, o.options ?? [])), + c.permissions?.map((p) => _LockfilePermission(p.type, p.id, p.hasPermission)) ?? [], + c.options.map((o) => _LockfileOption(o.type.value, o.name, o.description, o.options ?? [])), ).generateHash(); } @@ -54,15 +62,15 @@ class _LockfileCommand { return false; } - if (other.defaultPermissions != this.defaultPermissions || - other.name != this.name || - other.guild != this.guild || - other.defaultPermissions != this.defaultPermissions) { + if (other.defaultPermissions != defaultPermissions || other.name != name || other.guild != guild || other.defaultPermissions != defaultPermissions) { return false; } return true; } + + @override + int get hashCode => name.hashCode + guild.hashCode + defaultPermissions.hashCode + permissions.hashCode + description.hashCode + options.hashCode; } class _LockfileOption { @@ -74,7 +82,7 @@ class _LockfileOption { _LockfileOption(this.type, this.name, this.description, Iterable options) { this.options = options.map( - (o) => new _LockfileOption( + (o) => _LockfileOption( o.type.value, o.name, o.description, @@ -89,12 +97,15 @@ class _LockfileOption { return false; } - if (other.type != this.type || other.name != this.name || other.description != this.description) { + if (other.type != type || other.name != name || other.description != description) { return false; } return true; } + + @override + int get hashCode => type.hashCode + name.hashCode + description.hashCode; } class _LockfilePermission { @@ -110,12 +121,13 @@ class _LockfilePermission { return false; } - if (other.permissionType != this.permissionType || - other.permissionEntityId != this.permissionEntityId || - other.permissionsGranted != this.permissionsGranted) { + if (other.permissionType != permissionType || other.permissionEntityId != permissionEntityId || other.permissionsGranted != permissionsGranted) { return false; } return true; } + + @override + int get hashCode => permissionType.hashCode + permissionEntityId.hashCode + permissionsGranted.hashCode; } diff --git a/lib/src/internal/sync/ManualCommandSync.dart b/lib/src/internal/sync/manual_command_sync.dart similarity index 65% rename from lib/src/internal/sync/ManualCommandSync.dart rename to lib/src/internal/sync/manual_command_sync.dart index b29252b..837d795 100644 --- a/lib/src/internal/sync/ManualCommandSync.dart +++ b/lib/src/internal/sync/manual_command_sync.dart @@ -1,4 +1,7 @@ -part of nyxx_interactions; +import 'dart:async'; + +import 'package:nyxx_interactions/src/internal/sync/commands_sync.dart'; +import 'package:nyxx_interactions/src/builders/slash_command_builder.dart'; /// Manually define command syncing rules class ManualCommandSync implements ICommandsSync { @@ -9,5 +12,5 @@ class ManualCommandSync implements ICommandsSync { const ManualCommandSync({this.sync = true}); @override - FutureOr shouldSync(Iterable commands) => this.sync; + FutureOr shouldSync(Iterable commands) => sync; } diff --git a/lib/src/internal/utils.dart b/lib/src/internal/utils.dart index 05d950d..2249179 100644 --- a/lib/src/internal/utils.dart +++ b/lib/src/internal/utils.dart @@ -1,14 +1,19 @@ -part of nyxx_interactions; +import 'package:nyxx/nyxx.dart'; + +import 'package:nyxx_interactions/src/builders/slash_command_builder.dart'; +import 'package:nyxx_interactions/src/models/command_option.dart'; +import 'package:nyxx_interactions/src/models/interaction_option.dart'; +import 'package:nyxx_interactions/src/models/interaction.dart'; /// Slash command names and subcommands names have to match this regex final RegExp slashCommandNameRegex = RegExp(r"^[\w-]{1,32}$"); -Iterable> _partition(Iterable list, bool Function(T) predicate) { +Iterable> partition(Iterable list, bool Function(T) predicate) { final matches = []; final nonMatches = []; - for(final e in list) { - if(predicate(e)) { + for (final e in list) { + if (predicate(e)) { matches.add(e); continue; } @@ -20,8 +25,8 @@ Iterable> _partition(Iterable list, bool Function(T) predicate } /// Determine what handler should be executed based on [interaction] -String _determineInteractionCommandHandler(SlashCommandInteraction interaction) { - final commandHash = "${interaction.commandId}|${interaction.name}"; +String determineInteractionCommandHandler(ISlashCommandInteraction interaction) { + final commandHash = interaction.name; try { final subCommandGroup = interaction.options.firstWhere((element) => element.type == CommandOptionType.subCommandGroup); @@ -29,22 +34,22 @@ String _determineInteractionCommandHandler(SlashCommandInteraction interaction) return "$commandHash|${subCommandGroup.name}|${subCommand.name}"; // ignore: empty_catches - } on StateError { } + } on StateError {} try { final subCommand = interaction.options.firstWhere((element) => element.type == CommandOptionType.subCommand); return "$commandHash|${subCommand.name}"; // ignore: empty_catches - } on StateError { } + } on StateError {} return commandHash; } /// Groups [SlashCommandBuilder] for registering them later in bulk -Map> _groupSlashCommandBuilders(Iterable commands) { +Map> groupSlashCommandBuilders(Iterable commands) { final commandsMap = >{}; - for(final slashCommand in commands) { + for (final slashCommand in commands) { final id = slashCommand.guild!; if (commandsMap.containsKey(id)) { @@ -58,13 +63,9 @@ Map> _groupSlashCommandBuilders(Iterabl return commandsMap; } -Iterable _extractArgs(Iterable args) { - if (args.length == 1 - && (args.first.type == CommandOptionType.subCommand - || args.first.type == CommandOptionType.subCommandGroup - ) - ) { - return _extractArgs(args.first.options); +Iterable extractArgs(Iterable args) { + if (args.length == 1 && (args.first.type == CommandOptionType.subCommand || args.first.type == CommandOptionType.subCommandGroup)) { + return extractArgs(args.first.options); } return args; diff --git a/lib/src/models/ArgChoice.dart b/lib/src/models/ArgChoice.dart deleted file mode 100644 index a9d54db..0000000 --- a/lib/src/models/ArgChoice.dart +++ /dev/null @@ -1,15 +0,0 @@ -part of nyxx_interactions; - -/// Choice that user can pick from. For [CommandOptionType.integer] or [CommandOptionType.string] -class ArgChoice { - /// Name of choice - late final String name; - - /// Value of choice - late final dynamic value; - - ArgChoice._new(RawApiMap raw) { - this.name = raw["name"] as String; - this.value = raw["value"]; - } -} diff --git a/lib/src/models/CommandOption.dart b/lib/src/models/CommandOption.dart deleted file mode 100644 index 6822bbb..0000000 --- a/lib/src/models/CommandOption.dart +++ /dev/null @@ -1,74 +0,0 @@ -part of nyxx_interactions; - -/// The type that a user should input for a [CommandOptionBuilder] -class CommandOptionType extends IEnum { - /// Specify an arg as a sub command - static const subCommand = const CommandOptionType(1); - /// Specify an arg as a sub command group - static const subCommandGroup = const CommandOptionType(2); - /// Specify an arg as a string - static const string = const CommandOptionType(3); - /// Specify an arg as an int - static const integer = const CommandOptionType(4); - /// Specify an arg as a bool - static const boolean = const CommandOptionType(5); - /// Specify an arg as a user e.g @HarryET#2954 - static const user = const CommandOptionType(6); - /// Specify an arg as a channel e.g. #Help - static const channel = const CommandOptionType(7); - /// Specify an arg as a role e.g. @RoleName - static const role = const CommandOptionType(8); - - /// Create new instance of CommandArgType - const CommandOptionType(int value) : super(value); -} - -/// An argument for a [SlashCommand]. -class CommandOption { - /// The type of arg that will be later changed to an INT value, their values can be seen in the table below: - /// | Name | Value | - /// |-------------------|-------| - /// | SUB_COMMAND | 1 | - /// | SUB_COMMAND_GROUP | 2 | - /// | STRING | 3 | - /// | INTEGER | 4 | - /// | BOOLEAN | 5 | - /// | USER | 6 | - /// | CHANNEL | 7 | - /// | ROLE | 8 | - late final CommandOptionType type; - - /// The name of your argument / sub-group. - late final String name; - - /// The description of your argument / sub-group. - late final String description; - - /// If this argument is required - late final bool required; - - /// Choices for [CommandOptionType.string] and [CommandOptionType.string] types for the user to pick from - late final List choices; - - /// If the option is a subcommand or subcommand group type, this nested options will be the parameters - late final List options; - - CommandOption._new(RawApiMap raw) { - this.type = CommandOptionType(raw["type"] as int); - this.name = raw["name"] as String; - this.description = raw["description"] as String; - this.required = raw["required"] as bool? ?? false; - - this.choices = [ - if (raw["choices"] != null) - for(final choiceRaw in raw["choices"]) - ArgChoice._new(choiceRaw as RawApiMap) - ]; - - this.options = [ - if (raw["options"] != null) - for(final optionRaw in raw["options"]) - CommandOption._new(optionRaw as RawApiMap) - ]; - } -} diff --git a/lib/src/models/Interaction.dart b/lib/src/models/Interaction.dart deleted file mode 100644 index d5faba4..0000000 --- a/lib/src/models/Interaction.dart +++ /dev/null @@ -1,135 +0,0 @@ -part of nyxx_interactions; - -/// The Interaction data. e.g channel, guild and member -class Interaction extends SnowflakeEntity { - /// Reference to bot instance. - final Nyxx _client; - - /// The type of the interaction received. - late final int type; - - /// The guild the command was sent in. - late final Cacheable? guild; - - /// The channel the command was sent in. - late final Cacheable channel; - - /// The member who sent the interaction - late final Member? memberAuthor; - - /// Permission of member who sent the interaction. Will be set if [memberAuthor] - /// is not null - late final Permissions? memberAuthorPermissions; - - /// The user who sent the interaction. - late final User? userAuthor; - - /// Token to send requests - late final String token; - - /// Version of interactions api - late final int version; - - Interaction._new(this._client, RawApiMap raw) : super(Snowflake(raw["id"])) { - this.type = raw["type"] as int; - - if (raw["guild_id"] != null) { - this.guild = CacheUtility.createCacheableGuild(_client, Snowflake(raw["guild_id"]),); - } else { - this.guild = null; - } - - this.channel = CacheUtility.createCacheableTextChannel(_client, Snowflake(raw["channel_id"]),); - - if (raw["member"] != null) { - this.memberAuthor = EntityUtility.createGuildMember(_client, Snowflake(raw["guild_id"]), raw["member"] as RawApiMap); - this.memberAuthorPermissions = Permissions.fromInt(int.parse(raw["member"]["permissions"] as String)); - } else { - this.memberAuthor = null; - this.memberAuthorPermissions = null; - } - - if (raw["user"] != null) { - this.userAuthor = EntityUtility.createUser(_client, raw["user"] as RawApiMap); - } else if (raw["member"]["user"] != null) { - this.userAuthor = EntityUtility.createUser(_client, raw["member"]["user"] as RawApiMap); - } else { - this.userAuthor = null; - } - - this.token = raw["token"] as String; - this.version = raw["version"] as int; - } -} - -/// Interaction for slash command -class SlashCommandInteraction extends Interaction { - /// Name of interaction - late final String name; - - /// Args of the interaction - late final Iterable options; - - /// Id of command - late final Snowflake commandId; - - /// Additional data for command - late final InteractionDataResolved? resolved; - - SlashCommandInteraction._new(Nyxx client, RawApiMap raw) : super._new(client, raw) { - this.name = raw["data"]["name"] as String; - this.options = [ - if (raw["data"]["options"] != null) - for (final option in raw["data"]["options"] as List) - InteractionOption._new(option as RawApiMap) - ]; - this.commandId = Snowflake(raw["data"]["id"]); - - this.resolved = raw["data"]["resolved"] != null - ? InteractionDataResolved._new(raw["data"]["resolved"] as RawApiMap, this.guild?.id, client) - : null; - } - - /// Allows to fetch argument value by argument name - dynamic getArg(String name) { - try { - return this.options.firstWhere((element) => element.name == name).value; - } on Error { - return null; - } - } -} - -/// Interaction for button, dropdown, etc. -abstract class ComponentInteraction extends Interaction { - /// Custom id of component interaction - late final String customId; - - /// The message that the button was pressed on. - late final Message? message; - - ComponentInteraction._new(Nyxx client, RawApiMap raw): super._new(client, raw) { - this.customId = raw["data"]["custom_id"] as String; - - // Discord doesn't include guild's id in the message object even if its a guild message but is included in the data so its been added to the object so that guild message can be used if the interaction is from a guild. - this.message = EntityUtility.createMessage(_client, { - ...raw["message"], - if (guild != null) "guild_id": guild!.id.toString() - }); - } -} - -/// Interaction invoked when button is pressed -class ButtonInteraction extends ComponentInteraction { - ButtonInteraction._new(Nyxx client, Map raw): super._new(client, raw); -} - -/// Interaction when multi select is triggered -class MultiselectInteraction extends ComponentInteraction { - /// Values selected by the user - late final List values; - - MultiselectInteraction._new(Nyxx client, Map raw): super._new(client, raw) { - this.values = (raw["data"]["values"] as List).cast(); - } -} diff --git a/lib/src/models/InteractionDataResolved.dart b/lib/src/models/InteractionDataResolved.dart deleted file mode 100644 index 24bd2e5..0000000 --- a/lib/src/models/InteractionDataResolved.dart +++ /dev/null @@ -1,65 +0,0 @@ -part of nyxx_interactions; - -/// Partial channel object for interactions -class PartialChannel extends SnowflakeEntity { - /// Channel name - late final String name; - - /// Type of channel - late final ChannelType type; - - /// Permissions of user in channel - late final Permissions permissions; - - PartialChannel._new(RawApiMap raw): super(Snowflake(raw["id"])) { - this.name = raw["name"] as String; - this.type = ChannelType.from(raw["type"] as int); - this.permissions = Permissions.fromInt(int.parse(raw["permissions"].toString())); - } -} - -/// Additional data for slash command -class InteractionDataResolved { - /// Resolved [User]s - late final Iterable users; - - /// Resolved [Member]s - late final Iterable members; - - /// Resolved [Role]s - late final Iterable roles; - - /// Resolved [PartialChannel]s - late final Iterable channels; - - InteractionDataResolved._new(RawApiMap raw, Snowflake? guildId, Nyxx client) { - this.users = [ - if (raw["users"] != null) - for (final rawUserEntry in (raw["users"] as RawApiMap).entries) - EntityUtility.createUser(client, rawUserEntry.value as RawApiMap) - ]; - - this.members = [ - if (raw["members"] != null) - for (final rawMemberEntry in (raw["members"] as RawApiMap).entries) - EntityUtility.createGuildMember(client, guildId!, { - ...rawMemberEntry.value as RawApiMap, - "user": { - "id": rawMemberEntry.key - } - }) - ]; - - this.roles = [ - if (raw["roles"] != null) - for (final rawRoleEntry in (raw["roles"] as RawApiMap).entries) - EntityUtility.createRole(client, guildId!, rawRoleEntry.value as RawApiMap) - ]; - - this.channels = [ - if (raw["channels"] != null) - for (final rawChannelEntry in (raw["channels"] as RawApiMap).entries) - PartialChannel._new(rawChannelEntry.value as RawApiMap) - ]; - } -} diff --git a/lib/src/models/InteractionOption.dart b/lib/src/models/InteractionOption.dart deleted file mode 100644 index d4bd50d..0000000 --- a/lib/src/models/InteractionOption.dart +++ /dev/null @@ -1,33 +0,0 @@ -part of nyxx_interactions; - -/// The option given by the user when sending a command -class InteractionOption { - /// The value given by the user - late final dynamic value; - - /// Type of interaction - late final CommandOptionType type; - - /// Name of option - late final String name; - - /// Any args under this as you can have sub commands - late final Iterable options; - - /// True if options is focused - late final bool isFocused; - - InteractionOption._new(RawApiMap raw) { - this.value = raw["value"] as dynamic; - this.name = raw["name"] as String; - this.type = CommandOptionType(raw["type"] as int); - - if (raw["options"] != null) { - this.options = (raw["options"] as List).map((e) => InteractionOption._new(e as RawApiMap)); - } else { - this.options = []; - } - - this.isFocused = raw["focused"] as bool? ?? false; - } -} diff --git a/lib/src/models/SlashCommand.dart b/lib/src/models/SlashCommand.dart deleted file mode 100644 index 6e2221b..0000000 --- a/lib/src/models/SlashCommand.dart +++ /dev/null @@ -1,42 +0,0 @@ -part of nyxx_interactions; - -/// Represents slash command that is returned from Discord API. -class SlashCommand extends SnowflakeEntity { - /// Unique id of the parent application - late final Snowflake applicationId; - - /// Command name to be shown to the user in the Slash Command UI - late final String name; - - /// Command description shown to the user in the Slash Command UI - late final String description; - - /// The arguments that the command takes - late final List options; - - /// The type of command - late final SlashCommandType type; - - /// Guild id of the command, if not global - late final Cacheable? guild; - - /// Whether the command is enabled by default when the app is added to a guild - late final bool defaultPermissions; - - SlashCommand._new(RawApiMap raw, Nyxx client): super(Snowflake(raw["id"])) { - this.applicationId = Snowflake(raw["application_id"]); - this.name = raw["name"] as String; - this.description = raw["description"] as String; - this.type = SlashCommandType(raw["type"] as int? ?? 1); - this.guild = raw["guild_id"] != null - ? CacheUtility.createCacheableGuild(client, Snowflake(raw["guild_id"])) - : null; - this.defaultPermissions = raw["default_permission"] as bool? ?? true; - - this.options = [ - if (raw["options"] != null) - for(final optionRaw in raw["options"]) - CommandOption._new(optionRaw as RawApiMap) - ]; - } -} diff --git a/lib/src/models/arg_choice.dart b/lib/src/models/arg_choice.dart new file mode 100644 index 0000000..45ed1b4 --- /dev/null +++ b/lib/src/models/arg_choice.dart @@ -0,0 +1,26 @@ +import 'package:nyxx/nyxx.dart'; + +abstract class IArgChoice { + /// Name of choice + String get name; + + /// Value of choice + dynamic get value; +} + +/// Choice that user can pick from. For [CommandOptionType.integer] or [CommandOptionType.string] +class ArgChoice implements IArgChoice { + /// Name of choice + @override + late final String name; + + /// Value of choice + @override + late final dynamic value; + + /// Creates na instance of [ArgChoice] + ArgChoice(RawApiMap raw) { + name = raw["name"] as String; + value = raw["value"]; + } +} diff --git a/lib/src/models/command_option.dart b/lib/src/models/command_option.dart new file mode 100644 index 0000000..15cb527 --- /dev/null +++ b/lib/src/models/command_option.dart @@ -0,0 +1,118 @@ +import 'package:nyxx/nyxx.dart'; + +import 'package:nyxx_interactions/src/models/arg_choice.dart'; + +/// The type that a user should input for a [CommandOptionBuilder] +class CommandOptionType extends IEnum { + /// Specify an arg as a sub command + static const subCommand = CommandOptionType(1); + + /// Specify an arg as a sub command group + static const subCommandGroup = CommandOptionType(2); + + /// Specify an arg as a string + static const string = CommandOptionType(3); + + /// Specify an arg as an int + static const integer = CommandOptionType(4); + + /// Specify an arg as a bool + static const boolean = CommandOptionType(5); + + /// Specify an arg as a user e.g @HarryET#2954 + static const user = CommandOptionType(6); + + /// Specify an arg as a channel e.g. #Help + static const channel = CommandOptionType(7); + + /// Specify an arg as a role e.g. @RoleName + static const role = CommandOptionType(8); + + /// Create new instance of CommandArgType + const CommandOptionType(int value) : super(value); +} + +abstract class ICommandOption { + /// The type of arg that will be later changed to an INT value, their values can be seen in the table below: + /// | Name | Value | + /// |-------------------|-------| + /// | SUB_COMMAND | 1 | + /// | SUB_COMMAND_GROUP | 2 | + /// | STRING | 3 | + /// | INTEGER | 4 | + /// | BOOLEAN | 5 | + /// | USER | 6 | + /// | CHANNEL | 7 | + /// | ROLE | 8 | + CommandOptionType get type; + + /// The name of your argument / sub-group. + String get name; + + /// The description of your argument / sub-group. + String get description; + + /// If this argument is required + bool get required; + + /// Choices for [CommandOptionType.string] and [CommandOptionType.string] types for the user to pick from + List get choices; + + /// If the option is a subcommand or subcommand group type, this nested options will be the parameters + List get options; +} + +/// An argument for a [SlashCommand]. +class CommandOption implements ICommandOption { + /// The type of arg that will be later changed to an INT value, their values can be seen in the table below: + /// | Name | Value | + /// |-------------------|-------| + /// | SUB_COMMAND | 1 | + /// | SUB_COMMAND_GROUP | 2 | + /// | STRING | 3 | + /// | INTEGER | 4 | + /// | BOOLEAN | 5 | + /// | USER | 6 | + /// | CHANNEL | 7 | + /// | ROLE | 8 | + @override + late final CommandOptionType type; + + /// The name of your argument / sub-group. + @override + late final String name; + + /// The description of your argument / sub-group. + @override + late final String description; + + /// If this argument is required + @override + late final bool required; + + /// Choices for [CommandOptionType.string] and [CommandOptionType.string] types for the user to pick from + @override + late final List choices; + + /// If the option is a subcommand or subcommand group type, this nested options will be the parameters + @override + late final List options; + + /// Creates na instance of [CommandOption] + CommandOption(RawApiMap raw) { + type = CommandOptionType(raw["type"] as int); + name = raw["name"] as String; + description = raw["description"] as String; + required = raw["required"] as bool? ?? false; + + choices = [ + if (raw["choices"] != null) + for (final choiceRaw in raw["choices"]) ArgChoice(choiceRaw as RawApiMap) + ]; + + options = [ + if (raw["options"] != null) + for (final optionRaw in raw["options"]) CommandOption(optionRaw as RawApiMap) + ]; + } +} diff --git a/lib/src/models/interaction.dart b/lib/src/models/interaction.dart new file mode 100644 index 0000000..fcbbd84 --- /dev/null +++ b/lib/src/models/interaction.dart @@ -0,0 +1,224 @@ +import 'package:nyxx/nyxx.dart'; +import 'package:nyxx/src/core/permissions/permissions.dart'; +import 'package:nyxx/src/core/user/user.dart'; +import 'package:nyxx/src/core/user/member.dart'; +import 'package:nyxx/src/core/guild/guild.dart'; +import 'package:nyxx/src/internal/cache/cacheable.dart'; +import 'package:nyxx/src/core/channel/cacheable_text_channel.dart'; +import 'package:nyxx/src/core/message/message.dart'; + +import 'package:nyxx_interactions/src/models/interaction_option.dart'; +import 'package:nyxx_interactions/src/models/interaction_data_resolved.dart'; + +abstract class IInteraction implements SnowflakeEntity { + /// Reference to bot instance. + INyxx get client; + + /// The type of the interaction received. + int get type; + + /// The guild the command was sent in. + Cacheable? get guild; + + /// The channel the command was sent in. + Cacheable get channel; + + /// The member who sent the interaction + IMember? get memberAuthor; + + /// Permission of member who sent the interaction. Will be set if [memberAuthor] + /// is not null + IPermissions? get memberAuthorPermissions; + + /// The user who sent the interaction. + IUser? get userAuthor; + + /// Token to send requests + String get token; + + /// Version of interactions api + int get version; +} + +/// The Interaction data. e.g channel, guild and member +class Interaction extends SnowflakeEntity implements IInteraction { + /// Reference to bot instance. + @override + final INyxx client; + + /// The type of the interaction received. + @override + late final int type; + + /// The guild the command was sent in. + @override + late final Cacheable? guild; + + /// The channel the command was sent in. + @override + late final Cacheable channel; + + /// The member who sent the interaction + @override + late final IMember? memberAuthor; + + /// Permission of member who sent the interaction. Will be set if [memberAuthor] + /// is not null + @override + late final IPermissions? memberAuthorPermissions; + + /// The user who sent the interaction. + @override + late final IUser? userAuthor; + + /// Token to send requests + @override + late final String token; + + /// Version of interactions api + @override + late final int version; + + /// Creates na instance of [Interaction] + Interaction(this.client, RawApiMap raw) : super(Snowflake(raw["id"])) { + type = raw["type"] as int; + + if (raw["guild_id"] != null) { + guild = GuildCacheable( + client, + Snowflake(raw["guild_id"]), + ); + } else { + guild = null; + } + + channel = CacheableTextChannel( + client, + Snowflake(raw["channel_id"]), + ); + + if (raw["member"] != null) { + memberAuthor = Member(client, raw["member"] as RawApiMap, Snowflake(raw["guild_id"])); + memberAuthorPermissions = Permissions(int.parse(raw["member"]["permissions"] as String)); + } else { + memberAuthor = null; + memberAuthorPermissions = null; + } + + if (raw["user"] != null) { + userAuthor = User(client, raw["user"] as RawApiMap); + } else if (raw["member"]["user"] != null) { + userAuthor = User(client, raw["member"]["user"] as RawApiMap); + } else { + userAuthor = null; + } + + token = raw["token"] as String; + version = raw["version"] as int; + } +} + +abstract class ISlashCommandInteraction implements Interaction { + /// Name of interaction + String get name; + + /// Args of the interaction + Iterable get options; + + /// Id of command + late final Snowflake commandId; + + /// Additional data for command + late final IInteractionDataResolved? resolved; +} + +/// Interaction for slash command +class SlashCommandInteraction extends Interaction implements ISlashCommandInteraction { + /// Name of interaction + @override + late final String name; + + /// Args of the interaction + @override + late final Iterable options; + + /// Id of command + @override + late final Snowflake commandId; + + /// Additional data for command + @override + late final IInteractionDataResolved? resolved; + + /// Creates na instance of [SlashCommandInteraction] + SlashCommandInteraction(INyxx client, RawApiMap raw) : super(client, raw) { + name = raw["data"]["name"] as String; + options = [ + if (raw["data"]["options"] != null) + for (final option in raw["data"]["options"] as List) InteractionOption(option as RawApiMap) + ]; + commandId = Snowflake(raw["data"]["id"]); + + resolved = raw["data"]["resolved"] != null ? InteractionDataResolved(raw["data"]["resolved"] as RawApiMap, guild?.id, client) : null; + } + + /// Allows to fetch argument value by argument name + dynamic getArg(String name) { + try { + return options.firstWhere((element) => element.name == name).value; + } on Error { + return null; + } + } +} + +abstract class IComponentInteraction implements IInteraction { + /// Custom id of component interaction + String get customId; + + /// The message that the button was pressed on. + IMessage? get message; +} + +/// Interaction for button, dropdown, etc. +abstract class ComponentInteraction extends Interaction implements IComponentInteraction { + /// Custom id of component interaction + @override + late final String customId; + + /// The message that the button was pressed on. + @override + late final IMessage? message; + + /// Creates na instance of [ComponentInteraction] + ComponentInteraction(INyxx client, RawApiMap raw) : super(client, raw) { + customId = raw["data"]["custom_id"] as String; + + // Discord doesn't include guild's id in the message object even if its a guild message but is included in the data so its been added to the object so that guild message can be used if the interaction is from a guild. + message = Message(client, {...raw["message"], if (guild != null) "guild_id": guild!.id.toString()}); + } +} + +abstract class IButtonInteraction implements IComponentInteraction {} + +/// Interaction invoked when button is pressed +class ButtonInteraction extends ComponentInteraction implements IButtonInteraction { + ButtonInteraction(INyxx client, Map raw) : super(client, raw); +} + +abstract class IMultiselectInteraction implements IComponentInteraction { + /// Values selected by the user + List get values; +} + +/// Interaction when multi select is triggered +class MultiselectInteraction extends ComponentInteraction implements IMultiselectInteraction { + /// Values selected by the user + @override + late final List values; + + /// Creates na instance of [MultiselectInteraction] + MultiselectInteraction(INyxx client, Map raw) : super(client, raw) { + values = (raw["data"]["values"] as List).cast(); + } +} diff --git a/lib/src/models/interaction_data_resolved.dart b/lib/src/models/interaction_data_resolved.dart new file mode 100644 index 0000000..20412b2 --- /dev/null +++ b/lib/src/models/interaction_data_resolved.dart @@ -0,0 +1,101 @@ +import 'package:nyxx/nyxx.dart'; +import 'package:nyxx/src/core/permissions/permissions.dart'; +import 'package:nyxx/src/core/user/user.dart'; +import 'package:nyxx/src/core/user/member.dart'; +import 'package:nyxx/src/core/guild/role.dart'; + +abstract class IPartialChannel implements SnowflakeEntity { + /// Channel name + String get name; + + /// Type of channel + ChannelType get type; + + /// Permissions of user in channel + IPermissions get permissions; +} + +/// Partial channel object for interactions +class PartialChannel extends SnowflakeEntity implements IPartialChannel { + /// Channel name + @override + late final String name; + + /// Type of channel + @override + late final ChannelType type; + + /// Permissions of user in channel + @override + late final IPermissions permissions; + + /// Creates na instance of [PartialChannel] + PartialChannel(RawApiMap raw) : super(Snowflake(raw["id"])) { + name = raw["name"] as String; + type = ChannelType.from(raw["type"] as int); + permissions = Permissions(int.parse(raw["permissions"].toString())); + } +} + +abstract class IInteractionDataResolved { + /// Resolved [User]s + Iterable get users; + + /// Resolved [Member]s + Iterable get members; + + /// Resolved [Role]s + Iterable get roles; + + /// Resolved [PartialChannel]s + Iterable get channels; +} + +/// Additional data for slash command +class InteractionDataResolved implements IInteractionDataResolved { + /// Resolved [User]s + @override + late final Iterable users; + + /// Resolved [Member]s + @override + late final Iterable members; + + /// Resolved [Role]s + @override + late final Iterable roles; + + /// Resolved [PartialChannel]s + @override + late final Iterable channels; + + /// Creates na instance of [InteractionDataResolved] + InteractionDataResolved(RawApiMap raw, Snowflake? guildId, INyxx client) { + users = [ + if (raw["users"] != null) + for (final rawUserEntry in (raw["users"] as RawApiMap).entries) User(client, rawUserEntry.value as RawApiMap) + ]; + + members = [ + if (raw["members"] != null) + for (final rawMemberEntry in (raw["members"] as RawApiMap).entries) + Member( + client, + { + ...rawMemberEntry.value as RawApiMap, + "user": {"id": rawMemberEntry.key} + }, + guildId!) + ]; + + roles = [ + if (raw["roles"] != null) + for (final rawRoleEntry in (raw["roles"] as RawApiMap).entries) Role(client, rawRoleEntry.value as RawApiMap, guildId!) + ]; + + channels = [ + if (raw["channels"] != null) + for (final rawChannelEntry in (raw["channels"] as RawApiMap).entries) PartialChannel(rawChannelEntry.value as RawApiMap) + ]; + } +} diff --git a/lib/src/models/interaction_option.dart b/lib/src/models/interaction_option.dart new file mode 100644 index 0000000..70ac2ed --- /dev/null +++ b/lib/src/models/interaction_option.dart @@ -0,0 +1,58 @@ +import 'package:nyxx/nyxx.dart'; + +import 'package:nyxx_interactions/src/models/command_option.dart'; + +abstract class IInteractionOption { + /// The value given by the user + dynamic get value; + + /// Type of interaction + CommandOptionType get type; + + /// Name of option + String get name; + + /// Any args under this as you can have sub commands + Iterable get options; + + /// True if options is focused + bool get isFocused; +} + +/// The option given by the user when sending a command +class InteractionOption implements IInteractionOption { + /// The value given by the user + @override + late final dynamic value; + + /// Type of interaction + @override + late final CommandOptionType type; + + /// Name of option + @override + late final String name; + + /// Any args under this as you can have sub commands + @override + late final Iterable options; + + /// True if options is focused + @override + late final bool isFocused; + + /// Creates na instance of [InteractionOption] + InteractionOption(RawApiMap raw) { + value = raw["value"] as dynamic; + name = raw["name"] as String; + type = CommandOptionType(raw["type"] as int); + + if (raw["options"] != null) { + options = (raw["options"] as List).map((e) => InteractionOption(e as RawApiMap)); + } else { + options = []; + } + + isFocused = raw["focused"] as bool? ?? false; + } +} diff --git a/lib/src/models/slash_command.dart b/lib/src/models/slash_command.dart new file mode 100644 index 0000000..4730a5f --- /dev/null +++ b/lib/src/models/slash_command.dart @@ -0,0 +1,75 @@ +import 'package:nyxx/nyxx.dart'; +// ignore: implementation_imports +import 'package:nyxx/src/internal/cache/cacheable.dart'; +import 'package:nyxx_interactions/src/models/command_option.dart'; + +import 'package:nyxx_interactions/src/models/slash_command_type.dart'; + +abstract class ISlashCommand implements SnowflakeEntity { + /// Unique id of the parent application + Snowflake get applicationId; + + /// Command name to be shown to the user in the Slash Command UI + String get name; + + /// Command description shown to the user in the Slash Command UI + String get description; + + /// The arguments that the command takes + List get options; + + /// The type of command + SlashCommandType get type; + + /// Guild id of the command, if not global + Cacheable? get guild; + + /// Whether the command is enabled by default when the app is added to a guild + bool get defaultPermissions; +} + +/// Represents slash command that is returned from Discord API. +class SlashCommand extends SnowflakeEntity implements ISlashCommand { + /// Unique id of the parent application + @override + late final Snowflake applicationId; + + /// Command name to be shown to the user in the Slash Command UI + @override + late final String name; + + /// Command description shown to the user in the Slash Command UI + @override + late final String description; + + /// The arguments that the command takes + @override + late final List options; + + /// The type of command + @override + late final SlashCommandType type; + + /// Guild id of the command, if not global + @override + late final Cacheable? guild; + + /// Whether the command is enabled by default when the app is added to a guild + @override + late final bool defaultPermissions; + + /// Creates na instance of [SlashCommand] + SlashCommand(RawApiMap raw, INyxx client) : super(Snowflake(raw["id"])) { + applicationId = Snowflake(raw["application_id"]); + name = raw["name"] as String; + description = raw["description"] as String; + type = SlashCommandType(raw["type"] as int? ?? 1); + guild = raw["guild_id"] != null ? GuildCacheable(client, Snowflake(raw["guild_id"])) : null; + defaultPermissions = raw["default_permission"] as bool? ?? true; + + options = [ + if (raw["options"] != null) + for (final optionRaw in raw["options"]) CommandOption(optionRaw as RawApiMap) + ]; + } +} diff --git a/lib/src/models/SlashCommandType.dart b/lib/src/models/slash_command_type.dart similarity index 66% rename from lib/src/models/SlashCommandType.dart rename to lib/src/models/slash_command_type.dart index 101c169..1d686c0 100644 --- a/lib/src/models/SlashCommandType.dart +++ b/lib/src/models/slash_command_type.dart @@ -1,16 +1,16 @@ -part of nyxx_interactions; +import 'package:nyxx/nyxx.dart'; /// Type of the slash command. Since context menus reuses slash commands /// backed, slash commands cna have different types based on context. class SlashCommandType extends IEnum { /// Normal slash command, invoked from chat - static const SlashCommandType chat = const SlashCommandType(1); + static const SlashCommandType chat = SlashCommandType(1); /// Context menu when right clicking on user - static const SlashCommandType user = const SlashCommandType(2); + static const SlashCommandType user = SlashCommandType(2); /// Context menu when right clicking on message - static const SlashCommandType message = const SlashCommandType(3); + static const SlashCommandType message = SlashCommandType(3); /// Creates instance of [SlashCommandType] from [value] const SlashCommandType(int value) : super(value); diff --git a/lib/src/typedefs.dart b/lib/src/typedefs.dart new file mode 100644 index 0000000..a0a75b6 --- /dev/null +++ b/lib/src/typedefs.dart @@ -0,0 +1,15 @@ +import 'dart:async'; + +import 'package:nyxx_interactions/nyxx_interactions.dart'; + +/// Function that will handle execution of slash command interaction event +typedef SlashCommandHandler = FutureOr Function(ISlashCommandInteractionEvent); + +/// Function that will handle execution of button interaction event +typedef ButtonInteractionHandler = FutureOr Function(IButtonInteractionEvent); + +/// Function that will handle execution of dropdown event +typedef MultiselectInteractionHandler = FutureOr Function(IMultiselectInteractionEvent); + +/// Function that will handle execution of button interaction event +typedef AutocompleteInteractionHandler = FutureOr Function(IAutocompleteInteractionEvent); diff --git a/pubspec.lock b/pubspec.lock deleted file mode 100644 index b61d7b3..0000000 --- a/pubspec.lock +++ /dev/null @@ -1,348 +0,0 @@ -# Generated by pub -# See https://dart.dev/tools/pub/glossary#lockfile -packages: - _fe_analyzer_shared: - dependency: transitive - description: - name: _fe_analyzer_shared - url: "https://pub.dartlang.org" - source: hosted - version: "27.0.0" - analyzer: - dependency: transitive - description: - name: analyzer - url: "https://pub.dartlang.org" - source: hosted - version: "2.4.0" - args: - dependency: transitive - description: - name: args - url: "https://pub.dartlang.org" - source: hosted - version: "2.3.0" - async: - dependency: transitive - description: - name: async - url: "https://pub.dartlang.org" - source: hosted - version: "2.8.2" - boolean_selector: - dependency: transitive - description: - name: boolean_selector - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.0" - charcode: - dependency: transitive - description: - name: charcode - url: "https://pub.dartlang.org" - source: hosted - version: "1.3.1" - cli_util: - dependency: transitive - description: - name: cli_util - url: "https://pub.dartlang.org" - source: hosted - version: "0.3.4" - collection: - dependency: transitive - description: - name: collection - url: "https://pub.dartlang.org" - source: hosted - version: "1.15.0" - convert: - dependency: transitive - description: - name: convert - url: "https://pub.dartlang.org" - source: hosted - version: "3.0.1" - coverage: - dependency: transitive - description: - name: coverage - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.3" - crypto: - dependency: "direct main" - description: - name: crypto - url: "https://pub.dartlang.org" - source: hosted - version: "3.0.1" - file: - dependency: transitive - description: - name: file - url: "https://pub.dartlang.org" - source: hosted - version: "6.1.2" - frontend_server_client: - dependency: transitive - description: - name: frontend_server_client - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.2" - glob: - dependency: transitive - description: - name: glob - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.2" - http: - dependency: transitive - description: - name: http - url: "https://pub.dartlang.org" - source: hosted - version: "0.13.4" - http_multi_server: - dependency: transitive - description: - name: http_multi_server - url: "https://pub.dartlang.org" - source: hosted - version: "3.0.1" - http_parser: - dependency: transitive - description: - name: http_parser - url: "https://pub.dartlang.org" - source: hosted - version: "4.0.0" - io: - dependency: transitive - description: - name: io - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.3" - js: - dependency: transitive - description: - name: js - url: "https://pub.dartlang.org" - source: hosted - version: "0.6.3" - logging: - dependency: "direct main" - description: - name: logging - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.2" - matcher: - dependency: transitive - description: - name: matcher - url: "https://pub.dartlang.org" - source: hosted - version: "0.12.11" - meta: - dependency: transitive - description: - name: meta - url: "https://pub.dartlang.org" - source: hosted - version: "1.7.0" - mime: - dependency: transitive - description: - name: mime - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.0" - node_preamble: - dependency: transitive - description: - name: node_preamble - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.1" - nyxx: - dependency: "direct main" - description: - name: nyxx - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.2" - package_config: - dependency: transitive - description: - name: package_config - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.2" - path: - dependency: transitive - description: - name: path - url: "https://pub.dartlang.org" - source: hosted - version: "1.8.0" - pool: - dependency: transitive - description: - name: pool - url: "https://pub.dartlang.org" - source: hosted - version: "1.5.0" - pub_semver: - dependency: transitive - description: - name: pub_semver - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.0" - shelf: - dependency: transitive - description: - name: shelf - url: "https://pub.dartlang.org" - source: hosted - version: "1.2.0" - shelf_packages_handler: - dependency: transitive - description: - name: shelf_packages_handler - url: "https://pub.dartlang.org" - source: hosted - version: "3.0.0" - shelf_static: - dependency: transitive - description: - name: shelf_static - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.0" - shelf_web_socket: - dependency: transitive - description: - name: shelf_web_socket - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.1" - source_map_stack_trace: - dependency: transitive - description: - name: source_map_stack_trace - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.0" - source_maps: - dependency: transitive - description: - name: source_maps - url: "https://pub.dartlang.org" - source: hosted - version: "0.10.10" - source_span: - dependency: transitive - description: - name: source_span - url: "https://pub.dartlang.org" - source: hosted - version: "1.8.1" - stack_trace: - dependency: transitive - description: - name: stack_trace - url: "https://pub.dartlang.org" - source: hosted - version: "1.10.0" - stream_channel: - dependency: transitive - description: - name: stream_channel - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.0" - string_scanner: - dependency: transitive - description: - name: string_scanner - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.0" - term_glyph: - dependency: transitive - description: - name: term_glyph - url: "https://pub.dartlang.org" - source: hosted - version: "1.2.0" - test: - dependency: "direct dev" - description: - name: test - url: "https://pub.dartlang.org" - source: hosted - version: "1.18.2" - test_api: - dependency: transitive - description: - name: test_api - url: "https://pub.dartlang.org" - source: hosted - version: "0.4.5" - test_core: - dependency: transitive - description: - name: test_core - url: "https://pub.dartlang.org" - source: hosted - version: "0.4.5" - typed_data: - dependency: transitive - description: - name: typed_data - url: "https://pub.dartlang.org" - source: hosted - version: "1.3.0" - vm_service: - dependency: transitive - description: - name: vm_service - url: "https://pub.dartlang.org" - source: hosted - version: "7.3.0" - watcher: - dependency: transitive - description: - name: watcher - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.1" - web_socket_channel: - dependency: transitive - description: - name: web_socket_channel - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.0" - webkit_inspection_protocol: - dependency: transitive - description: - name: webkit_inspection_protocol - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.0" - yaml: - dependency: transitive - description: - name: yaml - url: "https://pub.dartlang.org" - source: hosted - version: "3.1.0" -sdks: - dart: ">=2.14.0 <3.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index f440a3f..47ebcbb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: nyxx_interactions -version: 2.0.2 +version: 3.0.0 description: Nyxx Interactions Module. Discord library for Dart. Simple, robust framework for creating discord bots for Dart language. homepage: https://github.com/nyxx-discord/nyxx repository: https://github.com/nyxx-discord/nyxx @@ -7,12 +7,16 @@ documentation: https://nyxx.l7ssha.xyz issue_tracker: https://github.com/nyxx-discord/nyxx/issues environment: - sdk: '>=2.13.0 <3.0.0' + sdk: '>=2.14.0 <3.0.0' dependencies: - crypto: "^3.0.1" - logging: "^1.0.1" - nyxx: "^2.0.0" + crypto: ^3.0.1 + logging: ^1.0.1 + nyxx: ^3.0.0 dev_dependencies: - test: "^1.17.0" + test: ^1.19.0 + mockito: ^5.0.16 + build_runner: ^2.1.4 + lints: ^1.0.1 + coverage: ^1.0.3 diff --git a/test/integration/.gitkeep b/test/integration/.gitkeep new file mode 100755 index 0000000..e69de29 diff --git a/test/mocks/nyxx_rest.mocks.dart b/test/mocks/nyxx_rest.mocks.dart new file mode 100644 index 0000000..c780649 --- /dev/null +++ b/test/mocks/nyxx_rest.mocks.dart @@ -0,0 +1,7 @@ +import 'package:mockito/mockito.dart'; +import 'package:nyxx/nyxx.dart'; + +class NyxxRestMock extends Fake implements INyxxRest { + @override + ClientOptions get options => ClientOptions(); +} diff --git a/test/mocks/nyxx_websocket.mocks.dart b/test/mocks/nyxx_websocket.mocks.dart new file mode 100644 index 0000000..a9494c1 --- /dev/null +++ b/test/mocks/nyxx_websocket.mocks.dart @@ -0,0 +1,31 @@ +import 'package:mockito/mockito.dart'; +import 'package:nyxx/nyxx.dart'; +import 'package:nyxx/src/events/ready_event.dart'; +import 'package:nyxx/src/events/raw_event.dart'; + +class NyxxWebsocketMock extends Fake implements INyxxWebsocket { + @override + ClientOptions get options => ClientOptions(); + + @override + IShardManager get shardManager => ShardManagerMock(); + + @override + IWebsocketEventController get eventsWs => EventsWsMock(this); +} + +class EventsWsMock extends Fake implements IWebsocketEventController { + @override + Stream get onReady => Stream.value(ReadyEvent(client)); + + final INyxx client; + + EventsWsMock(this.client); +} + +class ShardManagerMock extends Fake implements IShardManager { + @override + Stream get rawEvent => Stream.value(RawEvent(ShardMock(), {})); +} + +class ShardMock extends Fake implements IShard {} diff --git a/test/unit.dart b/test/unit.dart deleted file mode 100644 index ef22d89..0000000 --- a/test/unit.dart +++ /dev/null @@ -1,23 +0,0 @@ -import "package:nyxx/nyxx.dart"; -import "package:nyxx_interactions/interactions.dart"; - -import "package:test/test.dart"; - -final client = NyxxRest("dum", 0); -final slashCommandNameRegexMatcher = matches(slashCommandNameRegex); - -void main() { - group("test utils", () { - test("test slash command regex", () { - expect("test", slashCommandNameRegexMatcher); - expect("Atest", slashCommandNameRegexMatcher); - expect("test-test", slashCommandNameRegexMatcher); - - expect("test.test", isNot(slashCommandNameRegexMatcher)); - expect(".test", isNot(slashCommandNameRegexMatcher)); - expect("*test", isNot(slashCommandNameRegexMatcher)); - expect("/test", isNot(slashCommandNameRegexMatcher)); - expect("\\test", isNot(slashCommandNameRegexMatcher)); - }); - }); -} diff --git a/test/unit/builder.dart b/test/unit/builder.dart new file mode 100755 index 0000000..58b0a9f --- /dev/null +++ b/test/unit/builder.dart @@ -0,0 +1,33 @@ +import "package:nyxx_interactions/nyxx_interactions.dart"; + +import "package:test/test.dart"; + +void main() { + group("arg choice builder", () { + test("valid value string", () { + final builder = ArgChoiceBuilder("test", "value"); + + final expectedResult = { + "name": "test", + "value": "value", + }; + + expect(builder.build(), equals(expectedResult)); + }); + + test("valid value int", () { + final builder = ArgChoiceBuilder("test", 123); + + final expectedResult = { + "name": "test", + "value": 123, + }; + + expect(builder.build(), equals(expectedResult)); + }); + + test("invalid value", () { + expect(() => ArgChoiceBuilder("test", DateTime.now()), throwsA(isA())); + }); + }); +} diff --git a/test/unit/command_option_builder.dart b/test/unit/command_option_builder.dart new file mode 100755 index 0000000..2e5b89b --- /dev/null +++ b/test/unit/command_option_builder.dart @@ -0,0 +1,44 @@ +import 'package:nyxx/nyxx.dart'; +import 'package:nyxx_interactions/nyxx_interactions.dart'; +import 'package:test/test.dart'; + +main() { + test('.registerHandler success', () { + final builder = CommandOptionBuilder(CommandOptionType.subCommand, 'test', 'test'); + + builder.registerHandler((p0) => Future.value()); + expect(builder.handler, isA()); + }); + + test('.registerHandler failure', () { + final builder = CommandOptionBuilder(CommandOptionType.user, 'test', 'test'); + + expect(() => builder.registerHandler((p0) => Future.value()), throwsA(isA())); + }); + + test('.build', () { + final builder = CommandOptionBuilder(CommandOptionType.channel, 'test', 'test', + choices: [ + ArgChoiceBuilder("arg1", "val1"), + ], + channelTypes: [ + ChannelType.text, + ], + autoComplete: true); + + final expectedResult = { + "type": CommandOptionType.channel.value, + "name": "test", + 'description': 'test', + 'default': false, + 'required': false, + 'choices': [ + {'name': 'arg1', 'value': 'val1'} + ], + 'channel_types': [ChannelType.text], + 'autocomplete': true + }; + + expect(builder.build(), equals(expectedResult)); + }); +} diff --git a/test/unit/command_permission_builder.dart b/test/unit/command_permission_builder.dart new file mode 100644 index 0000000..c9c4bb9 --- /dev/null +++ b/test/unit/command_permission_builder.dart @@ -0,0 +1,21 @@ +import 'package:nyxx/nyxx.dart'; +import 'package:nyxx_interactions/nyxx_interactions.dart'; +import 'package:test/test.dart'; + +main() { + test("RoleCommandPermissionBuilder", () { + final builder = CommandPermissionBuilderAbstract.role(Snowflake.zero()); + + final expectedResult = {"id": '0', "type": 1, "permission": true}; + + expect(builder.build(), equals(expectedResult)); + }); + + test("RoleCommandPermissionBuilder", () { + final builder = CommandPermissionBuilderAbstract.user(Snowflake.zero(), hasPermission: false); + + final expectedResult = {"id": '0', "type": 2, "permission": false}; + + expect(builder.build(), equals(expectedResult)); + }); +} diff --git a/test/unit/command_sync.dart b/test/unit/command_sync.dart new file mode 100755 index 0000000..2f50c69 --- /dev/null +++ b/test/unit/command_sync.dart @@ -0,0 +1,12 @@ +import 'package:nyxx_interactions/nyxx_interactions.dart'; +import 'package:test/test.dart'; + +main() { + test("manual sync", () async { + final manualSyncTrue = ManualCommandSync(sync: true); + expect(await manualSyncTrue.shouldSync([]), isTrue); + + final manualSyncFalse = ManualCommandSync(sync: false); + expect(await manualSyncFalse.shouldSync([]), isFalse); + }); +} diff --git a/test/unit/component_builder.dart b/test/unit/component_builder.dart new file mode 100644 index 0000000..3bef64c --- /dev/null +++ b/test/unit/component_builder.dart @@ -0,0 +1,136 @@ +import 'package:nyxx/nyxx.dart'; +import 'package:nyxx_interactions/nyxx_interactions.dart'; +import 'package:test/test.dart'; + +main() { + test("components", () { + final customButton = ButtonBuilder("label", "customId", ComponentStyle.secondary); + final linkButton = LinkButtonBuilder("label2", "discord://-/"); + final multiselect = MultiselectBuilder("customId2", [MultiselectOptionBuilder("label1", "value1", true)]); + + final componentRow = ComponentRowBuilder() + ..addComponent(customButton) + ..addComponent(linkButton); + + final secondComponentRow = ComponentRowBuilder()..addComponent(multiselect); + + final messageBuilder = ComponentMessageBuilder() + ..addComponentRow(componentRow) + ..addComponentRow(secondComponentRow) + ..content = "test content"; + + final expectedResult = { + 'content': "test content", + 'components': [ + { + 'type': 1, + 'components': [ + {'type': 2, 'label': 'label', 'style': 2, 'custom_id': 'customId'}, + {'type': 2, 'label': 'label2', 'style': 5, 'url': 'discord://-/'} + ] + }, + { + 'type': 1, + 'components': [ + { + 'type': 3, + 'custom_id': 'customId2', + 'options': [ + {'label': 'label1', 'value': 'value1', 'default': true} + ] + } + ] + } + ] + }; + + expect(messageBuilder.build(), equals(expectedResult)); + }); + + test("MultiselectOptionBuilder emoji unicode", () { + final builder = MultiselectOptionBuilder("test", 'test')..emoji = UnicodeEmoji('😂'); + + final expectedResult = { + 'label': 'test', + 'value': 'test', + 'default': false, + 'emoji': {'name': '😂'} + }; + + expect(builder.build(), equals(expectedResult)); + }); + + test("MultiselectOptionBuilder emoji unicode", () { + final builder = MultiselectOptionBuilder("test", 'test')..emoji = IBaseGuildEmoji.fromId(Snowflake.zero()); + + final expectedResult = { + 'label': 'test', + 'value': 'test', + 'default': false, + 'emoji': {'id': '0'} + }; + + expect(builder.build(), equals(expectedResult)); + }); + + test('ComponentMessageBuilder component rows', () { + final messageBuilder = ComponentMessageBuilder(); + + expect(() => messageBuilder.addComponentRow(ComponentRowBuilder()), throwsA(isA())); + + messageBuilder + ..addComponentRow(ComponentRowBuilder()..addComponent(LinkButtonBuilder('test', 'test'))) + ..addComponentRow(ComponentRowBuilder()..addComponent(LinkButtonBuilder('test', 'test'))) + ..addComponentRow(ComponentRowBuilder()..addComponent(LinkButtonBuilder('test', 'test'))) + ..addComponentRow(ComponentRowBuilder()..addComponent(LinkButtonBuilder('test', 'test'))) + ..addComponentRow(ComponentRowBuilder()..addComponent(LinkButtonBuilder('test', 'test'))); + + expect(() => messageBuilder.addComponentRow(ComponentRowBuilder()..addComponent(LinkButtonBuilder('test', 'test'))), throwsA(isA())); + }); + + test("ButtonBuilder label length", () { + expect( + () => ButtonBuilder( + 'Fusce accumsan sit amet neque vitae viverra. Sed leo est, finibus ut velit at, commodo vestibulum nulla metus.', 'test', ComponentStyle.secondary), + throwsA(isA())); + }); + + test("ButtonBuilder customId length", () { + expect( + () => ButtonBuilder( + 'test', 'Fusce accumsan sit amet neque vitae viverra. Sed leo est, finibus ut velit at, commodo vestibulum nulla metus.', ComponentStyle.secondary), + throwsA(isA())); + }); + + test('MultiselectBuilder', () { + expect(() => MultiselectBuilder('Fusce accumsan sit amet neque vitae viverra. Sed leo est, finibus ut velit at, commodo vestibulum nulla metus.'), + throwsA(isA())); + + final builder = MultiselectBuilder("test")..addOption(MultiselectOptionBuilder("label", "value")); + + expect(builder.options, hasLength(1)); + }); + + test("LinkButtonBuilder url length", () { + const url = """ +Morbi non laoreet nulla, mollis suscipit nisi. Aenean vestibulum vehicula auctor. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Maecenas ullamcorper viverra aliquam. Duis sit amet nisl at libero blandit sagittis. Fusce quis faucibus libero. Quisque luctus est enim, quis efficitur sapien mollis eu. +Suspendisse aliquet volutpat ante eu ornare. Etiam ante erat, pulvinar vel justo sed, mollis rhoncus lacus. Nulla cursus, dolor et luctus cursus, diam tortor volutpat ex, et volutpat posuere. + """; + + expect(() => LinkButtonBuilder('test', url), throwsA(isA())); + }); + + test("ButtonBuilder emoji", () { + final builder = ButtonBuilder("label", "customId", ComponentStyle.primary)..emoji = UnicodeEmoji('😂'); + + final expectedResult = { + 'type': 2, + 'label': 'label', + 'style': 1, + 'emoji': {'name': '😂'}, + 'custom_id': 'customId' + }; + + expect(builder.build(), equals(expectedResult)); + }); +} diff --git a/test/unit/event_controller.dart b/test/unit/event_controller.dart new file mode 100755 index 0000000..55d0609 --- /dev/null +++ b/test/unit/event_controller.dart @@ -0,0 +1,13 @@ +import 'package:nyxx_interactions/nyxx_interactions.dart'; +import 'package:nyxx_interactions/src/internal/event_controller.dart'; +import 'package:test/test.dart'; + +main() { + test("event controller", () async { + final eventController = EventController(); + expect(eventController, isA()); + + await eventController.dispose(); + expect(eventController, isA()); + }); +} diff --git a/test/unit/model.dart b/test/unit/model.dart new file mode 100644 index 0000000..b4315ea --- /dev/null +++ b/test/unit/model.dart @@ -0,0 +1,97 @@ +import 'package:nyxx/nyxx.dart'; +import 'package:nyxx_interactions/nyxx_interactions.dart'; +import 'package:nyxx_interactions/src/models/arg_choice.dart'; +import 'package:nyxx_interactions/src/models/command_option.dart'; +import 'package:nyxx_interactions/src/models/interaction_option.dart'; +import 'package:nyxx_interactions/src/models/slash_command.dart'; +import 'package:test/test.dart'; + +import '../mocks/nyxx_rest.mocks.dart'; + +main() { + test('ArgChoice', () { + final entity = ArgChoice({"name": "test", "value": "test1"}); + + expect(entity.value, equals("test1")); + expect(entity.name, equals("test")); + }); + + test('CommandOption', () { + final entity = CommandOption({ + 'type': 3, + 'name': 'test', + 'description': 'this is description', + 'required': false, + 'choices': [ + {"name": "test", "value": "test1"} + ], + 'options': [ + {'type': 4, 'name': 'subOption', 'description': 'test'} + ], + }); + + expect(entity.type, equals(CommandOptionType.string)); + expect(entity.name, equals('test')); + expect(entity.description, equals('this is description')); + expect(entity.required, equals(false)); + expect(entity.choices, hasLength(1)); + expect(entity.options, hasLength(1)); + }); + + test('SlashCommand', () { + final client = NyxxRestMock(); + + final entity = SlashCommand({ + "id": 123, + "application_id": 456, + 'name': 'testname', + 'description': 'testdesc', + 'type': SlashCommandType.chat.value, + 'options': [ + {'type': 4, 'name': 'subOption', 'description': 'test'} + ], + }, client); + + expect(entity.id, equals(Snowflake(123))); + expect(entity.applicationId, equals(Snowflake(456))); + expect(entity.name, equals('testname')); + expect(entity.description, equals('testdesc')); + expect(entity.type, equals(SlashCommandType.chat)); + expect(entity.options, hasLength(1)); + expect(entity.defaultPermissions, isTrue); + expect(entity.guild, isNull); + }); + + test('InteractionOption options not empty', () { + final entity = InteractionOption({ + 'value': 'testval', + 'name': 'testname', + 'type': CommandOptionType.boolean.value, + 'focused': false, + 'options': [ + {'type': 4, 'name': 'subOption', 'description': 'test'} + ], + }); + + expect(entity.value, equals('testval')); + expect(entity.name, equals('testname')); + expect(entity.type, equals(CommandOptionType.boolean)); + expect(entity.isFocused, equals(false)); + expect(entity.options, hasLength(1)); + }); + + test('InteractionOption options empty', () { + final entity = InteractionOption({ + 'value': 'testval', + 'name': 'testname', + 'type': CommandOptionType.boolean.value, + 'focused': false, + }); + + expect(entity.value, equals('testval')); + expect(entity.name, equals('testname')); + expect(entity.type, equals(CommandOptionType.boolean)); + expect(entity.isFocused, equals(false)); + expect(entity.options, isEmpty); + }); +} diff --git a/test/unit/nyxx_backend.dart b/test/unit/nyxx_backend.dart new file mode 100644 index 0000000..5102017 --- /dev/null +++ b/test/unit/nyxx_backend.dart @@ -0,0 +1,20 @@ +import 'dart:async'; + +import 'package:nyxx_interactions/nyxx_interactions.dart'; +import 'package:test/test.dart'; +import 'package:nyxx_interactions/src/backend/interaction_backend.dart'; + +import '../mocks/nyxx_websocket.mocks.dart'; + +main() { + final client = NyxxWebsocketMock(); + + test("nyxx backend", () { + final backend = WebsocketInteractionBackend(client); + backend.setup(); + + expect(backend.getStream(), isA>()); + expect(backend.getStream(), isA>>()); + expect(backend.getStreamController(), isA>>()); + }); +} diff --git a/test/unit/slash_command_builder.dart b/test/unit/slash_command_builder.dart new file mode 100755 index 0000000..8917533 --- /dev/null +++ b/test/unit/slash_command_builder.dart @@ -0,0 +1,64 @@ +import 'package:nyxx/nyxx.dart'; +import "package:nyxx_interactions/nyxx_interactions.dart"; + +import "package:test/test.dart"; + +void main() { + test("invalid name", () { + expect(() => SlashCommandBuilder("invalid name", "test", []), throwsA(isA())); + }); + + test("missing description", () { + expect(() => SlashCommandBuilder("invalid-name", null, []), throwsA(isA())); + }); + + test("description present for context menu", () { + expect(() => SlashCommandBuilder("invalid-name", "test", [], type: SlashCommandType.user), throwsA(isA())); + }); + + test(".setId", () { + final slashCommandBuilder = SlashCommandBuilder("invalid-name", "test", []); + + expect(() => slashCommandBuilder.id, throwsA(isA())); + + slashCommandBuilder.setId(Snowflake.zero()); + expect(slashCommandBuilder.id, equals(Snowflake.zero())); + }); + + test(".addPermission", () { + final slashCommandBuilder = SlashCommandBuilder("invalid-name", "test", []); + + slashCommandBuilder + ..addPermission(RoleCommandPermissionBuilder(Snowflake.zero())) + ..addPermission(UserCommandPermissionBuilder(Snowflake.bulk())); + + expect(slashCommandBuilder.permissions, isNotNull); + expect(slashCommandBuilder.permissions, hasLength(2)); + }); + + test('.registerHandler failure', () { + final slashCommandBuilder = SlashCommandBuilder("invalid-name", "test", [CommandOptionBuilder(CommandOptionType.subCommand, "test", 'test')]); + + expect(() => slashCommandBuilder.registerHandler((p0) => Future.value()), throwsA(isA())); + }); + + test('.registerHandler success', () { + final slashCommandBuilder = SlashCommandBuilder("invalid-name", "test", []); + + slashCommandBuilder.registerHandler((p0) => Future.value()); + expect(slashCommandBuilder.handler, isA()); + }); + + test('.build', () { + final slashCommandBuilder = SlashCommandBuilder("invalid-name", "test", []); + + final expectedResult = { + "name": "invalid-name", + "description": "test", + "type": SlashCommandType.chat, + "default_permission": true, + }; + + expect(slashCommandBuilder.build(), equals(expectedResult)); + }); +} diff --git a/test/unit/utils.dart b/test/unit/utils.dart new file mode 100755 index 0000000..bf2cefa --- /dev/null +++ b/test/unit/utils.dart @@ -0,0 +1,29 @@ +import "package:nyxx_interactions/nyxx_interactions.dart"; +import 'package:nyxx_interactions/src/internal/utils.dart'; + +import "package:test/test.dart"; + +final slashCommandNameRegexMatcher = matches(slashCommandNameRegex); + +void main() { + test("test slash command regex", () { + expect("test", slashCommandNameRegexMatcher); + expect("Atest", slashCommandNameRegexMatcher); + expect("test-test", slashCommandNameRegexMatcher); + + expect("test.test", isNot(slashCommandNameRegexMatcher)); + expect(".test", isNot(slashCommandNameRegexMatcher)); + expect("*test", isNot(slashCommandNameRegexMatcher)); + expect("/test", isNot(slashCommandNameRegexMatcher)); + expect("\\test", isNot(slashCommandNameRegexMatcher)); + }); + + test("partition", () { + final input = [1, 2, 7, 4, 6, 9]; + + final result = partition(input, (e) => e < 5); + + expect(result.first, hasLength(3)); + expect(result.last, hasLength(3)); + }); +}