! Published.
This commit is contained in:
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