Verified Commit 8ba67b9f authored by Elias Häußler's avatar Elias Häußler 🐛

[FEATURE] Add new routing for Slack Slash command `/beer`

parent 73626ed2
......@@ -6,7 +6,9 @@ Version numbers are based on [Semantic Versioning](https://semver.org).
## [Unreleased]
### Added
- SlackController: Routing for `/beer` slash command using [`BeerCommandRoute`](src/classes/Routing/Slack/BeerCommandRoute.php) class
......
......@@ -39,6 +39,7 @@ The API serves different endpoints which can be accessed from various clients.
- Start or end your lunch break in Slack using slash command `/lunch`
- Display Redmine issue information in Slack using slash command `/issue` or `/redmine issue`
- Notify your team members in Slack that you're ready for stand-up using slash command `/standup`
- Spend beer on your team members in Slack using slash command `/beer`
## Requirements
......@@ -156,7 +157,7 @@ in the appropriate controller within the class constant `ROUTE_MAPPINGS`.
![Slack slash command `/lunch`](docs/assets/slack-lunch.gif)
The [`SlackController`](src/classes/Controller/SlackController.php) will be used when sending
requests in the form `<API_HOST>/slack/<parameters>`. It currently allows three route mappings:
requests in the form `<API_HOST>/slack/<parameters>`. It currently allows these route mappings:
- `authenticate`: Process user authentication at Slack using
[`AuthenticateRoute`](src/classes/Routing/Slack/AuthenticateRoute.php) class
......@@ -166,6 +167,8 @@ requests in the form `<API_HOST>/slack/<parameters>`. It currently allows three
[`RedmineCommandRoute`](src/classes/Routing/Slack/RedmineCommandRoute.php) class
- `standup`: Process a request for the Slack slash command `/standup` using
[`StandupCommandRoute`](src/classes/Routing/Slack/StandupCommandRoute.php) class
- `beer`: Process a request for the Slack slash command `/beer` using
[`BeerCommandRoute`](src/classes/Routing/Slack/BeerCommandRoute.php) class
## Deployment
......
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="robots" content="index, follow, all" />
<title>EliasHaeussler\Api\Exception\PersistenceFailedException | elias-haeussler.de API</title>
<link rel="stylesheet" type="text/css" href="../../../css/bootstrap.min.css">
<link rel="stylesheet" type="text/css" href="../../../css/bootstrap-theme.min.css">
<link rel="stylesheet" type="text/css" href="../../../css/sami.css">
<script src="../../../js/jquery-1.11.1.min.js"></script>
<script src="../../../js/bootstrap.min.js"></script>
<script src="../../../js/typeahead.min.js"></script>
<script src="../../../sami.js"></script>
<meta name="MobileOptimized" content="width">
<meta name="HandheldFriendly" content="true">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1">
</head>
<body id="class" data-name="class:EliasHaeussler_Api_Exception_PersistenceFailedException" data-root-path="../../../">
<div id="content">
<div id="left-column">
<div id="control-panel">
<script>
$('option[data-version="'+window.projectVersion+'"]').prop('selected', true);
</script>
<form id="search-form" action="../../../search.html" method="GET">
<span class="glyphicon glyphicon-search"></span>
<input name="search"
class="typeahead form-control"
type="search"
placeholder="Search">
</form>
</div>
<div id="api-tree"></div>
</div>
<div id="right-column">
<nav id="site-nav" class="navbar navbar-default" role="navigation">
<div class="container-fluid">
<div class="navbar-header">
<button type="button" class="navbar-toggle" data-toggle="collapse" data-target="#navbar-elements">
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="../../../index.html">elias-haeussler.de API</a>
</div>
<div class="collapse navbar-collapse" id="navbar-elements">
<ul class="nav navbar-nav">
<li><a href="../../../classes.html">Classes</a></li>
<li><a href="../../../namespaces.html">Namespaces</a></li>
<li><a href="../../../interfaces.html">Interfaces</a></li>
<li><a href="../../../traits.html">Traits</a></li>
<li><a href="../../../doc-index.html">Index</a></li>
<li><a href="../../../search.html">Search</a></li>
</ul>
</div>
</div>
</nav>
<div class="namespace-breadcrumbs">
<ol class="breadcrumb">
<li><span class="label label-default">class</span></li>
<li><a href="../../../EliasHaeussler.html">EliasHaeussler</a></li><li class="backslash">\</li><li><a href="../../../EliasHaeussler/Api.html">Api</a></li><li class="backslash">\</li><li><a href="../../../EliasHaeussler/Api/Exception.html">Exception</a></li><li class="backslash">\</li><li>PersistenceFailedException</li>
</ol>
</div>
<div id="page-content">
<div class="page-header">
<h1>
PersistenceFailedException
</h1>
</div>
<p> class
<strong>PersistenceFailedException</strong> extends <a target="_blank" href="http://php.net/Exception">Exception</a>
</p>
</div>
<div id="footer">
Generated by <a href="https://github.com/blueend-ag/Sami/">Sami, the API Documentation Generator</a>.
</div>
</div>
</div>
</body>
</html>
......@@ -105,6 +105,13 @@
<p><em></em></p>
<p></p>
</td>
</tr>
<tr>
<td>MENTION_PREFIX</td>
<td class="last">
<p><em></em></p>
<p></p>
</td>
</tr>
<tr>
<td>LINK_TEXT_SEPARATOR</td>
......@@ -205,7 +212,7 @@
<div id="method-details">
<div class="method-item">
<h3 id="method___callStatic">
<div class="location">at line 71</div>
<div class="location">at line 74</div>
<code> static string
<strong>__callStatic</strong>(string $name, array $arguments)
</code>
......@@ -263,7 +270,7 @@ be passed as first argument.</p> </div>
</div>
<div class="method-item">
<h3 id="method_link">
<div class="location">at line 95</div>
<div class="location">at line 98</div>
<code> static string
<strong>link</strong>(string $url, string $label = &#039;&#039;)
</code>
......@@ -310,7 +317,7 @@ be passed as first argument.</p> </div>
</div>
<div class="method-item">
<h3 id="method_mention">
<div class="location">at line 115</div>
<div class="location">at line 118</div>
<code> static string
<strong>mention</strong>(string $name, bool $isTeam = false)
</code>
......@@ -357,7 +364,7 @@ be passed as first argument.</p> </div>
</div>
<div class="method-item">
<h3 id="method_date">
<div class="location">at line 147</div>
<div class="location">at line 150</div>
<code> static string
<strong>date</strong>(<a target="_blank" href="http://php.net/DateTime">DateTime</a> $date, string $format = &#039;{date_pretty}&#039;, string $link = &#039;&#039;, string $fallback = &#039;&#039;)
</code>
......@@ -409,12 +416,6 @@ be passed as first argument.</p> </div>
<h4>See also</h4>
<table class="table table-condensed">
<tr>
<td>
<a href="https://api.slack.com/docs/message-formatting#formatting_dates">https://api.slack.com/docs/message-formatting#formatting_dates</a>
</td>
<td></td>
</tr>
</table>
......@@ -425,7 +426,7 @@ be passed as first argument.</p> </div>
</div>
<div class="method-item">
<h3 id="method_convertPlaceholders">
<div class="location">at line 169</div>
<div class="location">at line 172</div>
<code> static string
<strong>convertPlaceholders</strong>(string $text)
</code>
......@@ -467,7 +468,7 @@ be passed as first argument.</p> </div>
</div>
<div class="method-item">
<h3 id="method_wrapTextWithCharacters">
<div class="location">at line 196</div>
<div class="location">at line 199</div>
<code> static protected string
<strong>wrapTextWithCharacters</strong>(string $text, string $prefix, string $suffix = &#039;&#039;)
</code>
......@@ -519,7 +520,7 @@ be passed as first argument.</p> </div>
</div>
<div class="method-item">
<h3 id="method_buildPlaceholderPatternFromMessageFormats">
<div class="location">at line 212</div>
<div class="location">at line 215</div>
<code> static protected string
<strong>buildPlaceholderPatternFromMessageFormats</strong>()
</code>
......
......@@ -345,7 +345,7 @@ future.</p> </div>
</div>
<div class="method-item">
<h3 id="method_getScheduledTasks">
<div class="location">at line 192</div>
<div class="location">at line 193</div>
<code> static array
<strong>getScheduledTasks</strong>(string|null $className = null, int|null $uid = null, int $limit = 20, bool $ignoreExecutionTime = false)
</code>
......@@ -366,22 +366,22 @@ class name. The class name can either be a FQN or the full class name relative t
<tr>
<td>string|null</td>
<td>$className</td>
<td></td>
<td>Class name (either FQN or task class name) whose tasks should be returned</td>
</tr>
<tr>
<td>int|null</td>
<td>$uid</td>
<td></td>
<td>Uid of a specific task to be returned</td>
</tr>
<tr>
<td>int</td>
<td>$limit</td>
<td></td>
<td>Maximum numbers of tasks to be executed at this iteration</td>
</tr>
<tr>
<td>bool</td>
<td>$ignoreExecutionTime</td>
<td></td>
<td>Define whether to ignore exeuction time and return all tasks matching the constraints</td>
</tr>
</table>
......@@ -414,7 +414,7 @@ class name. The class name can either be a FQN or the full class name relative t
</div>
<div class="method-item">
<h3 id="method_finalizeExecution">
<div class="location">at line 270</div>
<div class="location">at line 271</div>
<code> static void
<strong>finalizeExecution</strong>(int $uid, bool $executionResult)
</code>
......@@ -470,7 +470,7 @@ class name. The class name can either be a FQN or the full class name relative t
</div>
<div class="method-item">
<h3 id="method_cancelScheduledTasks">
<div class="location">at line 291</div>
<div class="location">at line 292</div>
<code> static void
<strong>cancelScheduledTasks</strong>(string $className, array $argumentConstraints = [])
</code>
......@@ -527,7 +527,7 @@ executed will be canceled.</p> </div>
</div>
<div class="method-item">
<h3 id="method_log">
<div class="location">at line 326</div>
<div class="location">at line 327</div>
<code> static void
<strong>log</strong>(int $taskUid, string $message)
</code>
......@@ -583,7 +583,7 @@ executed will be canceled.</p> </div>
</div>
<div class="method-item">
<h3 id="method_persistTaskForSchedule">
<div class="location">at line 350</div>
<div class="location">at line 351</div>
<code> static protected bool
<strong>persistTaskForSchedule</strong>(string $className, <a target="_blank" href="http://php.net/DateTime">DateTime</a> $execution, array $arguments)
</code>
......
......@@ -302,6 +302,15 @@
</div>
<div class="col-md-6">
</div>
</div>
<div class="row">
<div class="col-md-6">
<a href="EliasHaeussler/Api/Exception/PersistenceFailedException.html"><abbr title="EliasHaeussler\Api\Exception\PersistenceFailedException">PersistenceFailedException</abbr></a>
</div>
<div class="col-md-6">
</div>
</div>
<div class="row">
......@@ -357,6 +366,15 @@
<div class="col-md-6">
Authenticate router for Slack API controller.
</div>
</div>
<div class="row">
<div class="col-md-6">
<a href="EliasHaeussler/Api/Routing/Slack/BeerCommandRoute.html"><abbr title="EliasHaeussler\Api\Routing\Slack\BeerCommandRoute">BeerCommandRoute</abbr></a>
</div>
<div class="col-md-6">
Beer router for Slack API controller.
</div>
</div>
<div class="row">
<div class="col-md-6">
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
......@@ -77,7 +77,7 @@
Set requirement of user environment for specific API controllers.
</div>
</div>
</div>
</div>
</div>
<div id="footer">
Generated by <a href="https://github.com/blueend-ag/Sami/">Sami, the API Documentation Generator</a>.
......
......@@ -25,6 +25,7 @@ use EliasHaeussler\Api\Exception\InvalidRequestException;
use EliasHaeussler\Api\Frontend\Message;
use EliasHaeussler\Api\Helpers\SlackMessage;
use EliasHaeussler\Api\Routing\Slack\AuthenticateRoute;
use EliasHaeussler\Api\Routing\Slack\BeerCommandRoute;
use EliasHaeussler\Api\Routing\Slack\LunchCommandRoute;
use EliasHaeussler\Api\Routing\Slack\RedmineCommandRoute;
use EliasHaeussler\Api\Routing\Slack\StandupCommandRoute;
......@@ -63,6 +64,7 @@ class SlackController extends BaseController
'lunch' => LunchCommandRoute::class,
'standup' => StandupCommandRoute::class,
'redmine' => RedmineCommandRoute::class,
'beer' => BeerCommandRoute::class,
self::ROUTE_AUTH => AuthenticateRoute::class,
];
......@@ -351,6 +353,36 @@ class SlackController extends BaseController
return $result;
}
/**
* Get user ID and user name from raw string.
*
* Returns user ID and user name of a user which is mentioned within a raw string. This method always returns an
* array of exactly two elements, being the first the user ID and the second the user name. If the string was not
* escaped or escaped incorrect, this method will only return the user name as second element of the returned array.
*
* @param string $rawString The raw input string in which a user is mentioned
*
* @return array Array of user ID (first element) and user name (second element)
*/
public function getUserDataFromString(string $rawString): array
{
$isEscaped = stripos($rawString, SlackMessage::MENTION_PREFIX) === 0;
$userId = null;
$userName = null;
if ($isEscaped) {
preg_match(sprintf('/%s(.+)\\|(.+)>/', SlackMessage::MENTION_PREFIX), $rawString, $matches);
if (count($matches) == 3) {
list(, $userId, $userName) = $matches;
}
} elseif (stripos($rawString, '@') === 0) {
$userName = ltrim($rawString, '@');
}
return [$userId, $userName];
}
/**
* Get raw command name from full slash command.
*
......
<?php
/**
* Copyright (c) 2019 Elias Häußler <elias@haeussler.dev>. All rights reserved.
*/
declare(strict_types=1);
namespace EliasHaeussler\Api\Exception;
/**
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*/
/**
* @author Elias Häußler <elias@haeussler.dev>
* @license GPL-3.0+
*/
class PersistenceFailedException extends \Exception
{
}
......@@ -44,6 +44,9 @@ class SlackMessage
'link' => '<>',
];
/** @var string Default prefix for mentions */
const MENTION_PREFIX = '<@';
/** @var string Character separating URL and link text */
const LINK_TEXT_SEPARATOR = '|';
......@@ -115,7 +118,7 @@ class SlackMessage
public static function mention(string $name, bool $isTeam = false): string
{
$name = trim($name);
$prefix = '<@';
$prefix = self::MENTION_PREFIX;
$suffix = '>';
if (in_array(strtolower($name), self::SPECIAL_MENTIONS)) {
......
<?php
/**
* Copyright (c) 2019 Elias Häußler <elias@haeussler.dev>. All rights reserved.
*/
declare(strict_types=1);
namespace EliasHaeussler\Api\Routing\Slack;
/**
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*/
use EliasHaeussler\Api\Controller\SlackController;
use EliasHaeussler\Api\Exception\MissingParameterException;
use EliasHaeussler\Api\Exception\PersistenceFailedException;
use EliasHaeussler\Api\Frontend\Message;
use EliasHaeussler\Api\Helpers\SlackMessage;
use EliasHaeussler\Api\Routing\BaseRoute;
use EliasHaeussler\Api\Utility\GeneralUtility;
use EliasHaeussler\Api\Utility\LocalizationUtility;
/**
* Beer router for Slack API controller.
*
* This class defines the concrete router for the "beer" route inside the Slack API controller. It enables Slack
* users to spend beer on their team members.
*
* @author Elias Häußler <elias@haeussler.dev>
* @license GPL-3.0+
*/
class BeerCommandRoute extends BaseRoute
{
/** @var string API request parameter for showing the help */
const REQUEST_PARAMETER_HELP = 'help';
/** @var SlackController Slack API Controller */
protected $controller;
/** @var mixed Provided API request parameters */
protected $requestParameters;
/**
* {@inheritdoc}
*
* @throws PersistenceFailedException
* @throws MissingParameterException
*/
public function processRequest()
{
// Show help text if request parameter starts with "help" keyword
if (stripos($this->requestParameters, self::REQUEST_PARAMETER_HELP) === 0) {
$this->showHelpText();
return;
}
// Get donor and receiver
$donorId = $this->controller->getRequestData('user_id');
list($receiverId) = $this->controller->getUserDataFromString($this->requestParameters);
// Show error message if no receiver was provided
if (empty($receiverId)) {
throw new MissingParameterException(LocalizationUtility::localize('exception.1556723086', 'slack'), 1556723086);
}
// Show message if donor equals receiver
if ($donorId == $receiverId) {
echo $this->controller->buildBotMessage(
Message::MESSAGE_TYPE_NOTICE,
LocalizationUtility::localize('beer.donorEqualsReceiver', 'slack', '', SlackMessage::emoji('point_up::skin-tone-3'))
);
return;
}
// Add beer to database
$queryBuilder = $this->controller->getDatabase()->createQueryBuilder();
$result = $queryBuilder->insert('slack_spent_beers')
->values([
'donor' => $queryBuilder->createNamedParameter($donorId),
'receiver' => $queryBuilder->createNamedParameter($receiverId),
])
->execute();
if ($result) {
$numberOfBeers = $this->getNumberOfBeersForUser($receiverId);
// Show success message
if ($numberOfBeers == 1) {
echo $this->controller->buildBotMessage(
Message::MESSAGE_TYPE_SUCCESS,
LocalizationUtility::localize(
'beer.success.single',
'slack',
'',
SlackMessage::emoji('beer'),
SlackMessage::mention($receiverId)
),
[],
true
);
} else {
echo $this->controller->buildBotMessage(
Message::MESSAGE_TYPE_SUCCESS,
LocalizationUtility::localize(
'beer.success.multiple',
'slack',
'',
SlackMessage::emoji('beers'),
SlackMessage::mention($receiverId),
$numberOfBeers
),
[],
true
);
}
} else {
// Show error message if persistence failed
throw new PersistenceFailedException(
LocalizationUtility::localize('exception.1556721904', 'slack'),
1556721904
);
}
}
/**
* {@inheritdoc}
*/
protected function initializeRequest()
{
// Get user-preferred language in case user is already authenticated
if ($this->controller->isUserAuthenticated()) {
try {
$this->controller->loadUserData();
$this->controller->getUserInformation();
} catch (\Exception $e) {
// Intended fallthrough as language is not necessarily needed
}
}
// Set provided request parameters
$this->requestParameters = trim($this->controller->getRequestData('text'));
}
/**
* Show help text for this command.
*/
protected function showHelpText(): void
{
$numberOfBeers = $this->getNumberOfBeersForUser($this->controller->getRequestData('user_id'));
$helpText = LocalizationUtility::localize(
'beer.help.text',
'slack',
'',
$numberOfBeers,
LocalizationUtility::localize('beer.' . ($numberOfBeers == 1 ? 'singular' : 'plural'), 'slack')
);
$message = SlackMessage::convertPlaceholders($helpText);
$attachments = [
$this->controller->buildAttachmentForBotMessage(
'',
'',
'',
GeneralUtility::getServerName(),
true
),
];
echo $this->controller->buildBotMessage(Message::MESSAGE_TYPE_NOTICE, $message, $attachments);
}
/**
* Get number of beers a user has received.
*
* Returns the number of beers a user has received so far.
*
* @param string $userId ID of the user whose number of received beers should be returned
*
* @return int Number of beers the given user has received so far
*/