! Published.
This commit is contained in:
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
src/Tests
|
||||||
|
src/vendor
|
||||||
|
src/.env
|
||||||
|
src/.env.example
|
||||||
|
src/debug/
|
||||||
|
volumes/
|
||||||
|
.git/
|
||||||
|
.git-crypt/
|
||||||
|
.github/
|
||||||
49
.github/workflows/latest.yaml
vendored
Normal file
49
.github/workflows/latest.yaml
vendored
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
name: Build the "latest" docker image
|
||||||
|
|
||||||
|
env:
|
||||||
|
PACKAGE_NAME: discord-bot
|
||||||
|
PACKAGE_TAG: latest
|
||||||
|
|
||||||
|
run-name: ${{ github.actor }} is building the "latest" docker image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
# tags:
|
||||||
|
# - v**
|
||||||
|
branches:
|
||||||
|
- 'master'
|
||||||
|
- 'main'
|
||||||
|
paths-ignore:
|
||||||
|
- README.md
|
||||||
|
- src/README.md
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build_docker_image:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checking out repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Login to Goliath Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ vars.PACKAGE_REPOSITORY }}
|
||||||
|
username: ${{ secrets.PACKAGE_USER }}
|
||||||
|
password: ${{ secrets.PACKAGE_PASS }}
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Create Docker Buildx contex
|
||||||
|
run: docker buildx create --name goliath; docker buildx use goliath; docker buildx inspect --bootstrap;
|
||||||
|
|
||||||
|
- name: Building docker image
|
||||||
|
run: |
|
||||||
|
docker buildx build . \
|
||||||
|
--file build/default/Dockerfile \
|
||||||
|
--platform linux/amd64,linux/arm64 \
|
||||||
|
--tag ${{ vars.PACKAGE_REPOSITORY }}/${{ vars.PACKAGE_ORGANIZATION }}/${{ env.PACKAGE_NAME }}:${{ env.PACKAGE_TAG }} \
|
||||||
|
--provenance=false \
|
||||||
|
--sbom=false \
|
||||||
|
--push
|
||||||
45
.github/workflows/testing.yaml
vendored
Normal file
45
.github/workflows/testing.yaml
vendored
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
name: Build docker image
|
||||||
|
|
||||||
|
env:
|
||||||
|
PACKAGE_NAME: discord-bot
|
||||||
|
PACKAGE_TAG: testing
|
||||||
|
|
||||||
|
|
||||||
|
run-name: ${{ github.actor }} is building the docker image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
# - 'test-**'
|
||||||
|
- 'dev'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build_docker_image:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checking out repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Login to Goliath Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ vars.PACKAGE_REPOSITORY }}
|
||||||
|
username: ${{ secrets.PACKAGE_USER }}
|
||||||
|
password: ${{ secrets.PACKAGE_PASS }}
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Create Docker Buildx contex
|
||||||
|
run: docker buildx create --name goliath; docker buildx use goliath; docker buildx inspect --bootstrap;
|
||||||
|
|
||||||
|
- name: Building docker image
|
||||||
|
run: |
|
||||||
|
docker buildx build . \
|
||||||
|
--file build/default/Dockerfile \
|
||||||
|
--platform linux/amd64,linux/arm64 \
|
||||||
|
--tag ${{ vars.PACKAGE_REPOSITORY }}/${{ vars.PACKAGE_ORGANIZATION }}/${{ env.PACKAGE_NAME }}:${{ env.PACKAGE_TAG }} \
|
||||||
|
--provenance=false \
|
||||||
|
--sbom=false \
|
||||||
|
--push
|
||||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
.vscode
|
||||||
|
volumes/debug/*
|
||||||
|
volumes/env/*
|
||||||
9
LICENSE
Normal file
9
LICENSE
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025 proxima
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
186
README.md
Normal file
186
README.md
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
# Proxima Discord Bot
|
||||||
|
|
||||||
|
[Proxima](https://proxima.goliath.hu) is a **Remainder Application**, like many others, which primarily uses [Discord](https://discord.com/) to deliver the remainders to the user.
|
||||||
|
|
||||||
|
It provides an API interface to be embedded in other services, using Bearer Token Authentication.
|
||||||
|
|
||||||
|
The Discord bot provides basic functionality for convenience to the user as well.
|
||||||
|
|
||||||
|
This is the source code for the discord bot.
|
||||||
|
|
||||||
|
The bot allows the user to manage (add, edit, remove, list) its own remainders on the installed servers.
|
||||||
|
|
||||||
|
This bot is intended to be run as a docker container, see below.
|
||||||
|
|
||||||
|
For more details, see: [Proxima -> Discord Bot](https://proxima.goliath.hu#discord-bot)
|
||||||
|
|
||||||
|
# Main Technologies used
|
||||||
|
- Based on [commandstring/dphp-bot](https://github.com/CommandString/discordphp-bot-template) package
|
||||||
|
- [PHP 8.2+](https://www.php.net/)
|
||||||
|
- [composer](https://getcomposer.org/)
|
||||||
|
- [ReactPHP ](https://reactphp.org/)
|
||||||
|
- [discord-php/DiscordPHP](https://github.com/discord-php/DiscordPHP)
|
||||||
|
- [team-reflex/discord-php](https://github.com/discord-php/DiscordPHP)
|
||||||
|
- [Smarty](https://www.smarty.net/)
|
||||||
|
- and many more...
|
||||||
|
|
||||||
|
# What is needed to run the bot
|
||||||
|
|
||||||
|
- A working docker instance with compose.
|
||||||
|
- A Discord account: [Discord](https://discord.com/)
|
||||||
|
- A Discord application: [Discord Developer Portal](https://discord.com/developers/applications)
|
||||||
|
- A dedicated Discord server where the bot can send important notifications.
|
||||||
|
|
||||||
|
# Running with Docker compose
|
||||||
|
|
||||||
|
### Follow these easy steps to set up and your own bot:
|
||||||
|
|
||||||
|
> **NOTE:** The program inside the container is running under a **non-privileged** user with
|
||||||
|
>
|
||||||
|
> `UID:1000` and `GID:1000`.
|
||||||
|
>
|
||||||
|
> The storage folder **must** be writable for that user!
|
||||||
|
>
|
||||||
|
> In the example, we just make it writable to everyone (please use proper access control instead!)
|
||||||
|
>
|
||||||
|
> *Or you can build your own image with different `UID`/`GID` values.*
|
||||||
|
|
||||||
|
#### 1. Create the directory structure (bash)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p volumes/bot/{storage,env}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Update directory permissions to allow write access to the restricted docker user
|
||||||
|
***This is for demonstration only, please use proper access control!***
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod o+w volumes/bot/storage
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Create the `.env` file from [src/.env.example](src/.env.example) file
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wget -O volumes/bot/env/.env https://proxima.goliath.hu/proxima/discord-bot/raw/branch/main/src/.env.example
|
||||||
|
|
||||||
|
-OR-
|
||||||
|
|
||||||
|
curl -f -o volumes/bot/env/.env https://proxima.goliath.hu/proxima/discord-bot/raw/branch/main/src/.env.example
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. Customize the `.env.config` file
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nano volumes/bot/env/.env
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4.1 Fill in the bot application informations
|
||||||
|
|
||||||
|
- Change the `APPLICATION_ID` for the discord bot application id.
|
||||||
|
|
||||||
|
see: [Discord Developer Portal](https://discord.com/developers/applications)
|
||||||
|
|
||||||
|
`<YOUR_APPLICATION>` -> `General Information` -> `Application ID`
|
||||||
|
|
||||||
|
- Change the `PUBLIC_KEY` for the discord bot public key.
|
||||||
|
|
||||||
|
see: [Discord Developer Portal](https://discord.com/developers/applications)
|
||||||
|
|
||||||
|
`<YOUR_APPLICATION>` -> `General Information` -> `Public Key`
|
||||||
|
|
||||||
|
- Change the `TOKEN` for the discord bot token.
|
||||||
|
|
||||||
|
see: [Discord Developer Portal](https://discord.com/developers/applications)
|
||||||
|
|
||||||
|
`<YOUR_APPLICATION>` -> `Bot` -> `Token`
|
||||||
|
|
||||||
|
### 4.2 Fill in the bot home server informations
|
||||||
|
|
||||||
|
- Change the `HOME_SERVER_ID` for dedicated discord server ID.
|
||||||
|
|
||||||
|
see: [Discord](https://discord.com/)
|
||||||
|
|
||||||
|
***NOTE: This can be used to manage the bot.***
|
||||||
|
|
||||||
|
- Change the `LOG_CHANNEL_ID` for dedicated channel on the discord server.
|
||||||
|
|
||||||
|
see: [Discord](https://discord.com/)
|
||||||
|
|
||||||
|
***NOTE: The system messages will be sent here.***
|
||||||
|
|
||||||
|
### 4.3 Fill in the backend informations
|
||||||
|
|
||||||
|
- Fill in the `API_URL` for backend api url.
|
||||||
|
|
||||||
|
ex.: `http://<your_backand_address>/api/v1/`
|
||||||
|
|
||||||
|
***NOTE: If it runs on localhost, teh address is:
|
||||||
|
`http://backend:9000/api/v1/`***
|
||||||
|
|
||||||
|
#### 4.3.1 Get the authentication token from the backend
|
||||||
|
|
||||||
|
- Go visit the webpage backend `/admin` page and log in.
|
||||||
|
|
||||||
|
- Go to the your `Profile` page.
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
- Here you can change your `name`, `email`, `password` at any time.
|
||||||
|
hjfNdG68K2vbOrTn9bXVqDZBpNDER20rt5FRB1Sv2acd4353
|
||||||
|
- Click on the `Craete Token` button, and create an access token for the `Bot`.
|
||||||
|
|
||||||
|
- You can name the token anything you want.
|
||||||
|
|
||||||
|
- Make sure you check all the `Ablities` checkboxes.
|
||||||
|
|
||||||
|
- You can set an expire date if you want or leave empty (the token will not expire).
|
||||||
|
|
||||||
|
- Press `create` and copy the new `token` and temporary save it.
|
||||||
|
(If you lose it, you can always generate a new one, just delete the old now useless one).
|
||||||
|
|
||||||
|
#### 4.3.2 Fill in the authentication token
|
||||||
|
|
||||||
|
- Fill in the `BACKEND_TOKEN` for backend api token created by the **4.3.1** section.
|
||||||
|
|
||||||
|
### 5. Create the [`docker-compose.yaml`](res/docker-compose.yaml) file:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
services:
|
||||||
|
discord-bot:
|
||||||
|
image: proxima.goliath.hu/proxima/discord-bot:latest
|
||||||
|
container_name: bot
|
||||||
|
volumes:
|
||||||
|
- "./volumes/bot/env/.env:/app/Bot/.env:ro"
|
||||||
|
- "./volumes/bot/storage:/app/Bot/Storage"
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- proxima
|
||||||
|
|
||||||
|
networks:
|
||||||
|
proxima:
|
||||||
|
external: true
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
***NOTE: the external network is only neded if the backend and bot are on the same host, so they can communicate by the hostname.***
|
||||||
|
|
||||||
|
***You can create the network with the following command:***
|
||||||
|
```bash
|
||||||
|
docker network create proxima
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6 Start up service
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
68
build/default/Dockerfile
Normal file
68
build/default/Dockerfile
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
ARG ALPINE_VERSION=3.21
|
||||||
|
|
||||||
|
FROM alpine:${ALPINE_VERSION}
|
||||||
|
|
||||||
|
# initialize arguments
|
||||||
|
ARG PHP_VERSION=82
|
||||||
|
ARG USER_ID=1000
|
||||||
|
ARG GROUP_ID=1000
|
||||||
|
ARG WORKDIR=/app/Bot
|
||||||
|
|
||||||
|
# install dependencies
|
||||||
|
RUN apk add \
|
||||||
|
bash \
|
||||||
|
git \
|
||||||
|
icu-data-full \
|
||||||
|
php${PHP_VERSION} \
|
||||||
|
php${PHP_VERSION}-fpm \
|
||||||
|
php${PHP_VERSION}-curl \
|
||||||
|
php${PHP_VERSION}-intl \
|
||||||
|
php${PHP_VERSION}-dom \
|
||||||
|
php${PHP_VERSION}-fileinfo \
|
||||||
|
php${PHP_VERSION}-iconv \
|
||||||
|
php${PHP_VERSION}-mbstring \
|
||||||
|
php${PHP_VERSION}-openssl \
|
||||||
|
php${PHP_VERSION}-pdo \
|
||||||
|
php${PHP_VERSION}-pdo_sqlite \
|
||||||
|
php${PHP_VERSION}-phar \
|
||||||
|
php${PHP_VERSION}-opcache \
|
||||||
|
php${PHP_VERSION}-session \
|
||||||
|
php${PHP_VERSION}-simplexml \
|
||||||
|
php${PHP_VERSION}-sqlite3 \
|
||||||
|
php${PHP_VERSION}-tokenizer \
|
||||||
|
php${PHP_VERSION}-xml \
|
||||||
|
php${PHP_VERSION}-xmlreader \
|
||||||
|
php${PHP_VERSION}-xmlwriter \
|
||||||
|
php${PHP_VERSION}-zip \
|
||||||
|
&& ln -s /usr/bin/php${PHP_VERSION} /usr/bin/php
|
||||||
|
|
||||||
|
|
||||||
|
# add user and group for the app
|
||||||
|
RUN addgroup -g ${GROUP_ID} bot \
|
||||||
|
&& adduser -u ${USER_ID} -G bot -D bot \
|
||||||
|
&& mkdir -p ${WORKDIR}/Storage/Logs \
|
||||||
|
&& chown -R ${USER_ID}:${GROUP_ID} ${WORKDIR}
|
||||||
|
|
||||||
|
# install composer
|
||||||
|
COPY --from=composer/composer:latest-bin /composer /usr/bin/composer
|
||||||
|
|
||||||
|
# set the workdir
|
||||||
|
WORKDIR ${WORKDIR}
|
||||||
|
|
||||||
|
# copy the application
|
||||||
|
COPY --chown=${USER_ID}:${GROUP_ID} src/ .
|
||||||
|
|
||||||
|
# change to the user
|
||||||
|
USER ${USER_ID}:${GROUP_ID}
|
||||||
|
|
||||||
|
# initialize composer
|
||||||
|
RUN composer install \
|
||||||
|
--no-interaction \
|
||||||
|
--no-dev \
|
||||||
|
--optimize-autoloader
|
||||||
|
|
||||||
|
# define the storage volume
|
||||||
|
VOLUME [ "/app/Bot/Storage" ]
|
||||||
|
|
||||||
|
# setting entrypoint
|
||||||
|
ENTRYPOINT [ "php", "Bot.php" ]
|
||||||
57
build/default/build.sh
Executable file
57
build/default/build.sh
Executable file
@@ -0,0 +1,57 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# setting constants
|
||||||
|
PACKAGE_REPOSITORY=proxima.goliath.hu/proxima/discord-bot
|
||||||
|
BUILD_TAG=default
|
||||||
|
UID=$(id -u)
|
||||||
|
GID=$(id -g)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
clear
|
||||||
|
|
||||||
|
echo This is a sample build script, if the workflow cannot be used.
|
||||||
|
echo Please customize the repository address/name for your own.
|
||||||
|
echo
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
echo Building "${BUILD_TAG}" package ..
|
||||||
|
echo
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
echo Changing to project root directory...
|
||||||
|
|
||||||
|
pushd ../.. > /dev/null
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
echo Determining tag name...
|
||||||
|
|
||||||
|
branch=$(git branch --show-current)
|
||||||
|
tag=temp
|
||||||
|
|
||||||
|
[[ $branch == dev ]] && tag=testing
|
||||||
|
[[ $branch == main ]] && tag=latest
|
||||||
|
[[ $branch == master ]] && tag=latest
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
echo Building image...
|
||||||
|
|
||||||
|
|
||||||
|
docker buildx build . \
|
||||||
|
--tag ${PACKAGE_REPOSITORY}:$tag \
|
||||||
|
--build-arg GROUP_ID=${GID} \
|
||||||
|
--build-arg USER_ID=${UID} \
|
||||||
|
--file build/${BUILD_TAG}/Dockerfile \
|
||||||
|
--platform linux/amd64,linux/arm64 \
|
||||||
|
--provenance=false \
|
||||||
|
--sbom=false \
|
||||||
|
# --push \
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
echo Changing back to build directory...
|
||||||
|
|
||||||
|
popd > /dev/null
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
echo Done.
|
||||||
15
res/docker-compose.yaml
Normal file
15
res/docker-compose.yaml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
---
|
||||||
|
services:
|
||||||
|
discord-bot:
|
||||||
|
image: proxima.goliath.hu/proxima/discord-bot:latest
|
||||||
|
container_name: bot
|
||||||
|
volumes:
|
||||||
|
- "./volumes/bot/env/.env:/app/Bot/.env:ro"
|
||||||
|
- "./volumes/bot/storage:/app/Bot/Storage"
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- proxima
|
||||||
|
|
||||||
|
networks:
|
||||||
|
proxima:
|
||||||
|
external: true
|
||||||
BIN
res/first_step_1.png
Normal file
BIN
res/first_step_1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.2 KiB |
BIN
res/first_step_2.png
Normal file
BIN
res/first_step_2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
706
res/logo.svg
Normal file
706
res/logo.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 29 KiB |
35
src/.env.example
Normal file
35
src/.env.example
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
### discord provided configuration
|
||||||
|
# see: https://discord.com/developers/applications
|
||||||
|
|
||||||
|
# the application id of the bot
|
||||||
|
APPLICATION_ID=<BOT_APPLICATION_ID>
|
||||||
|
|
||||||
|
# the authentication token of the bot
|
||||||
|
TOKEN=<BOT_AUTHENTICATION_TOKEN>
|
||||||
|
|
||||||
|
# the public key of the bot
|
||||||
|
PUBLIC_KEY=<BOT_PUBLIC_KEY>
|
||||||
|
|
||||||
|
|
||||||
|
### bot dedicated discord server configuration
|
||||||
|
|
||||||
|
# the server reserved for the bot development/managment/control/etc.
|
||||||
|
HOME_SERVER_ID=<BOT_HOME_SERVER_ID>
|
||||||
|
|
||||||
|
# the bot will send all errors/warning/etc. to this channel
|
||||||
|
LOG_CHANNEL_ID=<BOT_LOG_CHANNLE_ID>
|
||||||
|
|
||||||
|
|
||||||
|
### backend provided configuration
|
||||||
|
|
||||||
|
# the url of the backend api endpoints ex.: https://backend.example.com/api/v1/
|
||||||
|
API_URL=<BACKEND_API_URL>
|
||||||
|
|
||||||
|
# the authentication token provided by the backend
|
||||||
|
BACKEND_TOKEN=<BACKEND_AUTHENTICATION_TOKEN>
|
||||||
|
|
||||||
|
|
||||||
|
### general configuration
|
||||||
|
|
||||||
|
# The number of seconds after a cached item should expire. Default:30 sec
|
||||||
|
CACHE_TTL=30
|
||||||
32
src/.gitignore
vendored
Normal file
32
src/.gitignore
vendored
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
### PHP-CS-Fixer
|
||||||
|
.php-cs-fixer.cache
|
||||||
|
#.php-cs-fixer.php
|
||||||
|
|
||||||
|
### Composer
|
||||||
|
composer.phar
|
||||||
|
/vendor/
|
||||||
|
#composer.lock
|
||||||
|
|
||||||
|
### JetBrains IDEs
|
||||||
|
/.idea/
|
||||||
|
|
||||||
|
### VSCode
|
||||||
|
/.vscode/
|
||||||
|
|
||||||
|
### PHPUnit
|
||||||
|
/.phpunit.cache/
|
||||||
|
|
||||||
|
|
||||||
|
### Misc
|
||||||
|
#.env
|
||||||
|
Core/HMR/Cached
|
||||||
|
|
||||||
|
|
||||||
|
debug/
|
||||||
|
|
||||||
|
Storage/smarty/cache
|
||||||
|
Storage/smarty/templates_c
|
||||||
|
|
||||||
|
Logs/
|
||||||
|
|
||||||
|
.env
|
||||||
33
src/.php-cs-fixer.php
Normal file
33
src/.php-cs-fixer.php
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return (new PhpCsFixer\Config())
|
||||||
|
->setRules([
|
||||||
|
'@PER-CS' => true,
|
||||||
|
'@PHP82Migration' => true,
|
||||||
|
'new_with_parentheses' => [
|
||||||
|
'anonymous_class' => false,
|
||||||
|
],
|
||||||
|
'braces_position' => [
|
||||||
|
'anonymous_classes_opening_brace' => 'next_line_unless_newline_at_signature_end',
|
||||||
|
],
|
||||||
|
'function_declaration' => [
|
||||||
|
'closure_fn_spacing' => 'one',
|
||||||
|
'closure_function_spacing' => 'one',
|
||||||
|
],
|
||||||
|
'single_trait_insert_per_statement' => false,
|
||||||
|
'no_blank_lines_after_class_opening' => false,
|
||||||
|
|
||||||
|
])
|
||||||
|
->setFinder((new PhpCsFixer\Finder())
|
||||||
|
->in(__DIR__)
|
||||||
|
->exclude([
|
||||||
|
'Bootstrap', // skip original package files
|
||||||
|
'Core', // skip original package files
|
||||||
|
'Storage/Smarty', // skip temporary files
|
||||||
|
])
|
||||||
|
->notPath([
|
||||||
|
'BotDev.php', // skip original package files
|
||||||
|
'Client/ClientMessages.php', // fixer don't understand template, would messing up sapcing
|
||||||
|
])
|
||||||
|
)
|
||||||
|
;
|
||||||
32
src/Bootstrap/Commands.php
Normal file
32
src/Bootstrap/Commands.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Core\Commands\Command;
|
||||||
|
use Core\Commands\CommandQueue;
|
||||||
|
use Core\Commands\QueuedCommand;
|
||||||
|
use Core\Disabled;
|
||||||
|
|
||||||
|
use function Core\debug;
|
||||||
|
use function Core\discord;
|
||||||
|
use function Core\doesClassHaveAttribute;
|
||||||
|
use function Core\error;
|
||||||
|
use function Core\loopClasses;
|
||||||
|
|
||||||
|
$commandQueue = new CommandQueue();
|
||||||
|
$discord = discord();
|
||||||
|
loopClasses(BOT_ROOT . '/Commands', static function (string $className) use ($commandQueue) {
|
||||||
|
debug('Loading Command: ' . $className);
|
||||||
|
|
||||||
|
$attribute = doesClassHaveAttribute($className, Command::class);
|
||||||
|
$disabled = doesClassHaveAttribute($className, Disabled::class);
|
||||||
|
|
||||||
|
if (!$attribute || $disabled !== false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$commandQueue->appendCommand(new QueuedCommand(
|
||||||
|
$attribute->newInstance(),
|
||||||
|
new $className()
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
$commandQueue->runQueue(registerCommands: Config::AUTO_REGISTER_COMMANDS)->otherwise(static fn (Throwable $e) => error($e->getMessage()));
|
||||||
7
src/Bootstrap/Config.php
Normal file
7
src/Bootstrap/Config.php
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
class Config
|
||||||
|
{
|
||||||
|
public const AUTO_REGISTER_COMMANDS = true;
|
||||||
|
public const AUTO_DELETE_COMMANDS = true;
|
||||||
|
}
|
||||||
27
src/Bootstrap/Discord.php
Normal file
27
src/Bootstrap/Discord.php
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Core\Env;
|
||||||
|
use Discord\Discord;
|
||||||
|
use Discord\WebSockets\Intents;
|
||||||
|
use Bot\DiscordBot;
|
||||||
|
use Services\ReminderService;
|
||||||
|
|
||||||
|
|
||||||
|
use function Core\debug;
|
||||||
|
use function Core\discord as d;
|
||||||
|
|
||||||
|
Env::get()->remainderService = new ReminderService();
|
||||||
|
|
||||||
|
Env::get()->bot = DiscordBot::getInstance();
|
||||||
|
|
||||||
|
Env::get()->discord = new Discord([
|
||||||
|
'token' => Env::get()->TOKEN,
|
||||||
|
'intents' => Intents::getAllIntents(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
require_once BOT_ROOT . '/Bootstrap/Events.php';
|
||||||
|
|
||||||
|
d()->on('init', static function (Discord $discord) {
|
||||||
|
debug('Bootstrapping Commands...');
|
||||||
|
require_once BOT_ROOT . '/Bootstrap/Commands.php';
|
||||||
|
});
|
||||||
15
src/Bootstrap/Environment.php
Normal file
15
src/Bootstrap/Environment.php
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Core\Env;
|
||||||
|
|
||||||
|
//NOTE: remove comment lines from the .env file
|
||||||
|
$rawEnvFileContents = file_get_contents(BOT_ROOT . '/.env');
|
||||||
|
$filteredEnvFileContents = preg_replace('/^#.*$/m', '', $rawEnvFileContents);
|
||||||
|
|
||||||
|
$env = Env::createFromString($filteredEnvFileContents);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if (!isset($env->TOKEN)) {
|
||||||
|
throw new RuntimeException('No token supplied to environment!');
|
||||||
|
}
|
||||||
44
src/Bootstrap/Events.php
Normal file
44
src/Bootstrap/Events.php
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Core\Disabled;
|
||||||
|
use Core\Events\Event;
|
||||||
|
|
||||||
|
use function Core\discord;
|
||||||
|
use function Core\doesClassHaveAttribute;
|
||||||
|
use function Core\loopClasses;
|
||||||
|
|
||||||
|
$events = [];
|
||||||
|
$discord = discord();
|
||||||
|
|
||||||
|
loopClasses(BOT_ROOT . '/Core/Events', static function (string $className) use (&$events) {
|
||||||
|
if (!interface_exists($className) || $className === Event::class) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$attribute = doesClassHaveAttribute($className, Event::class);
|
||||||
|
|
||||||
|
if (!$attribute) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$events[$className] = $attribute->newInstance()->name;
|
||||||
|
});
|
||||||
|
|
||||||
|
loopClasses(BOT_ROOT . '/Events', static function (string $className) use ($events, $discord) {
|
||||||
|
if (doesClassHaveAttribute($className, Disabled::class) !== false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$event = new $className();
|
||||||
|
$reflection = new ReflectionClass($event);
|
||||||
|
|
||||||
|
foreach ($reflection->getInterfaceNames() as $interface) {
|
||||||
|
$eventName = $events['\\' . $interface] ?? null;
|
||||||
|
|
||||||
|
if ($eventName === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$discord->on($eventName, $event->handle(...));
|
||||||
|
}
|
||||||
|
});
|
||||||
7
src/Bootstrap/Requires.php
Normal file
7
src/Bootstrap/Requires.php
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
require_once BOT_ROOT . '/vendor/autoload.php';
|
||||||
|
require_once BOT_ROOT . '/Bootstrap/Config.php';
|
||||||
|
require_once BOT_ROOT . '/Bootstrap/Environment.php';
|
||||||
|
require_once BOT_ROOT . '/Services/ReminderService.php';
|
||||||
|
require_once BOT_ROOT . '/Bootstrap/Discord.php';
|
||||||
10
src/Bot.php
Normal file
10
src/Bot.php
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use function Core\discord;
|
||||||
|
|
||||||
|
const BOT_ROOT = __DIR__;
|
||||||
|
define('BOT_BUILD', trim(file_get_contents(BOT_ROOT . DIRECTORY_SEPARATOR . 'version')));
|
||||||
|
|
||||||
|
require_once __DIR__ . '/Bootstrap/Requires.php';
|
||||||
|
|
||||||
|
discord()->run(); // Run the bot
|
||||||
176
src/Bot/Cache.php
Normal file
176
src/Bot/Cache.php
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Bot;
|
||||||
|
|
||||||
|
use Client\ApiClient;
|
||||||
|
use Client\Models\DiscordUser;
|
||||||
|
use Client\Responses\DiscordUserResponse;
|
||||||
|
use Client\Responses\RemainderListResponse;
|
||||||
|
use Client\Traits\Singleton;
|
||||||
|
use Discord\Helpers\Deferred;
|
||||||
|
use React\Http\Message\Response;
|
||||||
|
use React\Promise\PromiseInterface;
|
||||||
|
|
||||||
|
use function Core\env;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Memory Cache Object
|
||||||
|
*/
|
||||||
|
class Cache
|
||||||
|
{
|
||||||
|
use Singleton;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var ObjectCache DiscordUser cache
|
||||||
|
*/
|
||||||
|
|
||||||
|
protected ObjectCache $discordUsers;
|
||||||
|
/**
|
||||||
|
* @var ObjectCache Remainder[] cache
|
||||||
|
*/
|
||||||
|
|
||||||
|
protected ObjectCache $remainderLists;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->discordUsers = new ObjectCache(env()->CACHE_TTL);
|
||||||
|
$this->remainderLists = new ObjectCache(env()->CACHE_TTL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Retrives the DiscordUser object from the cache
|
||||||
|
*
|
||||||
|
* If the DiscordUser object is in the cache, returns it,
|
||||||
|
* otherwise fetches it from the backand beforhand, and then returns it
|
||||||
|
*
|
||||||
|
* @param DiscordUser $discordUser
|
||||||
|
*
|
||||||
|
* @return PromiseInterface
|
||||||
|
* @promise-fulfilled fn (DiscordUser $discordUser): void
|
||||||
|
* @promise-rejected fn (mixed $reason): void
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public function getDiscordUser(DiscordUser $discordUser): PromiseInterface
|
||||||
|
{
|
||||||
|
$deferred = new Deferred();
|
||||||
|
|
||||||
|
$result = $this->discordUsers->get($discordUser->snowflake);
|
||||||
|
|
||||||
|
// if it is already in cache, return it
|
||||||
|
if (null !== $result) {
|
||||||
|
$deferred->resolve($result);
|
||||||
|
return $deferred->promise();
|
||||||
|
}
|
||||||
|
|
||||||
|
//not in cache, request it from the backend and cache it
|
||||||
|
ApiClient::getInstance()->identifyDiscordUser($discordUser)->then(
|
||||||
|
onFulfilled: function (Response $response) use ($deferred): void {
|
||||||
|
$apiResponse = DiscordUserResponse::make($response);
|
||||||
|
|
||||||
|
$this->storeDiscordUser($apiResponse->discordUser);
|
||||||
|
|
||||||
|
$deferred->resolve($apiResponse->discordUser);
|
||||||
|
},
|
||||||
|
onRejected: fn ($error) => $deferred->reject($error)
|
||||||
|
);
|
||||||
|
|
||||||
|
return $deferred->promise();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Stores the DiscordUser obect in the cache
|
||||||
|
*
|
||||||
|
* @param DiscordUser $discordUser
|
||||||
|
*
|
||||||
|
* @return DiscordUser The stored DiscordUser object
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public function storeDiscordUser(DiscordUser $discordUser): DiscordUser
|
||||||
|
{
|
||||||
|
return $this->discordUsers->store($discordUser->snowflake, $discordUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Removes the DiscordUser object from the cache
|
||||||
|
*
|
||||||
|
* @param DiscordUser $discordUser
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public function forgetDiscordUser(DiscordUser $discordUser): void
|
||||||
|
{
|
||||||
|
$this->discordUsers->forget($discordUser->snowflake);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Retrives the Remainder[] array from the cache
|
||||||
|
*
|
||||||
|
* @param DiscordUser $discordUser
|
||||||
|
*
|
||||||
|
* @return PromiseInterface
|
||||||
|
* @promise-fulfilled fn (array $remainders): void
|
||||||
|
* @promise-rejected fn (mixed $reason): void
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public function getRemainderList(DiscordUser $discordUser): PromiseInterface
|
||||||
|
{
|
||||||
|
$deferred = new Deferred();
|
||||||
|
|
||||||
|
$result = $this->remainderLists->get($discordUser->snowflake);
|
||||||
|
|
||||||
|
// if it is already in cache, return it
|
||||||
|
if (null !== $result) {
|
||||||
|
$deferred->resolve($result);
|
||||||
|
return $deferred->promise();
|
||||||
|
|
||||||
|
}
|
||||||
|
//not in cache, request it from the backend and cache it
|
||||||
|
ApiClient::getInstance()->getRemainders($discordUser)->then(
|
||||||
|
onFulfilled: function (Response $response) use ($deferred, $discordUser): void {
|
||||||
|
$apiResponse = RemainderListResponse::make($response);
|
||||||
|
|
||||||
|
$this->storeRemainderList($discordUser, $apiResponse->remainderList);
|
||||||
|
|
||||||
|
$deferred->resolve($apiResponse->remainderList);
|
||||||
|
},
|
||||||
|
onRejected: fn ($error) => $deferred->reject($error)
|
||||||
|
);
|
||||||
|
|
||||||
|
return $deferred->promise();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Stores the Remaindre[] array in the cache
|
||||||
|
*
|
||||||
|
* @param DiscordUser $discordUser
|
||||||
|
* @param array $remainderList
|
||||||
|
*
|
||||||
|
* @return array The stored Reaminder[] list
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public function storeRemainderList(DiscordUser $discordUser, array $remainderList): array
|
||||||
|
{
|
||||||
|
return $this->remainderLists->store($discordUser->snowflake, $remainderList);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Removes the Remainder[] array from the cache
|
||||||
|
*
|
||||||
|
* @param DiscordUser $discordUser
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public function forgetRemainderList(DiscordUser $discordUser): void
|
||||||
|
{
|
||||||
|
$this->remainderLists->forget($discordUser->snowflake);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
79
src/Bot/CacheItem.php
Normal file
79
src/Bot/CacheItem.php
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Bot;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache item to store data with expiration date
|
||||||
|
*/
|
||||||
|
class CacheItem
|
||||||
|
{
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var int the unix timestamp when the item was created
|
||||||
|
*/
|
||||||
|
protected int $created;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var int the unix timestamp when the item will expire
|
||||||
|
*/
|
||||||
|
protected int $expires;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Description for __construct]
|
||||||
|
*
|
||||||
|
* @param mixed $data the data to store
|
||||||
|
* @param int $ttl the 'time to live' interval for the data in seconds
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
protected mixed $data,
|
||||||
|
protected int $ttl
|
||||||
|
) {
|
||||||
|
$this->created = time();
|
||||||
|
$this->expires = $this->created + $this->ttl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets the time of expiration to current time + ttl
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public function refresh(): void
|
||||||
|
{
|
||||||
|
$this->expires = time() + $this->ttl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the data is expired
|
||||||
|
*
|
||||||
|
* @return bool true, if the data is expired, false otherwise
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public function isExpired(): bool
|
||||||
|
{
|
||||||
|
return time() > $this->expires;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the remaining ttl of the data
|
||||||
|
*
|
||||||
|
* @return int the seconds until the data expires
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public function ttl(): int
|
||||||
|
{
|
||||||
|
return $this->expires - time();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the stored data
|
||||||
|
*
|
||||||
|
* @return mixed
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public function getData(): mixed
|
||||||
|
{
|
||||||
|
return $this->data;
|
||||||
|
}
|
||||||
|
}
|
||||||
102
src/Bot/DevLogger.php
Normal file
102
src/Bot/DevLogger.php
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Bot;
|
||||||
|
|
||||||
|
use Client\Traits\Singleton;
|
||||||
|
use Monolog\Formatter\JsonFormatter;
|
||||||
|
use Monolog\Handler\StreamHandler;
|
||||||
|
use Monolog\Level;
|
||||||
|
use Monolog\Logger;
|
||||||
|
use Monolog\Processor\GitProcessor;
|
||||||
|
use Monolog\Processor\IntrospectionProcessor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a JSON log for the developer(s)
|
||||||
|
*
|
||||||
|
* @method static void debug(string|\Stringable $message, array $context = [])
|
||||||
|
* @method static void info(string|\Stringable $message, array $context = [])
|
||||||
|
* @method static void notice(string|\Stringable $message, array $context = [])
|
||||||
|
* @method static void warning(string|\Stringable $message, array $context = [])
|
||||||
|
* @method static void error(string|\Stringable $message, array $context = [])
|
||||||
|
* @method static void critical(string|\Stringable $message, array $context = [])
|
||||||
|
* @method static void alert(string|\Stringable $message, array $context = [])
|
||||||
|
* @method static void emergency(string|\Stringable $message, array $context = [])
|
||||||
|
*/
|
||||||
|
class DevLogger
|
||||||
|
{
|
||||||
|
use Singleton;
|
||||||
|
|
||||||
|
protected Logger $logger;
|
||||||
|
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
private function __construct()
|
||||||
|
{
|
||||||
|
$this->logger = new Logger('dev');
|
||||||
|
|
||||||
|
$handler = new StreamHandler(BOT_ROOT . '/Storage/Logs/dev.log');
|
||||||
|
$handler->setFormatter(new JsonFormatter());
|
||||||
|
|
||||||
|
$this->logger->pushHandler($handler);
|
||||||
|
$this->logger->pushProcessor(new GitProcessor());
|
||||||
|
$this->logger->pushProcessor(new IntrospectionProcessor(skipClassesPartials: [
|
||||||
|
__CLASS__,
|
||||||
|
'Bot\\DiscordBot',
|
||||||
|
'React\\Promise\\RejectedPromise',
|
||||||
|
'React\\Promise\\Promise',
|
||||||
|
'React\\Promise\\Deferred',
|
||||||
|
]));
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Makes a log entry
|
||||||
|
*
|
||||||
|
* @param string $level The lavel the log should be marked
|
||||||
|
* @param string|\Stringable $message The message to be logged
|
||||||
|
* @param array $context All additional data to be logged
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
protected function log(string $level, string|\Stringable $message, array $context = []): void
|
||||||
|
{
|
||||||
|
$this->logger->log($level, $message, $context);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Handles all the level variations and logs them
|
||||||
|
*
|
||||||
|
* For a list of available levels, see the docblock on top of the class
|
||||||
|
*
|
||||||
|
* @static
|
||||||
|
* @param mixed $method
|
||||||
|
* @param mixed $args
|
||||||
|
*
|
||||||
|
* @throws \BadMethodCallException
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public static function __callStatic($method, $args): void
|
||||||
|
{
|
||||||
|
// get the list of available levels
|
||||||
|
$logFunctions = array_map('strtolower', Level::NAMES);
|
||||||
|
|
||||||
|
// log if method is valid
|
||||||
|
if (in_array($method, $logFunctions)) {
|
||||||
|
static::getInstance()->log($method, ...$args);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// inform the developer of a bad level call
|
||||||
|
$message = "Bad programmer detected, a non existing function got called: \"{$method}\". Please correct the code.";
|
||||||
|
static::getInstance()->log('critical', $message);
|
||||||
|
|
||||||
|
throw new \BadMethodCallException($message, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
224
src/Bot/DiscordBot.php
Normal file
224
src/Bot/DiscordBot.php
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Bot;
|
||||||
|
|
||||||
|
use Client\ApiResponse;
|
||||||
|
use Client\ClientMessages;
|
||||||
|
use Client\Models\DiscordUser;
|
||||||
|
use Client\Template;
|
||||||
|
use Client\Traits\AssureTimezoneSet;
|
||||||
|
use Client\Traits\HasCache;
|
||||||
|
use Client\Traits\HasApiClient;
|
||||||
|
use Client\Traits\HasDiscord;
|
||||||
|
use Client\Traits\HasTemplate;
|
||||||
|
use Client\Traits\Singleton;
|
||||||
|
use Discord\Helpers\Deferred;
|
||||||
|
use Discord\Parts\Interactions\Interaction;
|
||||||
|
use Exception;
|
||||||
|
use React\Promise\PromiseInterface;
|
||||||
|
|
||||||
|
use function Core\messageWithContent;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper object for the bot
|
||||||
|
*
|
||||||
|
* @singleton
|
||||||
|
*/
|
||||||
|
class DiscordBot
|
||||||
|
{
|
||||||
|
|
||||||
|
use AssureTimezoneSet, Singleton, HasApiClient, HasDiscord, HasTemplate, HasCache;
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
private function __construct()
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Responds to an interaction with an ansi colored message
|
||||||
|
*
|
||||||
|
* @param Interaction $interaction The interaction to resopnd to
|
||||||
|
* @param string $template The smarty template to respond with
|
||||||
|
* @param array $variables The variables for the smarty template (colors are already loaded)
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public static function respondToInteraction(Interaction $interaction, string $template, array $variables = []): void
|
||||||
|
{
|
||||||
|
// try to respond
|
||||||
|
try {
|
||||||
|
$interaction->respondWithMessage(messageWithContent(Template::ansi($template, $variables)));
|
||||||
|
} catch (Exception $exception) {
|
||||||
|
|
||||||
|
// log the error
|
||||||
|
DevLogger::error(
|
||||||
|
message: 'respondToInteraction failed',
|
||||||
|
context: [
|
||||||
|
'exception' => $exception,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
self::failInteraction($interaction);
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Sends an error back to the discord client
|
||||||
|
*
|
||||||
|
* @param Interaction $interaction
|
||||||
|
* @param array $variables
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public static function failInteraction(Interaction $interaction, array $variables = []): void
|
||||||
|
{
|
||||||
|
static::respondToInteraction(
|
||||||
|
interaction: $interaction,
|
||||||
|
template: ClientMessages::errorGeneralError,
|
||||||
|
variables: $variables
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Fails the interaction and sends an error back to the discord client
|
||||||
|
*
|
||||||
|
* @param Interaction $interaction The interaction to resopnd to
|
||||||
|
* @param Exception $exception The exception that caused the failure
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public static function failApiRequestWithException(Interaction $interaction, Exception $exception)
|
||||||
|
{
|
||||||
|
DevLogger::warning(
|
||||||
|
message: 'Api request failed',
|
||||||
|
context: [
|
||||||
|
'exception' => $exception,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
static::respondToInteraction(
|
||||||
|
interaction: $interaction,
|
||||||
|
template: ClientMessages::errorGeneralError,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Fails the interaction and sends an error back to the discord client
|
||||||
|
*
|
||||||
|
* @param Interaction $interaction The interaction to resopnd to
|
||||||
|
* @param ApiResponse $apiResponse The ApiResponse that caused the failure
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public static function failApiRequestWithApiResponse(Interaction $interaction, ApiResponse $apiResponse)
|
||||||
|
{
|
||||||
|
DevLogger::warning(
|
||||||
|
message: 'Api request failed',
|
||||||
|
context: [
|
||||||
|
'response' => $apiResponse->toJsonLogData(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
static::respondToInteraction(
|
||||||
|
interaction: $interaction,
|
||||||
|
template: ClientMessages::errorGeneralError,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Sends a fail message to the discord client.
|
||||||
|
*
|
||||||
|
* @param Interaction $interaction The interaction with the discord client.
|
||||||
|
* @param ApiResponse|Exception $reason The reason the api call failed.
|
||||||
|
*
|
||||||
|
* @return mixed This may or may not return anything.
|
||||||
|
* NOTE: not set the return type to void so it can be used in arrow functions.
|
||||||
|
* NOTE: this is a wrapper to simulate methode overloading.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public static function failApirequest(Interaction $interaction, ApiResponse|Exception $reason)
|
||||||
|
{
|
||||||
|
return match (true) {
|
||||||
|
is_a($reason, Exception::class) => self::failApiRequestWithException($interaction, $reason),
|
||||||
|
is_a($reason, ApiResponse::class) => self::failApiRequestWithApiResponse($interaction, $reason),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a standardised dynamic event handler for an PromiseInterface onReject event
|
||||||
|
*
|
||||||
|
* @param Interaction $interaction
|
||||||
|
*
|
||||||
|
* @return callable
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public static function onPromiseRejected(Interaction $interaction): callable
|
||||||
|
{
|
||||||
|
return fn (ApiResponse|Exception $reason) => self::failApiRequest($interaction, $reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Returns a DiscordUsers with it's $remainder property populated
|
||||||
|
*
|
||||||
|
* This uses sepatare API calls for the DiscordUser and Remainder[], so they can be used/cached separatly
|
||||||
|
*
|
||||||
|
* @param Interaction $interaction
|
||||||
|
* @param DiscordUser $discordUser
|
||||||
|
*
|
||||||
|
* @return PromiseInterface
|
||||||
|
* @promise-fulfilled fn (DiscordUser $discordUser): void
|
||||||
|
* @promise-rejected fn (mixed $reason): void
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public function getDiscordUserRemainders(Interaction $interaction, DiscordUser $discordUser): PromiseInterface
|
||||||
|
{
|
||||||
|
|
||||||
|
$deferred = new Deferred();
|
||||||
|
|
||||||
|
// get the DiscordUser
|
||||||
|
$this->getCache()->getDiscordUser($discordUser)->then(
|
||||||
|
onFulfilled: function (DiscordUser $discordUser) use ($interaction, $deferred): void {
|
||||||
|
|
||||||
|
// fail and send error message to the discord client if the discorduser does not have a valid timezone
|
||||||
|
if ($this->failIfTimezoneNotSet($interaction, $discordUser)) {
|
||||||
|
$deferred->reject(new Exception("DiscordUser has no timezone set."));
|
||||||
|
}
|
||||||
|
|
||||||
|
// get the Remainders
|
||||||
|
$this->getCache()->getRemainderList($discordUser)->then(
|
||||||
|
onFulfilled: function (array $reaminders) use ($discordUser, $deferred): void {
|
||||||
|
$discordUser->remainders = $reaminders;
|
||||||
|
$deferred->resolve($discordUser);
|
||||||
|
},
|
||||||
|
onRejected: DiscordBot::onPromiseRejected($interaction) // getRemainderList
|
||||||
|
);
|
||||||
|
|
||||||
|
},
|
||||||
|
onRejected: DiscordBot::onPromiseRejected($interaction) // getDiscordUser
|
||||||
|
);
|
||||||
|
|
||||||
|
return $deferred->promise();
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the preferred dtaetime format.
|
||||||
|
*
|
||||||
|
* @return string the datetime format
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public static function getDateTimeFormat(): string
|
||||||
|
{
|
||||||
|
return 'Y-m-d H:i';
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
89
src/Bot/ObjectCache.php
Normal file
89
src/Bot/ObjectCache.php
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Bot;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Memory Cache for key=>value pair data
|
||||||
|
*/
|
||||||
|
class ObjectCache
|
||||||
|
{
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array The cached objects
|
||||||
|
*/
|
||||||
|
protected array $data = [];
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instantiates a new ObjectCache object
|
||||||
|
*
|
||||||
|
* @param int $ttl time to live for an item in the caches
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public function __construct(protected int $ttl = 30)
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Store the provided data
|
||||||
|
*
|
||||||
|
* If the key already exists, the data will be replaced
|
||||||
|
*
|
||||||
|
* @param mixed $key
|
||||||
|
* @param mixed $data
|
||||||
|
*
|
||||||
|
* @return mixed Returns the stored object
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public function store(mixed $key, mixed $data): mixed
|
||||||
|
{
|
||||||
|
$item = new CacheItem($data, $this->ttl);
|
||||||
|
$this->data[$key] = $item;
|
||||||
|
return $item->getData();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Removes the stored data from the cache
|
||||||
|
*
|
||||||
|
* @param mixed $key
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public function forget(mixed $key): void
|
||||||
|
{
|
||||||
|
if (array_key_exists($key, $this->data)) {
|
||||||
|
unset($this->data[$key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Retrives the stored data from the cache
|
||||||
|
*
|
||||||
|
* @param mixed $key The key that was used to store the data
|
||||||
|
*
|
||||||
|
* @return mixed The store data if exists, null otherwise
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public function get(mixed $key): mixed
|
||||||
|
{
|
||||||
|
if (array_key_exists($key, $this->data)) {
|
||||||
|
|
||||||
|
$item = $this->data[$key];
|
||||||
|
|
||||||
|
if ($item->isExpired()) {
|
||||||
|
$this->forget($key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $item->getData();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
161
src/BotDev.php
Normal file
161
src/BotDev.php
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/** @noinspection FileClassnameCaseInspection */
|
||||||
|
|
||||||
|
use Core\HMR\HotDirectory;
|
||||||
|
use React\EventLoop\Loop;
|
||||||
|
use React\EventLoop\TimerInterface;
|
||||||
|
use React\Promise\Promise;
|
||||||
|
use React\Promise\PromiseInterface;
|
||||||
|
|
||||||
|
use function React\Async\await;
|
||||||
|
|
||||||
|
require_once __DIR__ . '/vendor/autoload.php';
|
||||||
|
|
||||||
|
class Command
|
||||||
|
{
|
||||||
|
protected const TEMP = __DIR__ . '/temp';
|
||||||
|
|
||||||
|
private array $process = [];
|
||||||
|
private int $stdoutPos = 0;
|
||||||
|
private int $stderrPos = 0;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $command,
|
||||||
|
public readonly array $args = [],
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(string $command, array $args = []): self
|
||||||
|
{
|
||||||
|
return new self($command, $args);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function procExecute(string $command): array
|
||||||
|
{
|
||||||
|
$stdout = tempnam(sys_get_temp_dir(), 'dphp');
|
||||||
|
$stderr = tempnam(sys_get_temp_dir(), 'dphp');
|
||||||
|
$process = proc_open(
|
||||||
|
$command,
|
||||||
|
[
|
||||||
|
1 => ['file', $stdout, 'w'],
|
||||||
|
2 => ['file', $stderr, 'w'],
|
||||||
|
],
|
||||||
|
$pipes
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'files' => [$stdout, $stderr],
|
||||||
|
'command' => $command,
|
||||||
|
'process' => &$process,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function execute(): PromiseInterface
|
||||||
|
{
|
||||||
|
if ($this->isRunning()) {
|
||||||
|
throw new LogicException('Command is already running');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise(function ($resolve, $reject) {
|
||||||
|
$this->process = $this->procExecute($this->command . ' ' . implode(' ', $this->args));
|
||||||
|
Loop::addPeriodicTimer(
|
||||||
|
1,
|
||||||
|
function (TimerInterface $timer) use (&$stdout, &$stderr, $reject, $resolve) {
|
||||||
|
$status = proc_get_status($this->process['process']);
|
||||||
|
$stdout = file_get_contents($this->process['files'][0]);
|
||||||
|
$stderr = file_get_contents($this->process['files'][1]);
|
||||||
|
|
||||||
|
if ($status['running']) {
|
||||||
|
if ($this->stdoutPos < strlen($stdout)) {
|
||||||
|
echo substr($stdout, $this->stdoutPos);
|
||||||
|
$this->stdoutPos = strlen($stdout);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->stderrPos < strlen($stderr)) {
|
||||||
|
echo substr($stderr, $this->stderrPos);
|
||||||
|
$this->stderrPos = strlen($stderr);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($status['exitcode'] !== 0) {
|
||||||
|
$reject([$stderr, $this->command, $this->args]);
|
||||||
|
} else {
|
||||||
|
$resolve($stdout, $stderr);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->process = [];
|
||||||
|
|
||||||
|
Loop::cancelTimer($timer);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isRunning(): bool
|
||||||
|
{
|
||||||
|
return $this->process !== [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getProcess(): array
|
||||||
|
{
|
||||||
|
return $this->process;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function kill(): void
|
||||||
|
{
|
||||||
|
if (!$this->isRunning()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->stdoutPos = $this->stderrPos = 0;
|
||||||
|
|
||||||
|
proc_terminate($this->process['process']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$directory = new HotDirectory(__DIR__);
|
||||||
|
|
||||||
|
$restart = static function () use (&$command) {
|
||||||
|
$command ??= new Command('php', ['Bot.php']);
|
||||||
|
$time = date('H:i:s');
|
||||||
|
echo "\nRestarting bot ({$time})...\n";
|
||||||
|
|
||||||
|
$command->kill();
|
||||||
|
|
||||||
|
await(new Promise(static function ($resolve, $reject) use (&$command) {
|
||||||
|
Loop::addPeriodicTimer(2, static function (TimerInterface $timer) use (&$command, $resolve) {
|
||||||
|
if ($command->isRunning()) {
|
||||||
|
echo "Command is still running\n";
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Loop::cancelTimer($timer);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolve();
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
$command->execute();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
echo "Error: {$e->getMessage()}\n";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$restart();
|
||||||
|
|
||||||
|
$directory->on(HotDirectory::EVENT_FILE_ADDED, $restart(...));
|
||||||
|
$directory->on(HotDirectory::EVENT_FILE_CHANGED, $restart(...));
|
||||||
|
$directory->on(HotDirectory::EVENT_FILE_REMOVED, $restart(...));
|
||||||
|
|
||||||
|
try {
|
||||||
|
Loop::run();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Loop::run();
|
||||||
|
}
|
||||||
240
src/Client/ApiClient.php
Normal file
240
src/Client/ApiClient.php
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Client;
|
||||||
|
|
||||||
|
use Client\Models\DiscordUser;
|
||||||
|
use Client\Models\Remainder;
|
||||||
|
use Client\Traits\HasDiscord;
|
||||||
|
use Client\Traits\Singleton;
|
||||||
|
use React\Http\Browser;
|
||||||
|
use React\Promise\PromiseInterface;
|
||||||
|
|
||||||
|
use function Core\env;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class to comunicase to the backend API
|
||||||
|
* @singleton
|
||||||
|
*/
|
||||||
|
class ApiClient
|
||||||
|
{
|
||||||
|
use HasDiscord, Singleton;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The base URL of the API backend
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected string $baseUrl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The React Browser to be used to query the backend API
|
||||||
|
*
|
||||||
|
* @var Browser|null
|
||||||
|
*/
|
||||||
|
protected ?Browser $client = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The token to authorize the requests to the backend API
|
||||||
|
*
|
||||||
|
* @var string|null|null
|
||||||
|
*/
|
||||||
|
protected ?string $token = null;
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
private function __construct()
|
||||||
|
{
|
||||||
|
$this->token = env()->BACKEND_TOKEN;
|
||||||
|
$this->baseUrl = env()->API_URL;
|
||||||
|
|
||||||
|
$this->client = (new Browser(null, $this->getDiscord()->getLoop()))
|
||||||
|
->withBase($this->baseUrl)
|
||||||
|
->withHeader('Accept', 'application/json')
|
||||||
|
->withHeader('Content-Type', 'application/json')
|
||||||
|
->withHeader('Authorization', "Bearer $this->token");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Fetches DiscordUser identified by snowflake from backend.
|
||||||
|
*
|
||||||
|
* @see /docs#discord-user-by-snowflake-managment-GETapi-v1-discord-user-by-snowflake--discord_user_snowflake-
|
||||||
|
*
|
||||||
|
* @param string $snowflake
|
||||||
|
*
|
||||||
|
* @return PromiseInterface
|
||||||
|
* @api-response DiscordUserResponse
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public function getDiscordBySnowflake(string $snowflake): PromiseInterface
|
||||||
|
{
|
||||||
|
return $this->client->withRejectErrorResponse(true)->get(
|
||||||
|
url: "discord-user-by-snowflake/$snowflake"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Fetches DiscordUser identified by snowflake from backend.
|
||||||
|
*
|
||||||
|
* If the DiscordUserdoes does not exists, it will be created using the given data.
|
||||||
|
*
|
||||||
|
* @see /docs#discord-user-by-snowflake-managment-PUTapi-v1-discord-user-by-snowflake--snowflake-
|
||||||
|
*
|
||||||
|
* @param DiscordUser $discordUser
|
||||||
|
*
|
||||||
|
* @return PromiseInterface
|
||||||
|
* @api-response DiscordUserResponse
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public function identifyDiscordUser(DiscordUser $discordUser): PromiseInterface
|
||||||
|
{
|
||||||
|
return $this->client->withRejectErrorResponse(true)->put(
|
||||||
|
url: "discord-user-by-snowflake/$discordUser->snowflake",
|
||||||
|
body: $discordUser->toJson(true)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Creates a new DiscordUser using the given data on the backend.
|
||||||
|
*
|
||||||
|
* @see /docs#discord-user-managment-POSTapi-v1-discord-users
|
||||||
|
*
|
||||||
|
* @param DiscordUser $discordUser
|
||||||
|
*
|
||||||
|
* @return PromiseInterface
|
||||||
|
* @api-response DiscordUserResponse
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public function createDiscordUser(DiscordUser $discordUser): PromiseInterface
|
||||||
|
{
|
||||||
|
return $this->client->withRejectErrorResponse(true)->post(
|
||||||
|
url: 'discord-users',
|
||||||
|
body: json_encode($discordUser)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Update the specified DiscordUser on the backend.
|
||||||
|
*
|
||||||
|
* @see /docs#discord-user-managment-PUTapi-v1-discord-users--id-
|
||||||
|
*
|
||||||
|
* @param DiscordUser $discordUser
|
||||||
|
*
|
||||||
|
* @return PromiseInterface
|
||||||
|
* @api-response DiscordUserResponse
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public function updateDiscordUser(DiscordUser $discordUser): PromiseInterface
|
||||||
|
{
|
||||||
|
return $this->client->withRejectErrorResponse(true)->put(
|
||||||
|
url: "discord-users/$discordUser->id",
|
||||||
|
body: json_encode($discordUser)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Update the specified Remainder on the backend.
|
||||||
|
*
|
||||||
|
* @see /docs#remainder-managment-PUTapi-v1-discord-users--discord_user_id--remainders--id-
|
||||||
|
*
|
||||||
|
* @param Remainder $remainder
|
||||||
|
* @param array $changes
|
||||||
|
*
|
||||||
|
* @return PromiseInterface
|
||||||
|
* @api-response RemainderResponse
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public function updateRemainder(Remainder $remainder, array $changes): PromiseInterface
|
||||||
|
{
|
||||||
|
return $this->client->withRejectErrorResponse(true)->put(
|
||||||
|
url: "discord-users/$remainder->discord_user_id/remainders/$remainder->id",
|
||||||
|
body: json_encode($changes)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Create a new Remainder on the backend.
|
||||||
|
*
|
||||||
|
* @see /docs#remainder-managment-POSTapi-v1-discord-users--discord_user_id--remainders
|
||||||
|
*
|
||||||
|
* @param Remainder $remainder
|
||||||
|
*
|
||||||
|
* @return PromiseInterface
|
||||||
|
* @api-response RemainderResponse
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public function createRemainder(Remainder $remainder): PromiseInterface
|
||||||
|
{
|
||||||
|
return $this->client->withRejectErrorResponse(true)->post(
|
||||||
|
url: "discord-users/$remainder->discord_user_id/remainders",
|
||||||
|
body: json_encode($remainder)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Remove the specified Remainder on the backend.
|
||||||
|
*
|
||||||
|
* @see /docs#remainder-managment-DELETEapi-v1-discord-users--discord_user_id--remainders--id-
|
||||||
|
*
|
||||||
|
* @param DiscordUser $discordUser
|
||||||
|
* @param Remainder $remainder
|
||||||
|
*
|
||||||
|
* @return PromiseInterface
|
||||||
|
* @api-response <empty>
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public function deleteRemainder(DiscordUser $discordUser, Remainder $remainder): PromiseInterface
|
||||||
|
{
|
||||||
|
return $this->client->withRejectErrorResponse(true)->delete(
|
||||||
|
url: "discord-users/$remainder->discord_user_id/remainders/$remainder->id",
|
||||||
|
body: json_encode([
|
||||||
|
'snowflake' => $discordUser->snowflake,
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Fetches all the "actual" reaminders for the given second.
|
||||||
|
*
|
||||||
|
* @see /docs#remainder-by-dueat-managment-GETapi-v1-remainder-by-due-at--due_at-
|
||||||
|
*
|
||||||
|
* @return PromiseInterface
|
||||||
|
* @api-response RemainderListResponse
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public function getActualRemainders(): PromiseInterface
|
||||||
|
{
|
||||||
|
return $this->client->withRejectErrorResponse(true)->get(
|
||||||
|
url: 'remainder-by-due-at/' . time() . '?withDiscordUser'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Fetches the Remainders for the DiscordUser from the backend.
|
||||||
|
*
|
||||||
|
* @see /docs#remainder-managment-GETapi-v1-discord-users--discord_user_id--remainders
|
||||||
|
* @endpoint GET api/v1/discord-users/{discord_user_id}/remainders
|
||||||
|
*
|
||||||
|
* @param DiscordUser $discordUser
|
||||||
|
*
|
||||||
|
* @return PromiseInterface
|
||||||
|
* @api-response RemainderListResponse
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public function getRemainders(DiscordUser $discordUser): PromiseInterface
|
||||||
|
{
|
||||||
|
return $this->client->withRejectErrorResponse(true)->get(
|
||||||
|
url: "discord-users/$discordUser->id/remainders"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
225
src/Client/ApiResponse.php
Normal file
225
src/Client/ApiResponse.php
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Client;
|
||||||
|
|
||||||
|
use Bot\DevLogger;
|
||||||
|
use Exception;
|
||||||
|
use React\Http\Message\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class to handle/parse API resrponse
|
||||||
|
*/
|
||||||
|
class ApiResponse
|
||||||
|
{
|
||||||
|
public readonly int $responseCode;
|
||||||
|
protected array $responseData;
|
||||||
|
|
||||||
|
protected array $internalError = [];
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Creates a json ready array to be used in DevLogger
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public function toJsonLogData(): array
|
||||||
|
{
|
||||||
|
$result = [
|
||||||
|
'code' => $this->responseCode,
|
||||||
|
'response' => $this->responseData,
|
||||||
|
'errors' => [
|
||||||
|
'internalErrors' => $this->internalError,
|
||||||
|
'responseErrors' => (array_key_exists('errors', $this->responseData))
|
||||||
|
? $this->responseData['errors']
|
||||||
|
: [],
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
public function __construct(
|
||||||
|
public readonly Response $response
|
||||||
|
) {
|
||||||
|
|
||||||
|
// get HTTP response code
|
||||||
|
$this->responseCode = $response->getStatusCode();
|
||||||
|
|
||||||
|
// parse response
|
||||||
|
try {
|
||||||
|
$this->responseData = json_decode(json: $response->getBody(), associative: true, flags: JSON_THROW_ON_ERROR);
|
||||||
|
} catch (Exception $exception) {
|
||||||
|
// store internal error
|
||||||
|
$this->internalError[] = $error = [
|
||||||
|
'type' => 'json_decode_error',
|
||||||
|
'error_code' => $exception->getCode(),
|
||||||
|
'error_message' => $exception->getMessage(),
|
||||||
|
'response-code' => $this->responseCode,
|
||||||
|
'response-data' => $response->getBody(),
|
||||||
|
];
|
||||||
|
|
||||||
|
//NOTE: if needed, more details can be added here
|
||||||
|
// log the error for the developer(s)
|
||||||
|
DevLogger::error(
|
||||||
|
message: "JSON decoding failed",
|
||||||
|
context: $error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// visual sugar
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Instantiate a new ApiResponse object
|
||||||
|
*
|
||||||
|
* @param Response $response
|
||||||
|
*
|
||||||
|
* @return static
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public static function make(Response $response): static
|
||||||
|
{
|
||||||
|
return new static($response);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Checks if the API returns 401|Unauthorised response
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public function isUnauthenticated(): bool
|
||||||
|
{
|
||||||
|
return $this->responseCode === 401;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Checks if the respons has errors
|
||||||
|
*
|
||||||
|
* @return bool true if any errors wer found, false otherwise
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public function hasErrors(): bool
|
||||||
|
{
|
||||||
|
if (count($this->internalError) > 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('errors', $this->responseData)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Checks if the response contains the specified error
|
||||||
|
*
|
||||||
|
* @param string $error
|
||||||
|
*
|
||||||
|
* @return bool true if the specified error was found, false otherwise
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public function hasError(string $error): bool
|
||||||
|
{
|
||||||
|
return array_key_exists('errors', $this->responseData)
|
||||||
|
&& array_key_exists($error, $this->responseData['errors']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Checks if the response has the specified path
|
||||||
|
*
|
||||||
|
* @param string $path Comma separated path Ex.: 'data.error.reason'
|
||||||
|
*
|
||||||
|
* @return bool true, if the path is present, false otherwise
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public function hasPath(string $path): bool
|
||||||
|
{
|
||||||
|
// get the list of path nodes
|
||||||
|
$nodes = explode('.', $path);
|
||||||
|
$current = &$this->responseData;
|
||||||
|
|
||||||
|
// check all the nodes
|
||||||
|
foreach ($nodes as $node) {
|
||||||
|
if (!array_key_exists($node, $current)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$current = &$current[$node];
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Gets the specified path from the response
|
||||||
|
*
|
||||||
|
* @param string $path Comma separated path Ex.: 'data.error.reason'
|
||||||
|
*
|
||||||
|
* @return mixed the value at the path in the response
|
||||||
|
*
|
||||||
|
* @throws Exception If the specified path don't exists
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public function getPath(string $path): mixed
|
||||||
|
{
|
||||||
|
$nodes = explode('.', $path);
|
||||||
|
$current = &$this->responseData;
|
||||||
|
|
||||||
|
$pathErrorNode = '';
|
||||||
|
foreach ($nodes as $node) {
|
||||||
|
if (!array_key_exists($node, $current)) {
|
||||||
|
$pathErrorNode .= ">>$node<<";
|
||||||
|
throw new Exception(message: "Path ($pathErrorNode) not found", code: 404);
|
||||||
|
}
|
||||||
|
$pathErrorNode .= ".$node";
|
||||||
|
$current = &$current[$node];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $current;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Checks if the respons was a success
|
||||||
|
*
|
||||||
|
* It was a success, if the HTTP return code is a 2xx
|
||||||
|
*
|
||||||
|
* @return bool true if the response was successful, false otherwise
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public function success(): bool
|
||||||
|
{
|
||||||
|
|
||||||
|
if ($this->hasErrors()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->responseCode >= 200 && $this->responseCode < 300) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Checks if the respons was a failure
|
||||||
|
*
|
||||||
|
* @return bool true if the response failed, false otherwise
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public function failed(): bool
|
||||||
|
{
|
||||||
|
return !$this->success();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
347
src/Client/ClientMessages.php
Normal file
347
src/Client/ClientMessages.php
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
<?php
|
||||||
|
#cs-fixer:ignore
|
||||||
|
|
||||||
|
namespace Client;
|
||||||
|
|
||||||
|
use Client\Models\DiscordUser;
|
||||||
|
use Client\Models\Remainder;
|
||||||
|
|
||||||
|
//NOTE: the first empty line in each message are ignored, it is only here for better readability
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores the message templates to communicate with the discord client
|
||||||
|
*
|
||||||
|
* @uses Smarty
|
||||||
|
*/
|
||||||
|
class ClientMessages
|
||||||
|
{
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* The DiscordUser has not set it's timezone yet
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
* @category Warninig
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public const warningTimezoneNotset = <<<'EOL'
|
||||||
|
|
||||||
|
{$yellow}Warning{$reset}: you're {$darkYellow}timezone{$reset} is not set.
|
||||||
|
Please run {$darkCyan}"{$green}/profile timezone{$darkCyan}"{$reset} command to specify your timezone first!
|
||||||
|
|
||||||
|
EOL;
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* The provided timezone is not valid.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
* @category Error
|
||||||
|
* @param string $timezone
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public const errorTimezoneNotValid = <<<'EOL'
|
||||||
|
|
||||||
|
{$red}Error{$reset}: The timezone {$darkCyan}"{$red}{$timezone}{$darkCyan}"{$reset} is not a valid timezone!
|
||||||
|
Please provide a valid timezone!
|
||||||
|
|
||||||
|
EOL;
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* The provided locale is not valid.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
* @category Error
|
||||||
|
* @param string $locale
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public const errorLocaleNotValid = <<<'EOL'
|
||||||
|
|
||||||
|
{$red}Error{$reset}: The locale {$darkCyan}"{$red}{$locale}{$darkCyan}"{$reset} is not a valid locale!
|
||||||
|
Please provide a valid locale!
|
||||||
|
|
||||||
|
EOL;
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* The provided datetime is not valid.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
* @category Error
|
||||||
|
* @param string $time
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public const errorDateTimeNotValid = <<<'EOL'
|
||||||
|
|
||||||
|
{$red}Error{$reset}: The time {$darkCyan}"{$red}{$time}{$darkCyan}"{$reset} is not a valid datetime value!
|
||||||
|
Please provide a valid datetime value!
|
||||||
|
|
||||||
|
EOL;
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* The provided datetime is in the past.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
* @category Error
|
||||||
|
* @param string $time
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public const errorDateTimeInThePast = <<<'EOL'
|
||||||
|
|
||||||
|
{$red}Error{$reset}: The time {$darkCyan}"{$red}{$time}{$darkCyan}"{$reset} is in the past!
|
||||||
|
Please provide a datetime in the future when the remainder can be used!
|
||||||
|
|
||||||
|
EOL;
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Displays the profile for the DiscordUser
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
* @category Info
|
||||||
|
* @param DiscordUser $discordUser
|
||||||
|
* @param string $localTime The local time for the user based on the DiscordUser's timezone
|
||||||
|
* @param string $localeName The name of the locale of the DiscordUser. Ex.: "Hungarian (Hungary)"
|
||||||
|
*/
|
||||||
|
public const infoProfile = <<<'EOL'
|
||||||
|
|
||||||
|
Your {$darkYellow}timezone{$reset} is: {$darkCyan}"{$green}{$discordUser->timezone}{$darkCyan}"{$reset},
|
||||||
|
Your {$darkYellow}local time{$reset} is: {$darkCyan}"{$green}{$localTime}{$darkCyan}"{$reset}
|
||||||
|
Your {$darkYellow}locale{$reset} is: {$darkCyan}"{$green}{$discordUser->locale|default:'n/a'} - {$localeName|default:'not defined'}{$darkCyan}"{$reset}
|
||||||
|
|
||||||
|
EOL;
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Remainder created succesfully
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
* @category Info|Success
|
||||||
|
* @param DiscordUser $discordUser
|
||||||
|
* @param Remainder $remainder
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public const successRemainderCreated = <<<'EOL'
|
||||||
|
|
||||||
|
You're new remainder is created.
|
||||||
|
{$darkYellow}Due at{$darkCyan}:{$reset} "{$green}{$remainder->due_at|carbon:{$discordUser->timezone}}{$reset}" ({$yellow}{$remainder->humanReadable()}{$reset})
|
||||||
|
{$darkYellow}Message{$darkCyan}:{$reset} "{$green}{$remainder->message}{$reset}"
|
||||||
|
|
||||||
|
EOL;
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Remainder updated succesfully
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
* @category Info|Success
|
||||||
|
* @param DiscordUser $discordUser
|
||||||
|
* @param Remainder $remainder
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public const successRemainderUpdated = <<<'EOL'
|
||||||
|
|
||||||
|
You're remainder is updated.
|
||||||
|
{$darkYellow}Due at{$darkCyan}:{$reset} "{$green}{$remainder->due_at|carbon:{$discordUser->timezone}}{$reset}" ({$yellow}{$remainder->humanReadable()}{$reset})
|
||||||
|
{$darkYellow}Message{$darkCyan}:{$reset} "{$green}{$remainder->message}{$reset}"
|
||||||
|
|
||||||
|
EOL;
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Shows general details of the error.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
* @category Error
|
||||||
|
* @param int $code The error code or HTTP response code
|
||||||
|
* @param string $message The error description
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public const errorDetaiedError = <<<'EOL'
|
||||||
|
|
||||||
|
{$red}Error{$reset}: Something went wrong...
|
||||||
|
{$darkYellow} Code{$darkCyan}:{$reset} {$yellow}{$code}{$reset}
|
||||||
|
{$darkYellow} Message{$darkCyan}:{$reset} {$yellow}{$message}{$reset}
|
||||||
|
|
||||||
|
EOL;
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Displays the remainders of the DiscordUser
|
||||||
|
*
|
||||||
|
* @deprecated Use listRemaindersCompacted instead
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
* @category Info
|
||||||
|
*/
|
||||||
|
public const listRemainders = <<<'EOL'
|
||||||
|
|
||||||
|
{foreach $remainders as $remainder}
|
||||||
|
{$remainder@index|string_format:"%02d"}: Due at: {$darkYellow}{$remainder->due_at|carbon:{$discordUser->timezone}}{$reset} with Message: {$darkYellow}{$remainder->message|truncate:30:"..."}{$reset}
|
||||||
|
{foreachelse}
|
||||||
|
No remainders found.
|
||||||
|
{/foreach}
|
||||||
|
|
||||||
|
EOL;
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Displays the remainders of the DiscordUser
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
* @category Info
|
||||||
|
* @param DiscordUser $discordUser
|
||||||
|
* @param array $remainders
|
||||||
|
* @param array $paginate
|
||||||
|
* 'pageSize' int The count of remainders to show on one page. Default: 20
|
||||||
|
* 'pageCount' int The count of available pages. (1 based)
|
||||||
|
* 'page' int The index of the current page. (1 based)
|
||||||
|
* 'itemCount' int The count of ALL items, (1 based)
|
||||||
|
* 'first' int The index of the first item (from all items) (1 based)
|
||||||
|
* 'last' int The index of the last item (from all items) (1 based)
|
||||||
|
*/
|
||||||
|
public const listRemaindersCompacted = <<<'EOL'
|
||||||
|
|
||||||
|
{if $paginate['pageCount']>1}
|
||||||
|
Shown {$blue}{$paginate['first']}{$reset}..{$blue}{$paginate['last']}{$reset} of {$blue}{$paginate['itemCount']}{$reset} remainders, page {$blue}{$paginate['page']}{$reset} of {$blue}{$paginate['pageCount']}{$reset}:
|
||||||
|
|
||||||
|
{/if}
|
||||||
|
{foreach $remainders as $remainder}
|
||||||
|
{if $remainder->isOverDue()}
|
||||||
|
{assign var='dueAtColor' value=$darkRed}
|
||||||
|
{else}
|
||||||
|
{assign var='dueAtColor' value=$darkYellow}
|
||||||
|
{/if}
|
||||||
|
{($remainder@iteration+$paginate['first']-1)|string_format:"%02d"}: {$dueAtColor}{$remainder->due_at|carbon:{$discordUser->timezone}} {$darkCyan}- "{$green}{$remainder->message|truncate:20:"..."}{$darkCyan}"{$reset}
|
||||||
|
{foreachelse}
|
||||||
|
No remainders found.
|
||||||
|
{/foreach}
|
||||||
|
|
||||||
|
EOL;
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Invalid page index provided.
|
||||||
|
*
|
||||||
|
* If the user tries to view a non-existing page.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
* @category Error
|
||||||
|
* @param int $page The index of the requested (non-existing) page.
|
||||||
|
* @param int $pageCount The number of available pages (1 based)
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public const errorListPageInvalid = <<<'EOL'
|
||||||
|
|
||||||
|
{$red}Error{$reset}: The page {$yellow}{$page}{$reset} is invalid!
|
||||||
|
Please chose between {$yellow}1{$reset} and {$yellow}{$pageCount}{$reset}.
|
||||||
|
|
||||||
|
EOL;
|
||||||
|
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* The template to be shown in the autocomplete list for teh remainder option.
|
||||||
|
*
|
||||||
|
* Used in Editremainder and RemoveRemainder to list remainders in autocomplete list.
|
||||||
|
*
|
||||||
|
* @deprecated Autocomplete list does not allow ansi coloring, use simple templating in code. Ex.: sprintf(...)
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
* @category Info
|
||||||
|
* @param DiscordUser $discordUser
|
||||||
|
* @param Remainder $remainder
|
||||||
|
*/
|
||||||
|
public const editRemainder = <<<'EOL'
|
||||||
|
|
||||||
|
{$index}: Due at: {$darkYellow}{$remainder->due_at|carbon:{$discordUser->timezone}}{$reset} with Message: {$darkYellow}{$remainder->message}{$reset}
|
||||||
|
|
||||||
|
EOL;
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* General error to be shown to the user in the discord client.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
* @category Error
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public const errorGeneralError = <<<'EOL'
|
||||||
|
|
||||||
|
{$red}Error{$reset}: Something went wrong on our side, sorry...
|
||||||
|
{$white}Please try again later.{$reset}
|
||||||
|
|
||||||
|
EOL;
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Error reasons while the updating of the DiscordUser profile faild.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
* @category Error
|
||||||
|
* @param array $errors
|
||||||
|
* 'timezone' string If present the timezone is not a valid timezone
|
||||||
|
* 'locale' string If present the locale is not a valid locale
|
||||||
|
*/
|
||||||
|
public const errorUpdateProfileError = <<<'EOL'
|
||||||
|
|
||||||
|
{if isset($errors['timezone'])}
|
||||||
|
{$red}Error{$reset}: The timezone {$darkCyan}"{$red}{$errors['timezone']}{$darkCyan}"{$reset} is not a valid timezone!
|
||||||
|
Please provide a valid timezone!
|
||||||
|
{/if}
|
||||||
|
{if isset($errors['locale'])}
|
||||||
|
{$red}Error{$reset}: The locale {$darkCyan}"{$red}{$errors['locale']}{$darkCyan}"{$reset} is not a valid locale!
|
||||||
|
Please provide a valid locale!
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
EOL;
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Profile updated succesfully.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
* @category Info|Success
|
||||||
|
* @param DiscordUser $discordUser
|
||||||
|
* @param string $localTime The local time for the user based on the DiscordUser's timezone
|
||||||
|
* @param array $updated The list of properties updated.
|
||||||
|
* 'timezone' array If present the DiscordUsers timezone updated succesfully.
|
||||||
|
* 'od' string The old value for the timezone.
|
||||||
|
* 'new' string The new value for the timezone.
|
||||||
|
* 'locale' array If present the DiscordUsers locale updated succesfully.
|
||||||
|
* 'old' string The old value for the locale.
|
||||||
|
* 'new' string The new value for the locale.
|
||||||
|
* 'name' string The display name for the locale. Ex.: "Hungarian (Hungary)"
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public const successProfileUpdated = <<<'EOL'
|
||||||
|
|
||||||
|
{if isset($updated['timezone'])}
|
||||||
|
Your {$darkYellow}timezone{$reset} succesfully updated to {$darkCyan}"{$green}{$discordUser->timezone}{$darkCyan}"{$reset}.
|
||||||
|
Your {$darkYellow}local time{$reset} is: {$darkCyan}"{$green}{$localTime}{$darkCyan}"{$reset}
|
||||||
|
{/if}
|
||||||
|
{if isset($updated['locale'])}
|
||||||
|
Your {$darkYellow}locale{$reset} succesfully updated to {$darkCyan}"{$green}{$discordUser->locale|default:'n/a'} ({$updated['locale']['name']|default:'not defined'}){$darkCyan}"{$reset}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
EOL;
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* The provided emainderAlias (template by self::editRemainder) is invalid.
|
||||||
|
*
|
||||||
|
* If the user in the discord client does not chooses a valid item from the autocomplete list.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
* @category Error
|
||||||
|
* @param string $remainder The remainderAlias given by the user in the discord client. Ex.: "bad remainder index"
|
||||||
|
*/
|
||||||
|
public const errorInvalidRemainderAlias = <<<'EOL'
|
||||||
|
|
||||||
|
{$red}Error{$reset}: The remainder {$darkCyan}"{$red}{$remainder}{$darkCyan}"{$reset} is not a valid remainder!
|
||||||
|
Please chose one from the selection list!
|
||||||
|
|
||||||
|
EOL;
|
||||||
|
|
||||||
|
}
|
||||||
88
src/Client/Models/DiscordUser.php
Normal file
88
src/Client/Models/DiscordUser.php
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Client\Models;
|
||||||
|
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Client\Responses\Loadable;
|
||||||
|
use Discord\Parts\Interactions\Interaction;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The DiscordUser model
|
||||||
|
*/
|
||||||
|
class DiscordUser extends Loadable
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Creates a new DiscordUser instance.
|
||||||
|
*
|
||||||
|
* @param ?int $id
|
||||||
|
* @param ?string $snowflake
|
||||||
|
* @param ?string $user_name
|
||||||
|
* @param ?string $global_name
|
||||||
|
* @param ?string $locale
|
||||||
|
* @param ?string $timezone
|
||||||
|
* @param array $remainders=[]
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public ?int $id,
|
||||||
|
public ?string $snowflake,
|
||||||
|
public ?string $user_name,
|
||||||
|
public ?string $global_name,
|
||||||
|
public ?string $locale,
|
||||||
|
public ?string $timezone,
|
||||||
|
public array $remainders = [],
|
||||||
|
) {
|
||||||
|
// if there is a list of remianders, instantiate them
|
||||||
|
if (0 !== count($remainders)) {
|
||||||
|
$this->remainders = Remainder::collectionFromArray($remainders);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Returns the local time based on the timezone
|
||||||
|
*
|
||||||
|
* @return Carbon
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public function localTime(): Carbon
|
||||||
|
{
|
||||||
|
return Carbon::now($this->timezone ?? 'UTC');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Creates a new instance using the data from teh interaction
|
||||||
|
*
|
||||||
|
* NOTE: This uses only the data available in the discord interaction object
|
||||||
|
*
|
||||||
|
* @param Interaction $interaction
|
||||||
|
*
|
||||||
|
* @return static
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public static function fromInteraction(Interaction $interaction): static
|
||||||
|
{
|
||||||
|
return new static(
|
||||||
|
id: null,
|
||||||
|
snowflake: $interaction->user->id,
|
||||||
|
user_name: $interaction->user->username,
|
||||||
|
global_name: $interaction->user->global_name,
|
||||||
|
locale: $interaction->user?->locale,
|
||||||
|
timezone: null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Determines if thetimezone is set
|
||||||
|
*
|
||||||
|
* @return bool true if the timezone is set, false otherwise
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public function hasTimeZone(): bool
|
||||||
|
{
|
||||||
|
return null !== $this->timezone;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
129
src/Client/Models/Remainder.php
Normal file
129
src/Client/Models/Remainder.php
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Client\Models;
|
||||||
|
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Client\Responses\Loadable;
|
||||||
|
|
||||||
|
use function Core\isTimeZoneValid;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Remainder model
|
||||||
|
*/
|
||||||
|
class Remainder extends Loadable
|
||||||
|
{
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Creates a new Remainder instance.
|
||||||
|
*
|
||||||
|
* @param ?int $id
|
||||||
|
* @param ?string $discord_user_id
|
||||||
|
* @param ?string $channel_id
|
||||||
|
* @param int|Carbon|null $due_at
|
||||||
|
* @param ?string $message
|
||||||
|
* @param ?string $status
|
||||||
|
* @param ?string $error
|
||||||
|
* @param DiscordUser|array|null $discord_user
|
||||||
|
*
|
||||||
|
* NOTE: the $discord_user parameter can be:
|
||||||
|
* null - if not present (default)
|
||||||
|
* array - if the backend API returns it with the remainder (a DiscordUser object will be instantiated)
|
||||||
|
* DiscordUser - if the command handler assigns an existing DiscordUser object to it
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public ?int $id,
|
||||||
|
public ?string $discord_user_id,
|
||||||
|
public ?string $channel_id,
|
||||||
|
public int|Carbon|null $due_at,
|
||||||
|
public ?string $message,
|
||||||
|
public ?string $status,
|
||||||
|
public ?string $error,
|
||||||
|
public DiscordUser|array|null $discord_user = null
|
||||||
|
) {
|
||||||
|
// if there is a a filled array with DiscordUser properties, instantiate a new DiscorDuser
|
||||||
|
if (is_array($discord_user)) {
|
||||||
|
$this->discord_user = DiscordUser::makeFromArray($discord_user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Returns a human readable string of the relative difference to the current time.
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*
|
||||||
|
* NOTE: do not remove this, it is used in the smarty template
|
||||||
|
*/
|
||||||
|
public function humanReadable(): string
|
||||||
|
{
|
||||||
|
return $this->dueAtAsCarbon()->diffForHumans();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function dueAtAsCarbon(): Carbon
|
||||||
|
{
|
||||||
|
return is_a($this->due_at, 'Carbon\Carbon')
|
||||||
|
? $this->due_at
|
||||||
|
: Carbon::createFromTimestamp($this->due_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Returns a human readable string based on the discorduser's timezone if available, otherwise defaults to UTC.
|
||||||
|
*
|
||||||
|
* @param DiscordUser|string $timezone=null
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*
|
||||||
|
* NOTE: a supplied valid timezone will be used even if the remainder defines their own!
|
||||||
|
* this is the intended behaviour, so the time can be shown to the viewers own timezone
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public function dueAt(DiscordUser|string $timezone = null): string
|
||||||
|
{
|
||||||
|
$defaulted = false;
|
||||||
|
|
||||||
|
// try to find timezone
|
||||||
|
$timezone = match (true) {
|
||||||
|
is_a($timezone, DiscordUser::class) => $timezone->timezone,
|
||||||
|
is_string($timezone) && isTimeZoneValid($timezone) => $timezone,
|
||||||
|
$timezone === null && $this->discord_user !== null => $this->discord_user->timezone,
|
||||||
|
default => false
|
||||||
|
};
|
||||||
|
|
||||||
|
// if timezone was not found, set as default to UTC
|
||||||
|
if (false === $timezone) {
|
||||||
|
$defaulted = true;
|
||||||
|
$timezone = 'UTC';
|
||||||
|
}
|
||||||
|
|
||||||
|
// make sure result is a Carbon object
|
||||||
|
$result = $this->dueAtAsCarbon();
|
||||||
|
|
||||||
|
// apply the timezone
|
||||||
|
$result->setTimezone($timezone);
|
||||||
|
|
||||||
|
// append UTC for notification in case the timezone may differ from the discorduser's timezone
|
||||||
|
if ($defaulted) {
|
||||||
|
$result .= ' (UTC)';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the DueAt time is in the past and the Remainder is not closed
|
||||||
|
*
|
||||||
|
* @return bool true if the remainder is not colsed and the due_at is in the past, false otherwise
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public function isOverDue(): bool
|
||||||
|
{
|
||||||
|
return
|
||||||
|
$this->dueAtAsCarbon() < Carbon::now()
|
||||||
|
&& $this->status !== 'finished'
|
||||||
|
&& $this->status !== 'failed'
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
43
src/Client/Responses/DiscordUserResponse.php
Normal file
43
src/Client/Responses/DiscordUserResponse.php
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Client\Responses;
|
||||||
|
|
||||||
|
use Client\ApiResponse;
|
||||||
|
use Client\Models\DiscordUser;
|
||||||
|
use React\Http\Message\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles API responses for DiscordUser
|
||||||
|
*
|
||||||
|
* @see /docs#discord-user-by-snowflake-managment-GETapi-v1-discord-user-by-snowflake--discord_user_snowflake-
|
||||||
|
* @see /docs#discord-user-by-snowflake-managment-PUTapi-v1-discord-user-by-snowflake--snowflake-
|
||||||
|
* @see /docs#discord-user-managment-POSTapi-v1-discord-users
|
||||||
|
* @see /docs#discord-user-managment-GETapi-v1-discord-users--id-
|
||||||
|
* @see /docs#discord-user-managment-PUTapi-v1-discord-users--id-
|
||||||
|
*/
|
||||||
|
class DiscordUserResponse extends ApiResponse
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The instantiated DiscordUser object returned by the API request
|
||||||
|
*
|
||||||
|
* @var DiscordUser
|
||||||
|
*/
|
||||||
|
public DiscordUser $discordUser;
|
||||||
|
|
||||||
|
public function __construct(Response $response)
|
||||||
|
{
|
||||||
|
parent::__construct($response);
|
||||||
|
|
||||||
|
if ($this->hasErrors()) {
|
||||||
|
return;
|
||||||
|
} // add error handling and/or reporting for this situation
|
||||||
|
|
||||||
|
if (!$this->hasPath('data')) {
|
||||||
|
return;
|
||||||
|
} // add error handling and/or reporting for this situation
|
||||||
|
|
||||||
|
$this->discordUser = DiscordUser::makeFromArray($this->getPath('data'));
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
141
src/Client/Responses/Loadable.php
Normal file
141
src/Client/Responses/Loadable.php
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Client\Responses;
|
||||||
|
|
||||||
|
use Client\Traits\FromJson;
|
||||||
|
use JsonSerializable;
|
||||||
|
|
||||||
|
//TODO: maybe rename this class to some more usefull name
|
||||||
|
class Loadable implements JsonSerializable
|
||||||
|
{
|
||||||
|
use FromJson;
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
public function __construct(...$properties)
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Returns the properties
|
||||||
|
*
|
||||||
|
* @param bool $ignoreNullValues=false if true, the result will ignore null valued properties
|
||||||
|
*
|
||||||
|
* @return array the list op properties
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
protected function getProperties(bool $ignoreNullValues = false): array
|
||||||
|
{
|
||||||
|
return match ($ignoreNullValues) {
|
||||||
|
true => array_filter(get_object_vars($this), fn ($value) => $value !== null),
|
||||||
|
false => get_object_vars($this)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* returns self as a json serialize ready array
|
||||||
|
*
|
||||||
|
* @return mixed
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public function jsonSerialize(): mixed
|
||||||
|
{
|
||||||
|
return $this->getProperties(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Returns self as a json string
|
||||||
|
*
|
||||||
|
* @param bool $ignoreNullValues=false if true, the result will ignore null valued properties
|
||||||
|
*
|
||||||
|
* @return mixed (string|false)
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public function toJson(bool $ignoreNullValues = false): mixed
|
||||||
|
{
|
||||||
|
return json_encode($this->getProperties($ignoreNullValues));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Instantiates static from provided parameters
|
||||||
|
*
|
||||||
|
* NOTE: This must be defined by the descendant class
|
||||||
|
*
|
||||||
|
* @param array|null|bool $data
|
||||||
|
*
|
||||||
|
* @return self|null
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public static function makeFromArray(array|null|bool $data): ?self
|
||||||
|
{
|
||||||
|
return match ($data) {
|
||||||
|
false => null,
|
||||||
|
default => new static(...$data)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Created a specific object as $className object
|
||||||
|
*
|
||||||
|
* @param string $className
|
||||||
|
* @param object $object
|
||||||
|
*
|
||||||
|
* @return mixed
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public static function objToClass(string $className, object $object): mixed
|
||||||
|
{
|
||||||
|
return unserialize(
|
||||||
|
str_replace(
|
||||||
|
'O:8:"stdClass"',
|
||||||
|
sprintf('O:%d:"%s"', strlen($className), $className),
|
||||||
|
serialize($object)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Instantiates a list of static obejcts from the provided json string
|
||||||
|
*
|
||||||
|
* @param string $source
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public static function collectionFromJson(string $source): array
|
||||||
|
{
|
||||||
|
return static::collectionFromArray(json_decode(json: $source, associative: true));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Instantiates a list of static obejcts from the provided array
|
||||||
|
*
|
||||||
|
* @param array|null|bool $data
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public static function collectionFromArray(array|null|bool $data): array
|
||||||
|
{
|
||||||
|
$result = [];
|
||||||
|
|
||||||
|
if ($data === null || $data === false) {
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($data as $item) {
|
||||||
|
$result[] = new static(...$item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
59
src/Client/Responses/RemainderListResponse.php
Normal file
59
src/Client/Responses/RemainderListResponse.php
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Client\Responses;
|
||||||
|
|
||||||
|
use Client\ApiResponse;
|
||||||
|
use Client\Models\Remainder;
|
||||||
|
use React\Http\Message\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles API responses for Remainder lists
|
||||||
|
*
|
||||||
|
* @see /docs#remainder-managment-GETapi-v1-discord-users--discord_user_id--remainders
|
||||||
|
* @see /docs#remainder-by-dueat-managment-GETapi-v1-remainder-by-due-at--due_at-
|
||||||
|
*/
|
||||||
|
class RemainderListResponse extends ApiResponse
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The list of instantiated Remainder objects
|
||||||
|
*
|
||||||
|
* @var array
|
||||||
|
*/
|
||||||
|
public array $remainderList;
|
||||||
|
|
||||||
|
public function __construct(Response $response)
|
||||||
|
{
|
||||||
|
|
||||||
|
parent::__construct($response);
|
||||||
|
|
||||||
|
if ($this->hasErrors()) {
|
||||||
|
return;
|
||||||
|
} // NOTE: add error handling and/or reporting for this situation
|
||||||
|
|
||||||
|
if (!$this->hasPath('data')) {
|
||||||
|
return;
|
||||||
|
} // NOTE: add error handling and/or reporting for this situation
|
||||||
|
|
||||||
|
$this->remainderList = Remainder::collectionFromArray($this->getPath('data'));
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Searches the Remainder by id
|
||||||
|
*
|
||||||
|
* @param int $id The ID to search for
|
||||||
|
*
|
||||||
|
* @return Remainder|null returns the Remainder if found, null otherwise
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public function remainderById(int $id): ?Remainder
|
||||||
|
{
|
||||||
|
foreach ($this->remainderList as $remainder) {
|
||||||
|
if ($remainder->id === $id) {
|
||||||
|
return $remainder;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
54
src/Client/Responses/RemainderResponse.php
Normal file
54
src/Client/Responses/RemainderResponse.php
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Client\Responses;
|
||||||
|
|
||||||
|
use Client\ApiResponse;
|
||||||
|
use Client\Models\Remainder;
|
||||||
|
use React\Http\Message\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles API responses for Remainder
|
||||||
|
*
|
||||||
|
* @see /docs#remainder-managment-POSTapi-v1-discord-users--discord_user_id--remainders
|
||||||
|
* @see /docs#remainder-managment-PUTapi-v1-discord-users--discord_user_id--remainders--id-
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
class RemainderResponse extends ApiResponse
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The instantiated Remainder object returned by the API request
|
||||||
|
*
|
||||||
|
* @var Remainder
|
||||||
|
*/
|
||||||
|
public Remainder $remainder;
|
||||||
|
/**
|
||||||
|
* The list of all changed properties of the Remainder object
|
||||||
|
*
|
||||||
|
* @var array
|
||||||
|
* [*] The fields of the $changes array:
|
||||||
|
* 'old' mixed The old value of the property
|
||||||
|
* 'new' mixed The new value of the property
|
||||||
|
*/
|
||||||
|
public array $changes = [];
|
||||||
|
|
||||||
|
public function __construct(Response $response)
|
||||||
|
{
|
||||||
|
|
||||||
|
parent::__construct($response);
|
||||||
|
|
||||||
|
if ($this->hasErrors()) {
|
||||||
|
return;
|
||||||
|
} // add error handling and/or reporting for this situation
|
||||||
|
|
||||||
|
if (!$this->hasPath('data')) {
|
||||||
|
return;
|
||||||
|
} // add error handling and/or reporting for this situation
|
||||||
|
|
||||||
|
$this->remainder = Remainder::makeFromArray($this->getPath('data'));
|
||||||
|
|
||||||
|
if ($this->hasPath('changes')) {
|
||||||
|
$this->changes = $this->getPath('changes');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
133
src/Client/Template.php
Normal file
133
src/Client/Template.php
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Client;
|
||||||
|
|
||||||
|
use Bot\DiscordBot;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Client\Traits\Singleton;
|
||||||
|
use Smarty\Data;
|
||||||
|
use Smarty\Smarty;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class to compile text templates
|
||||||
|
* @singleton
|
||||||
|
*/
|
||||||
|
class Template
|
||||||
|
{
|
||||||
|
use Singleton;
|
||||||
|
|
||||||
|
protected Smarty $smarty;
|
||||||
|
|
||||||
|
protected ?Data $colorData;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ansi color sequences
|
||||||
|
*
|
||||||
|
* @var array
|
||||||
|
*/
|
||||||
|
protected array $colors = [
|
||||||
|
'black' => "\033[0;30m",
|
||||||
|
'red' => "\033[1;31m",
|
||||||
|
'green' => "\033[1;32m",
|
||||||
|
'yellow' => "\033[1;33m",
|
||||||
|
'blue' => "\033[1;34m",
|
||||||
|
'magenta' => "\033[1;35m",
|
||||||
|
'cyan' => "\033[1;36m",
|
||||||
|
'white' => "\033[1;37m",
|
||||||
|
'gray' => "\033[0;37m",
|
||||||
|
'darkRed' => "\033[0;31m",
|
||||||
|
'darkGreen' => "\033[0;32m",
|
||||||
|
'darkYellow' => "\033[0;33m",
|
||||||
|
'darkBlue' => "\033[0;34m",
|
||||||
|
'darkMagenta' => "\033[0;35m",
|
||||||
|
'darkCyan' => "\033[0;36m",
|
||||||
|
'darkWhite' => "\033[0;37m",
|
||||||
|
'darkGray' => "\033[1;30m",
|
||||||
|
'bgBlack' => "\033[40m",
|
||||||
|
'bgRed' => "\033[41m",
|
||||||
|
'bgGreen' => "\033[42m",
|
||||||
|
'bgYellow' => "\033[43m",
|
||||||
|
'bgBlue' => "\033[44m",
|
||||||
|
'bgMagenta' => "\033[45m",
|
||||||
|
'bgCyan' => "\033[46m",
|
||||||
|
'bgWhite' => "\033[47m",
|
||||||
|
'bold' => "\033[1m",
|
||||||
|
'italics' => "\033[3m",
|
||||||
|
'reset' => "\033[0m",
|
||||||
|
];
|
||||||
|
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
private function __construct()
|
||||||
|
{
|
||||||
|
// initialize template engine
|
||||||
|
$this->smarty = new Smarty();
|
||||||
|
$this->smarty->setTemplateDir(BOT_ROOT . '/Storage/Smarty/templates');
|
||||||
|
$this->smarty->setConfigDir(BOT_ROOT . '/Storage/Smarty/config');
|
||||||
|
$this->smarty->setCompileDir(BOT_ROOT . '/Storage/Smarty/templates_c');
|
||||||
|
$this->smarty->setCacheDir(BOT_ROOT . '/Storage/Smarty/cache');
|
||||||
|
|
||||||
|
// create color data container
|
||||||
|
$this->colorData = $this->smarty->createData();
|
||||||
|
foreach ($this->colors as $key => $value) {
|
||||||
|
$this->colorData->assign($key, $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// add carbon modifier
|
||||||
|
$this->smarty->registerPlugin(
|
||||||
|
type: Smarty::PLUGIN_MODIFIER,
|
||||||
|
name: 'carbon',
|
||||||
|
callback: fn (string $date, string $timeZone = null): string
|
||||||
|
=> Carbon::createFromTimestamp($date)
|
||||||
|
->setTimezone($timeZone)
|
||||||
|
->format(DiscordBot::getDateTimeFormat())
|
||||||
|
);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Compiles and returns the template as an ansi sequence
|
||||||
|
*
|
||||||
|
* @param string $stringTemplate
|
||||||
|
* @param array $variables
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public function fetchAnsi(string $stringTemplate, array $variables = []): string
|
||||||
|
{
|
||||||
|
// create template
|
||||||
|
$template = $this->smarty->createTemplate(
|
||||||
|
template_name: 'string:' . $stringTemplate,
|
||||||
|
parent: $this->colorData,
|
||||||
|
);
|
||||||
|
|
||||||
|
// assign variables to the template
|
||||||
|
foreach ($variables as $key => $value) {
|
||||||
|
$template->assign($key, $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// compile template
|
||||||
|
$result = $template->fetch();
|
||||||
|
|
||||||
|
$result = "```ansi\n{$result}\n```";
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Static wrapper for the fetchAnsi() function
|
||||||
|
*
|
||||||
|
* @param string $template
|
||||||
|
* @param array $variables
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public static function ansi(string $template, array $variables = []): string
|
||||||
|
{
|
||||||
|
return static::getInstance()->fetchAnsi($template, $variables);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
66
src/Client/Traits/AssureTimezoneSet.php
Normal file
66
src/Client/Traits/AssureTimezoneSet.php
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Client\Traits;
|
||||||
|
|
||||||
|
use Bot\DiscordBot;
|
||||||
|
use Client\ClientMessages;
|
||||||
|
use Client\Models\DiscordUser;
|
||||||
|
use Discord\Parts\Interactions\Interaction;
|
||||||
|
|
||||||
|
use function Core\optionChoise;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Functions used to assure, that the discorduser has a valid timezone set.
|
||||||
|
*
|
||||||
|
* NOTE: discord does not provide a timezone for the user, so to be able to handle/display time correctly,
|
||||||
|
* based on the users own timezone, a timezone must be known/set.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
trait AssureTimezoneSet
|
||||||
|
{
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Fails the interaction if the discordUser has no timezone set.
|
||||||
|
*
|
||||||
|
* @param Interaction $interaction Interaction object of the discord client
|
||||||
|
* @param DiscordUser $discordUser DiscordUser to check for a valid timezone
|
||||||
|
*
|
||||||
|
* @return bool true if the error was sent to the discord client, false if no action was taken
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public function failIfTimezoneNotSet(Interaction $interaction, DiscordUser $discordUser): bool
|
||||||
|
{
|
||||||
|
if (!$discordUser->hasTimeZone()) {
|
||||||
|
DiscordBot::respondToInteraction(
|
||||||
|
interaction: $interaction,
|
||||||
|
template: ClientMessages::warningTimezoneNotset
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Sned error optionChoises as the autocomplete list if the discordUser has no timezone set.
|
||||||
|
*
|
||||||
|
* @param Interaction $interaction Interaction object of the discord client
|
||||||
|
* @param DiscordUser $discordUser DiscordUser to check for a valid timezone
|
||||||
|
*
|
||||||
|
* @return bool true if the error list was sent to the discord client, false if no action was taken
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public function failAutoCompleteIfTimezoneNotSet(Interaction $interaction, DiscordUser $discordUser): bool
|
||||||
|
{
|
||||||
|
$result = [];
|
||||||
|
if (!$discordUser->hasTimeZone()) {
|
||||||
|
$result[] = optionChoise("-1");
|
||||||
|
$result[] = optionChoise("Warning: you're timezone is not set.");
|
||||||
|
$result[] = optionChoise("Please run \"/profile timezone\" to specify your timezone!");
|
||||||
|
$interaction->autoCompleteResult($result);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/Client/Traits/FromJson.php
Normal file
36
src/Client/Traits/FromJson.php
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Client\Traits;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Common methodes to help instantiate an object from properties
|
||||||
|
*
|
||||||
|
* This trait can be used to instantiate an object, with the parameters provided by the backend api.
|
||||||
|
*/
|
||||||
|
trait FromJson
|
||||||
|
{
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Instantiates an object from the class with the provided json values
|
||||||
|
*
|
||||||
|
* @param string $source json string provided by the backend api
|
||||||
|
*
|
||||||
|
* @return self|null
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public static function fromJson(string $source): self|null
|
||||||
|
{
|
||||||
|
return static::makeFromArray(json_decode(json: $source, associative: true));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Instantiates an object from the class with the provided parameters
|
||||||
|
* @abstract
|
||||||
|
* @param array|null|bool $data parameter array provided by the backend api
|
||||||
|
*
|
||||||
|
* @return self|null
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
abstract public static function makeFromArray(array|null|bool $data): self|null;
|
||||||
|
}
|
||||||
24
src/Client/Traits/HasApiClient.php
Normal file
24
src/Client/Traits/HasApiClient.php
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Client\Traits;
|
||||||
|
|
||||||
|
use Client\ApiClient;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function to access the global ApiClient singleton instance.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
trait HasApiClient
|
||||||
|
{
|
||||||
|
// ------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Returns the global ApiClient instance.
|
||||||
|
*
|
||||||
|
* @return ApiClient
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
protected function getApiClient(): ApiClient
|
||||||
|
{
|
||||||
|
return ApiClient::getInstance();
|
||||||
|
}
|
||||||
|
}
|
||||||
24
src/Client/Traits/HasCache.php
Normal file
24
src/Client/Traits/HasCache.php
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Client\Traits;
|
||||||
|
|
||||||
|
use Bot\Cache;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function to access the global Cache singleton instance.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
trait HasCache
|
||||||
|
{
|
||||||
|
// ------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Returns the global Cache instance.
|
||||||
|
*
|
||||||
|
* @return Cache
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
protected function getCache(): Cache
|
||||||
|
{
|
||||||
|
return Cache::getInstance();
|
||||||
|
}
|
||||||
|
}
|
||||||
32
src/Client/Traits/HasDiscord.php
Normal file
32
src/Client/Traits/HasDiscord.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Client\Traits;
|
||||||
|
|
||||||
|
use Discord\Discord;
|
||||||
|
|
||||||
|
use function Core\env;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function to access the global Discord singleton instance.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
trait HasDiscord
|
||||||
|
{
|
||||||
|
protected Discord|null $discord = null;
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Returns the global Discord instance.
|
||||||
|
*
|
||||||
|
* @return Discord
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
protected function getDiscord(): Discord
|
||||||
|
{
|
||||||
|
if (null === $this->discord) {
|
||||||
|
$this->discord = env()->discord;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->discord;
|
||||||
|
}
|
||||||
|
}
|
||||||
24
src/Client/Traits/HasTemplate.php
Normal file
24
src/Client/Traits/HasTemplate.php
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Client\Traits;
|
||||||
|
|
||||||
|
use Client\Template;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function to access the global Template singleton instance.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
trait HasTemplate
|
||||||
|
{
|
||||||
|
// ------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Returns the global Template instance.
|
||||||
|
*
|
||||||
|
* @return Template
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
protected function getTemplate(): Template
|
||||||
|
{
|
||||||
|
return Template::getInstance();
|
||||||
|
}
|
||||||
|
}
|
||||||
130
src/Client/Traits/RemainderListCommand.php
Normal file
130
src/Client/Traits/RemainderListCommand.php
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Client\Traits;
|
||||||
|
|
||||||
|
use Bot\DiscordBot;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Client\ClientMessages;
|
||||||
|
use Client\Models\DiscordUser;
|
||||||
|
use Client\Models\Remainder;
|
||||||
|
use Discord\Parts\Interactions\Interaction;
|
||||||
|
use Discord\Parts\Interactions\Request\Option as RequestOption;
|
||||||
|
|
||||||
|
use function Core\optionChoise;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Common functions used in EditRemainder and RemoveRemainder.
|
||||||
|
*/
|
||||||
|
trait RemainderListCommand
|
||||||
|
{
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Autocompletes the interaction with an error message
|
||||||
|
*
|
||||||
|
* Send an error for the invalid remainder alias to the client as an autocomplete result
|
||||||
|
*
|
||||||
|
* @param Interaction $interaction
|
||||||
|
*
|
||||||
|
* @return false
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
protected function invalidRemainderAlias(Interaction $interaction): false
|
||||||
|
{
|
||||||
|
$result = [];
|
||||||
|
|
||||||
|
$remainderAlias = $interaction->data->options->get('name', 'remainder')->value;
|
||||||
|
|
||||||
|
$result[] = optionChoise(sprintf('Error: The remainder "%s" is not a valid remainder!', $remainderAlias));
|
||||||
|
$result[] = optionChoise('Please chose one from the selection list!');
|
||||||
|
$interaction->autoCompleteResult($result);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Gets the actual remainder from the dispaly list (remainder alias list) or false if not found.
|
||||||
|
*
|
||||||
|
* @param Interaction $interaction
|
||||||
|
* @param array $remainders
|
||||||
|
*
|
||||||
|
* @return Remainder|false
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
protected function getActualRemainder(Interaction $interaction, array $remainders): Remainder|false
|
||||||
|
{
|
||||||
|
$remainderAlias = $interaction->data->options->get('name', 'remainder')->value;
|
||||||
|
// extract the index from the alias
|
||||||
|
$result = preg_match('/\(#(\d*)\)/', $remainderAlias, $matches);
|
||||||
|
if (1 !== $result) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$remainderIndex = $matches[1];
|
||||||
|
// select the remainder
|
||||||
|
$remainder = $remainders[$remainderIndex];
|
||||||
|
|
||||||
|
return $remainder;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Generates autocomplete list for the remainder parameter.
|
||||||
|
*
|
||||||
|
* @param Interaction $interaction
|
||||||
|
* @param RequestOption $option
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
protected function autoCompleteRemainder(Interaction $interaction, RequestOption $option, DiscordUser $discordUser): void
|
||||||
|
{
|
||||||
|
$searchString = $option->value;
|
||||||
|
|
||||||
|
$result = [];
|
||||||
|
|
||||||
|
$index = 0;
|
||||||
|
foreach ($discordUser->remainders as $remainder) {
|
||||||
|
$message = sprintf(
|
||||||
|
"(#%d): %s -> \"%s\"",
|
||||||
|
$index,
|
||||||
|
Carbon::parse($remainder->due_at)
|
||||||
|
->setTimezone($discordUser->timezone)
|
||||||
|
->format(DiscordBot::getDateTimeFormat()),
|
||||||
|
$remainder->message
|
||||||
|
);
|
||||||
|
|
||||||
|
//NOTE: max 100 chars....
|
||||||
|
$message = mb_strimwidth($message, 0, 80, '...');
|
||||||
|
|
||||||
|
if ($searchString === '' || false !== stripos($message, $searchString)) {
|
||||||
|
$result[] = optionChoise($message);
|
||||||
|
}
|
||||||
|
|
||||||
|
$index++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$interaction->autoCompleteResult($result);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send an error for the invalid remainder alias to the client as a response message.
|
||||||
|
*
|
||||||
|
* @param Interaction $interaction
|
||||||
|
* @param string $remainderAlias
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
protected function failInvalidRemainderAlias(Interaction $interaction, string $remainderAlias): void
|
||||||
|
{
|
||||||
|
DiscordBot::respondToInteraction(
|
||||||
|
interaction: $interaction,
|
||||||
|
template: ClientMessages::errorInvalidRemainderAlias,
|
||||||
|
variables: [
|
||||||
|
'remainder' => $remainderAlias,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
50
src/Client/Traits/Singleton.php
Normal file
50
src/Client/Traits/Singleton.php
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Client\Traits;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The functionality needed to use a class as a singlaton obejct.
|
||||||
|
*
|
||||||
|
* NOTE: to be able to ensure that only one instance can exist,
|
||||||
|
* a private __construct method must be defined in each class, that uses this trait!
|
||||||
|
*/
|
||||||
|
trait Singleton
|
||||||
|
{
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* the instance of he object
|
||||||
|
*
|
||||||
|
* @var ?self
|
||||||
|
*/
|
||||||
|
protected static ?self $instance = null;
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Returns the instance of the singleton
|
||||||
|
*
|
||||||
|
* @return self
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public static function getInstance(): self
|
||||||
|
{
|
||||||
|
if (null === self::$instance) {
|
||||||
|
self::$instance = new self();
|
||||||
|
}
|
||||||
|
return self::$instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Cloning this object
|
||||||
|
*
|
||||||
|
* Made private, so this cannot be used to cheat singletin pattern
|
||||||
|
*
|
||||||
|
* @return [type]
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
private function __clone(): void
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
222
src/Commands/CreateRemainder.php
Normal file
222
src/Commands/CreateRemainder.php
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Commands;
|
||||||
|
|
||||||
|
use Bot\DiscordBot;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Client\ClientMessages;
|
||||||
|
use Client\Models\DiscordUser;
|
||||||
|
use Client\Models\Remainder;
|
||||||
|
use Client\Responses\RemainderResponse;
|
||||||
|
use Client\Traits\AssureTimezoneSet;
|
||||||
|
use Client\Traits\HasCache;
|
||||||
|
use Client\Traits\HasApiClient;
|
||||||
|
use Client\Traits\HasDiscord;
|
||||||
|
use Core\Commands\Command;
|
||||||
|
use Core\Commands\CommandHandler;
|
||||||
|
use Discord\Builders\CommandBuilder;
|
||||||
|
use Discord\Parts\Interactions\Command\Option;
|
||||||
|
use Discord\Parts\Interactions\Interaction;
|
||||||
|
use React\Http\Message\Response;
|
||||||
|
use Discord\Parts\Interactions\Request\Option as RequestOption;
|
||||||
|
|
||||||
|
use function Core\isDateTimeValid;
|
||||||
|
use function Core\optionChoise;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The "/rem" command handler.
|
||||||
|
*
|
||||||
|
* Creates a Remainder for the DiscordUser.
|
||||||
|
*
|
||||||
|
* @example /rem when <due_at> message <message> [channel] <channel> - Create a remainder.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
#[Command]
|
||||||
|
class CreateRemainder implements CommandHandler
|
||||||
|
{
|
||||||
|
use HasApiClient, HasCache, HasDiscord, AssureTimezoneSet;
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Handles the request from the discord client.
|
||||||
|
*
|
||||||
|
* @param Interaction $interaction
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public function handle(Interaction $interaction): void
|
||||||
|
{
|
||||||
|
$when = $interaction->data->options->get('name', 'when')->value;
|
||||||
|
$message = $interaction->data->options->get('name', 'message')->value;
|
||||||
|
$channel = $interaction->data->options->get('name', 'channel')?->value;
|
||||||
|
|
||||||
|
$discordUser = DiscordUser::fromInteraction($interaction);
|
||||||
|
|
||||||
|
$this->getCache()->getDiscordUser($discordUser)->then(
|
||||||
|
onFulfilled: function (DiscordUser $discordUser) use ($when, $message, $channel, $interaction) {
|
||||||
|
|
||||||
|
// fail and send error message to the discord client if the discorduser does not have a valid timezone
|
||||||
|
if ($this->failIfTimezoneNotSet($interaction, $discordUser)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isDateTimeValid($when)) {
|
||||||
|
DiscordBot::respondToInteraction(
|
||||||
|
interaction: $interaction,
|
||||||
|
template: ClientMessages::errorDateTimeNotValid,
|
||||||
|
variables: [
|
||||||
|
'time' => $when,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// get the due_at time based on the discord users timezone
|
||||||
|
$due_at = Carbon::parse($when, $discordUser->timezone);
|
||||||
|
|
||||||
|
$newRemainder = new Remainder(
|
||||||
|
id: null,
|
||||||
|
discord_user_id: $discordUser->id,
|
||||||
|
channel_id: $channel ?? null,
|
||||||
|
due_at: $due_at->getTimestamp(),
|
||||||
|
message: $message,
|
||||||
|
status: 'new',
|
||||||
|
error: null,
|
||||||
|
discord_user: $discordUser,
|
||||||
|
);
|
||||||
|
|
||||||
|
// create remainder
|
||||||
|
$this->getApiClient()->createRemainder(remainder: $newRemainder)->then(
|
||||||
|
onFulfilled: function (Response $response) use ($interaction, $discordUser) {
|
||||||
|
|
||||||
|
$remainder = (RemainderResponse::make($response))->remainder;
|
||||||
|
|
||||||
|
$this->getCache()->forgetRemainderList($discordUser);
|
||||||
|
|
||||||
|
DiscordBot::respondToInteraction(
|
||||||
|
interaction: $interaction,
|
||||||
|
template: ClientMessages::successRemainderCreated,
|
||||||
|
variables: [
|
||||||
|
'discordUser' => $discordUser,
|
||||||
|
'remainder' => $remainder,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
},
|
||||||
|
onRejected: DiscordBot::onPromiseRejected($interaction)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onRejected: DiscordBot::onPromiseRejected($interaction)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Generates autocomplete list.
|
||||||
|
*
|
||||||
|
* @param Interaction $interaction
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public function autocomplete(Interaction $interaction): void
|
||||||
|
{
|
||||||
|
$option = $interaction->data->options->get('focused', 1);
|
||||||
|
|
||||||
|
match ($option->name) {
|
||||||
|
'when' => $this->autoCompleteWhen($interaction, $option),
|
||||||
|
default => $interaction->autoCompleteResult([]),
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Generates autocomplete list for the when/due_at parameter.
|
||||||
|
*
|
||||||
|
* @param Interaction $interaction
|
||||||
|
* @param RequestOption $option
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
protected function autoCompleteWhen(Interaction $interaction, RequestOption $option): void
|
||||||
|
{
|
||||||
|
$searchString = $option->value;
|
||||||
|
|
||||||
|
$discordUser = DiscordUser::fromInteraction($interaction);
|
||||||
|
|
||||||
|
$this->getCache()->getDiscordUser($discordUser)->then(
|
||||||
|
onFulfilled: function (DiscordUser $discordUser) use ($interaction, $searchString): void {
|
||||||
|
|
||||||
|
|
||||||
|
if ($this->failAutoCompleteIfTimezoneNotSet($interaction, $discordUser)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = [];
|
||||||
|
$due_at = 'n/a'; //NOTE: "Must be between 1 and 100 in length.", no empty string allowed...
|
||||||
|
|
||||||
|
if ($searchString === '') {
|
||||||
|
// no data jet, dispay placeholder
|
||||||
|
$result[] = optionChoise("Start typing a time...");
|
||||||
|
} else {
|
||||||
|
// try to parse the time
|
||||||
|
|
||||||
|
if (isDateTimeValid($searchString)) {
|
||||||
|
$due_at = Carbon::parse($searchString, $discordUser->timezone)->diffForHumans();
|
||||||
|
} else {
|
||||||
|
$result[] = optionChoise('Error: invalid time');
|
||||||
|
}
|
||||||
|
|
||||||
|
$result[] = optionChoise($searchString);
|
||||||
|
$result[] = optionChoise($due_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
$interaction->autoCompleteResult($result);
|
||||||
|
},
|
||||||
|
onRejected: DiscordBot::onPromiseRejected($interaction)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Defines the structure of the command
|
||||||
|
*
|
||||||
|
* @return CommandBuilder
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public function getConfig(): CommandBuilder
|
||||||
|
{
|
||||||
|
$discord = $this->getDiscord();
|
||||||
|
|
||||||
|
return (new CommandBuilder())
|
||||||
|
->setName('rem')
|
||||||
|
->setDescription('Sets a reminder')
|
||||||
|
->addOption(
|
||||||
|
(new Option($discord))
|
||||||
|
->setName('when')
|
||||||
|
->setType(Option::STRING)
|
||||||
|
->setDescription('The time to remind you')
|
||||||
|
->setRequired(true)
|
||||||
|
->setAutoComplete(true)
|
||||||
|
)
|
||||||
|
->addOption(
|
||||||
|
(new Option($discord))
|
||||||
|
->setName('message')
|
||||||
|
->setType(Option::STRING)
|
||||||
|
->setDescription('The body of the remainder')
|
||||||
|
->setRequired(true)
|
||||||
|
)
|
||||||
|
->addOption(
|
||||||
|
(new Option($discord))
|
||||||
|
->setName('channel')
|
||||||
|
->setType(Option::CHANNEL)
|
||||||
|
->setDescription('The channel of the remainder')
|
||||||
|
->setRequired(false)
|
||||||
|
)
|
||||||
|
;
|
||||||
|
}
|
||||||
|
}
|
||||||
300
src/Commands/EditRemainder.php
Normal file
300
src/Commands/EditRemainder.php
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Commands;
|
||||||
|
|
||||||
|
use Bot\DiscordBot;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Client\ClientMessages;
|
||||||
|
use Client\Models\DiscordUser;
|
||||||
|
use Client\Responses\RemainderResponse;
|
||||||
|
use Client\Traits\AssureTimezoneSet;
|
||||||
|
use Client\Traits\HasCache;
|
||||||
|
use Client\Traits\HasApiClient;
|
||||||
|
use Client\Traits\HasDiscord;
|
||||||
|
use Client\Traits\HasTemplate;
|
||||||
|
use Client\Traits\RemainderListCommand;
|
||||||
|
use Core\Commands\Command;
|
||||||
|
use Core\Commands\CommandHandler;
|
||||||
|
use Discord\Builders\CommandBuilder;
|
||||||
|
use Discord\Parts\Interactions\Command\Option;
|
||||||
|
use Discord\Parts\Interactions\Interaction;
|
||||||
|
use React\Http\Message\Response;
|
||||||
|
use Discord\Parts\Interactions\Request\Option as RequestOption;
|
||||||
|
|
||||||
|
use function Core\isDateTimeValid;
|
||||||
|
use function Core\optionChoise;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The "/edit" command handler.
|
||||||
|
*
|
||||||
|
* Edits a Remainder for the DiscordUser.
|
||||||
|
*
|
||||||
|
* @example /edit remainder <remainder> [when] <when> [message] <message> [channel] <channel> - Edit a remainder.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
#[Command]
|
||||||
|
class EditRemainder implements CommandHandler
|
||||||
|
{
|
||||||
|
use
|
||||||
|
AssureTimezoneSet,
|
||||||
|
HasCache,
|
||||||
|
HasApiClient,
|
||||||
|
HasDiscord,
|
||||||
|
HasTemplate,
|
||||||
|
RemainderListCommand;
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Handles the request from the discord client.
|
||||||
|
*
|
||||||
|
* @param Interaction $interaction
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public function handle(Interaction $interaction): void
|
||||||
|
{
|
||||||
|
|
||||||
|
$discordUser = DiscordUser::fromInteraction($interaction);
|
||||||
|
|
||||||
|
DiscordBot::getInstance()->getDiscordUserRemainders($interaction, $discordUser)->then(
|
||||||
|
onFulfilled: function (DiscordUser $discordUser) use ($interaction): void {
|
||||||
|
|
||||||
|
// get the remainder to edit
|
||||||
|
$remainder = $this->getActualRemainder($interaction, $discordUser->remainders);
|
||||||
|
|
||||||
|
//fail if the actual remainder cannot be evaulated
|
||||||
|
if (false === $remainder) {
|
||||||
|
$remainderAlias = $interaction->data->options->get('name', 'remainder')->value;
|
||||||
|
$this->failInvalidRemainderAlias($interaction, $remainderAlias);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// get the option values
|
||||||
|
$when = $interaction->data->options->get('name', 'when')?->value;
|
||||||
|
$message = $interaction->data->options->get('name', 'message')?->value;
|
||||||
|
$channel = $interaction->data->options->get('name', 'channel')?->value;
|
||||||
|
|
||||||
|
$changes = [];
|
||||||
|
|
||||||
|
// fail if when/due_at was provided, but is invalid
|
||||||
|
if ($when && !isDateTimeValid($when)) {
|
||||||
|
DiscordBot::respondToInteraction(
|
||||||
|
interaction: $interaction,
|
||||||
|
template: ClientMessages::errorDateTimeNotValid,
|
||||||
|
variables: [
|
||||||
|
'time' => $when,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if when/due_at was provided, update it
|
||||||
|
if ($when) {
|
||||||
|
$changes['due_at'] = Carbon::parse($when, $discordUser->timezone)->getTimestamp();
|
||||||
|
|
||||||
|
// fail if the new time is already past
|
||||||
|
if (Carbon::now()->getTimestamp() >= $changes['due_at']) {
|
||||||
|
DiscordBot::respondToInteraction(
|
||||||
|
interaction: $interaction,
|
||||||
|
template: ClientMessages::errorDateTimeInThePast,
|
||||||
|
variables: [
|
||||||
|
'time' => $when,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// if message was provided, update it
|
||||||
|
if ($message) {
|
||||||
|
$changes['message'] = $message;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if channel was provided, update it
|
||||||
|
if ($channel) {
|
||||||
|
$changes['channel_id'] = $channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
// update the remiander
|
||||||
|
$this->getApiClient()->updateRemainder($remainder, $changes)->then(
|
||||||
|
onFulfilled: function (Response $response) use ($interaction, $discordUser) {
|
||||||
|
|
||||||
|
$remainder = (RemainderResponse::make($response))->remainder;
|
||||||
|
|
||||||
|
$this->getCache()->forgetRemainderList($discordUser);
|
||||||
|
|
||||||
|
DiscordBot::respondToInteraction(
|
||||||
|
interaction: $interaction,
|
||||||
|
template: ClientMessages::successRemainderUpdated,
|
||||||
|
variables: [
|
||||||
|
'discordUser' => $discordUser,
|
||||||
|
'remainder' => $remainder,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
},
|
||||||
|
onRejected: DiscordBot::onPromiseRejected($interaction) // updateRemainder
|
||||||
|
);
|
||||||
|
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Generates autocomplete list.
|
||||||
|
*
|
||||||
|
* @param Interaction $interaction
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public function autocomplete(Interaction $interaction): void
|
||||||
|
{
|
||||||
|
$option = $interaction->data->options->get('focused', 1);
|
||||||
|
|
||||||
|
$discordUser = DiscordUser::fromInteraction($interaction);
|
||||||
|
|
||||||
|
DiscordBot::getInstance()->getDiscordUserRemainders($interaction, $discordUser)->then(
|
||||||
|
onFulfilled: function (DiscordUser $discordUser) use ($interaction, $option): void {
|
||||||
|
|
||||||
|
$parameters = [$interaction, $option, $discordUser];
|
||||||
|
|
||||||
|
// fill the lkist for the specified option
|
||||||
|
match ($option->name) {
|
||||||
|
'remainder' => $this->autoCompleteRemainder(...$parameters),
|
||||||
|
'when' => $this->autoCompleteWhen(...$parameters),
|
||||||
|
'message' => $this->autoCompleteMessage(...$parameters),
|
||||||
|
default => $interaction->autoCompleteResult([]),
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Generates autocomplete list for the when/due_at parameter.
|
||||||
|
*
|
||||||
|
* @param Interaction $interaction
|
||||||
|
* @param RequestOption $option
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
protected function autoCompleteWhen(Interaction $interaction, RequestOption $option, DiscordUser $discordUser): void
|
||||||
|
{
|
||||||
|
$searchString = $option->value;
|
||||||
|
|
||||||
|
$result = [];
|
||||||
|
$timezone = $discordUser->timezone;
|
||||||
|
$remainder = $this->getActualRemainder($interaction, $discordUser->remainders);
|
||||||
|
|
||||||
|
// fail, if the remainder cannot be evaluated
|
||||||
|
if (false === $remainder) {
|
||||||
|
$this->invalidRemainderAlias($interaction);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// set the current value es default
|
||||||
|
if ($searchString == '') {
|
||||||
|
$searchString = Carbon::createFromTimestamp($remainder->due_at)
|
||||||
|
->setTimezone($timezone)
|
||||||
|
->format(DiscordBot::getDateTimeFormat());
|
||||||
|
}
|
||||||
|
|
||||||
|
// fill the human readable value or show an error in case of an invalid value
|
||||||
|
$due_at = isDateTimeValid($searchString)
|
||||||
|
? Carbon::parse($searchString, $timezone)->diffForHumans()
|
||||||
|
: 'Error: invalid time';
|
||||||
|
|
||||||
|
// add values to result list
|
||||||
|
$result[] = optionChoise($searchString);
|
||||||
|
$result[] = optionChoise($due_at);
|
||||||
|
|
||||||
|
//send autocomplete results
|
||||||
|
$interaction->autoCompleteResult($result);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Generates autocomplete list for the message parameter.
|
||||||
|
*
|
||||||
|
* @param Interaction $interaction
|
||||||
|
* @param RequestOption $option
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
protected function autoCompleteMessage(Interaction $interaction, RequestOption $option, DiscordUser $discordUser): void
|
||||||
|
{
|
||||||
|
$searchString = $option->value;
|
||||||
|
|
||||||
|
$result = [];
|
||||||
|
$remainder = $this->getActualRemainder($interaction, $discordUser->remainders);
|
||||||
|
|
||||||
|
// fail, if the remainder cannot be evaluated
|
||||||
|
if (false === $remainder) {
|
||||||
|
$this->invalidRemainderAlias($interaction);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($searchString == '') {
|
||||||
|
$searchString = $remainder->message;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result[] = optionChoise($searchString);
|
||||||
|
|
||||||
|
$interaction->autoCompleteResult($result);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Defines the structure of the command
|
||||||
|
*
|
||||||
|
* @return CommandBuilder
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public function getConfig(): CommandBuilder
|
||||||
|
{
|
||||||
|
return (new CommandBuilder())
|
||||||
|
->setName('edit')
|
||||||
|
->setDescription('Edit a reminder.')
|
||||||
|
->addOption(
|
||||||
|
(new Option($this->getDiscord()))
|
||||||
|
->setName('remainder')
|
||||||
|
->setType(Option::STRING)
|
||||||
|
->setDescription('The reminder to edit.')
|
||||||
|
->setRequired(true)
|
||||||
|
->setAutoComplete(true)
|
||||||
|
)
|
||||||
|
->addOption(
|
||||||
|
(new Option($this->getDiscord()))
|
||||||
|
->setName('when')
|
||||||
|
->setType(Option::STRING)
|
||||||
|
->setDescription('The time to remind you')
|
||||||
|
->setAutoComplete(true)
|
||||||
|
->setRequired(false)
|
||||||
|
)
|
||||||
|
->addOption(
|
||||||
|
(new Option($this->getDiscord()))
|
||||||
|
->setName('message')
|
||||||
|
->setType(Option::STRING)
|
||||||
|
->setDescription('The body of the remainder')
|
||||||
|
->setAutoComplete(true)
|
||||||
|
->setRequired(false)
|
||||||
|
)
|
||||||
|
->addOption(
|
||||||
|
(new Option($this->getDiscord()))
|
||||||
|
->setName('channel')
|
||||||
|
->setType(Option::CHANNEL)
|
||||||
|
->setDescription('The channel of the remainder')
|
||||||
|
->setRequired(false)
|
||||||
|
)
|
||||||
|
;
|
||||||
|
}
|
||||||
|
}
|
||||||
132
src/Commands/ListRemainders.php
Normal file
132
src/Commands/ListRemainders.php
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Commands;
|
||||||
|
|
||||||
|
use Bot\DiscordBot;
|
||||||
|
use Client\ClientMessages;
|
||||||
|
use Client\Models\DiscordUser;
|
||||||
|
use Client\Traits\AssureTimezoneSet;
|
||||||
|
use Client\Traits\HasCache;
|
||||||
|
use Client\Traits\HasDiscord;
|
||||||
|
use Core\Commands\Command;
|
||||||
|
use Core\Commands\CommandHandler;
|
||||||
|
use Discord\Builders\CommandBuilder;
|
||||||
|
use Discord\Parts\Interactions\Command\Option;
|
||||||
|
use Discord\Parts\Interactions\Interaction;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The "/list" command handler.
|
||||||
|
*
|
||||||
|
* Lists DiscordUser remainders.
|
||||||
|
*
|
||||||
|
* @example /list [page] <page=1> - Shows the paginated list of remainders for the DiscordUser.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
#[Command]
|
||||||
|
class ListRemainders implements CommandHandler
|
||||||
|
{
|
||||||
|
use HasCache, HasDiscord, AssureTimezoneSet;
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Handles the request from the discord client.
|
||||||
|
*
|
||||||
|
* @param Interaction $interaction
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public function handle(Interaction $interaction): void
|
||||||
|
{
|
||||||
|
|
||||||
|
$discordUser = DiscordUser::fromInteraction($interaction);
|
||||||
|
|
||||||
|
DiscordBot::getInstance()->getDiscordUserRemainders($interaction, $discordUser)->then(
|
||||||
|
onFulfilled: function (DiscordUser $discordUser) use ($interaction) {
|
||||||
|
|
||||||
|
$pageSize = 20; // keep it low, so the message will fit in the 2000 character limit
|
||||||
|
$itemCount = count($discordUser->remainders);
|
||||||
|
$pageCount = match ($itemCount) {
|
||||||
|
0 => 1,
|
||||||
|
default => ceil($itemCount / $pageSize)
|
||||||
|
};
|
||||||
|
|
||||||
|
$page = $interaction->data->options->get('name', 'page')?->value ?? 1;
|
||||||
|
|
||||||
|
// fail if the page is not valid
|
||||||
|
if ($page < 1 || $page > $pageCount) {
|
||||||
|
DiscordBot::respondToInteraction(
|
||||||
|
interaction: $interaction,
|
||||||
|
template: ClientMessages::errorListPageInvalid,
|
||||||
|
variables: [
|
||||||
|
'page' => $page,
|
||||||
|
'pageCount' => $pageCount,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// paginate remainders
|
||||||
|
$first = $pageSize * ($page - 1);
|
||||||
|
$currnetRemainders = array_slice($discordUser->remainders, $first, $pageSize);
|
||||||
|
|
||||||
|
// start counting from 1 instead of 0
|
||||||
|
$first++;
|
||||||
|
|
||||||
|
DiscordBot::respondToInteraction(
|
||||||
|
interaction: $interaction,
|
||||||
|
template: ClientMessages::listRemaindersCompacted,
|
||||||
|
variables: [
|
||||||
|
'discordUser' => $discordUser,
|
||||||
|
'remainders' => $currnetRemainders,
|
||||||
|
'paginate' => [
|
||||||
|
'pageSize' => $pageSize,
|
||||||
|
'pageCount' => $pageCount,
|
||||||
|
'page' => $page,
|
||||||
|
'itemCount' => $itemCount,
|
||||||
|
'first' => $first,
|
||||||
|
'last' => $first + count($currnetRemainders) - 1,
|
||||||
|
],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Generates autocomplete list.
|
||||||
|
*
|
||||||
|
* @param Interaction $interaction
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public function autocomplete(Interaction $interaction): void
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Defines the structure of the command
|
||||||
|
*
|
||||||
|
* @return CommandBuilder
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public function getConfig(): CommandBuilder
|
||||||
|
{
|
||||||
|
return (new CommandBuilder())
|
||||||
|
->setName('list')
|
||||||
|
->setDescription('Lists the current reminders.')
|
||||||
|
->addOption(
|
||||||
|
(new Option($this->getDiscord()))
|
||||||
|
->setName('page')
|
||||||
|
->setType(Option::INTEGER)
|
||||||
|
->setDescription('The page to show. (defulats: 1).')
|
||||||
|
//->setRequired(false)
|
||||||
|
//->setAutoComplete(true)
|
||||||
|
)
|
||||||
|
;
|
||||||
|
}
|
||||||
|
}
|
||||||
257
src/Commands/Profile.php
Normal file
257
src/Commands/Profile.php
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Commands;
|
||||||
|
|
||||||
|
use Bot\DiscordBot;
|
||||||
|
use Client\ClientMessages;
|
||||||
|
use Client\Models\DiscordUser;
|
||||||
|
use Client\Traits\AssureTimezoneSet;
|
||||||
|
use Client\Traits\HasCache;
|
||||||
|
use Client\Traits\HasDiscord;
|
||||||
|
use Core\Commands\Command;
|
||||||
|
use Core\Commands\CommandHandler;
|
||||||
|
use DateTimeZone;
|
||||||
|
use Discord\Builders\CommandBuilder;
|
||||||
|
use Discord\Parts\Interactions\Command\Option;
|
||||||
|
use Discord\Parts\Interactions\Interaction;
|
||||||
|
use Discord\Parts\Interactions\Request\Option as RequestOption;
|
||||||
|
|
||||||
|
use function Core\isLocaleValid;
|
||||||
|
use function Core\isTimeZoneValid;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The "/profile" command handler.
|
||||||
|
*
|
||||||
|
* Manages DiscordUser profile.
|
||||||
|
*
|
||||||
|
* @example /profile - Shows the current profile info
|
||||||
|
* @example /profile timezone <timezone> - Updates the timezone.
|
||||||
|
* @example /profile locale <locale> - Updates the locale.
|
||||||
|
* @example /profile timezone <timezone> locale <locale> - Updates timezone and locale.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
#[Command]
|
||||||
|
class Profile implements CommandHandler
|
||||||
|
{
|
||||||
|
use HasCache, HasDiscord, AssureTimezoneSet;
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Displays the current profile of the DiscordUser.
|
||||||
|
*
|
||||||
|
* @param Interaction $interaction
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
protected function showProfileInfo(Interaction $interaction): void
|
||||||
|
{
|
||||||
|
|
||||||
|
$discordUser = DiscordUser::fromInteraction($interaction);
|
||||||
|
|
||||||
|
$this->getCache()->getDiscordUser($discordUser)->then(
|
||||||
|
onFulfilled: function (DiscordUser $discordUser) use ($interaction) {
|
||||||
|
|
||||||
|
// fail and send error message to the discord client if the discorduser does not have a valid timezone
|
||||||
|
if ($this->failIfTimezoneNotSet($interaction, $discordUser)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DiscordBot::respondToInteraction(
|
||||||
|
interaction: $interaction,
|
||||||
|
template: ClientMessages::infoProfile,
|
||||||
|
variables: [
|
||||||
|
'discordUser' => $discordUser,
|
||||||
|
'localTime' => $discordUser->localTime(),
|
||||||
|
'localeName' => locale_get_display_name($discordUser->locale ?? 'not defined', 'en'),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
},
|
||||||
|
onRejected: DiscordBot::onPromiseRejected($interaction)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Handles the request from the discord client.
|
||||||
|
*
|
||||||
|
* @param Interaction $interaction
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public function handle(Interaction $interaction): void
|
||||||
|
{
|
||||||
|
|
||||||
|
// Show info if no update was requested
|
||||||
|
if ($interaction->data->options->count() == 0) {
|
||||||
|
$this->showProfileInfo($interaction);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$discordUser = DiscordUser::fromInteraction($interaction);
|
||||||
|
$updated = [];
|
||||||
|
$errors = [];
|
||||||
|
|
||||||
|
// update timezone if present
|
||||||
|
if ($interaction->data->options->has('timezone')) {
|
||||||
|
$timezone = $interaction->data->options->get('name', 'timezone');
|
||||||
|
if (!isTimeZoneValid($timezone->value)) {
|
||||||
|
$errors['timezone'] = $timezone->value;
|
||||||
|
} else {
|
||||||
|
$updated['timezone'] = [
|
||||||
|
'old' => $discordUser->timezone,
|
||||||
|
'new' => $timezone->value,
|
||||||
|
];
|
||||||
|
$discordUser->timezone = $timezone->value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// update locale if present
|
||||||
|
if ($interaction->data->options->has('locale')) {
|
||||||
|
$locale = $interaction->data->options->get('name', 'locale');
|
||||||
|
if (!isLocaleValid($locale->value)) {
|
||||||
|
$errors['locale'] = $locale->value;
|
||||||
|
} else {
|
||||||
|
$updated['locale'] = [
|
||||||
|
'old' => $discordUser->locale,
|
||||||
|
'new' => $locale->value,
|
||||||
|
'name' => locale_get_display_name($locale->value ?? 'not defined', 'en'),
|
||||||
|
];
|
||||||
|
$discordUser->locale = $locale->value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
//fail if errors were found
|
||||||
|
if (count($errors) > 0) {
|
||||||
|
var_dump($errors);
|
||||||
|
DiscordBot::respondToInteraction(
|
||||||
|
$interaction,
|
||||||
|
ClientMessages::errorUpdateProfileError,
|
||||||
|
['errors' => $errors]
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// update profile
|
||||||
|
if (count($updated) > 0) {
|
||||||
|
$this->getCache()->forgetDiscordUser($discordUser);
|
||||||
|
$this->getCache()->getDiscordUser($discordUser)->then(
|
||||||
|
onFulfilled: function (DiscordUser $discordUser) use ($interaction, $updated) {
|
||||||
|
|
||||||
|
$this->getCache()->storeDiscordUser($discordUser);
|
||||||
|
|
||||||
|
DiscordBot::respondToInteraction(
|
||||||
|
interaction: $interaction,
|
||||||
|
template: ClientMessages::successProfileUpdated,
|
||||||
|
variables: [
|
||||||
|
'discordUser' => $discordUser,
|
||||||
|
'localTime' => $discordUser->localTime(),
|
||||||
|
'updated' => $updated,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onRejected: DiscordBot::onPromiseRejected($interaction)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Generates autocomplete list.
|
||||||
|
*
|
||||||
|
* @param Interaction $interaction
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public function autocomplete(Interaction $interaction): void
|
||||||
|
{
|
||||||
|
$option = $interaction->data->options->get('focused', 1);
|
||||||
|
|
||||||
|
$interaction->autoCompleteResult(match ($option->name) {
|
||||||
|
'timezone' => $this->autoCompleteTimeZone($interaction, $option),
|
||||||
|
'locale' => $this->autoCompleteLocale($interaction, $option),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Generates autocomplete list for the timezone parameter.
|
||||||
|
*
|
||||||
|
* @param Interaction $interaction
|
||||||
|
* @param RequestOption $option
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
protected function autoCompleteTimeZone(Interaction $interaction, RequestOption $option): array
|
||||||
|
{
|
||||||
|
$searchString = $option->value;
|
||||||
|
|
||||||
|
$timezoneList = DateTimeZone::listIdentifiers();
|
||||||
|
$matches = array_filter($timezoneList, fn (string $value) => stripos($value, $searchString) !== false);
|
||||||
|
sort($matches);
|
||||||
|
$matches = array_slice($matches, 0, 25);
|
||||||
|
$result = array_map(fn (string $value) => ['name' => $value, 'value' => $value], $matches);
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Generates autocomplete list for the locale parameter.
|
||||||
|
*
|
||||||
|
* @param Interaction $interaction
|
||||||
|
* @param RequestOption $option
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
protected function autoCompleteLocale(Interaction $interaction, RequestOption $option): array
|
||||||
|
{
|
||||||
|
|
||||||
|
$searchString = $option->value;
|
||||||
|
$matches = array_filter(LOCALES, fn (string $value) => stripos($value, $searchString) !== false);
|
||||||
|
sort($matches);
|
||||||
|
$matches = array_slice($matches, 0, 25);
|
||||||
|
$result = array_map(fn (string $value) => ['name' => $value, 'value' => $value], $matches);
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Defines the structure of the command
|
||||||
|
*
|
||||||
|
* @return CommandBuilder
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public function getConfig(): CommandBuilder
|
||||||
|
{
|
||||||
|
$discord = $this->getDiscord();
|
||||||
|
|
||||||
|
return (new CommandBuilder())
|
||||||
|
->setName('profile')
|
||||||
|
->setDescription('Manages your profile')
|
||||||
|
->addOption(
|
||||||
|
(new Option($discord))
|
||||||
|
->setName('timezone')
|
||||||
|
->setType(Option::STRING)
|
||||||
|
->setDescription('TimeZone')
|
||||||
|
->setAutoComplete(true)
|
||||||
|
)
|
||||||
|
->addOption(
|
||||||
|
(new Option($discord))
|
||||||
|
->setName('locale')
|
||||||
|
->setType(Option::STRING)
|
||||||
|
->setDescription('Locale')
|
||||||
|
->setAutoComplete(true)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
210
src/Commands/RemoveRemainder.php
Normal file
210
src/Commands/RemoveRemainder.php
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Commands;
|
||||||
|
|
||||||
|
use Bot\DiscordBot;
|
||||||
|
use Client\Models\DiscordUser;
|
||||||
|
use Client\Traits\AssureTimezoneSet;
|
||||||
|
use Client\Traits\HasCache;
|
||||||
|
use Client\Traits\HasApiClient;
|
||||||
|
use Client\Traits\HasDiscord;
|
||||||
|
use Client\Traits\HasTemplate;
|
||||||
|
use Client\Traits\RemainderListCommand;
|
||||||
|
use Core\Commands\Command;
|
||||||
|
use Core\Commands\CommandHandler;
|
||||||
|
use Discord\Builders\CommandBuilder;
|
||||||
|
use Discord\Builders\Components\ActionRow;
|
||||||
|
use Discord\Builders\Components\Button;
|
||||||
|
use Discord\Builders\MessageBuilder;
|
||||||
|
use Discord\Parts\Interactions\Command\Option;
|
||||||
|
use Discord\Parts\Interactions\Interaction;
|
||||||
|
use Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The "/delete" command handler.
|
||||||
|
*
|
||||||
|
* Lists DiscordUser remainders.
|
||||||
|
*
|
||||||
|
* @example /list - Shows the remainders of the DiscordUser.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
#[Command]
|
||||||
|
class RemoveRemainder implements CommandHandler
|
||||||
|
{
|
||||||
|
use HasApiClient, HasCache, HasDiscord, HasTemplate, AssureTimezoneSet, RemainderListCommand;
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
protected function btnCancelListener(
|
||||||
|
Interaction $interaction,
|
||||||
|
DiscordUser $discordUser,
|
||||||
|
MessageBuilder $messageBuilder,
|
||||||
|
ActionRow $actionRow,
|
||||||
|
): callable {
|
||||||
|
return fn (Interaction $iAnswer2) =>
|
||||||
|
$interaction->updateOriginalResponse($messageBuilder
|
||||||
|
->setContent('Kept reaminder.')
|
||||||
|
->removeComponent($actionRow))
|
||||||
|
->otherwise(DiscordBot::onPromiseRejected($interaction));
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
protected function btnOkListener(
|
||||||
|
Interaction $interaction,
|
||||||
|
DiscordUser $discordUser,
|
||||||
|
MessageBuilder $messageBuilder,
|
||||||
|
ActionRow $actionRow,
|
||||||
|
): callable {
|
||||||
|
return function (Interaction $iAnswer) use ($interaction, $discordUser, $messageBuilder, $actionRow) {
|
||||||
|
|
||||||
|
$remainder = $this->getActualRemainder($interaction, $discordUser->remainders);
|
||||||
|
|
||||||
|
$this->getApiClient()->deleteRemainder($discordUser, $remainder)->then(
|
||||||
|
onFulfilled: function ($data) use ($interaction, $discordUser, $iAnswer, $messageBuilder, $actionRow, $remainder): void {
|
||||||
|
$this->getCache()->forgetRemainderList($discordUser);
|
||||||
|
|
||||||
|
// update client message
|
||||||
|
$interaction->updateOriginalResponse($messageBuilder
|
||||||
|
->setContent('Remainder deleted succesfully.')
|
||||||
|
->removeComponent($actionRow))
|
||||||
|
->otherwise(DiscordBot::onPromiseRejected($interaction));
|
||||||
|
|
||||||
|
},
|
||||||
|
onRejected: function (Exception $exception) use ($iAnswer, $interaction) {
|
||||||
|
|
||||||
|
$interaction->deleteOriginalResponse();
|
||||||
|
DiscordBot::failApiRequestWithException($iAnswer, $exception);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Handles the request from the discord client.
|
||||||
|
*
|
||||||
|
* @param Interaction $interaction
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public function handle(Interaction $interaction): void
|
||||||
|
{
|
||||||
|
|
||||||
|
$discordUser = DiscordUser::fromInteraction($interaction);
|
||||||
|
|
||||||
|
DiscordBot::getInstance()->getDiscordUserRemainders($interaction, $discordUser)->then(
|
||||||
|
onFulfilled: function (DiscordUser $discordUser) use ($interaction): void {
|
||||||
|
|
||||||
|
$remainder = $this->getActualRemainder($interaction, $discordUser->remainders);
|
||||||
|
//fail if the actual remainder cannot be evaulated
|
||||||
|
if (false === $remainder) {
|
||||||
|
$remainderAlias = $interaction->data->options->get('name', 'remainder')->value;
|
||||||
|
$this->failInvalidRemainderAlias($interaction, $remainderAlias);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// create message handlers
|
||||||
|
$messageBuilder = MessageBuilder::new();
|
||||||
|
$actionRow = ActionRow::new();
|
||||||
|
|
||||||
|
// add OK button
|
||||||
|
$btnOK = Button::new(Button::STYLE_SUCCESS)
|
||||||
|
->setLabel('Yes, delete the remainder!')
|
||||||
|
->setEmoji('👎')
|
||||||
|
->setListener($this->btnOkListener(
|
||||||
|
interaction: $interaction,
|
||||||
|
discordUser: $discordUser,
|
||||||
|
messageBuilder: $messageBuilder,
|
||||||
|
actionRow: $actionRow,
|
||||||
|
), $this->getDiscord())
|
||||||
|
;
|
||||||
|
|
||||||
|
// add CANCEL button
|
||||||
|
$btnCancel = Button::new(Button::STYLE_DANGER)
|
||||||
|
->setLabel('No, keep the remainder.')
|
||||||
|
->setEmoji('👍')
|
||||||
|
->setListener($this->btnCancelListener(
|
||||||
|
interaction: $interaction,
|
||||||
|
discordUser: $discordUser,
|
||||||
|
messageBuilder: $messageBuilder,
|
||||||
|
actionRow: $actionRow,
|
||||||
|
), $this->getDiscord())
|
||||||
|
;
|
||||||
|
|
||||||
|
$actionRow->addComponent($btnOK)->addComponent($btnCancel);
|
||||||
|
|
||||||
|
// send temporary response
|
||||||
|
//TODO: maybe test for success/failure here as well...
|
||||||
|
$interaction->acknowledgeWithResponse(true)->done(function () use ($interaction, $messageBuilder, $actionRow) {
|
||||||
|
$interaction->updateOriginalResponse(
|
||||||
|
builder: $messageBuilder
|
||||||
|
->setContent('Are you sure you want to delete this remainder?')
|
||||||
|
->addComponent($actionRow)
|
||||||
|
)->otherwise(DiscordBot::onPromiseRejected($interaction));
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
}
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Generates autocomplete list.
|
||||||
|
*
|
||||||
|
* @param Interaction $interaction
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public function autocomplete(Interaction $interaction): void
|
||||||
|
{
|
||||||
|
$option = $interaction->data->options->get('focused', 1);
|
||||||
|
|
||||||
|
$discordUser = DiscordUser::fromInteraction($interaction);
|
||||||
|
|
||||||
|
DiscordBot::getInstance()->getDiscordUserRemainders($interaction, $discordUser)->then(
|
||||||
|
onFulfilled: function (DiscordUser $discordUser) use ($interaction, $option): void {
|
||||||
|
|
||||||
|
$parameters = [$interaction, $option, $discordUser];
|
||||||
|
|
||||||
|
// fill the list for the specified option
|
||||||
|
match ($option->name) {
|
||||||
|
'remainder' => $this->autoCompleteRemainder(...$parameters),
|
||||||
|
default => $interaction->autoCompleteResult([]),
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Defines the structure of the command
|
||||||
|
*
|
||||||
|
* @return CommandBuilder
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public function getConfig(): CommandBuilder
|
||||||
|
{
|
||||||
|
return (new CommandBuilder())
|
||||||
|
->setName('delete')
|
||||||
|
->setDescription('Delete a reminder.')
|
||||||
|
->addOption(
|
||||||
|
(new Option($this->getDiscord()))
|
||||||
|
->setName('remainder')
|
||||||
|
->setType(Option::STRING)
|
||||||
|
->setDescription('The reminder to delete.')
|
||||||
|
->setRequired(true)
|
||||||
|
->setAutoComplete(true)
|
||||||
|
)
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
//👍 ☠ 👎
|
||||||
19
src/Core/Commands/Command.php
Normal file
19
src/Core/Commands/Command.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Core\Commands;
|
||||||
|
|
||||||
|
use Attribute;
|
||||||
|
use LogicException;
|
||||||
|
|
||||||
|
#[Attribute(Attribute::TARGET_CLASS)]
|
||||||
|
class Command
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly string|array|null $name = null,
|
||||||
|
public readonly ?string $guild = null,
|
||||||
|
) {
|
||||||
|
if ($guild !== null && preg_match('/[^0-9]/', $this->guild)) {
|
||||||
|
throw new LogicException('Guild ID must be alphanumeric');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/Core/Commands/CommandHandler.php
Normal file
15
src/Core/Commands/CommandHandler.php
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Core\Commands;
|
||||||
|
|
||||||
|
use Discord\Builders\CommandBuilder;
|
||||||
|
use Discord\Parts\Interactions\Interaction;
|
||||||
|
|
||||||
|
interface CommandHandler
|
||||||
|
{
|
||||||
|
public function handle(Interaction $interaction): void;
|
||||||
|
|
||||||
|
public function autocomplete(Interaction $interaction): void;
|
||||||
|
|
||||||
|
public function getConfig(): CommandBuilder|array;
|
||||||
|
}
|
||||||
164
src/Core/Commands/CommandQueue.php
Normal file
164
src/Core/Commands/CommandQueue.php
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Core\Commands;
|
||||||
|
|
||||||
|
use Discord\Repository\Guild\GuildCommandRepository;
|
||||||
|
use Discord\Repository\Interaction\GlobalCommandRepository;
|
||||||
|
use React\Promise\Promise;
|
||||||
|
use React\Promise\PromiseInterface;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
use function Core\debug;
|
||||||
|
use function Core\discord;
|
||||||
|
use function Core\error;
|
||||||
|
use function React\Async\async;
|
||||||
|
use function React\Async\await;
|
||||||
|
|
||||||
|
class CommandQueue
|
||||||
|
{
|
||||||
|
/** @var QueuedCommand[] */
|
||||||
|
protected array $queue = [];
|
||||||
|
|
||||||
|
public function appendCommand(QueuedCommand $command): self
|
||||||
|
{
|
||||||
|
$this->queue[] = $command;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function runQueue(bool $loadCommands = true, bool $registerCommands = true): PromiseInterface
|
||||||
|
{
|
||||||
|
$discord = discord();
|
||||||
|
$discord->getLogger()->info('Running command queue...');
|
||||||
|
|
||||||
|
return new Promise(function ($resolve) use ($registerCommands, $discord, $loadCommands) {
|
||||||
|
debug('Running Loop for ' . count($this->queue) . ' commands...');
|
||||||
|
async(function () use ($registerCommands, $discord, $loadCommands, $resolve) {
|
||||||
|
if ($registerCommands) {
|
||||||
|
debug('Getting commands...');
|
||||||
|
/** @var GlobalCommandRepository $globalCommands */
|
||||||
|
$globalCommands = await($discord->application->commands->freshen());
|
||||||
|
|
||||||
|
/** @var GuildCommandRepository[] $guildCommands */
|
||||||
|
$guildCommands = [];
|
||||||
|
|
||||||
|
foreach ($this->queue as $command) {
|
||||||
|
debug("Checking {$command->getName()}...");
|
||||||
|
/** @var GlobalCommandRepository|GuildCommandRepository $rCommands */
|
||||||
|
$rCommands = $command->properties->guild === null ?
|
||||||
|
$globalCommands :
|
||||||
|
$guildCommands[$command->properties->guild] ??= await($discord->guilds->get('id', $command->properties->guild)->commands->freshen());
|
||||||
|
|
||||||
|
$rCommand = $rCommands->get('name', $command->getName());
|
||||||
|
|
||||||
|
if ($rCommand === null || $command->hasCommandChanged($rCommand)) {
|
||||||
|
debug("Command {$command->getName()} has changed, re-registering it...");
|
||||||
|
$command->setNeedsRegistered(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($loadCommands) {
|
||||||
|
$this->loadCommands();
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolve();
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function loadCommands(): void
|
||||||
|
{
|
||||||
|
debug('Loading commands...');
|
||||||
|
$discord = discord();
|
||||||
|
|
||||||
|
$listen = static function (string|array $name, QueuedCommand $command) use ($discord) {
|
||||||
|
try {
|
||||||
|
$registered = $discord->listenCommand($command->getName(), $command->handler->handle(...), $command->handler->autocomplete(...));
|
||||||
|
|
||||||
|
if (!is_array($command->name) || count($command->name) === 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$loop = static function (array $commands) use (&$loop, $registered, $command) {
|
||||||
|
foreach ($commands as $commandName) {
|
||||||
|
if (is_array($commandName)) {
|
||||||
|
$loop($commandName);
|
||||||
|
}
|
||||||
|
|
||||||
|
$registered->addSubCommand($commandName, $command->handler->handle(...), $command->handler->autocomplete(...));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$names = $command->name;
|
||||||
|
array_shift($names);
|
||||||
|
|
||||||
|
$loop($names);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
if (preg_match_all('/The command `(\w+)` already exists\./m', $e->getMessage(), $matches, PREG_SET_ORDER)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
error($e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach ($this->queue as $command) {
|
||||||
|
$listen($command->name, $command);
|
||||||
|
|
||||||
|
debug("Loaded command {$command->getName()}");
|
||||||
|
|
||||||
|
if (!$command->needsRegistered()) {
|
||||||
|
debug("Command {$command->getName()} does not need to be registered");
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->registerCommand($command);
|
||||||
|
debug("Command {$command->getName()} was registered");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function registerCommand(QueuedCommand $command): PromiseInterface
|
||||||
|
{
|
||||||
|
return new Promise(static function ($resolve, $reject) use ($command) {
|
||||||
|
$discord = discord();
|
||||||
|
$commands = $command->properties->guild === null ?
|
||||||
|
$discord->application->commands :
|
||||||
|
$discord->guilds->get('id', $command->properties->guild)?->commands ?? null;
|
||||||
|
|
||||||
|
if ($commands === null && $command->properties->guild !== null) {
|
||||||
|
$discord->getLogger()->error("Failed to register command {$command->getName()}: Guild {$command->properties->guild} not found");
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$commands->save(
|
||||||
|
$commands->create(
|
||||||
|
$command->handler->getConfig()->toArray()
|
||||||
|
)
|
||||||
|
)->then(static function () use ($command, $resolve) {
|
||||||
|
debug("Command {$command->getName()} was registered");
|
||||||
|
$resolve();
|
||||||
|
})->otherwise(static function (Throwable $e) use ($command, $reject) {
|
||||||
|
error("Failed to register command {$command->getName()}: {$e->getMessage()}");
|
||||||
|
$reject($e);
|
||||||
|
});
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
error("Failed to register command {$command->getName()}: {$e->getMessage()}");
|
||||||
|
$reject($e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function queueAndRunCommands(bool $loadCommands = true, bool $registerCommands = true, QueuedCommand ...$commands): PromiseInterface
|
||||||
|
{
|
||||||
|
$queue = (new self());
|
||||||
|
|
||||||
|
foreach ($commands as $command) {
|
||||||
|
$queue->appendCommand($command);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $queue->runQueue($loadCommands, $registerCommands);
|
||||||
|
}
|
||||||
|
}
|
||||||
77
src/Core/Commands/QueuedCommand.php
Normal file
77
src/Core/Commands/QueuedCommand.php
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Core\Commands;
|
||||||
|
|
||||||
|
use ArrayAccess;
|
||||||
|
use Discord\Builders\CommandBuilder;
|
||||||
|
use Discord\Parts\Interactions\Command\Command as DiscordCommand;
|
||||||
|
use LogicException;
|
||||||
|
|
||||||
|
class QueuedCommand
|
||||||
|
{
|
||||||
|
protected bool $needsRegistered = false;
|
||||||
|
public readonly string|array $name;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public readonly Command $properties,
|
||||||
|
public readonly CommandHandler $handler
|
||||||
|
) {
|
||||||
|
$name = $this->properties->name ?? $this->handler->getConfig()->toArray()['name'] ?? null;
|
||||||
|
|
||||||
|
if ($name === null) {
|
||||||
|
$className = get_class($this->handler);
|
||||||
|
throw new LogicException("Command {$className} has no name");
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->name = $name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getName(): string
|
||||||
|
{
|
||||||
|
return is_array($this->name) ? $this->name[0] : $this->name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasCommandChanged(DiscordCommand $rCommand): bool
|
||||||
|
{
|
||||||
|
$command = $this->handler->getConfig();
|
||||||
|
$rCommand = $rCommand->jsonSerialize();
|
||||||
|
|
||||||
|
if ($command instanceof CommandBuilder) {
|
||||||
|
$command = $command->jsonSerialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
$areTheSame = static function (array|ArrayAccess $a, array|ArrayAccess $b) use (&$areTheSame): bool {
|
||||||
|
$ignoreFields = ['default_permission', 'required'];
|
||||||
|
|
||||||
|
foreach ($a as $key => $value) {
|
||||||
|
$bValue = $b[$key] ?? null;
|
||||||
|
|
||||||
|
if ($value === $bValue || in_array($key, $ignoreFields)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($value) && (is_array($bValue) || $bValue instanceof ArrayAccess)) {
|
||||||
|
if (!$areTheSame($value, $bValue)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
return !$areTheSame($command, $rCommand);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setNeedsRegistered(bool $needsRegistered): void
|
||||||
|
{
|
||||||
|
$this->needsRegistered = $needsRegistered;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function needsRegistered(): bool
|
||||||
|
{
|
||||||
|
return $this->needsRegistered;
|
||||||
|
}
|
||||||
|
}
|
||||||
10
src/Core/Disabled.php
Normal file
10
src/Core/Disabled.php
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Core;
|
||||||
|
|
||||||
|
use Attribute;
|
||||||
|
|
||||||
|
#[Attribute(Attribute::TARGET_CLASS)]
|
||||||
|
class Disabled
|
||||||
|
{
|
||||||
|
}
|
||||||
22
src/Core/Env.php
Normal file
22
src/Core/Env.php
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Core;
|
||||||
|
|
||||||
|
use Discord\Discord;
|
||||||
|
use Services\ReminderService;
|
||||||
|
use Tnapf\Env\Env as BaseEnv;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property-read string $TOKEN The authentication token provided by discord
|
||||||
|
* @property-read string $BACKEND_TOKEN The authentication token provided by the backend
|
||||||
|
* @property-read string $API_URL The url of the backend api endpoints
|
||||||
|
* @property-read string $LOG_CHANNEL_ID The channel to send errors/warning/etc. messages by the bot
|
||||||
|
* @property-read string $APPLICATION_ID The applicatin id if the bot provided by discord
|
||||||
|
* @property-read string $CACHE_TTL The number of seconds to keep a cache item alive
|
||||||
|
* @property Discord $discord The global Discord object
|
||||||
|
* @property ReminderService $remainderService The periodic service that sends remainder every second
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
class Env extends BaseEnv
|
||||||
|
{
|
||||||
|
}
|
||||||
12
src/Core/Events/ApplicationCommandPermissionsUpdate.php
Normal file
12
src/Core/Events/ApplicationCommandPermissionsUpdate.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Core\Events;
|
||||||
|
|
||||||
|
use Discord\Discord;
|
||||||
|
use Discord\Parts\Guild\CommandPermissions;
|
||||||
|
|
||||||
|
#[Event(\Discord\WebSockets\Event::APPLICATION_COMMAND_PERMISSIONS_UPDATE)]
|
||||||
|
interface ApplicationCommandPermissionsUpdate
|
||||||
|
{
|
||||||
|
public function handle(CommandPermissions $commandPermission, Discord $discord, ?CommandPermissions $oldCommandPermission): void;
|
||||||
|
}
|
||||||
13
src/Core/Events/AutoModerationRuleCreate.php
Normal file
13
src/Core/Events/AutoModerationRuleCreate.php
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Core\Events;
|
||||||
|
|
||||||
|
use Discord\Discord;
|
||||||
|
use Discord\Parts\Guild\AutoModeration\Rule;
|
||||||
|
|
||||||
|
/** @see https://discord-php.github.io/DiscordPHP/guide/events/auto_moderations.html#auto-moderation-rule-create */
|
||||||
|
#[Event(\Discord\WebSockets\Event::AUTO_MODERATION_RULE_CREATE)]
|
||||||
|
interface AutoModerationRuleCreate
|
||||||
|
{
|
||||||
|
public function handle(Rule $rule, Discord $discord): void;
|
||||||
|
}
|
||||||
14
src/Core/Events/Event.php
Normal file
14
src/Core/Events/Event.php
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Core\Events;
|
||||||
|
|
||||||
|
use Attribute;
|
||||||
|
|
||||||
|
#[Attribute(Attribute::TARGET_CLASS)]
|
||||||
|
class Event
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $name
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/Core/Events/Init.php
Normal file
11
src/Core/Events/Init.php
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Core\Events;
|
||||||
|
|
||||||
|
use Discord\Discord;
|
||||||
|
|
||||||
|
#[Event('init')]
|
||||||
|
interface Init
|
||||||
|
{
|
||||||
|
public function handle(Discord $discord): void;
|
||||||
|
}
|
||||||
13
src/Core/Events/MessageCreate.php
Normal file
13
src/Core/Events/MessageCreate.php
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Core\Events;
|
||||||
|
|
||||||
|
use Discord\Discord;
|
||||||
|
use Discord\Parts\Channel\Message;
|
||||||
|
|
||||||
|
/** @see https://discord-php.github.io/DiscordPHP/guide/events/messages.html#message-create */
|
||||||
|
#[Event(\Discord\WebSockets\Event::MESSAGE_CREATE)]
|
||||||
|
interface MessageCreate
|
||||||
|
{
|
||||||
|
public function handle(Message $message, Discord $discord): void;
|
||||||
|
}
|
||||||
12
src/Core/Events/MessageDelete.php
Normal file
12
src/Core/Events/MessageDelete.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Core\Events;
|
||||||
|
|
||||||
|
use Discord\Discord;
|
||||||
|
|
||||||
|
/** @see https://discord-php.github.io/DiscordPHP/guide/events/messages.html#message-delete */
|
||||||
|
#[Event(\Discord\WebSockets\Event::MESSAGE_DELETE)]
|
||||||
|
interface MessageDelete
|
||||||
|
{
|
||||||
|
public function handle(object $message, Discord $discord): void;
|
||||||
|
}
|
||||||
13
src/Core/Events/MessageDeleteBulk.php
Normal file
13
src/Core/Events/MessageDeleteBulk.php
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Core\Events;
|
||||||
|
|
||||||
|
use Discord\Discord;
|
||||||
|
use Discord\Helpers\Collection;
|
||||||
|
|
||||||
|
/** @see https://discord-php.github.io/DiscordPHP/guide/events/messages.html#message-delete-bulk */
|
||||||
|
#[Event(\Discord\WebSockets\Event::MESSAGE_DELETE_BULK)]
|
||||||
|
interface MessageDeleteBulk
|
||||||
|
{
|
||||||
|
public function handle(Collection $messages, Discord $discord): void;
|
||||||
|
}
|
||||||
13
src/Core/Events/MessageUpdate.php
Normal file
13
src/Core/Events/MessageUpdate.php
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Core\Events;
|
||||||
|
|
||||||
|
use Discord\Discord;
|
||||||
|
use Discord\Parts\Channel\Message;
|
||||||
|
|
||||||
|
/** @see https://discord-php.github.io/DiscordPHP/guide/events/messages.html#message-update */
|
||||||
|
#[Event(\Discord\WebSockets\Event::MESSAGE_UPDATE)]
|
||||||
|
interface MessageUpdate
|
||||||
|
{
|
||||||
|
public function handle(Message $message, Discord $discord, ?Message $oldMessage): void;
|
||||||
|
}
|
||||||
58
src/Core/HMR/HotDirectory.php
Normal file
58
src/Core/HMR/HotDirectory.php
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Core\HMR;
|
||||||
|
|
||||||
|
use CommandString\Utils\FileSystemUtils;
|
||||||
|
use Evenement\EventEmitter;
|
||||||
|
use LogicException;
|
||||||
|
use React\EventLoop\Loop;
|
||||||
|
|
||||||
|
class HotDirectory extends EventEmitter
|
||||||
|
{
|
||||||
|
public const EVENT_FILE_CHANGED = 'fileChanged';
|
||||||
|
public const EVENT_FILE_ADDED = 'fileAdded';
|
||||||
|
public const EVENT_FILE_REMOVED = 'fileRemoved';
|
||||||
|
|
||||||
|
/** @var HotFile[] */
|
||||||
|
protected array $files = [];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $directory,
|
||||||
|
int $interval = 1
|
||||||
|
) {
|
||||||
|
if (!file_exists($directory)) {
|
||||||
|
throw new LogicException("Directory {$directory} does not exist");
|
||||||
|
}
|
||||||
|
|
||||||
|
$files = FileSystemUtils::getAllFiles($directory);
|
||||||
|
|
||||||
|
foreach ($files as $file) {
|
||||||
|
$hotFile = new HotFile($file, $interval);
|
||||||
|
$this->files[$file] = $hotFile;
|
||||||
|
|
||||||
|
$hotFile
|
||||||
|
->on(HotFile::EVENT_CHANGED, fn (HotFile $file) => $this->emit(self::EVENT_FILE_CHANGED, [$this, $file]))
|
||||||
|
->on(HotFile::EVENT_REMOVED, fn (HotFile $file) => $this->emit(self::EVENT_FILE_REMOVED, [$this, $file]));
|
||||||
|
}
|
||||||
|
|
||||||
|
Loop::addPeriodicTimer($interval, function () use ($interval) {
|
||||||
|
foreach (FileSystemUtils::getAllFiles($this->directory) as $file) {
|
||||||
|
if (isset($this->files[$file])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->files[$file] = new HotFile($file, $interval);
|
||||||
|
|
||||||
|
$this->emit(self::EVENT_FILE_ADDED, [$this, $file]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return HotFile[]
|
||||||
|
*/
|
||||||
|
public function getFiles(): array
|
||||||
|
{
|
||||||
|
return $this->files;
|
||||||
|
}
|
||||||
|
}
|
||||||
66
src/Core/HMR/HotFile.php
Normal file
66
src/Core/HMR/HotFile.php
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Core\HMR;
|
||||||
|
|
||||||
|
use Evenement\EventEmitter;
|
||||||
|
use LogicException;
|
||||||
|
use React\EventLoop\Loop;
|
||||||
|
use React\EventLoop\TimerInterface;
|
||||||
|
|
||||||
|
class HotFile extends EventEmitter
|
||||||
|
{
|
||||||
|
public const EVENT_CHANGED = 'hasChanged';
|
||||||
|
public const EVENT_REMOVED = 'removed';
|
||||||
|
|
||||||
|
protected string $hash = '';
|
||||||
|
public readonly string $name;
|
||||||
|
private TimerInterface $timer;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $file,
|
||||||
|
int $interval = 1
|
||||||
|
) {
|
||||||
|
if (!file_exists($file)) {
|
||||||
|
throw new LogicException("File {$file} does not exist");
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->name = basename($file);
|
||||||
|
$this->hash = $this->createHash();
|
||||||
|
|
||||||
|
$this->timer = Loop::addPeriodicTimer($interval, function () {
|
||||||
|
if (!file_exists($this->file)) {
|
||||||
|
$this->emit(self::EVENT_REMOVED, [$this]);
|
||||||
|
$this->__destruct();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->hasChanged()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->hash = $this->createHash();
|
||||||
|
$this->emit(self::EVENT_CHANGED, [$this]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getContents(): string
|
||||||
|
{
|
||||||
|
return file_get_contents($this->file);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createHash(): string
|
||||||
|
{
|
||||||
|
return hash('sha256', $this->getContents());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasChanged(): bool
|
||||||
|
{
|
||||||
|
return $this->createHash() !== $this->hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __destruct()
|
||||||
|
{
|
||||||
|
Loop::cancelTimer($this->timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
274
src/Core/functions.php
Normal file
274
src/Core/functions.php
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Core;
|
||||||
|
|
||||||
|
use CommandString\Utils\FileSystemUtils;
|
||||||
|
use Discord\Builders\Components\ActionRow;
|
||||||
|
use Discord\Builders\Components\Button;
|
||||||
|
use Discord\Builders\MessageBuilder;
|
||||||
|
use Discord\Discord;
|
||||||
|
use Discord\Helpers\Collection;
|
||||||
|
use Discord\Parts\Embed\Embed;
|
||||||
|
use Discord\Parts\Interactions\Command\Choice;
|
||||||
|
use Discord\Parts\Interactions\Command\Option as CommandOption;
|
||||||
|
use Discord\Parts\Interactions\Request\Option;
|
||||||
|
use Discord\Parts\Interactions\Interaction;
|
||||||
|
use LogicException;
|
||||||
|
use ReflectionAttribute;
|
||||||
|
use ReflectionClass;
|
||||||
|
use ReflectionException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the Env instance
|
||||||
|
*
|
||||||
|
* @throws LogicException if the Env instance is not set
|
||||||
|
*/
|
||||||
|
function env(): Env
|
||||||
|
{
|
||||||
|
$env = Env::get();
|
||||||
|
|
||||||
|
if ($env === null) {
|
||||||
|
throw new LogicException('Env is not set');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $env;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the Discord instance from the Environment
|
||||||
|
*
|
||||||
|
* @throws LogicException if the Discord instance is not set
|
||||||
|
*/
|
||||||
|
function discord(): ?Discord
|
||||||
|
{
|
||||||
|
if (!isset(env()->discord)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return env()->discord;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new Option used for building slash commands
|
||||||
|
*/
|
||||||
|
function newSlashCommandOption(string $name, string $description, int $type, bool $required = false): CommandOption
|
||||||
|
{
|
||||||
|
return newDiscordPart(CommandOption::class)
|
||||||
|
->setName($name)
|
||||||
|
->setDescription($description)
|
||||||
|
->setType($type)
|
||||||
|
->setRequired($required);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new Choice used for building slash commands
|
||||||
|
*/
|
||||||
|
function newSlashCommandChoice(string $name, float|int|string $value): Choice
|
||||||
|
{
|
||||||
|
return newDiscordPart(Choice::class)
|
||||||
|
->setName($name)
|
||||||
|
->setValue($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new MessageBuilder object with the content define for creating simple MessageBuilders quickly
|
||||||
|
*
|
||||||
|
* ```php
|
||||||
|
* $message = messageWithContent("Hello World");
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
function messageWithContent(string $content): MessageBuilder
|
||||||
|
{
|
||||||
|
return MessageBuilder::new()->setContent($content);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append to grab and empty array field. You can supply an embed to have the empty field added, or
|
||||||
|
* if you leave the `$embed` option `null`, then an array containing the empty field will be returned
|
||||||
|
*
|
||||||
|
* ```php
|
||||||
|
* $embed = newDiscordPart("\Discord\Parts\Embed\Embed");
|
||||||
|
* emptyEmbedField($embed);
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* or
|
||||||
|
*
|
||||||
|
* ```php
|
||||||
|
* $embed = newDiscordPart("\Discord\Parts\Embed\Embed");
|
||||||
|
* $emptyField = emptyEmbedField();
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
function emptyEmbedField(?Embed $embed = null): array|Embed
|
||||||
|
{
|
||||||
|
$emptyField = ['name' => "\u{200b}", 'value' => "\u{200b}"];
|
||||||
|
|
||||||
|
if ($embed !== null) {
|
||||||
|
return $embed->addField($emptyField);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $emptyField;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @template T
|
||||||
|
*
|
||||||
|
* @param class-string<T> $class
|
||||||
|
*
|
||||||
|
* @return T
|
||||||
|
*/
|
||||||
|
function newDiscordPart(string $class, mixed ...$args): mixed
|
||||||
|
{
|
||||||
|
return new $class(discord(), ...$args);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quickly build an action row with multiple buttons
|
||||||
|
*
|
||||||
|
* ```php
|
||||||
|
* $banButton = (new Button(Button::STYLE_DANGER))->setLabel("Ban User");
|
||||||
|
* $kickButton = (new Button(Button::STYLE_DANGER))->setLabel("Kick User");
|
||||||
|
* $actionRow = buildActionRowWithButtons($banButton, $kickButton);
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* *This can also be paired with newButton*
|
||||||
|
*
|
||||||
|
* ```php
|
||||||
|
* $actionRow = buildActionWithButtons(
|
||||||
|
* newButton(Button::STYLE_DANGER, "Ban User")
|
||||||
|
* newButton(Button::STYLE_DANGER, "Kick User")
|
||||||
|
* );
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
function buildActionRowWithButtons(Button ...$buttons): ActionRow
|
||||||
|
{
|
||||||
|
$actionRow = new ActionRow();
|
||||||
|
|
||||||
|
foreach ($buttons as $button) {
|
||||||
|
$actionRow->addComponent($button);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $actionRow;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quickly create button objects
|
||||||
|
*
|
||||||
|
* ```php
|
||||||
|
* $button = newButton(Button::STYLE_DANGER, "Kick User", "Kick|Command_String");
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
function newButton(int $style, string $label, ?string $custom_id = null): Button
|
||||||
|
{
|
||||||
|
return (new Button($style, $custom_id))->setLabel($label);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an option from an Interaction/Interaction Repository by specifying the option(s) name
|
||||||
|
*
|
||||||
|
* For regular slash commands
|
||||||
|
* `/ban :user`
|
||||||
|
*
|
||||||
|
* ```php
|
||||||
|
* $user = getOptionFromInteraction($interaction, "user");
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* For sub commands / sub command groups you can stack the names
|
||||||
|
* `/admin ban :user`
|
||||||
|
*
|
||||||
|
* ```php
|
||||||
|
* $user = getOptionFromInteraction($interaction->data->options, "ban", "user");
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
function getOptionFromInteraction(Collection|Interaction $options, string ...$names): ?Option
|
||||||
|
{
|
||||||
|
if ($options instanceof Interaction) {
|
||||||
|
$options = $options->data->options;
|
||||||
|
}
|
||||||
|
|
||||||
|
$option = null;
|
||||||
|
foreach ($names as $key => $name) {
|
||||||
|
$option = $options->get('name', $name);
|
||||||
|
|
||||||
|
if ($key !== count($names) - 1) {
|
||||||
|
$options = $option?->options;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($options === null || $option === null) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $option;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logging Functions
|
||||||
|
|
||||||
|
function log($level, string $message, array $context = []): void
|
||||||
|
{
|
||||||
|
env()->discord->getLogger()->log($level, $message, $context);
|
||||||
|
}
|
||||||
|
|
||||||
|
function debug(string $message, array $context = []): void
|
||||||
|
{
|
||||||
|
env()->discord->getLogger()->debug($message, $context);
|
||||||
|
}
|
||||||
|
|
||||||
|
function error(string $message, array $context = []): void
|
||||||
|
{
|
||||||
|
env()->discord->getLogger()->error($message, $context);
|
||||||
|
}
|
||||||
|
|
||||||
|
function info(string $message, array $context = []): void
|
||||||
|
{
|
||||||
|
env()->discord->getLogger()->info($message, $context);
|
||||||
|
}
|
||||||
|
|
||||||
|
function warning(string $message, array $context = []): void
|
||||||
|
{
|
||||||
|
env()->discord->getLogger()->warning($message, $context);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Internal Functions //
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loop through all the classes in a directory and call a callback function with the class name
|
||||||
|
*/
|
||||||
|
function loopClasses(string $directory, callable $callback): void
|
||||||
|
{
|
||||||
|
$convertPathToNamespace = static fn (string $path): string => str_replace([realpath(BOT_ROOT), '/'], ['', '\\'], $path);
|
||||||
|
|
||||||
|
foreach (FileSystemUtils::getAllFilesWithExtensions($directory, ['php']) as $file) {
|
||||||
|
$className = basename($file, '.php');
|
||||||
|
$path = dirname($file);
|
||||||
|
$namespace = $convertPathToNamespace($path);
|
||||||
|
$className = $namespace . '\\' . $className;
|
||||||
|
|
||||||
|
$callback($className, $namespace, $file, $path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @template T
|
||||||
|
*
|
||||||
|
* @param class-string $class
|
||||||
|
* @param class-string<T> $attribute
|
||||||
|
*
|
||||||
|
* @throws ReflectionException
|
||||||
|
*
|
||||||
|
* @return T|false
|
||||||
|
*/
|
||||||
|
function doesClassHaveAttribute(string $class, string $attribute): object|false
|
||||||
|
{
|
||||||
|
return (new ReflectionClass($class))->getAttributes($attribute, ReflectionAttribute::IS_INSTANCEOF)[0] ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteAllFilesInDirectory(string $directory): void
|
||||||
|
{
|
||||||
|
if (is_dir($directory) === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (FileSystemUtils::getAllFiles($directory) as $file) {
|
||||||
|
unlink($file);
|
||||||
|
}
|
||||||
|
}
|
||||||
122
src/Core/helpers.php
Normal file
122
src/Core/helpers.php
Normal file
File diff suppressed because one or more lines are too long
28
src/Events/Message.php
Normal file
28
src/Events/Message.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Events;
|
||||||
|
|
||||||
|
use Core\Events\MessageCreate;
|
||||||
|
use Discord\Parts\Channel\Message as DiscordMessage;
|
||||||
|
use Discord\Discord;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event handler for the "Message" event
|
||||||
|
*/
|
||||||
|
class Message implements MessageCreate
|
||||||
|
{
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Handles the event
|
||||||
|
*
|
||||||
|
* @param DiscordMessage $message
|
||||||
|
* @param Discord $discord
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public function handle(DiscordMessage $message, Discord $discord): void
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
}
|
||||||
52
src/Events/Ready.php
Normal file
52
src/Events/Ready.php
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Events;
|
||||||
|
|
||||||
|
use Core\Events\Init;
|
||||||
|
use Discord\Discord;
|
||||||
|
use Discord\Builders\MessageBuilder;
|
||||||
|
|
||||||
|
use function Core\debug;
|
||||||
|
use function Core\env;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event handler for the "Ready" event
|
||||||
|
*/
|
||||||
|
class Ready implements Init
|
||||||
|
{
|
||||||
|
// --------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Handles the event
|
||||||
|
*
|
||||||
|
* @param Discord $discord
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public function handle(Discord $discord): void
|
||||||
|
{
|
||||||
|
debug("Bot is ready!");
|
||||||
|
|
||||||
|
$logChannelID = env()->LOG_CHANNEL_ID;
|
||||||
|
$appID = env()->APPLICATION_ID;
|
||||||
|
$appVersion = BOT_BUILD;
|
||||||
|
$logChannel = $discord->getChannel($logChannelID);
|
||||||
|
|
||||||
|
// create start notice
|
||||||
|
$message = MessageBuilder::new()
|
||||||
|
->setContent("<@$appID>(v$appVersion) ONLINE.\n")
|
||||||
|
->setAllowedMentions([
|
||||||
|
'parse' => ['users'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// send start notice to the log channel
|
||||||
|
$logChannel->sendMessage($message);
|
||||||
|
|
||||||
|
// regiter RemainderService handler
|
||||||
|
$loop = $discord->getLoop();
|
||||||
|
debug('Registering onLoop event handler');
|
||||||
|
$timer = $loop->addPeriodicTimer(1, [env()->remainderService, 'onLoop']);
|
||||||
|
debug('Registered onLoop event handler');
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/LICENSE
Normal file
21
src/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) Totally Not Another PHP Framework
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
95
src/README.md
Normal file
95
src/README.md
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|

|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
|
## The source code of the bot.
|
||||||
|
|
||||||
|
- [Bootstrap](Bootstrap)<br>
|
||||||
|
The startup files to boot the application.
|
||||||
|
|
||||||
|
- [Bot](Bot)
|
||||||
|
- [Cache.php](Bot/Cache.php), [CacheItem.php](Bot/CacheItem.php), [ObjectCache.php](Bot/ObjectCache.php)<br>
|
||||||
|
Minimal caching to minimize API calls to the backend.
|
||||||
|
|
||||||
|
- [DevLogger.php](Bot/DevLogger.php)<br>
|
||||||
|
Helper class to log "impossible" events, that should not happen.
|
||||||
|
The log is written in [JSON](https://www.json.org/) format in the `/app/Bot/Storag/Logs/dev.log` file.
|
||||||
|
|
||||||
|
- [Client](Client)<br>
|
||||||
|
The helper classes to communicate with the backend and the discord client
|
||||||
|
|
||||||
|
- [Models](Client/Models)<br>
|
||||||
|
The data models
|
||||||
|
|
||||||
|
- [Responses](Client/Responses)<br>
|
||||||
|
The responses from the backend
|
||||||
|
|
||||||
|
- [Traits](Client/Traits)<br>
|
||||||
|
Commonly used classes
|
||||||
|
|
||||||
|
- [ApiClient.php](Client/ApiClient.php)<br>
|
||||||
|
The main class to manage all user data and communication with both ends.
|
||||||
|
|
||||||
|
- [ApiResponse.php](Client/ApiResponse.php)<br>
|
||||||
|
Helper class to manage all the communication in one place.
|
||||||
|
|
||||||
|
- [ClientMessages.php](Client/ClientMessages.php)<br>
|
||||||
|
The message templates sent to the discord client. Uses the [Smarty](https://www.smarty.net/) template engine.
|
||||||
|
|
||||||
|
- [Template.php](Client/Template.php)<br>
|
||||||
|
Minimal template "engine" to generate [ANSI](https://gist.github.com/kkrypt0nn/a02506f3712ff2d1c8ca7c9e0aed7c06) colored messages for the discord client.
|
||||||
|
|
||||||
|
- [Commands](Commands)<br>
|
||||||
|
The classes to handle [slash command](https://discord.com/developers/docs/tutorials/upgrading-to-application-commands)s from the discord client.
|
||||||
|
|
||||||
|
- [CreateRemainder.php](Commands/CreateRemainder.php)<br>
|
||||||
|
The `/rem <when> <message> (channel)` command to create a new remainder
|
||||||
|
|
||||||
|
- [EditRemainder.php](Commands/EditRemainder.php)<br>
|
||||||
|
The `/edit <remainder> (when) (message) (channel)` command to create a new remainder
|
||||||
|
|
||||||
|
- [ListRemainders.php](Commands/ListRemainders.php)<br>
|
||||||
|
The `/list (page)` command to show a paginated list of the current remainders
|
||||||
|
|
||||||
|
- [Profile.php](Commands/Profile.php)<br>
|
||||||
|
The `/profile (timezone) (locale)` command to display/modify the actual users profile
|
||||||
|
|
||||||
|
- [RemoveRemainder.php](Commands/RemoveRemainder.php)<br>
|
||||||
|
The `/delete <remainder>` command to remove a remainder (needs confirmation)
|
||||||
|
|
||||||
|
- [Core](Core)<br>
|
||||||
|
The core components of the [commandstring/dphp-bot](https://github.com/CommandString/discordphp-bot-template) package
|
||||||
|
|
||||||
|
- [Events](Events)<br>
|
||||||
|
The main event handling for the discord client
|
||||||
|
|
||||||
|
- [Message.php](Events/Ready.php)<br>
|
||||||
|
Handles all messages comming from the discord client<br>
|
||||||
|
***NOTE: currentky no custom handling is done here***
|
||||||
|
|
||||||
|
- [Ready.php](Events/Ready.php)<br>
|
||||||
|
Starts the main remainder pull service when the discord server becomes ready
|
||||||
|
|
||||||
|
|
||||||
|
- [Services](Services)<br>
|
||||||
|
The main services to handle background tasks
|
||||||
|
|
||||||
|
- [ReminderService.php](Services/ReminderService.php)<br>
|
||||||
|
Periodically pulls actual remainder from the backend and sends remainders to the discord client
|
||||||
|
|
||||||
|
- [Storage](Storage)<br>
|
||||||
|
Stores temporary files and program logs
|
||||||
|
|
||||||
|
- [Test](Test)<br>
|
||||||
|
A rather scarce list of test, the function testings is handled by an outside service currently
|
||||||
|
|
||||||
|
- [.env.example](.env.example)<br>
|
||||||
|
The sample configuration file to be filled before deploying the bot
|
||||||
|
|
||||||
|
- [Bot.php](Bot.php)<br>
|
||||||
|
The main entrypoint for the bot
|
||||||
|
|
||||||
|
- [BotDev.php](BotDev.php)<br>
|
||||||
|
Main entripoint if not run in container
|
||||||
230
src/Services/ReminderService.php
Normal file
230
src/Services/ReminderService.php
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Services;
|
||||||
|
|
||||||
|
use Bot\DevLogger;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Client\ApiClient;
|
||||||
|
use Client\ApiResponse;
|
||||||
|
use Client\Models\Remainder;
|
||||||
|
use Client\Responses\RemainderListResponse;
|
||||||
|
use Client\Traits\HasApiClient;
|
||||||
|
use Client\Traits\HasDiscord;
|
||||||
|
use Discord\Parts\Channel\Channel;
|
||||||
|
use Discord\Parts\User\User;
|
||||||
|
use Exception;
|
||||||
|
use React\Http\Message\Response;
|
||||||
|
|
||||||
|
use function Core\debug;
|
||||||
|
use function Core\warning;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches actual remainders and sends them to the discord api
|
||||||
|
*/
|
||||||
|
class ReminderService
|
||||||
|
{
|
||||||
|
use HasApiClient, HasDiscord;
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------------------------------------------------
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
//NOTE: the discord() is not ready jet, so no logger nor debug() is available at this point
|
||||||
|
echo "RemainderService created.\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a remainder to a specified channel
|
||||||
|
*
|
||||||
|
* @param Remainder $remainder The remainder to send
|
||||||
|
* @param Channel $channel The channel to send the remainder to
|
||||||
|
* @param User $user The discord user to send the remainder to
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
protected function sendRemainderToChannel(Remainder $remainder, Channel $channel, User $user): void
|
||||||
|
{
|
||||||
|
debug(sprintf('Remainder (%d) user (@%d) private channel (#%d) found', $remainder->id, $user->id, $channel->id));
|
||||||
|
|
||||||
|
$channel->sendMessage("Remainder: $remainder->message")->then(
|
||||||
|
onFulfilled: function ($result) use (&$remainder, $channel) {
|
||||||
|
|
||||||
|
debug(sprintf('Remainder (%d) sent to specified channel (#%d)', $remainder->id, $channel->id));
|
||||||
|
|
||||||
|
$this->getApiClient()->updateRemainder($remainder, ['status' => 'finished'])->then(
|
||||||
|
onFulfilled: function (Response $response) use ($remainder) {
|
||||||
|
debug(sprintf('Remainder (%d) updated as finished', $remainder->id));
|
||||||
|
},
|
||||||
|
onRejected: function (Exception $exception) use ($remainder) {
|
||||||
|
DevLogger::warning(
|
||||||
|
message: 'Api request failed',
|
||||||
|
context: [
|
||||||
|
'exception' => $exception,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
//TODO: use $this->onLoopReject maybe???
|
||||||
|
debug(sprintf('Remainder (%d) update as finished FAILED with message: "%s"', $remainder->id, $exception->getMessage()));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onRejected: fn (Exception $exception) => DevLogger::warning(
|
||||||
|
message: 'Send Message failed',
|
||||||
|
context: [
|
||||||
|
'exception' => $exception,
|
||||||
|
'remainder' => $remainder,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a remainder trough the discord api
|
||||||
|
*
|
||||||
|
* @param Remainder $remainder The Remainder to send
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
protected function sendRemainder(Remainder $remainder)//: PromiseInterface
|
||||||
|
{
|
||||||
|
// get the discord User
|
||||||
|
$this->getDiscord()->users->fetch($remainder->discord_user->snowflake)->then(
|
||||||
|
onFulfilled: function (User $user) use (&$remainder) {
|
||||||
|
|
||||||
|
debug(sprintf('Remainder (%d) Discord::User (@%d) found', $remainder->id, $user->id));
|
||||||
|
|
||||||
|
// if the remainder _DOES_NOT_ have a channel set, get a private channel to the DiscordUser
|
||||||
|
if (null === $remainder->channel_id) {
|
||||||
|
$user->getPrivateChannel()->then(
|
||||||
|
onFulfilled: fn (Channel $channel) =>
|
||||||
|
$this->sendRemainderToChannel($remainder, $channel, $user),
|
||||||
|
onRejected: fn (Exception $exception) => DevLogger::warning(
|
||||||
|
message: 'Send Message ot Private channel failed',
|
||||||
|
context: [
|
||||||
|
'exception' => $exception,
|
||||||
|
'remainder' => $remainder,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$channel = $this->getDiscord()->getChannel($remainder->channel_id);
|
||||||
|
|
||||||
|
// if the channel cannot be found (maybe deleted) or inaccesible (not authorised to see it)
|
||||||
|
if ($channel === null) {
|
||||||
|
|
||||||
|
$this->getApiClient()->updateRemainder(
|
||||||
|
remainder: $remainder,
|
||||||
|
changes: ['status' => 'failed', 'error' => 'Channel not found.']
|
||||||
|
)->then(
|
||||||
|
onFulfilled: function ($result) use (&$remainder) {
|
||||||
|
DevLogger::warning(
|
||||||
|
message: 'Remainder had an invalid channel',
|
||||||
|
context: [
|
||||||
|
'reaminder' => $remainder,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
warning('Channel not found event detected. See dev.log for more details.');
|
||||||
|
return;
|
||||||
|
},
|
||||||
|
onRejected: fn (Exception $exception) => DevLogger::warning(
|
||||||
|
message: 'Api request failed',
|
||||||
|
context: [
|
||||||
|
'exception' => $exception,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$this->sendRemainderToChannel($remainder, $channel, $user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRejected: function (Exception $exception) use (&$remainder) {
|
||||||
|
DevLogger::warning(
|
||||||
|
message: 'Remainder had an invalid user snowflake',
|
||||||
|
context: [
|
||||||
|
'exception' => $exception,
|
||||||
|
'reaminder' => $remainder,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
warning('User not found event detected. See dev.log for more details.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// ------------------------------------------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* The periodically called handler
|
||||||
|
*
|
||||||
|
* Retrievs the actual remainders from the backend and sends them trough the discord api
|
||||||
|
*
|
||||||
|
* @param mixed $timer
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public function onLoop($timer)
|
||||||
|
{
|
||||||
|
|
||||||
|
// get actual remaindres
|
||||||
|
$this->getApiClient()->getActualRemainders()->then(
|
||||||
|
onFulfilled: function (Response $response) {
|
||||||
|
|
||||||
|
$actualRemainders = RemainderListResponse::make($response);
|
||||||
|
|
||||||
|
// print debug info
|
||||||
|
debug(
|
||||||
|
sprintf(
|
||||||
|
'Gettnig actual remainders at "%s", got %d remainder.',
|
||||||
|
Carbon::now('Europe/Budapest'),
|
||||||
|
count($actualRemainders->remainderList)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// send each remainder
|
||||||
|
foreach ($actualRemainders->remainderList as $remiainder) {
|
||||||
|
$this->getApiClient()->updateRemainder($remiainder, ['status' => 'pending'])->then(
|
||||||
|
onFulfilled: function (Response $response) use ($remiainder) {
|
||||||
|
$this->sendRemainder($remiainder);
|
||||||
|
},
|
||||||
|
onRejected: $this->onLoopRejected(false)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
},
|
||||||
|
onRejected: $this->onLoopRejected(true)
|
||||||
|
);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a function to handle Promise onReject
|
||||||
|
*
|
||||||
|
* Saves the reject reason to the dev log and optionally send a debug meseg to the output
|
||||||
|
*
|
||||||
|
* @param bool $showDebug if true, a debug message is written to the output, otherwise no message
|
||||||
|
*
|
||||||
|
* @return callable The function to handle the onReject callback
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
private function onLoopRejected(bool $showDebug = false): callable
|
||||||
|
{
|
||||||
|
return function (Exception|ApiResponse $reason) use ($showDebug) {
|
||||||
|
$keyName = is_a($reason, ApiClient::class) ? 'apiResponse' : 'exception';
|
||||||
|
|
||||||
|
DevLogger::warning(
|
||||||
|
message: 'Api request failed',
|
||||||
|
context: [
|
||||||
|
$keyName => $reason,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($showDebug) {
|
||||||
|
$debugMessage = sprintf(
|
||||||
|
'Gettnig actual remainders at "%s", failed, see dev.log for details.',
|
||||||
|
Carbon::now('Europe/Budapest')
|
||||||
|
);
|
||||||
|
debug($debugMessage);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
2
src/Storage/.gitignore
vendored
Normal file
2
src/Storage/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
286
src/TODO
Normal file
286
src/TODO
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
☐ Add initial script on entry to cehck if: (maybe entrypoint.sh)
|
||||||
|
//- intl is installed
|
||||||
|
the storage files ar writable:
|
||||||
|
- Storage/Logs
|
||||||
|
- Storage/Smarty/templates_c
|
||||||
|
# ---- Completed ----
|
||||||
|
✔ Create install instructions for folders to create @done(25-04-28 18:21)
|
||||||
|
|
||||||
|
✘ MAYBEE - Add .git folder to image @cancelled(25-04-28 12:39)
|
||||||
|
OR - remove git version logging from dev log...
|
||||||
|
NOTE: it works, just there is no git info in the dev log...
|
||||||
|
NOTE: no real need for it now, during dev it works, otherwise not needed...
|
||||||
|
|
||||||
|
✘ Test to use without intl, define locale names it would save lots of space in both image sizes... @cancelled(25-04-28 12:37)
|
||||||
|
(locale_get_display_name, etc...)
|
||||||
|
NOTE: postponed for later
|
||||||
|
|
||||||
|
✔ @critical /profile fails (BOT not serponding) @done(25-04-16 11:53)
|
||||||
|
if no profile is found - OK
|
||||||
|
if setting timezone - OK
|
||||||
|
Your timezone succesfully updated to "Europe/Budapest".
|
||||||
|
Your local time is: "2025-04-14 12:08:51"
|
||||||
|
if requesting now - FAIL
|
||||||
|
if setting timezone - OK
|
||||||
|
if setting locale - FAIL (autocomplete OK)
|
||||||
|
if bot is run on Beast, /profile works... (backend still runs on hercules)
|
||||||
|
Your timezone is: "Europe/Budapest",
|
||||||
|
Your local time is: "2025-04-14 12:27:27"
|
||||||
|
Your locale is: "n/a - not defined"
|
||||||
|
NOTE: Fatal error: Uncaught Error: Call to undefined function locale_get_display_name()
|
||||||
|
NOTE: updated Dockerfile to start from the default alpine image instead of php
|
||||||
|
|
||||||
|
✔ @critical If there are no remainders, a /list fails with: @done(25-04-16 11:04)
|
||||||
|
Error: The page 1 is invalid!
|
||||||
|
Please chose between 1 and 0.
|
||||||
|
|
||||||
|
✔ @high The usage of 'withRejectErrorResponse(false)' is not needed anymore. Remaove it or change parameter to true. @done(25-01-08 11:09)
|
||||||
|
NOTE: there is no need for the more detailed response, client cannot do anything with that extra information,
|
||||||
|
it can be useed/handled by the developer...
|
||||||
|
NOTE: for now, the function is kept in place, only the parameter is set to true.
|
||||||
|
|
||||||
|
✔ @low Run cs-fixer befor deployment to github. @done(25-01-01 13:21)
|
||||||
|
|
||||||
|
✔ @low Cache::getDiscordUser() (and all others) have this description: @done(25-01-01 13:24)
|
||||||
|
@promise-rejected fn (mixed $reason): void
|
||||||
|
theretically those will return an Exception or ApiResponse as a reason, maybe narrow down the "mixed" type...
|
||||||
|
NOTE: things can cheange outside of my code, so this cannot be guarantead, so i keep it this way for now...
|
||||||
|
|
||||||
|
✔ @low ->format('Y-m-d H:i') used in many places, maybe this should be adjustable by the discorduser... @done(24-12-30 14:36)
|
||||||
|
or maybe put in the .env file
|
||||||
|
NOTE: added DiscordBot::getDateTimeFormat(), this can be overwritten later if needed...
|
||||||
|
|
||||||
|
✔ @critical Update all browser response handling to make use of responseCodes @done(24-12-30 14:27)
|
||||||
|
|
||||||
|
✔ @critical update all api calls to use 'withRejectErrorResponse(false)' @done(24-12-30 14:27)
|
||||||
|
|
||||||
|
✘ @high Add mode to change default behaviour to send messages to the user. (DM/default channel/etc.) (DM may be not optimal..) @cancelled(24-12-30 14:20)
|
||||||
|
NOTE: emphemeral messages can only be sent as a reply to an interaction, so the bot cannot sent them...
|
||||||
|
NOTE: there could be two options: send DM ort send to the channel where the remainder was added by default,
|
||||||
|
but that can be achieved now, so skip this for now, maybe later if needed...
|
||||||
|
|
||||||
|
✔ @high Add a .env variable to ENABLE RemainderService. @done(24-12-30 14:16)
|
||||||
|
NOTE: On the first run in the stack, both the backend abd the bot are running,
|
||||||
|
but the bot needs an api token before it can access the backend, which should be created by the user
|
||||||
|
_OR_ find a way to automate it
|
||||||
|
so the current setup steps are: -
|
||||||
|
- customize .env (pw, url, etc)
|
||||||
|
- start stack (or even better "docker compose up backend")
|
||||||
|
- generate backend token
|
||||||
|
- set beckend token in bot .env
|
||||||
|
- restart stack (or only the bot)
|
||||||
|
BUT! in the meantime, the bot logs unauth errors in the dev.log !!!
|
||||||
|
for deployment ideas see: https://github.com/refactorian/laravel-docker/blob/main/docker-compose.yml
|
||||||
|
NOTE: did no add switch, created a setup readme instead
|
||||||
|
detailed setup instruction are in the discord-bot-docker repository
|
||||||
|
|
||||||
|
✘ @medium deprecated warning in the https://github.com/discord-php/DiscordPHP/blob/master/src/Discord/Builders/CommandAttributes.php file line 260 @cancelled(24-12-30 14:15)
|
||||||
|
NOTE: in the composer, we have a "dev-master" for this package. Why? - master has an exception on start, maybe will dig in later
|
||||||
|
PHP Deprecated: Creation of dynamic property Discord\Builders\CommandBuilder::$options is deprecated in /mnt/devel/Projects/discord-bot-goliath/src/vendor/team-reflex/discord-php/src/Discord/Builders/CommandAttributes.php on line 260
|
||||||
|
NOTE: not my program, the DiscordPHP package
|
||||||
|
|
||||||
|
✘ @low MAYBE make use of the "changes" array of the api results... @cancelled(24-12-29 14:37)
|
||||||
|
NOTE: backend api updated, so it will return the changes list if anything changes,
|
||||||
|
currently the bot is not using it, no need for it...
|
||||||
|
NOTE: maybe later...
|
||||||
|
|
||||||
|
✘ @high Add failApiRequest like without the interaction (like: we get the actual remainders every second) @cancelled(24-12-29 14:36)
|
||||||
|
NOTE: for now the RemainderService is the only place for them, but with two different datasets, not worth the effort
|
||||||
|
|
||||||
|
✔ @high Clean up testing/dev stuff (USER_VONJAN_ID, devtest command, ReminderService::seedTest, etc...) @done(24-12-29 14:32)
|
||||||
|
|
||||||
|
✔ @today Clean up ReminderService! @done(24-12-29 14:20)
|
||||||
|
|
||||||
|
✔ @low Smarty needs wiritable cache directories !!! @done(24-12-28 14:51)
|
||||||
|
NOTE: this is a docker building thing!!!
|
||||||
|
NOTE: added a storage volume and updated the name for smarty (from "Storage" to "storage")
|
||||||
|
|
||||||
|
✘ @low _MAYBE_ convert DiscordBot to HasDiscordBot/HasBot trait... @cancelled(24-12-28 11:12)
|
||||||
|
NOTE: nope! This way is a clear indication for the responsoibilities of that class,
|
||||||
|
better than use self/$this for that functionality!
|
||||||
|
|
||||||
|
✘ @low The following code is duplicate inEditRemainder: (autoCompleteMessage, autoCompleteWhen) @cancelled(24-12-28 11:07)
|
||||||
|
```php
|
||||||
|
$remainder = $this->getActualRemainder($interaction, $discordUser->remainders);
|
||||||
|
|
||||||
|
// fail, if the remainder cannot be evaluated
|
||||||
|
if (false === $remainder) {
|
||||||
|
$this->invalidRemainderAlias($interaction);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
NOTE: Not worth to factor it out to a new function, keeping it as is
|
||||||
|
|
||||||
|
✘ @low _MAYBE_ add an admin command for maintance... @cancelled(24-12-28 09:52)
|
||||||
|
NOTE: not really needed, maybe add later...
|
||||||
|
|
||||||
|
✘ @low Add "admin" interface/config/option/etc. to temporary suspend/disable remainderservice (possible even on a running bot) @cancelled(24-12-28 09:52)
|
||||||
|
NOTE: not really needed, maybe add later...
|
||||||
|
|
||||||
|
✔ @high Make all messages the sam color scema! (/list differs from /profile) @done(24-12-28 09:19)
|
||||||
|
|
||||||
|
✔ @low _MAYBE_ remove bool|null possibilities (from all functions) in Loadable class @done(24-12-27 16:13)
|
||||||
|
NOTE: in plases, wher it can safeguard from other errors, the null is kept...
|
||||||
|
|
||||||
|
✔ @low Maybe add phpdoc to all functions... @done(24-12-27 16:07)
|
||||||
|
NOTE: all self coded functions/classes have PHPDoc blocks now, the original template code is untouched...
|
||||||
|
|
||||||
|
✔ @high DevLogger - add exact time for the log! @done(24-12-27 16:06)
|
||||||
|
NOTE: datetime field already exists...
|
||||||
|
|
||||||
|
✔ @high Add a way to notify the the Bot to invalidate cached data... or set a time limit for the cache... @done(24-12-27 16:04)
|
||||||
|
NOTE: currently, if on the admin a new remainder is added or an old one is removed/updated/finished/cancelled the cached list does not update...
|
||||||
|
webhook maybe? or add a message server to the bot...
|
||||||
|
IDEA: add a server @see: https://reactphp.org/http/#server-usage
|
||||||
|
NOTE: added ttl to cache (admin modifications are not normal/intended behaviour, user does not have a dedicated dashboard for it),
|
||||||
|
no reason to overcomplicate it unless the functionality is needed...
|
||||||
|
|
||||||
|
✘ @low _MAYBE_ add more info to DevLogger in DiscordBot::failApiRequest - like method (GET/PUT/etc), url (/remainder-by-due-at/{timestamp}?withDiscordUser) @cancelled(24-12-27 15:57)
|
||||||
|
to make easier for dev to categorize the problem
|
||||||
|
IDEA: make an apiClient interface, with all the calls
|
||||||
|
make normal implementation, without debug
|
||||||
|
make a debug implementation
|
||||||
|
on startup, if debug is needed, use the debug version, otherwise the normal one
|
||||||
|
That needs a new PromiseResponse class, which channels the then() params to the promise then...
|
||||||
|
TODO: make a new debug class with a getDebugInfo($object) function, which use reflaction to see, if the $object has $debugInfo or not
|
||||||
|
if it has, return $object->getDebugInfo(),
|
||||||
|
if not, return []
|
||||||
|
NOTE: method/url is not accessable from the response nor from debug_backtrace, it would need way to much time to figure it out, skipping...
|
||||||
|
|
||||||
|
✘ @low Refactor Cache @cancelled(24-12-27 11:17)
|
||||||
|
- rename "getRemainderList" to "remainders"
|
||||||
|
- make the {get|store|forget)DiscordUser magic functions,
|
||||||
|
- rename getDiscordUserBySnowflake to getDiscordUser (snowflake is not used anywhere else!) - DONE
|
||||||
|
NOTE: a large scale refactoring could be nice, but is to much work for this small project,
|
||||||
|
so it is not happening now.
|
||||||
|
basicly the idea is: use like `$this->getCache($discordUser)->forget()`
|
||||||
|
but that would need to define a remainderList class (instead of the current array),
|
||||||
|
the DiscordUser->remainders needed to be modified and all occurances it, the list still neededa key in the cache,
|
||||||
|
which now is the discordUser->id..
|
||||||
|
The Cache::getInstance() had to be rewritten, it should return the ObjectCache basesd on the parameter's class,
|
||||||
|
Iz could only hold 'cacheable' obejcts which needed caching methods defined...
|
||||||
|
etc... it needed many works with very few or nothing benefits...
|
||||||
|
Maybe some day...
|
||||||
|
|
||||||
|
✔ @critical WHY IS THE SINGLETON A TRAIT NOT A CLASS ???? WTF ??? @done(24-12-27 09:51)
|
||||||
|
NOTE: becouse of the late static binding.
|
||||||
|
It is the "best" way to make this usable.
|
||||||
|
|
||||||
|
✘ @low (BAD IDEA) (MAKE THIS HAPPEN!!! IT IS A COOL WAY!!!) maybe make possible to call Cache::forgetDiscordUser($discordUser); @cancelled(24-12-19 11:37)
|
||||||
|
or maybe use Cache::getInstance()->forgetDiscordUser($discordUser)
|
||||||
|
or use HasCache + $this->forgetDiscordUser($discordUser)
|
||||||
|
magic method __call_static, if methode starts with "get|store|forget" run "return $this->{$methode}($params)"
|
||||||
|
NOTE: using a static method to access/change the values of the instantiated class is bad practise, can be confusing...
|
||||||
|
REJECTED! See note above.
|
||||||
|
|
||||||
|
✔ @high Make sure, all commands return an error to the user in case of a failure. (profile return 401, but list does silently fail) @done(24-12-19 11:33)
|
||||||
|
- list - FAIL - OK!
|
||||||
|
- delete - autocomplete - FAIL - OK
|
||||||
|
- edit - autocomplete - FAIL - OK
|
||||||
|
Done.
|
||||||
|
|
||||||
|
✘ @low AssureTimeZoneSet and it's methods could use better names... @cancelled(24-12-19 11:37)
|
||||||
|
Nope, it is good enough for now, but renamed part from "TimeZone" to "Timezone".
|
||||||
|
|
||||||
|
✔ @low timezone and timeZone are used as well, maybe simply use "timezone" everywhere... @done(24-12-19 11:26)
|
||||||
|
Renamed everyhing to timezone.
|
||||||
|
|
||||||
|
✔ @low HasClient and getApiClient() names does not match, maybe rename the trait to HasApiClient... @done(24-12-19 11:17)
|
||||||
|
Renamed HasClient trait to HasApiClient.
|
||||||
|
|
||||||
|
✔ @high in the PHPDoc (ApiClient) the return type of theonFulfilled() fv should be declared! @done(24-12-19 11:11)
|
||||||
|
NOTE: added @api-response <response class> to the doc (non-standard, just for this purpose)
|
||||||
|
|
||||||
|
✔ @low ClientMessages::listRemaindersCompacted - describe the 'paginate' array fields in the PHPdoc!! @done(24-12-19 10:44)
|
||||||
|
see: https://stackoverflow.com/questions/15414103/best-way-to-document-array-options-in-phpdoc
|
||||||
|
NOTE: no standards for this one, it is sort of "compatible", refctor it only if absolutly neccessary.
|
||||||
|
|
||||||
|
✔ @high in the ClientMessages "warning" and "error" are mixed, fix them! @done(24-12-19 10:19)
|
||||||
|
like: errorDateTimeInThePast says "Worning", but it is an error (?maybe???)
|
||||||
|
|
||||||
|
✔ @low Rename msgToArray to a more appropirate name. @done(24-12-18 10:59)
|
||||||
|
Renamed to optionChoise.
|
||||||
|
|
||||||
|
✔ @high The "//-------------..." separator lines are different length, make them all the same length!!! (for now 118 is empty) @done(24-12-18 09:19)
|
||||||
|
|
||||||
|
✔ @high All server errors should only show a mininmal error to the discord client, concrete errors should only be sent to the dev log!!! @done(24-12-17 16:01)
|
||||||
|
- /profile shows a 401 error!!!
|
||||||
|
NOTE: done, the user sees a "general error", the dev.log is for the operator to handle it
|
||||||
|
|
||||||
|
✔ @high /delete command should handle "-1" as remainder position id - or any non valid value - for the remainder record iindex. @done(24-12-17 15:56)
|
||||||
|
NOTE: added parameter checking and error handling/reporting, aloso the same for /edit
|
||||||
|
|
||||||
|
✔ @high Paginate /list output (2000 char max/messaged - discord limitation), show x, and if more are there, add a paging @done(24-12-17 14:50)
|
||||||
|
button bar there...
|
||||||
|
NOPE! add a header on the /list page (first row: shown 1..20 remainders, page 1/3)
|
||||||
|
and add an optional page parameter for /list
|
||||||
|
NOTE: added header info and optional "page" parameter to the command.
|
||||||
|
|
||||||
|
✔ @low RemoveRemainder has a lot in common with EditRemainder, maybe some code could be reused here... @done(24-12-17 08:52)
|
||||||
|
commonly used code moved into RemainderListCommand trait
|
||||||
|
|
||||||
|
✔ @low errorApiError is the one with "Something went wrong on our side, sorry", @done(24-12-16 09:18)
|
||||||
|
but this is more like a general response, not only api error response, rename this to general!!!
|
||||||
|
and update all occurances!!!
|
||||||
|
NOTE: Renamed the old ApiError to GeneralError, and the old GeneralError to errorDetaiedError, this can be removed...
|
||||||
|
|
||||||
|
✔ @critical The DiscordBot uses the HasDiscord trait, but that requires instantiation of the class, so it is BAAAADDDDD!!! @done(24-12-13 11:50)
|
||||||
|
Ehh, it is a singleton, so DiscordBot::getInstance()->getDiscord() is technically usable, soooo...
|
||||||
|
|
||||||
|
✔ @high Handle edge cases like if no remainders exists jet (list/edit/delete) @done(24-12-13 11:47)
|
||||||
|
NOTE: /list added, /dedit and /delete is wip, but this is testing, not functionality...
|
||||||
|
|
||||||
|
✔ @high create structured resopnse for the bot communication @done(24-12-13 11:46)
|
||||||
|
|
||||||
|
✔ @high Remainder->dueAt() Add an ' (UTC)' string to the end of the time if no timezone info was present!!! @done(24-12-13 11:46)
|
||||||
|
|
||||||
|
✔ @critical CreateRemainder: 19:00 megadva, 18:00 van mentve... @done(24-12-12 18:49)
|
||||||
|
note: in sql, utc is stored, we need to asjust the shown value based on the users timezone...
|
||||||
|
the Remainder should have an accessor, but for that it needs the DU...
|
||||||
|
NOTE: /list already uses this, the smarty carbon plugin has a timezone parameter for this, use it!!!
|
||||||
|
NOTE: copied from /list
|
||||||
|
|
||||||
|
✔ @high Add .env.example @done(24-12-09 20:03)
|
||||||
|
- is the public-key needed?
|
||||||
|
- only add variables that are needed!
|
||||||
|
|
||||||
|
✔ @low add tesing for malformed api response. @done(24-11-08 13:46)
|
||||||
|
NOTE: ApiResponse saves the error ('type' => 'json_decode_error') to the "internalError" property if the parsing fails...
|
||||||
|
and an DevLogger::error is saved.
|
||||||
|
|
||||||
|
✔ @low maybe create a jsonResponse for the apiClient, which parses/handles error/data/code/stc... @done(24-11-08 13:44)
|
||||||
|
NOTE: apiClient handles and saves teh status of the response
|
||||||
|
|
||||||
|
✔ @high DiscordUserBySnowflakeResponse is in theory the same as /discord_users/{discord_user} @done(24-11-08 13:36)
|
||||||
|
eighter make another class for it or rename the current one to match both cases
|
||||||
|
@crytical DO THIS ! remove DiscordUserBySnowflakeResponse, replace it with DiscordUserResponse
|
||||||
|
NOTE: done! backend is updated as well...
|
||||||
|
|
||||||
|
✔ @high DevLogger with the magic method and all the phpdoc block, we could have wrote that directly as well... @done(24-11-08 13:34)
|
||||||
|
The "direct" methode is more readable, but way too redundant and boring, i kepp it this way.
|
||||||
|
|
||||||
|
✔ @high add deleteRemainder command. @done(24-11-08 13:34)
|
||||||
|
|
||||||
|
✔ @critical Add a DEV monolog target, and log errors/warnings there for the dev! @done(24-11-04 09:52)
|
||||||
|
|
||||||
|
✔ @high Add a 'LogAndCall' type function to log failed api requests (like. 422/401/etc) and call the callback @done(24-09-23 10:26)
|
||||||
|
NOTE: the callback has ben replaced wit a response to interaction
|
||||||
|
|
||||||
|
✔ @today Maybe add 'Content-Type' => 'application/json' as a default header to the ApiClient->client @done(24-09-19 12:17)
|
||||||
|
|
||||||
|
✔ @low MAYBE make ApiClient a singleton as well as the Template... @done(24-09-19 12:11)
|
||||||
|
|
||||||
|
✔ @high Make sure, that the /profile timezone cannot be sent without timezona data, and check for a valid timezone on server side. @done(24-09-11 22:25)
|
||||||
|
value checking added,
|
||||||
|
new functionality added to /profile if called without any options
|
||||||
|
|
||||||
|
✔ @high /profile fails if the DU is not found by snowflake, create it first @done(24-09-11 22:24)
|
||||||
|
__OR__ maybe use that call as a get_or_create ???
|
||||||
|
Added put method (update) to the controller (firstOrCreate)
|
||||||
|
|
||||||
|
✔ @high Maybe replace Loadable->nullSafe() with Loadable->toJson() which using nullsafe ans json_encode @done(24-09-11 22:01)
|
||||||
|
NOTE: implemeted JsonSerializable interface, so json_encode is safe to use wit skipping null properties,
|
||||||
|
added toJson(bool $unfiltered=false), which can be called with true parameter, to get all properties (even null ones)
|
||||||
|
|
||||||
|
✔ @critical Implement GetOrRegisterDiscordUserBySnowflake on the backend and call that instead of only get !!! @done(24-09-06 15:39)
|
||||||
|
|
||||||
24
src/Tests/CommandAttributeTest.php
Normal file
24
src/Tests/CommandAttributeTest.php
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests;
|
||||||
|
|
||||||
|
use Core\Commands\Command;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class CommandAttributeTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testItRejectsBadSnowflakes(): void
|
||||||
|
{
|
||||||
|
$this->expectException(\LogicException::class);
|
||||||
|
$this->expectExceptionMessage('Guild ID must be alphanumeric');
|
||||||
|
|
||||||
|
new Command(guild: 'not a snowflake');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItAcceptsGoodSnowflakes(): void
|
||||||
|
{
|
||||||
|
$this->expectNotToPerformAssertions();
|
||||||
|
|
||||||
|
new Command(guild: '1234567890');
|
||||||
|
}
|
||||||
|
}
|
||||||
32
src/Tests/FunctionsTest.php
Normal file
32
src/Tests/FunctionsTest.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Discord\Builders\Components\Button;
|
||||||
|
use Monolog\Test\TestCase;
|
||||||
|
|
||||||
|
use function Core\messageWithContent;
|
||||||
|
use function Core\newButton;
|
||||||
|
|
||||||
|
class FunctionsTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testItCreatesAMessageWithContent(): void
|
||||||
|
{
|
||||||
|
$message = messageWithContent('Hello World');
|
||||||
|
|
||||||
|
$this->assertEquals([
|
||||||
|
'content' => 'Hello World',
|
||||||
|
], $message->jsonSerialize());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItCreatesAButton(): void
|
||||||
|
{
|
||||||
|
$button = newButton(Button::STYLE_DANGER, 'DANGER');
|
||||||
|
|
||||||
|
$this->assertEquals($button->getLabel(), 'DANGER');
|
||||||
|
$this->assertEquals($button->getStyle(), Button::STYLE_DANGER);
|
||||||
|
|
||||||
|
$button = newButton(Button::STYLE_PRIMARY, 'PRIMARY', 'primary_button');
|
||||||
|
$this->assertEquals($button->getLabel(), 'PRIMARY');
|
||||||
|
$this->assertEquals($button->getStyle(), Button::STYLE_PRIMARY);
|
||||||
|
$this->assertEquals($button->getCustomId(), 'primary_button');
|
||||||
|
}
|
||||||
|
}
|
||||||
58
src/composer.json
Normal file
58
src/composer.json
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
{
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.1",
|
||||||
|
"commandstring/utils": "^1.7",
|
||||||
|
"react/async": "^4.1",
|
||||||
|
"smarty/smarty": "^5.4",
|
||||||
|
"team-reflex/discord-php": "dev-master",
|
||||||
|
"tnapf/env": "^1.1"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"ergebnis/composer-normalize": "^2.31",
|
||||||
|
"fakerphp/faker": "^1.21",
|
||||||
|
"friendsofphp/php-cs-fixer": "^3.16",
|
||||||
|
"jetbrains/phpstorm-attributes": "^1.0",
|
||||||
|
"phpunit/phpunit": "^10.1",
|
||||||
|
"roave/security-advisories": "dev-latest",
|
||||||
|
"xheaven/composer-git-hooks": "^3.0"
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Bot\\": "Bot/",
|
||||||
|
"Client\\": "Client/",
|
||||||
|
"Commands\\": "Commands/",
|
||||||
|
"Core\\": "Core/",
|
||||||
|
"Events\\": "Events/",
|
||||||
|
"Services\\": "Services/",
|
||||||
|
"Tests\\": "Tests/"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"Core/functions.php",
|
||||||
|
"Core/helpers.php"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"allow-plugins": {
|
||||||
|
"ergebnis/composer-normalize": true
|
||||||
|
},
|
||||||
|
"sort-packages": true
|
||||||
|
},
|
||||||
|
"extra": {
|
||||||
|
"composer-normalize": {
|
||||||
|
"indent-size": 2,
|
||||||
|
"indent-style": "space"
|
||||||
|
},
|
||||||
|
"hooks": {
|
||||||
|
"pre-commit": "composer fix:dry",
|
||||||
|
"pre-push": "composer test"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"post-autoload-dump": "[ $COMPOSER_DEV_MODE -eq 0 ] || composer normalize",
|
||||||
|
"fix": "php-cs-fixer fix --using-cache=no",
|
||||||
|
"fix:dry": "php-cs-fixer fix --using-cache=no --diff --dry-run",
|
||||||
|
"fix:list": "php-cs-fixer fix --using-cache=no --dry-run",
|
||||||
|
"test": "phpunit",
|
||||||
|
"test:coverage": "phpunit --coverage-html .phpunit.cache/cov-html"
|
||||||
|
}
|
||||||
|
}
|
||||||
7448
src/composer.lock
generated
Normal file
7448
src/composer.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
17
src/phpunit.xml
Normal file
17
src/phpunit.xml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" colors="true"
|
||||||
|
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.1/phpunit.xsd"
|
||||||
|
beStrictAboutOutputDuringTests="true" failOnRisky="true" failOnWarning="true"
|
||||||
|
cacheDirectory=".phpunit.cache">
|
||||||
|
<testsuites>
|
||||||
|
<testsuite name="default">
|
||||||
|
<directory>Tests</directory>
|
||||||
|
</testsuite>
|
||||||
|
</testsuites>
|
||||||
|
<coverage />
|
||||||
|
<source>
|
||||||
|
<include>
|
||||||
|
<directory suffix=".php">src</directory>
|
||||||
|
</include>
|
||||||
|
</source>
|
||||||
|
</phpunit>
|
||||||
1
src/version
Normal file
1
src/version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
0.6.0.0.rc
|
||||||
Reference in New Issue
Block a user