Initial commit - based from qabot but converted into a composer module

This commit is contained in:
Deon George 2021-08-06 12:22:22 +10:00
commit 9d66b5ed91
No known key found for this signature in database
GPG Key ID: 7670E8DC27415254
73 changed files with 4948 additions and 0 deletions

30
composer.json Normal file
View File

@ -0,0 +1,30 @@
{
"name": "leenooks/slack",
"description": "Leenooks Slack Interaction.",
"keywords": ["laravel", "leenooks"],
"license": "MIT",
"authors": [
{
"name": "Deon George",
"email": "deon@leenooks.net"
}
],
"require": {
"mpociot/phpws": "^2.1"
},
"require-dev": {
},
"autoload": {
"psr-4": {
"Slack\\": "src"
}
},
"extra": {
"laravel": {
"providers": [
"Slack\\Providers\\SlackServiceProvider"
]
}
},
"minimum-stability": "dev"
}

454
src/API.php Normal file
View File

@ -0,0 +1,454 @@
<?php
namespace Slack;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Log;
use Slack\Exceptions\{SlackAlreadyPinnedException,
SlackChannelNotFoundException,
SlackException,
SlackHashConflictException,
SlackMessageNotFoundException,
SlackNoAuthException,
SlackNoPinException,
SlackNotFoundException,
SlackNotInChannelException,
SlackThreadNotFoundException,
SlackTokenScopeException};
use Slack\Models\{Team,User};
use Slack\Response\ChannelList;
use Slack\Response\Generic;
use Slack\Response\User as ResponseUser;
use Slack\Response\Team as ResponseTeam;
use Slack\Response\Test;
class API
{
private const LOGKEY = 'API';
private const scopes = [
'auth.test'=>'', // No scope required
'chat.delete'=>'chat:write',
'chat.postMessage'=>'chat:write',
'chat.update'=>'chat:write',
'conversations.history'=>'channels:history', // Also need groups:history for Private Channels and im:history for messages to the bot.
'conversations.info'=>'channels:history', // (channels:read) Also need groups:read for Private Channels
'conversations.list'=>'channels:read',
'conversations.replies'=>'channels:history', // Also need groups:history for Private Channels
'dialog.open'=>'', // No scope required
'pins.add'=>'pins:write',
'pins.remove'=>'pins:write',
'team.info'=>'team:read',
'views.open'=>'', // No scope required
'views.publish'=>'', // No scope required
'views.push'=>'', // No scope required
'views.update'=>'', // No scope required
'users.conversations'=>'channels:read',
'users.info'=>'users:read',
];
// Our slack token to use
private $_token;
public function __construct(Team $o)
{
$this->_token = $o->token;
Log::debug(sprintf('%s:Slack API with token [%s]',static::LOGKEY,$this->_token->token_hidden),['m'=>__METHOD__]);
}
public function authTest(): Test
{
Log::debug(sprintf('%s:Auth Test',static::LOGKEY),['m'=>__METHOD__]);
return new Test($this->execute('auth.test',[]));
}
/**
* Delete a message in a channel
*
* @param $channel
* @param $timestamp
* @return Generic
* @throws \Exception
*/
public function deleteChat($channel,$timestamp): Generic
{
Log::debug(sprintf('%s:Delete Message [%s] in [%s]',static::LOGKEY,$timestamp,$channel),['m'=>__METHOD__]);
return new Generic($this->execute('chat.delete',['channel'=>$channel,'ts'=>$timestamp]));
}
/**
* Get Messages on a channel from a specific timestamp
*
* @param $channel
* @param $timestamp
* @return Generic
* @throws \Exception
*/
public function getChannelHistory($channel,$timestamp,$limit=20): Generic
{
Log::debug(sprintf('%s:Message History for Channel [%s] from Timestamp [%s]',static::LOGKEY,$channel,$timestamp),['m'=>__METHOD__]);
return new Generic($this->execute('conversations.history',['channel'=>$channel,'oldest'=>$timestamp,'limit'=>$limit]));
}
/**
* Get information on a channel.
*
* @param $channel
* @return Generic
* @throws \Exception
*/
public function getChannelInfo($channel): Generic
{
Log::debug(sprintf('%s:Channel Information [%s]',static::LOGKEY,$channel),['m'=>__METHOD__]);
return new Generic($this->execute('conversations.info',['channel'=>$channel]));
}
/**
* Get a list of channels.
*
* @param int $limit
* @return Generic
* @throws \Exception
*/
public function getChannelList(int $limit=100): Generic
{
Log::debug(sprintf('%s:Channel List',static::LOGKEY),['m'=>__METHOD__]);
return new Generic($this->execute('conversations.list',['limit'=>$limit]));
}
/**
* Get all messages from a thread
*
* @param $channel
* @param $thread_ts
* @return Generic
* @throws \Exception
*/
public function getMessageHistory($channel,$thread_ts): Generic
{
Log::debug(sprintf('%s:Get Message Threads for Message [%s] on Channel [%s]',static::LOGKEY,$thread_ts,$channel),['m'=>__METHOD__]);
return new Generic($this->execute('conversations.replies',['channel'=>$channel,'ts'=>$thread_ts]));
}
/**
* Get information on a user
*
* @param string $team_id
* @return ResponseTeam
* @throws \Exception
*/
public function getTeam(string $team_id): ResponseTeam
{
Log::debug(sprintf('%s:Team Info [%s]',static::LOGKEY,$team_id),['m'=>__METHOD__]);
return new ResponseTeam($this->execute('team.info',['team'=>$team_id]));
}
/**
* Get information on a user
*
* @param $user_id
* @return ResponseUser
* @throws \Exception
*/
public function getUser($user_id): ResponseUser
{
Log::debug(sprintf('%s:User Info [%s]',static::LOGKEY,$user_id),['m'=>__METHOD__]);
return new ResponseUser($this->execute('users.info',['user'=>$user_id]));
}
/**
* Open a dialogue with the user
*
* @param string $trigger
* @param string $dialog
* @return Generic
* @throws \Exception
*/
public function dialogOpen(string $trigger,string $dialog): Generic
{
Log::debug(sprintf('%s:Open a Dialog',static::LOGKEY),['m'=>__METHOD__,'d'=>$dialog,'t'=>$trigger]);
return new Generic($this->execute('dialog.open',json_encode(['dialog'=>$dialog,'trigger_id'=>$trigger])));
}
/**
* Migrate users to Enterprise IDs
*
* @param array $users
* @return Generic
* @throws \Exception
*/
public function migrationExchange(array $users): Generic
{
Log::debug(sprintf('%s:Migrate Exchange [%s] users',static::LOGKEY,count($users)),['m'=>__METHOD__]);
return new Generic($this->execute('migration.exchange',['users'=>join(',',$users)]));
}
/**
* Pin a message in a channel
*
* @param $channel
* @param $timestamp
* @return Generic
* @throws \Exception
*/
public function pinMessage(string $channel,string $timestamp): Generic
{
Log::debug(sprintf('%s:Pin Message [%s|%s]',static::LOGKEY,$channel,$timestamp),['m'=>__METHOD__]);
return new Generic($this->execute('pins.add',json_encode(['channel'=>$channel,'timestamp'=>$timestamp])));
}
/**
* Post a Slack Message
*
* @param Message $request
* @return Generic
* @throws \Exception
*/
public function postMessage(Message $request): Generic
{
Log::debug(sprintf('%s:Post a Slack Message',static::LOGKEY),['m'=>__METHOD__,'r'=>$request]);
return new Generic($this->execute('chat.postMessage',json_encode($request)));
}
/**
* Remove a Pin from a message
*
* @param $channel
* @param $timestamp
* @return Generic
* @throws \Exception
*/
public function unpinMessage($channel,$timestamp): Generic
{
Log::debug(sprintf('%s:Remove Pin from Message [%s|%s]',static::LOGKEY,$channel,$timestamp),['m'=>__METHOD__]);
return new Generic($this->execute('pins.remove',json_encode(['channel'=>$channel,'timestamp'=>$timestamp])));
}
/**
* Update a Slack Message
*
* @param Message $request
* @return Generic
* @throws \Exception
*/
public function updateMessage(Message $request): Generic
{
Log::debug(sprintf('%s:Update a Slack Message',static::LOGKEY),['m'=>__METHOD__,'r'=>$request]);
return new Generic($this->execute('chat.update',json_encode($request)));
}
/**
* Get the list of channels for a user (the bot normally)
*
* @param User $uo
* @param int $limit
* @param string|null $cursor
* @return ChannelList
* @throws \Exception
*/
public function getUserChannels(User $uo,int $limit=100,string $cursor=NULL): ChannelList
{
Log::debug(sprintf('%s:Channel List for [%s] (%s:%s)',static::LOGKEY,$uo->user_id,$limit,$cursor),['m'=>__METHOD__]);
$args = collect([
'limit'=>$limit,
'exclude_archived'=>false,
'types'=>'public_channel,private_channel',
'user'=>$uo->user_id,
]);
if ($cursor)
$args->put('cursor',$cursor);
return new ChannelList($this->execute('users.conversations',$args->toArray()));
}
public function viewOpen(string $trigger,string $view): Generic
{
Log::debug(sprintf('%s:Open a view',static::LOGKEY),['m'=>__METHOD__,'t'=>$trigger]);
return new Generic($this->execute('views.open',json_encode(['trigger_id'=>$trigger,'view'=>$view])));
}
/**
* Publish a view
*
* @param string $user
* @param string $view
* @param string $hash
* @return Generic
* @throws \Exception
* @todo Add some smarts to detect if the new view is the same as the current view, and thus no need to post.
*/
public function viewPublish(string $user,string $view,string $hash=''): Generic
{
Log::debug(sprintf('%s:Publish a view',static::LOGKEY),['m'=>__METHOD__,'u'=>$user,'h'=>$hash]);
return new Generic($this->execute('views.publish',json_encode($hash ? ['user_id'=>$user,'view'=>$view,'hash'=>$hash] : ['user_id'=>$user,'view'=>$view])));
}
public function viewPush(string $trigger,string $view): Generic
{
Log::debug(sprintf('%s:Push a view',static::LOGKEY),['m'=>__METHOD__,'t'=>$trigger]);
return new Generic($this->execute('views.push',json_encode(['trigger_id'=>$trigger,'view'=>$view])));
}
public function viewUpdate(string $view_id,string $view): Generic
{
Log::debug(sprintf('%s:Update a view',static::LOGKEY),['m'=>__METHOD__,'id'=>$view_id]);
return new Generic($this->execute('views.update',json_encode(['view_id'=>$view_id,'view'=>$view])));
}
/**
* Call the Slack API
*
* @param string $method
* @param null $parameters
* @return object
* @throws \Exception
*/
private function execute(string $method,$parameters = NULL): object
{
switch (config('app.env')) {
case 'dev': $url = 'http://steno:3000';
break;
case 'testing': $url = 'http://localhost:3000';
break;
case 'testing-l': $url = 'http://steno_replay:3000';
break;
default:
$url = 'https://slack.com';
}
$url .= '/api/'.$method;
// If we dont have a scope definition, or if the scope definition is not in the token
if (is_null($x=Arr::get(self::scopes,$method)) OR (($x !== '') AND ! $this->_token->hasScope($x))) {
throw new SlackTokenScopeException(sprintf('Token [%d:%s] doesnt have the required scope: [%s] for [%s]',$this->_token->id,$this->_token->token_hidden,serialize($x),$method));
}
// If we are passed an array, we'll do a normal post.
if (is_array($parameters)) {
$parameters['token'] = $this->_token->token;
$request = $this->prepareRequest(
$url,
$parameters
);
// If we are json, then we'll do an application/json post
} elseif (is_json($parameters)) {
$request = $this->prepareRequest(
$url,
$parameters,
[
'Content-Type: application/json; charset=utf-8',
'Content-Length: '.strlen($parameters),
'Authorization: Bearer '.$this->_token->token,
]
);
} else {
throw new \Exception('Parameters unknown');
}
try {
$response = curl_exec($request);
if (! $response)
throw new \Exception('CURL exec returned an empty response: '.serialize(curl_getinfo($request)));
$result = json_decode($response);
} catch (\Exception $e) {
Log::error(sprintf('%s:Got an error while posting to [%s] (%s)',static::LOGKEY,$url,$e->getMessage()),['m'=>__METHOD__]);
throw new \Exception($e->getMessage());
}
if (! $result) {
Log::error(sprintf('%s:Our result shouldnt be empty',static::LOGKEY),['m'=>__METHOD__,'r'=>$request,'R'=>$response]);
throw new SlackException('Slack Result is Empty');
}
if (! $result->ok) {
switch ($result->error) {
case 'already_pinned':
throw new SlackAlreadyPinnedException('Already Pinned',curl_getinfo($request,CURLINFO_HTTP_CODE));
case 'not_authed':
throw new SlackNoAuthException('No Auth Token',curl_getinfo($request,CURLINFO_HTTP_CODE));
case 'channel_not_found':
throw new SlackChannelNotFoundException('Channel Not Found',curl_getinfo($request,CURLINFO_HTTP_CODE));
case 'hash_conflict':
if (App::environment() == 'dev')
file_put_contents('/tmp/hash_conflict.'.$method,print_r(json_decode(json_decode($parameters)->view),TRUE));
throw new SlackHashConflictException('Hash Conflict',curl_getinfo($request,CURLINFO_HTTP_CODE));
case 'message_not_found':
throw new SlackMessageNotFoundException('Message Not Found',curl_getinfo($request,CURLINFO_HTTP_CODE));
case 'no_pin':
throw new SlackNoPinException('No Pin',curl_getinfo($request,CURLINFO_HTTP_CODE));
case 'not_in_channel':
throw new SlackNotInChannelException('Not In Channel',curl_getinfo($request,CURLINFO_HTTP_CODE));
case 'not_found':
file_put_contents('/tmp/method.'.$method,print_r(['request'=>is_json($parameters) ? json_decode($parameters,TRUE) : $parameters,'response'=>$result],TRUE));
throw new SlackNotFoundException('Not Found',curl_getinfo($request,CURLINFO_HTTP_CODE));
case 'thread_not_found':
throw new SlackThreadNotFoundException('Thread Not Found',curl_getinfo($request,CURLINFO_HTTP_CODE));
default:
Log::error(sprintf('%s:Generic Error',static::LOGKEY),['m'=>__METHOD__,'t'=>$this->_token->team_id,'r'=>$result]);
throw new SlackException($result->error,curl_getinfo($request,CURLINFO_HTTP_CODE));
}
}
curl_close($request);
return $result;
}
/**
* Setup the API call
*
* @param $url
* @param string $parameters
* @param array $headers
* @return resource
*/
private function prepareRequest($url,$parameters='',$headers = [])
{
$request = curl_init();
curl_setopt($request,CURLOPT_URL,$url);
curl_setopt($request,CURLOPT_RETURNTRANSFER,TRUE);
curl_setopt($request,CURLOPT_HTTPHEADER,$headers);
curl_setopt($request,CURLINFO_HEADER_OUT,TRUE);
curl_setopt($request,CURLOPT_SSL_VERIFYPEER,FALSE);
curl_setopt($request,CURLOPT_POSTFIELDS,$parameters);
return $request;
}
}

111
src/Base.php Normal file
View File

@ -0,0 +1,111 @@
<?php
namespace Slack;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Slack\Models\{Channel,Enterprise,Team,User};
/**
* Class Base - is a Base to all incoming Slack POST requests
*
* @package Slack
*/
abstract class Base
{
private const LOGKEY = 'SB-';
protected $_data = [];
public const signature_version = 'v0';
public function __construct(Request $request)
{
$this->_data = json_decode(json_encode($request->all()));
if (get_class($this) == self::class)
Log::debug(sprintf('SB-:Received from Slack [%s]',get_class($this)),['m'=>__METHOD__]);
}
/**
* Requests to the object should pull values from $_data
*
* @param string $key
* @return mixed
*/
abstract public function __get(string $key);
/**
* Return the Channel object that a Response is related to
*
* @param bool $create
* @return Channel|null
*/
final public function channel(bool $create=FALSE): ?Channel
{
$o = Channel::firstOrNew(
[
'channel_id'=>$this->channel_id,
]);
if (! $o->exists and $create) {
$o->team_id = $this->team()->id;
$o->save();
}
return $o->exists ? $o : NULL;
}
final public function enterprise(): Enterprise
{
return Enterprise::firstOrNew(
[
'enterprise_id'=>$this->enterprise_id
]);
}
/**
* Return the SlackTeam object that a Response is related to
*
* @param bool $any
* @return Team|null
*/
final public function team(bool $any=FALSE): ?Team
{
$o = Team::firstOrNew(
[
'team_id'=>$this->team_id
]);
if (! $o->exists and $any) {
$o = $this->enterprise()->teams->first();
}
return $o->exists ? $o : NULL;
}
/**
* Return the User Object
* The user object may not exist, especially if the event was triggered by a different user
*
* @note Users with both team_id and enterprise_id set to NULL should never be created
*/
final public function user(): User
{
$o = User::firstOrNew(
[
'user_id'=>$this->user_id,
]);
if (! $o->exists) {
$o->team_id = $this->enterprise_id ? NULL : $this->team()->id;
$o->enterprise_id = ($x=$this->enterprise())->exists ? $x->id : NULL;
$o->save();
Log::debug(sprintf('%s: User Created in DB [%s] (%s)',self::LOGKEY,$this->user_id,$o->id));
}
return $o;
}
}

104
src/BlockKit.php Normal file
View File

@ -0,0 +1,104 @@
<?php
namespace Slack;
use Illuminate\Support\Collection;
/**
* Class BlockKit - Slack Blockit Objects
*
* @package Slack
*/
class BlockKit implements \JsonSerializable
{
protected Collection $_data;
public function __construct()
{
$this->_data = collect();
}
public function jsonSerialize()
{
return $this->_data;
}
/**
* Render a BlockKit Button
*
* @param string $label
* @param string $value
* @param string|null $action_id
* @return \Illuminate\Support\Collection
* @throws \Exception
*/
public function button(string $label,string $value,string $action_id=NULL): Collection
{
$x = collect();
$x->put('type','button');
$x->put('text',$this->text($label,'plain_text'));
$x->put('value',$value);
if ($action_id)
$x->put('action_id',$action_id);
return $x;
}
/**
* Render the input dialog
*
* @param string $label
* @param string $action
* @param int $minlength
* @param string $placeholder
* @param bool $multiline
* @param string $hint
* @param string $initial
* @return $this
* @throws \Exception
*/
protected function input(string $label,string $action,int $minlength,string $placeholder='',bool $multiline=FALSE,string $hint='',string $initial='')
{
$this->_data->put('type','input');
$this->_data->put('element',[
'type'=>'plain_text_input',
'action_id'=>$action,
'placeholder'=>$this->text($placeholder ?: ' ','plain_text'),
'multiline'=>$multiline,
'min_length'=>$minlength,
'initial_value'=>$initial,
]);
$this->_data->put('label',[
'type'=>'plain_text',
'text'=>$label,
'emoji'=>true,
]);
$this->_data->put('optional',$minlength ? FALSE : TRUE);
if ($hint)
$this->_data->put('hint',$this->text($hint,'plain_text'));
return $this;
}
/**
* Returns a BlockKit Text item
*
* @param string $text
* @param string $type
* @return array
* @throws \Exception
*/
public function text(string $text,string $type='mrkdwn'): array
{
// Quick Validation
if (! in_array($type,['mrkdwn','plain_text']))
throw new \Exception('Invalid text type: '.$type);
return [
'type'=>$type,
'text'=>$text,
];
}
}

256
src/Blockkit/Block.php Normal file
View File

@ -0,0 +1,256 @@
<?php
namespace Slack\Blockkit;
use Illuminate\Support\Collection;
use Slack\BlockKit;
/**
* Class Blockkit Block
* Represents a Block used in Blockkit
*
* @package Slack\Blockkit
*/
class Block extends BlockKit
{
/**
* Add Actions Block
*
* @param Collection $elements
* @return $this
*/
public function addAction(Collection $elements): self
{
// Initialise
$this->_data = collect();
$this->_data->put('type','actions');
$this->_data->put('elements',$elements);
return $this;
}
/**
* A context block
*
* @param Collection $elements
* @return $this
*/
public function addContext(Collection $elements): self
{
// Initialise
$this->_data = collect();
$this->_data->put('type','context');
$this->_data->put('elements',$elements);
return $this;
}
/**
* Add a bock divider
*/
public function addDivider(): self
{
$this->_data->put('type','divider');
return $this;
}
/**
* Add a block header
*
* @param string $text
* @param string $type
* @return Block
* @throws \Exception
*/
public function addHeader(string $text,string $type='plain_text'): self
{
$this->_data->put('type','header');
$this->_data->put('text',$this->text($text,$type));
return $this;
}
/**
* Generates a multiselect that queries back to the server for values
*
* @param string $label
* @param string $action
* @return $this
* @throws \Exception
*/
public function addMultiSelectInput(string $label,string $action): self
{
$this->_data->put('type','section');
$this->_data->put('text',$this->text('mrkdwn',$label));
$this->_data->put('accessory',[
'action_id'=>$action,
'type'=>'multi_external_select',
]);
return $this;
}
/**
* @param string $label
* @param string $action
* @param Collection $options
* @param Collection|null $selected
* @param int|null $maximum
* @return $this
* @throws \Exception
*/
public function addMultiSelectStaticInput(string $label,string $action,Collection $options,Collection $selected=NULL,int $maximum=NULL): self
{
$this->_data->put('type','section');
$this->_data->put('text',$this->text($label,'mrkdwn'));
$x = collect();
$x->put('action_id',$action);
$x->put('type','multi_static_select');
$x->put('options',$options->transform(function ($item) {
return ['text'=>$this->text($item->name,'plain_text'),'value'=>(string)$item->id];
}));
if ($selected and $selected->count())
$x->put('initial_options',$selected->transform(function ($item) {
return ['text'=>$this->text($item->name,'plain_text'),'value'=>(string)$item->id];
}));
if ($maximum)
$x->put('max_selected_items',$maximum);
$this->_data->put('accessory',$x);
return $this;
}
/**
* @param Collection $options
* @param string $action
* @return Collection
*/
public function addOverflow(Collection $options,string $action): Collection
{
return collect([
'type'=>'overflow',
'options'=>$options,
'action_id'=>$action,
]);
}
/**
* A section block
*
* @param string $text
* @param string $type
* @param Collection|null $accessories
* @param string|null $block_id
* @return $this
* @throws \Exception
*/
public function addSection(string $text,string $type='mrkdwn',Collection $accessories=NULL,string $block_id=NULL): self
{
// Initialise
$this->_data = collect();
$this->_data->put('type','section');
$this->_data->put('text',$this->text($text,$type));
if ($block_id)
$this->_data->put('block_id',$block_id);
if ($accessories AND $accessories->count())
$this->_data->put('accessory',$accessories);
return $this;
}
/**
* @param string $label
* @param string $action
* @param Collection $options
* @param string|null $default
* @return $this
* @throws \Exception
*/
public function addSelect(string $label,string $action,Collection $options,string $default=NULL): self
{
$this->_data->put('type','section');
$this->_data->put('text',$this->text($label,'mrkdwn'));
// Accessories
$x = collect();
$x->put('action_id',$action);
$x->put('type','static_select');
$x->put('options',$options->map(function ($item) {
if (is_array($item))
$item = (object)$item;
return [
'text'=>[
'type'=>'plain_text',
'text'=>(string)$item->name,
],
'value'=>(string)($item->value ?: $item->id)
];
}));
if ($default) {
$choice = $options->filter(function($item) use ($default) {
if (is_array($item))
$item = (object)$item;
return ($item->value == $default) ? $item : NULL;
})->filter()->pop();
if ($choice) {
$x->put('initial_option',[
'text'=>$this->text($choice['name'],'plain_text'),
'value'=>(string)$choice['value']
]);
}
}
$this->_data->put('accessory',$x);
return $this;
}
/**
* Generates a single-line input dialog
*
* @param string $label
* @param string $action
* @param string $placeholder
* @param int $minlength
* @param string $hint
* @param string $initial
* @return $this
* @throws \Exception
*/
public function addSingleLineInput(string $label,string $action,string $placeholder='',int $minlength=5,string $hint='',string $initial=''): self
{
return $this->input($label,$action,$minlength,$placeholder,FALSE,$hint,$initial);
}
/**
* Generates a multi-line input dialog
*
* @param string $label
* @param string $action
* @param string $placeholder
* @param int $minlength
* @param string $hint
* @param string $initial
* @return $this
* @throws \Exception
*/
public function addMultiLineInput(string $label,string $action,string $placeholder='',int $minlength=20,string $hint='',string $initial=''): self
{
return $this->input($label,$action,$minlength,$placeholder,TRUE,$hint,$initial);
}
}

View File

@ -0,0 +1,37 @@
<?php
namespace Slack\Blockkit;
use Slack\BlockKit;
/**
* This class creates a slack actions used in BlockKit Actions
*/
class BlockAction extends BlockKit
{
/**
* Add a block button
*
* @param string $text
* @param string $action
* @param string $value
* @param string $style
* @return BlockAction
* @throws \Exception
*/
public function addButton(string $text,string $action,string $value,string $style=''): self
{
if ($style AND ! in_array($style,['primary','danger']))
abort('Invalid style: '.$style);
$this->_data->put('type','button');
$this->_data->put('action_id',$action);
$this->_data->put('text',$this->text($text,'plain_text'));
$this->_data->put('value',$value);
if ($style)
$this->_data->put('style',$style);
return $this;
}
}

115
src/Blockkit/Modal.php Normal file
View File

@ -0,0 +1,115 @@
<?php
namespace Slack\Blockkit;
use Illuminate\Support\Str;
use Slack\BlockKit;
/**
* This class creates a slack Modal Response
*/
class Modal extends BlockKit
{
protected $blocks;
private $action = NULL;
public function __construct(string $title)
{
parent::__construct();
$this->blocks = collect();
$this->_data->put('type','modal');
$this->_data->put('title',$this->text(Str::limit($title,24),'plain_text'));
}
public function action(string $action)
{
$this->action = $action;
}
/**
* The data that will be returned when converted to JSON.
*/
public function jsonSerialize()
{
if ($this->blocks->count())
$this->_data->put('blocks',$this->blocks);
switch ($this->action) {
case 'clear':
return ['response_action'=>'clear'];
case 'update':
return ['response_action'=>'update','view'=>$this->_data];
default:
return $this->_data;
}
}
/**
* Add a block to the modal
*
* @param Block $block
* @return $this
*/
public function addBlock(Block $block): self
{
$this->blocks->push($block);
return $this;
}
public function callback(string $id): self
{
$this->_data->put('callback_id',$id);
return $this;
}
public function close(string $text='Cancel'): self
{
$this->_data->put('close',
[
'type'=>'plain_text',
'text'=>$text,
'emoji'=>true,
]);
return $this;
}
public function meta(string $id): self
{
$this->_data->put('private_metadata',$id);
return $this;
}
public function notifyClose(): self
{
$this->_data->put('notify_on_close',TRUE);
return $this;
}
public function private(array $data): self
{
$this->_data->put('private_metadata',json_encode($data));
return $this;
}
public function submit(string $text='Submit'): self
{
$this->_data->put('submit',
[
'type'=>'plain_text',
'text'=>$text,
'emoji'=>true,
]);
return $this;
}
}

134
src/Client/API.php Normal file
View File

@ -0,0 +1,134 @@
<?php
namespace Slack\Client;
use GuzzleHttp;
use Psr\Http\Message\ResponseInterface;
use React\EventLoop\LoopInterface;
use React\Promise\Deferred;
use React\Promise\PromiseInterface;
abstract class API
{
/**
* The base URL for API requests.
*/
const BASE_URL = 'https://slack.com/api/';
/**
* @var string The Slack API token string.
*/
protected $token;
/**
* @var GuzzleHttp\ClientInterface A Guzzle HTTP client.
*/
protected $httpClient;
/**
* @var LoopInterface An event loop instance.
*/
protected $loop;
/**
* Creates a new API client instance.
*
* @param LoopInterface $loop
* @param GuzzleHttp\ClientInterface|null $httpClient A Guzzle client instance to
* send requests with.
*/
public function __construct(LoopInterface $loop,GuzzleHttp\ClientInterface $httpClient = null)
{
$this->loop = $loop;
$this->httpClient = $httpClient ?: new GuzzleHttp\Client();
}
/**
* Sets the Slack API token to be used during method calls.
*
* @param string $token The API token string.
*/
public function setToken($token)
{
$this->token = $token;
}
/**
* Sends an API request.
*
* @param string $method The API method to call.
* @param array $args An associative array of arguments to pass to the
* method call.
* @param bool $multipart Whether to send as a multipart request. Default to false
* @param bool $callDeferred Wether to call the API asynchronous or not.
*
* @return \React\Promise\PromiseInterface A promise for an API response.
*/
public function apiCall(string $method,array $args=[],bool $multipart=FALSE,bool $callDeferred=TRUE): PromiseInterface
{
// create the request url
$requestUrl = self::BASE_URL.$method;
// set the api token
$args['token'] = $this->token;
// send a post request with all arguments
$requestType = $multipart ? 'multipart' : 'form_params';
$requestData = $multipart ? $this->convertToMultipartArray($args) : $args;
$promise = $this->httpClient->postAsync($requestUrl,[
//$requestType => $requestData,
'headers'=>[
'Content-Type'=>'application/json',
'Authorization'=>'Bearer '.$args['token'],
]
]);
//dump(['m'=>__METHOD__,'l'=>__LINE__,'promise'=>$promise]);
// Add requests to the event loop to be handled at a later date.
if ($callDeferred) {
$this->loop->futureTick(function () use ($promise) {
$promise->wait();
});
} else {
$promise->wait();
}
// When the response has arrived, parse it and resolve. Note that our
// promises aren't pretty; Guzzle promises are not compatible with React
// promises, so the only Guzzle promises ever used die in here and it is
// React from here on out.
$deferred = new Deferred();
$promise->then(function (ResponseInterface $response) use ($deferred) {
// get the response as a json object
$payload = Payload::fromJson((string) $response->getBody());
// check if there was an error
if (isset($payload['ok']) && $payload['ok'] === TRUE) {
$deferred->resolve($payload);
} else {
// make a nice-looking error message and throw an exception
$niceMessage = ucfirst(str_replace('_', ' ', $payload['error']));
$deferred->reject(new ApiException($niceMessage));
}
});
return $deferred->promise();
}
private function convertToMultipartArray(array $options): array
{
$convertedOptions = [];
foreach ($options as $key => $value) {
$convertedOptions[] = [
'name' => $key,
'contents' => $value,
];
}
return $convertedOptions;
}
}

View File

@ -0,0 +1,7 @@
<?php
namespace Slack\Client;
class ApiException extends \RuntimeException implements Exception
{
}

7
src/Client/Exception.php Normal file
View File

@ -0,0 +1,7 @@
<?php
namespace Slack\Client;
interface Exception
{
}

117
src/Client/Payload.php Normal file
View File

@ -0,0 +1,117 @@
<?php
namespace Slack\Client;
/**
* Stores incoming or outgoing message data for a Slack API call.
*/
class Payload implements \ArrayAccess, \JsonSerializable
{
/**
* @var array The response data.
*/
protected $data;
/**
* Creates a new payload object.
*
* @param array $data The payload data.
*/
public function __construct(array $data)
{
$this->data = $data;
}
/**
* Creates a response object from a JSON message.
*
* @param string $json A JSON string.
* @return Payload The parsed response.
*/
public static function fromJson($json): self
{
$data = json_decode((string)$json,true);
if (json_last_error() !== JSON_ERROR_NONE || (! is_array($data))) {
throw new \UnexpectedValueException('Invalid JSON message.');
}
return new static($data);
}
/**
* Gets the payload data.
*
* @return array The payload data.
*/
public function getData()
{
return $this->data;
}
/**
* Serializes the payload to a JSON message.
*
* @return string A JSON message.
*/
public function toJson(): string
{
return json_encode($this->data,true);
}
/**
* @param mixed $offset
* @param mixed $value
*/
public function offsetSet($offset, $value)
{
if (is_null($offset)) {
$this->data[] = $value;
} else {
$this->data[$offset] = $value;
}
}
/**
* @param mixed $offset
* @return bool
*/
public function offsetExists($offset)
{
return isset($this->data[$offset]);
}
/**
* @param mixed $offset
*/
public function offsetUnset($offset)
{
unset($this->data[$offset]);
}
/**
* @param mixed $offset
* @return null
*/
public function offsetGet($offset)
{
return $this->data[$offset] ?? NULL;
}
/**
* @return array
*/
public function jsonSerialize()
{
return $this->data;
}
/**
* @return string
*/
public function __toString()
{
return $this->toJson();
}
}

274
src/Client/SocketMode.php Normal file
View File

@ -0,0 +1,274 @@
<?php
namespace Slack\Client;
use Devristo\Phpws\Client\WebSocket;
use Devristo\Phpws\Messaging\WebSocketMessageInterface;
use Evenement\EventEmitterTrait;
use GuzzleHttp\ClientInterface;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Log;
use React\EventLoop\LoopInterface;
use React\Promise\Deferred;
use React\Promise;
class SocketMode extends API
{
use EventEmitterTrait;
private bool $connected;
private WebSocket $websocket;
/**
* @var \Zend\Log\Logger Logger for this client
*/
protected $logger = null;
private array $pendingMessages;
public function __construct(LoopInterface $loop, ClientInterface $httpClient = null) {
parent::__construct($loop, $httpClient);
$this->logger = new \Zend\Log\Logger();
$this->logger->addWriter(new \Zend\Log\Writer\Stream('php://stderr'));
}
/**
* Connects to the real-time messaging server.
*
* @return \React\Promise\PromiseInterface
*/
public function connect()
{
$deferred = new Deferred;
// Request a real-time connection...
$this->apiCall('apps.connections.open')
// then connect to the socket...
->then(function (Payload $response) {
/*
$responseData = $response->getData();
// get the team info
$this->team = new Team($this, $responseData['team']);
// Populate self user.
$this->users[$responseData['self']['id']] = new User($this, $responseData['self']);
// populate list of users
foreach ($responseData['users'] as $data) {
$this->users[$data['id']] = new User($this, $data);
}
// populate list of channels
foreach ($responseData['channels'] as $data) {
$this->channels[$data['id']] = new Channel($this, $data);
}
// populate list of groups
foreach ($responseData['groups'] as $data) {
$this->groups[$data['id']] = new Group($this, $data);
}
// populate list of dms
foreach ($responseData['ims'] as $data) {
$this->dms[$data['id']] = new DirectMessageChannel($this, $data);
}
// populate list of bots
foreach ($responseData['bots'] as $data) {
$this->bots[$data['id']] = new Bot($this, $data);
}
*/
// initiate the websocket connection
// write PHPWS things to the existing logger
$this->websocket = new WebSocket($response['url'].'&debug_reconnects=true', $this->loop, $this->logger);
$this->websocket->on('message', function ($message) {
Log::debug('Calling onMessage for',['m'=>serialize($message)]);
$this->onMessage($message);
});
return $this->websocket->open();
}, function($exception) use ($deferred) {
// if connection was not successful
$deferred->reject(new ConnectionException(
'Could not connect to Slack API: '.$exception->getMessage(),
$exception->getCode()
));
})
// then wait for the connection to be ready.
->then(function () use ($deferred) {
$this->once('hello', function () use ($deferred) {
$deferred->resolve();
});
$this->once('error', function ($data) use ($deferred) {
$deferred->reject(new ConnectionException(
'Could not connect to WebSocket: '.$data['error']['msg'],
$data['error']['code']));
});
});
return $deferred->promise();
}
/**
* Disconnects the client.
*/
public function disconnect()
{
if (! $this->connected) {
return Promise\reject(new ConnectionException('Client not connected. Did you forget to call `connect()`?'));
}
$this->websocket->close();
$this->connected = FALSE;
}
/**
* Returns whether the client is connected.
*
* @return bool
*/
public function isConnected()
{
return $this->connected;
}
/**
* Handles incoming websocket messages, parses them, and emits them as remote events.
*
* @param WebSocketMessageInterface $messageRaw A websocket message.
*/
private function onMessage(WebSocketMessageInterface $message)
{
Log::debug('+ Start',['m'=>__METHOD__]);
// parse the message and get the event name
$payload = Payload::fromJson($message->getData());
if (isset($payload['type'])) {
$this->emit('_internal_message', [$payload['type'], $payload]);
switch ($payload['type']) {
case 'hello':
$this->connected = TRUE;
break;
/*
case 'team_rename':
$this->team->data['name'] = $payload['name'];
break;
case 'team_domain_change':
$this->team->data['domain'] = $payload['domain'];
break;
case 'channel_joined':
$channel = new Channel($this, $payload['channel']);
$this->channels[$channel->getId()] = $channel;
break;
case 'channel_created':
$this->getChannelById($payload['channel']['id'])->then(function (Channel $channel) {
$this->channels[$channel->getId()] = $channel;
});
break;
case 'channel_deleted':
unset($this->channels[$payload['channel']]);
break;
case 'channel_rename':
$this->channels[$payload['channel']['id']]->data['name']
= $payload['channel']['name'];
break;
case 'channel_archive':
$this->channels[$payload['channel']]->data['is_archived'] = true;
break;
case 'channel_unarchive':
$this->channels[$payload['channel']]->data['is_archived'] = false;
break;
case 'group_joined':
$group = new Group($this, $payload['channel']);
$this->groups[$group->getId()] = $group;
break;
case 'group_rename':
$this->groups[$payload['group']['id']]->data['name']
= $payload['channel']['name'];
break;
case 'group_archive':
$this->groups[$payload['group']['id']]->data['is_archived'] = true;
break;
case 'group_unarchive':
$this->groups[$payload['group']['id']]->data['is_archived'] = false;
break;
case 'im_created':
$dm = new DirectMessageChannel($this, $payload['channel']);
$this->dms[$dm->getId()] = $dm;
break;
case 'bot_added':
$bot = new Bot($this, $payload['bot']);
$this->bots[$bot->getId()] = $bot;
break;
case 'bot_changed':
$bot = new Bot($this, $payload['bot']);
$this->bots[$bot->getId()] = $bot;
break;
case 'team_join':
$user = new User($this, $payload['user']);
$this->users[$user->getId()] = $user;
break;
case 'user_change':
$user = new User($this, $payload['user']);
$this->users[$user->getId()] = $user;
break;
*/
default:
Log::debug(sprintf('Unhandled type [%s]',$payload['type']),['m'=>__METHOD__,'p'=>$payload]);
}
// emit an event with the attached json
$this->emit($payload['type'], [$payload]);
}
if (isset($payload['envelope_id'])) {
// @acknowledge the event
$this->websocket->send(json_encode(['envelope_id'=>$payload['envelope_id']]));
Log::debug(sprintf('Responded to event [%s] for (%s)',$payload['envelope_id'],$payload['type']),['m'=>__METHOD__]);
}
if (! isset($payload['type']) || $payload['type'] == 'pong') {
// If reply_to is set, then it is a server confirmation for a previously
// sent message
if (isset($payload['reply_to'])) {
if (isset($this->pendingMessages[$payload['reply_to']])) {
$deferred = $this->pendingMessages[$payload['reply_to']];
// Resolve or reject the promise that was waiting for the reply.
if (isset($payload['ok']) && $payload['ok'] === true || $payload['type'] == 'pong') {
$deferred->resolve();
} else {
$deferred->reject($payload['error']);
}
unset($this->pendingMessages[$payload['reply_to']]);
}
}
}
Log::debug('= End',['m'=>__METHOD__]);
}
}

54
src/Command/Base.php Normal file
View File

@ -0,0 +1,54 @@
<?php
namespace Slack\Command;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Slack\Base as SlackBase;
abstract class Base extends SlackBase
{
public function __construct(Request $request)
{
Log::info(sprintf('SCb:Slack SLASHCOMMAND Initialised [%s]',get_class($this)),['m'=>__METHOD__]);
parent::_construct($request);
}
/**
* Enable getting values for keys in the response
*
* @note: This method is limited to certain values to ensure integrity reasons
* @note: Classes should return:
* + channel_id,
* + team_id,
* + ts,
* + user_id
* @param string $key
* @return mixed|object
*/
public function __get(string $key)
{
switch ($key) {
case 'command':
$command = preg_replace('/^([a-z]+)(\s?.*)/','$1',$this->_data->text);
return $command ?: 'help';
case 'slashcommand':
return object_get($this->_data,'command');
case 'channel_id':
case 'response_url':
case 'enterprise_id':
case 'team_id':
case 'user_id':
return object_get($this->_data,$key);
case 'text':
return preg_replace("/^{$this->command}\s*/",'',object_get($this->_data,$key));
case 'trigger':
return object_get($this->_data,'trigger_id');
}
}
}

52
src/Command/Factory.php Normal file
View File

@ -0,0 +1,52 @@
<?php
namespace Slack\Command;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Log;
class Factory {
private const LOGKEY = 'SCf';
/**
* @var array event type to event class mapping
*/
public const map = [
'ask'=>Watson::class,
'ate'=>Ask::class,
'help'=>Help::class,
'goto'=>Link::class,
'leaders'=>Leaders::class,
'products'=>Products::class,
'review'=>Review::class,
'wc'=>WatsonCollection::class,
];
/**
* Returns new event instance
*
* @param string $type
* @param Request $request
* @return Base
*/
public static function create(string $type,Request $request)
{
$class = Arr::get(self::map,$type,Unknown::class);
Log::debug(sprintf('%s:Working out Slash Command Class for [%s] as [%s]',static::LOGKEY,$type,$class),['m'=>__METHOD__]);
if (App::environment() == 'dev')
file_put_contents('/tmp/command.'.$type,print_r(json_decode(json_encode($request->all())),TRUE));
return new $class($request);
}
public static function make(Request $request): Base
{
$data = json_decode(json_encode($request->all()));
$command = preg_replace('/^([a-z]+)(\s?.*)/','$1',$data->text);
return self::create($command ?: 'help',$request);
}
}

25
src/Command/Help.php Normal file
View File

@ -0,0 +1,25 @@
<?php
namespace Slack\Command;
use Slack\Message;
use Slack\Message\Attachment;
class Help extends Base
{
private const LOGKEY = 'SH-';
public function respond(): Message
{
$o = new Message;
$o->setText('Hi, I am the a *NEW* Bot');
// Version
$a = new Attachment;
$a->addField('Version',config('app.version'),TRUE);
$o->addAttachment($a);
return $o;
}
}

31
src/Command/Unknown.php Normal file
View File

@ -0,0 +1,31 @@
<?php
namespace Slack\Command;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Slack\Message;
/**
* Catch all unknown slack commands that we havent specifically programmed for.
*
* @package Slack\Command
*/
final class Unknown extends Base
{
public function __construct(Request $request)
{
Log::notice(sprintf('SCU:UNKNOWN Slack Interaction Option received [%s]',get_class($this)),['m'=>__METHOD__]);
parent::__construct($request);
}
public function respond(): Message
{
$o = new Message;
$o->setText(sprintf('I didnt understand your command "%s". You might like to try `%s help` instead.',$this->command,$this->slashcommand));
return $o;
}
}

View File

@ -0,0 +1,60 @@
<?php
namespace Slack\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use React\EventLoop\Loop;
use Slack\Client\SocketMode;
class SlackSocketClient extends Command
{
private const LOGKEY = 'SSC';
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'slack:socketmode';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Start SocketMode Client';
public function __construct()
{
// Make sure our socket_token is defined
if (! config('slack.socket_token'))
throw new \Exception('SocketMode Client Token not defined.');
parent::__construct();
}
/**
* Execute the console command.
*
* @return void
* @throws \Exception
*/
public function handle()
{
$loop = Loop::get();
$client = new SocketMode($loop);
$client->setToken(config('slack.socket_token'));
$client->on('events_api', function ($data) use ($client) {
dump(['data'=>$data]);
});
$client->connect()->then(function () {
Log::debug(sprintf('%s: Connected to slack.',self::LOGKEY));
});
$loop->run();
}
}

View File

@ -0,0 +1,44 @@
<?php
namespace Slack\Event;
/**
* This class handles when the user opens the app home page
*
* EG:
* [token] => {SLACKTOKEN}
* [team_id] => {SLACKTEAM}
* [api_app_id] => A4TCZ007N
* [event] => stdClass Object
* (
* [type] => app_home_opened
* [user] => {SLACKUSER}
* [channel] => {SLACKCHANNEL}
* [tab] => messages
* [event_ts] => 1599626320.358395
* )
* [type] => event_callback
* [event_id] => Ev01APNQ0T4Z
* [event_time] => 1599626320
* [authed_users] => Array
* (
* ...
* )
*/
class AppHomeOpened extends Base
{
public function __get($key)
{
switch ($key) {
case 'user_id':
return object_get($this->_data,'event.user');
case 'tab':
return object_get($this->_data,'event.tab');
case 'view':
return object_get($this->_data,'event.view',new \stdClass);
default:
return parent::__get($key);
}
}
}

44
src/Event/Base.php Normal file
View File

@ -0,0 +1,44 @@
<?php
namespace Slack\Event;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Slack\Base as SlackBase;
abstract class Base extends SlackBase
{
public function __construct(Request $request)
{
Log::info(sprintf('SEb:Slack Event Initialised [%s]',get_class($this)),['m'=>__METHOD__]);
parent::__construct($request);
}
/**
* Enable getting values for keys in the response
*
* @note: This method is limited to certain values to ensure integrity reasons
* @note: Classes should return:
* + channel_id,
* + team_id,
* + ts,
* + user_id
* @param string $key
* @return mixed|object
*/
public function __get(string $key)
{
switch ($key) {
case 'channel_id':
// For interactive post responses, the channel ID is "channel"
return object_get($this->_data,$key) ?: object_get($this->_data,'channel');
case 'enterprise_id':
case 'team_id':
case 'ts':
case 'user_id':
return object_get($this->_data,$key);
}
}
}

39
src/Event/ChannelLeft.php Normal file
View File

@ -0,0 +1,39 @@
<?php
namespace Slack\Event;
/**
* This class handles Pin Added responses received from Slack
*
* EG:
* [token] => {SLACKTOKEN}
* [team_id] => {SLACKTEAM}
* [api_app_id] => A4TCZ007N
* [event] => stdClass Object
* (
* [type] => channel_left
* [channel] => {SLACKCHANNEL}
* [actor_id] => {SLACKUSER}
* [event_ts] => 1601602161.000100
* )
* [type] => event_callback
* [event_id] => Ev01CHTCNMFA
* [event_time] => 1601602161
* [authed_users] => Array
* (
* ...
* )
*/
class ChannelLeft extends Base
{
public function __get($key)
{
switch ($key) {
case 'channel_id':
return object_get($this->_data,'event.channel');
default:
return parent::__get($key);
}
}
}

58
src/Event/Factory.php Normal file
View File

@ -0,0 +1,58 @@
<?php
namespace Slack\Event;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Log;
class Factory {
private const LOGKEY = 'SEf';
/**
* @var array event type to event class mapping
*/
public const map = [
'app_home_opened'=>AppHomeOpened::class,
'member_joined_channel'=>MemberJoinedChannel::class,
'channel_left'=>ChannelLeft::class,
'group_left'=>GroupLeft::class,
'message'=>Message::class,
'reaction_added'=>ReactionAdded::class,
'pin_added'=>PinAdded::class,
'pin_removed'=>PinRemoved::class,
];
/**
* Returns new event instance
*
* @param string $type
* @param Request $request
* @return Base
*/
public static function create(string $type,Request $request)
{
$class = Arr::get(self::map,$type,Unknown::class);
Log::debug(sprintf('%s:Working out Event Class for [%s] as [%s]',static::LOGKEY,$type,$class),['m'=>__METHOD__]);
if (App::environment() == 'dev')
file_put_contents('/tmp/event.'.$type,print_r($request->all(),TRUE));
return new $class($request);
}
public static function make(Request $request): Base
{
// During the life of the event, this method is called twice - once during Middleware processing, and finally by the Controller.
static $o = NULL;
static $or = NULL;
if (! $o OR ($or != $request)) {
$or = $request;
$o = self::create($request->input('event.type'),$request);
}
return $o;
}
}

39
src/Event/GroupLeft.php Normal file
View File

@ -0,0 +1,39 @@
<?php
namespace Slack\Event;
/**
* This class handles Pin Added responses received from Slack
*
* EG:
* [token] => {SLACKTOKEN}
* [team_id] => {SLACKTEAM}
* [api_app_id] => A4TCZ007N
* [event] => stdClass Object
* (
* [type] => group_left
* [channel] => {SLACKCHANNEL}
* [actor_id] => {SLACKUSER}
* [event_ts] => 1601602161.000100
* )
* [type] => event_callback
* [event_id] => Ev01CHTCNMFA
* [event_time] => 1601602161
* [authed_users] => Array
* (
* ...
* )
*/
class GroupLeft extends Base
{
public function __get($key)
{
switch ($key) {
case 'channel_id':
return object_get($this->_data,'event.channel');
default:
return parent::__get($key);
}
}
}

View File

@ -0,0 +1,45 @@
<?php
namespace Slack\Event;
/**
* This class handles when a user is invited to a channel
*
* EG:
* [token] => Oow8S2EFvrZoS9z8N4nwf9Jo
* [team_id] => {SLACKTEAM}
* [api_app_id] => A4TCZ007N
* [event] => stdClass Object
* (
* [type] => member_joined_channel
* [user] => {SLACKUSER}
* [channel] => {SLACKCHANNEL}
* [channel_type] => G
* [team] => {SLACKTEAM}
* [inviter] => {SLACKUSER}
* [event_ts] => 1605160285.000800
* )
* [type] => event_callback
* [event_id] => Ev01EKN4AYRZ
* [event_time] => 1605160285
* [authed_users] => Array
* (
* ...
* )
* [event_context] => 1-member_joined_channel-T159T77TM-G4D3PH40L
*/
class MemberJoinedChannel extends Base
{
public function __get($key)
{
switch ($key) {
case 'channel_id':
return object_get($this->_data,'event.channel');
case 'invited':
return object_get($this->_data,'event.user');
default:
return parent::__get($key);
}
}
}

65
src/Event/Message.php Normal file
View File

@ -0,0 +1,65 @@
<?php
namespace Slack\Event;
/**
* This class handles message responses received from Slack
* These events come as messages on a thread, or new messages posted in the main stream.
* There are also subtype events, representing deletion and broadcasting
*
* EG:
* [token] => {SLACKTOKEN}
* [team_id] => {SLACKTEAM}
* [api_app_id] => A4TCZ007N
* [event] => stdClass Object
* (
* [client_msg_id] => f49c853e-8958-4f8d-b8ed-fe2c302755de
* [type] => message
* [subtype] => message_deleted
* [text] => foo
* [user] => {SLACKUSER}
* [ts] => 1599718357.012700
* [team] => {SLACKTEAM}
* [blocks] => Array
* (
* ...
* )
* [thread_ts] => 1598854309.005800
* [parent_user_id] => {SLACKUSER}
* [channel] => {SLACKCHANNEL}
* [event_ts] => 1599718357.012700
* [channel_type] => group
* )
* [type] => event_callback
* [event_id] => Ev01AE1G2402
* [event_time] => 1599718357
* [authed_users] => Array
* (
* ...
* )
*/
class Message extends Base
{
public function __get($key)
{
switch ($key) {
case 'channel_id':
return object_get($this->_data,'event.channel');
case 'user_id':
return object_get($this->_data,'event.user');
case 'type':
return object_get($this->_data,'event.subtype');
case 'thread_ts':
return object_get($this->_data,'event.'.($this->type == 'message_deleted' ? 'previous_message.' : '').$key);
case 'deleted_ts':
case 'text':
case 'ts':
return object_get($this->_data,'event.'.$key);
default:
return parent::__get($key);
}
}
}

78
src/Event/PinAdded.php Normal file
View File

@ -0,0 +1,78 @@
<?php
namespace Slack\Event;
/**
* This class handles Pin Added responses received from Slack
*
* EG:
* [token] => {SLACKTOKEN}
* [team_id] => {SLACKTEAM}
* [api_app_id] => A4TCZ007N
* [event] => stdClass Object
* (
* [type] => pin_added
* [user] => {SLACKUSER}
* [channel_id] => {SLACKCHANNEL}
* [item] => stdClass Object
* (
* [type] => message
* [created] => 1599637814
* [created_by] => {SLACKUSER}
* [channel] => {SLACKCHANNEL}
* [message] => stdClass Object
* (
* [bot_id] => B4TC0EYKU
* [type] => message
* [text] =>
* [user] => {SLACKUSER}
* [ts] => 1599617180.008300
* [team] => {SLACKTEAM}
* [bot_profile] => stdClass Object
* (
* ...
* )
* [pinned_to] => Array
* (
* [0] => G4D0B9B7V
* )
* [permalink] => https://leenooks.slack.com/archives/G4D0B9B7V/p1599617180008300
* )
* )
* [item_user] => {SLACKUSER}
* [pin_count] => 25
* [pinned_info] => stdClass Object
* (
* [channel] => {SLACKCHANNEL}
* [pinned_by] => {SLACKUSER}
* [pinned_ts] => 1599637814
* )
* [event_ts] => 1599637814.008900
* )
* [type] => event_callback
* [event_id] => Ev01AHHE5TS8
* [event_time] => 1599637814
* [authed_users] => Array
* (
* [0] => {SLACKUSER}
* )
*/
class PinAdded extends Base
{
public function __get($key)
{
switch ($key) {
case 'user_id':
return object_get($this->_data,'event.user');
case 'ts':
return object_get($this->_data,'event.item.message.ts');
case 'channel_id':
return object_get($this->_data,'event.'.$key);
default:
return parent::__get($key);
}
}
}

75
src/Event/PinRemoved.php Normal file
View File

@ -0,0 +1,75 @@
<?php
namespace Slack\Event;
/**
* This class handles Pin Removed responses received from Slack
*
* EG:
* [token] => {SLACKTOKEN}
* [team_id] => {SLACKTEAM}
* [api_app_id] => A4TCZ007N
* [event] => stdClass Object
* (
* [type] => pin_removed
* [user] => {SLACKUSER}
* [channel_id] => {SLACKCHANNEL}
* [item] => stdClass Object
* (
* [type] => message
* [created] => 1599550210
* [created_by] => {SLACKUSER}
* [channel] => {SLACKCHANNEL}
* [message] => stdClass Object
* (
* [bot_id] => {SLACKUSER}
* [type] => message
* [text] =>
* [user] => {SLACKUSER}
* [ts] => 1599550210.007600
* [team] => {SLACKTEAM}
* [bot_profile] => stdClass Object
* (
* ...
* )
* )
* [permalink] => https://leenooks.slack.com/archives/G4D0B9B7V/p1599550210007600
* )
* [item_user] => {SLACKUSER}
* [pin_count] => 24
* [pinned_info] => stdClass Object
* (
* [channel] => {SLACKCHANNEL}
* [pinned_by] => {SLACKUSER}
* [pinned_ts] => 1599550210
* )
* [has_pins] => 1
* [event_ts] => 1599636527.008800
* )
* [type] => event_callback
* [event_id] => Ev01A887TPRT
* [event_time] => 1599636527
* [authed_users] => Array
* (
* ...
* )
*/
class PinRemoved extends Base
{
public function __get($key)
{
switch ($key) {
case 'user_id':
return object_get($this->_data,'event.user');
case 'ts':
return object_get($this->_data,'event.item.message.ts');
case 'channel_id':
return object_get($this->_data,'event.'.$key);
default:
return parent::__get($key);
}
}
}

View File

@ -0,0 +1,55 @@
<?php
namespace Slack\Event;
/**
* This class handles when the user adds a reaction to a message
*
* EG:
* [token] => {SLACKTOKEN}
* [team_id] => {SLACKTEAM}
* [api_app_id] => A4TCZ007N
* [event] => stdClass Object
* (
* [type] => reaction_added
* [user] => {SLACKUSER}
* [item] => stdClass Object
* (
* [type] => message
* [channel] => {SLACKCHANNEL}
* [ts] => 1598854309.005800
* )
* [reaction] => question
* [item_user] => {SLACKUSER}
* [event_ts] => 1599709789.010500
* )
* [type] => event_callback
* [event_id] => Ev01ADSDSE74
* [event_time] => 1599709789
* [authed_users] => Array
* (
* ...
* )
*/
class ReactionAdded extends Base
{
public function __get($key)
{
switch ($key) {
case 'user_id':
return object_get($this->_data,'event.user');
case 'reaction':
return object_get($this->_data,'event.'.$key);
case 'channel_id':
return object_get($this->_data,'event.item.channel');
case 'ts':
return object_get($this->_data,'event.item.ts');
default:
return parent::__get($key);
}
}
}

21
src/Event/Unknown.php Normal file
View File

@ -0,0 +1,21 @@
<?php
namespace Slack\Event;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
/**
* Catch all unknown slack event that we havent specifically programmed for.
*
* @package Slack\Event
*/
class Unknown extends Base
{
public function __construct(Request $request)
{
Log::notice(sprintf('SEU:UNKNOWN Slack Event received [%s]',get_class($this)),['m'=>__METHOD__]);
parent::__contruct($request);
}
}

View File

@ -0,0 +1,7 @@
<?php
namespace Slack\Exceptions;
class SlackAlreadyPinnedException extends SlackException
{
}

View File

@ -0,0 +1,7 @@
<?php
namespace Slack\Exceptions;
class SlackChannelNotFoundException extends SlackException
{
}

View File

@ -0,0 +1,7 @@
<?php
namespace Slack\Exceptions;
class SlackException extends \Exception
{
}

View File

@ -0,0 +1,7 @@
<?php
namespace Slack\Exceptions;
class SlackHashConflictException extends SlackException
{
}

View File

@ -0,0 +1,7 @@
<?php
namespace Slack\Exceptions;
class SlackMessageNotFoundException extends SlackException
{
}

View File

@ -0,0 +1,7 @@
<?php
namespace Slack\Exceptions;
class SlackNoAuthException extends SlackException
{
}

View File

@ -0,0 +1,7 @@
<?php
namespace Slack\Exceptions;
class SlackNoPinException extends SlackException
{
}

View File

@ -0,0 +1,7 @@
<?php
namespace Slack\Exceptions;
class SlackNotFoundException extends SlackException
{
}

View File

@ -0,0 +1,7 @@
<?php
namespace Slack\Exceptions;
class SlackNotInChannelException extends SlackException
{
}

View File

@ -0,0 +1,7 @@
<?php
namespace Slack\Exceptions;
class SlackThreadNotFoundException extends SlackException
{
}

View File

@ -0,0 +1,7 @@
<?php
namespace Slack\Exceptions;
class SlackTokenScopeException extends SlackException
{
}

View File

@ -0,0 +1,187 @@
<?php
namespace Slack\Http\Controllers;
use App\Http\Controllers\Controller;
use GuzzleHttp\Client;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Redirect;
use Slack\Jobs\TeamUpdate;
use Slack\Models\{Enterprise,Team,Token,User};
class SlackAppController extends Controller
{
private const LOGKEY = 'CSA';
protected static $scopes = [
/*
'channels:history',
'channels:read',
'chat:write',
'chat:write.customize',
'groups:history',
'groups:read',
'im:history',
'im:read',
'im:write',
'team:read',
*/
];
protected static $user_scopes = [
//'pins.write',
];
private const slack_authorise_url = 'https://slack.com/oauth/v2/authorize';
private const slack_oauth_url = 'https://slack.com/api/oauth.v2.access';
private const slack_button = 'https://platform.slack-edge.com/img/add_to_slack.png';
/**
* Install this app - Slack Button
*/
public function button()
{
return sprintf(
'<a href="%s?%s"><img alt="Add to Slack" height="40" width="139" src="%s" srcSet="%s 1x, %s@2x.png 2x" /></a>',
self::slack_authorise_url,
http_build_query($this->parameters()),
self::slack_button,self::slack_button,self::slack_button
);
}
public function home()
{
return sprintf('Hi, for instructions on how to install me, please reach out to <strong>@deon.</strong>');
}
public function setup()
{
return Redirect::to(self::slack_authorise_url.'?'.http_build_query($this->parameters()));
}
/**
* Install this Slack Application.
*
* @param Request $request
* @return string
* @throws \GuzzleHttp\Exception\GuzzleException
*/
public function install(Request $request)
{
if (! config('slack.client_id') OR ! config('slack.client_secret'))
abort(403,'Slack ClientID or Secret not set');
$client = new Client;
$response = $client->request('POST',self::slack_oauth_url,[
'auth'=>[config('slack.client_id'),config('slack.client_secret')],
'form_params'=>[
'code'=>$request->get('code'),
'redirect_url'=>$request->url(),
],
]);
if ($response->getStatusCode() != 200)
abort(403,'Something didnt work, status code not 200');
$output = json_decode($response->getBody());
if (App::environment() == 'local')
file_put_contents('/tmp/install',print_r($output,TRUE));
if (! $output->ok)
abort(403,'Something didnt work, status not OK ['.(string)$response->getBody().']');
// Are we an enterprise?
$eo = NULL;
if ($output->enterprise) {
$eo = Enterprise::firstOrNew(
[
'enterprise_id'=>$output->enterprise->id
]);
$eo->name = $output->enterprise->name;
$eo->active = TRUE;
$eo->save();
}
// Store our team details
$so = Team::firstOrNew(
[
'team_id'=>$output->team->id
]);
// We just installed, so we'll make it active, even if it already exists.
$so->description = $output->team->name;
$so->active = 1;
$so->enterprise_id = $eo ? $eo->id : NULL;
$so->save();
dispatch((new TeamUpdate($so))->onQueue('slack'));
// Store our app token
$to = $so->token;
if (! $to) {
$to = new Token;
$to->description = 'App: Oauth';
}
$to->active = 1;
$to->token = $output->access_token;
$to->scope = $output->scope;
$so->token()->save($to);
Log::debug(sprintf('%s:TOKEN Created [%s]',self::LOGKEY,$to->id),['m'=>__METHOD__]);
// Create the bot user
// Store the user who install, and make them admin
$bo = User::firstOrNew(
[
'user_id'=>$output->bot_user_id,
]);
$bo->enterprise_id = $eo ? $eo->id : NULL;
$bo->team_id = $so->id;
$bo->active = 0;
$bo->admin = 0;
$bo->save();
$so->bot_id = $bo->id;
$so->save();
Log::debug(sprintf('%s:BOT Created [%s]',self::LOGKEY,$bo->id),['m'=>__METHOD__]);
// Store the user who install, and make them admin
$uo = User::firstOrNew(
[
'user_id'=>$output->authed_user->id,
]);
$uo->enterprise_id = $eo ? $eo->id : NULL;
$uo->team_id = $eo ? NULL : $so->id;
$uo->active = 1;
$uo->admin = 1;
$uo->save();
Log::debug(sprintf('%s:ADMIN Created [%s]',self::LOGKEY,$uo->id),['m'=>__METHOD__]);
// Update Slack Object with admin_id
$so->admin_id = $uo->id;
$so->save();
return sprintf('All set up! Head back to your slack instance <strong>%s</strong>."',$so->description);
}
private function parameters(): array
{
return [
'client_id' => config('slack.client_id'),
'scope' => join(',',config('slack.bot_scopes')),
'user_scope' => join(',',config('slack.user_scopes')),
];
}
}

65
src/Interactive/Base.php Normal file
View File

@ -0,0 +1,65 @@
<?php
namespace Slack\Interactive;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Slack\Base as SlackBase;
abstract class Base extends SlackBase
{
// Does the event respond with a reply to the HTTP request, or via a post with a trigger
public $respondNow = FALSE;
// When retrieving multiple action values, this is the index we are retrieving.
protected $index = 0;
public function __construct(Request $request)
{
Log::info(sprintf('SIb:Slack INTERACTIVE MESSAGE Initialised [%s]',get_class($this)),['m'=>__METHOD__]);
// Our data is in a payload value
$this->_data = json_decode($request->input('payload'));
}
/**
* Enable getting values for keys in the response
*
* @note: This method is limited to certain values to ensure integrity reasons
* @note: Classes should return:
* + channel_id,
* + team_id,
* + ts,
* + user_id
* @param string $key
* @return mixed|object
*/
public function __get(string $key)
{
switch ($key) {
case 'enterprise_id':
return object_get($this->_data,'team.enterprise_id');
case 'team_id':
return object_get($this->_data,'team.id');
case 'user_id':
return object_get($this->_data,'user.id');
case 'callback_id':
case 'trigger_id':
case 'type':
return object_get($this->_data,$key);
}
}
/**
* Enable updating the index to actions with $event->index = <id>
*
* @param $key
* @param $value
*/
public function __set($key,$value)
{
if ($key == 'index')
$this->{$key} = $value;
}
}

View File

@ -0,0 +1,161 @@
<?php
namespace Slack\Interactive;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Slack\Models\Channel;
/**
* This class handles when the users responds with a Block Action event
*
* EG:
* [type] => block_actions
* [user] => stdClass Object
* (
* [id] => {SLACKUSER}
* [username] => {SLACKUSER}
* [name] => {SLACKUSER}
* [team_id] => {SLACKTEAM}
* )
* [api_app_id] => A4TCZ007N
* [token] => {SLACKTOKEN}
* [container] => stdClass Object
* (
* [type] => view
* [view_id] => V018HRRS38R
* )
* [trigger_id] => 1346041864311.39333245939.7c6adb7bca538143098386f07effa532
* [team] => stdClass Object
* (
* [id] => {SLACKTEAM}
* [domain] => {SLACKDOMAIN}
* )
* [view] => stdClass Object
* (
* ...
* )
* [actions] => Array
* (
* [0] => stdClass Object
* (
* [action_id] => faq_product
* [block_id] => IRFcN
* [text] => stdClass Object
* (
* [type] => plain_text
* [text] => ASK QUESTION
* [emoji] => 1
* )
* [value] => question_new
* [type] => button
* [action_ts] => 1600065294.860855
* )
* )
*/
class BlockActions extends Base
{
private const LOGKEY = 'IBA';
public function __get($key)
{
switch ($key) {
case 'callback_id':
return object_get($this->_data,'view.callback_id');
// An event can have more than 1 action, each action can have 1 value.
case 'action_id':
return $this->action('action');
case 'action_value':
return $this->action('value');
case 'value':
switch (Arr::get(object_get($this->_data,'actions'),$this->index)->type) {
case 'external_select':
case 'overflow':
case 'static_select':
return object_get(Arr::get(object_get($this->_data,'actions'),$this->index),'selected_option.value');
default:
return object_get(Arr::get(object_get($this->_data,'actions'),$this->index),'value');
}
// For Block Actions that are messages
case 'message_ts':
return object_get($this->_data,'message.ts');
case 'channel_id':
return object_get($this->_data,'channel.id') ?: Channel::findOrFail($this->action('value'))->channel_id;
case 'view_id':
return object_get($this->_data,'view.id');
case 'actions':
return object_get($this->_data,$key);
// For some reason this object is not making sense, and while we should be getting team.id or even view.team_id, the actual team appears to be in user.team_id
// @todo Currently working with Slack to understand this behaviour
case 'team_id': // view.team_id represent workspace publishing view
return object_get($this->_data,'user.team_id');
default:
return parent::__get($key);
}
}
/**
* Separate out an action command to the id that the command relates to
*
* @param string $key
* @return string|null
*/
private function action(string $key): ?string
{
$regex = '/^([a-z_]+)\|([0-9]+)$/';
$action = NULL;
$value = NULL;
// We only take the action up to the pipe symbol
$action_id = object_get(Arr::get(object_get($this->_data,'actions'),$this->index),'action_id');
if (preg_match($regex,$action_id)) {
$action = preg_replace($regex,'$1',$action_id);
$value = preg_replace($regex,'$2',$action_id);
}
switch ($key) {
case 'action':
return $action ?: $action_id;
case 'value':
return $value;
}
return NULL;
}
/**
* Some block actions are triggered by messages, and thus dont have a callback_id
*
* @return bool
*/
public function isMessage(): bool
{
return object_get($this->_data,'message') ? TRUE : FALSE;
}
/**
* Get the selected options from a block action actions array
*
* @return Collection
*/
public function selected_options(): Collection
{
$result = collect();
foreach (Arr::get(object_get($this->_data,'actions'),'0')->selected_options as $option) {
$result->push($option->value);
}
return $result;
}
}

View File

@ -0,0 +1,56 @@
<?php
namespace Slack\Interactive;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Log;
class Factory {
private const LOGKEY = 'SIF';
/**
* @var array event type to event class mapping
*/
public const map = [
'block_actions'=>BlockActions::class,
'interactive_message'=>InteractiveMessage::class,
'shortcut'=>Shortcut::class,
'view_closed'=>ViewClosed::class,
'view_submission'=>ViewSubmission::class,
];
/**
* Returns new event instance
*
* @param string $type
* @param Request $request
* @return Base
*/
public static function create(string $type,Request $request)
{
$class = Arr::get(self::map,$type,Unknown::class);
Log::debug(sprintf('%s:Working out Interactive Message Event Class for [%s] as [%s]',static::LOGKEY,$type,$class),['m'=>__METHOD__]);
if (App::environment() == 'dev')
file_put_contents('/tmp/interactive.'.$type,print_r(json_decode($request->input('payload')),TRUE));
return new $class($request);
}
public static function make(Request $request): Base
{
// During the life of the event, this method is called twice - once during Middleware processing, and finally by the Controller.
static $o = NULL;
static $or = NULL;
if (! $o OR ($or != $request)) {
$data = json_decode($request->input('payload'));
$or = $request;
$o = self::create($data->type,$request);
}
return $o;
}
}

View File

@ -0,0 +1,118 @@
<?php
namespace Slack\Interactive;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Log;
use Slack\Message;
/**
* Class InteractiveMessage
*
* @package Slack\Interactive
*
* [type] => interactive_message
* [actions] => Array
* (
* [0] => stdClass Object
* (
* [name] => type
* [type] => select
* [selected_options] => Array
* (
* [0] => stdClass Object
* (
* [value] => ID|1
* )
* )
* )
* )
* [callback_id] => classify|438
* [team] => stdClass Object
* (
* [id] => {SLACKTEAM}
* [domain] => {SLACKDOMAIN}
* )
* [channel] => stdClass Object
* (
* [id] => {SLACKCHANNEL}
* [name] => directmessage
* )
* [user] => stdClass Object
* (
* [id] => {SLACKUSER}
* [name] => {SLACKUSER}
* )
* [action_ts] => 1603777165.467584
* [message_ts] => 1603768794.012800
* [attachment_id] => 3
* [token] => Oow8S2EFvrZoS9z8N4nwf9Jo
* [is_app_unfurl] =>
* [original_message] => stdClass Object
* (
* ...
* )
* [response_url] => {SLACKRESPONSEURL}
* [trigger_id] => 1452241456197.39333245939.7f8618e13013ae0a0ae7d86be2258021
*/
class InteractiveMessage extends Base
{
private const LOGKEY = 'IIM';
// Does the event respond with a reply to the HTTP request, or via a post with a trigger
public $respondNow = TRUE;
public function __get($key)
{
switch ($key) {
// An event can have more than 1 action, each action can have 1 value.
case 'action_id':
case 'name':
case 'type':
return object_get(Arr::get(object_get($this->_data,'actions'),$this->index),$key);
case 'value':
switch ($this->type) {
case 'button':
return Arr::get(object_get($this->_data,'actions'),$this->index)->value;
case 'select':
return Arr::get(object_get(Arr::get(object_get($this->_data,'actions'),$this->index),'selected_options'),0)->value;
}
break;
case 'channel_id':
return object_get($this->_data,'channel.id');
case 'message_ts':
return object_get($this->_data,$key);
default:
return parent::__get($key);
}
}
public function respond(): Message
{
Log::info(sprintf('%s:Interactive Message - Callback [%s] Name [%s] Type [%s]',static::LOGKEY,$this->callback_id,$this->name,$this->type),['m'=>__METHOD__]);
$action = NULL;
$id = NULL;
if (preg_match('/^(.*)\|([0-9]+)/',$this->callback_id)) {
[$action,$id] = explode('|',$this->callback_id,2);
} elseif (preg_match('/^[a-z_]+$/',$this->callback_id)) {
$id = $this->name;
$action = $this->callback_id;
} else {
// If we get here, its an action that we dont know about.
Log::notice(sprintf('%s:Unhandled CALLBACK [%s]',static::LOGKEY,$this->callback_id),['m'=>__METHOD__]);
}
switch ($action) {
default:
Log::notice(sprintf('%s:Unhandled ACTION [%s]',static::LOGKEY,$action),['m'=>__METHOD__]);
return (new Message)->setText('That didnt work, I didnt know what to do with your button - you might like to tell '.$this->team()->owner->slack_user);
}
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace Slack\Interactive;
/**
* This class handles when the user opens the app via a shortcut configured in Interactivity & Shortcuts
*
* EG:
* [type] => shortcut
* [token] => {SLACKTOKEN}
* [action_ts] => 1600393871.567037
* [team] => stdClass Object
* (
* [id] => {SLACKTEAM}
* [domain] => {SLACKDOMAIN}
* )
* [user] => stdClass Object
* (
* [id] => {SLACKUSER}
* [username] => {SLACKUSER}
* [team_id] => {SLACKTEAM}
* )
* [callback_id] => sc_question_ask
* [trigger_id] => 1357077877831.39333245939.79f59e011ce5e5a1865d0ae2ac94b3be
*/
class Shortcut extends Base
{
public function __get($key)
{
switch ($key) {
case 'user_id':
return object_get($this->_data,'event.user');
default:
return parent::__get($key);
}
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace Slack\Interactive;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
/**
* Catch all unknown slack event that we havent specifically programmed for.
*
* @package Slack\Interactive
*/
class Unknown extends Base
{
public function __construct(Request $request)
{
Log::notice(sprintf('SIU:UNKNOWN Slack Interaction Option received [%s]',get_class($this)),['m'=>__METHOD__]);
parent::__construct($request);
}
}

View File

@ -0,0 +1,87 @@
<?php
namespace Slack\Interactive;
use Illuminate\Support\Arr;
/**
* Class ViewClosed
* [type] => view_closed
* [team] => stdClass Object
* (
* [id] => {SLACKTEAM}
* [domain] => {SLACKDOMAIN}
* )
* [user] => stdClass Object
* (
* [id] => {SLACKUSER}
* [username] => {SLACKUSER}
* [name] => {SLACKUSER}
* [team_id] => {SLACKTEAM}
* )
* [api_app_id] => A4TCZ007N
* [token] => Oow8S2EFvrZoS9z8N4nwf9Jo
* [view] => stdClass Object
* (
* [id] => V01DRFF9SKT
* [team_id] => {SLACKTEAM}
* [type] => modal
* [blocks] => Array
* (
* )
* [private_metadata] =>
* [callback_id] => askme-products
* [state] => stdClass Object
* (
* [values] => stdClass Object
* (
* )
* [hash] => 1603754939.JuTA8UTb
* [title] => stdClass Object
* (
* [type] => plain_text
* [text] => AskMe Products
* [emoji] => 1
* )
* [clear_on_close] =>
* [notify_on_close] => 1
* [close] => stdClass Object
* (
* [type] => plain_text
* [text] => Close
* [emoji] => 1
* )
* [submit] =>
* [previous_view_id] =>
* [root_view_id] => V01DRFF9SKT
* [app_id] => A4TCZ007N
* [external_id] =>
* [app_installed_team_id] => T159T77TM
* [bot_id] => B4TC0EYKU
* )
* [is_cleared] =>
*
* @package Slack\Interactive
*/
class ViewClosed extends Base
{
private const LOGKEY = 'IVC';
public function __get($key)
{
switch ($key) {
case 'callback_id':
return object_get($this->_data,'view.callback_id');
// An event can have more than 1 action, each action can have 1 value.
case 'action_id':
return object_get(Arr::get(object_get($this->_data,'actions'),$this->index),$key);
case 'view':
return object_get($this->_data,$key);
default:
return parent::__get($key);
}
}
}

View File

@ -0,0 +1,93 @@
<?php
namespace Slack\Interactive;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Slack\Models\Team;
use Slack\Blockkit\Modal;
/**
* Incoming modal view submission event.
*
* @package Slack\Interactive
*/
class ViewSubmission extends Base
{
private const LOGKEY = 'IVS';
// View Submissions must respond with via a trigger or inline
public $respondNow = TRUE;
public function __get($key)
{
switch ($key) {
case 'callback_id':
return object_get($this->_data,'view.'.$key);
case 'meta':
return object_get($this->_data,'view.private_metadata');
case 'view_id':
return object_get($this->_data,'view.id');
default:
return parent::__get($key);
}
}
private function blocks(): Collection
{
$result = collect();
foreach (object_get($this->_data,'view.blocks',[]) as $id=>$block) {
switch (object_get($block,'type')) {
case 'input':
$result->put($block->element->action_id,$block->block_id);
break;
case 'section':
$result->put($block->block_id,$id);
break;
}
}
return $result;
}
public function value(string $block_id): ?string
{
$key = Arr::get($this->blocks(),$block_id);
// Return the state value, or the original block value
return object_get($this->_data,'view.state.values.'.$key.'.'.$block_id.'.value') ?: object_get(Arr::get(object_get($this->_data,'view.blocks'),$key),'text.text','');
}
public function respond(): Modal
{
// Do some magic with event data
Log::info(sprintf('%s:View Submission for Callback [%s] User [%s] in [%s]',self::LOGKEY,$this->callback_id,$this->user_id,$this->team_id),['m'=>__METHOD__]);
$action = NULL;
$id = NULL;
if (preg_match('/^(.*)\|([0-9]+)/',$this->callback_id)) {
[$action,$cid] = explode('|',$this->callback_id,2);
} elseif (preg_match('/^[a-z_]+$/',$this->callback_id)) {
$action = $this->callback_id;
} else {
// If we get here, its an action that we dont know about.
Log::notice(sprintf('%s:Unhandled CALLBACK [%s]',static::LOGKEY,$this->callback_id),['m'=>__METHOD__]);
}
switch ($action) {
default:
Log::notice(sprintf('%s:Unhandled ACTION [%s]',self::LOGKEY,$action),['m'=>__METHOD__]);
}
return new Modal(new Team);
}
}

55
src/Jobs/DeleteChat.php Normal file
View File

@ -0,0 +1,55 @@
<?php
namespace Slack\Jobs;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Log;
use Slack\Models\{Channel,User};
use Slack\Exceptions\SlackException;
class DeleteChat extends Job
{
private const LOGKEY = 'JDC';
/**
* Create a new job instance.
*
* @param Model $o
* @param string $ts
* @throws \Exception
*/
public function __construct(Model $o,string $ts)
{
if ($o instanceof Channel) {
$this->_data['cid'] = $o->channel_id;
} elseif ($o instanceof User) {
$this->_data['cid'] = $o->user_id;
} else
throw new \Exception('Invalid Model: '.get_class($o));
$this->_data['to'] = $o->team;
$this->_data['ts'] = $ts;
}
/**
* Execute the job.
*
* @return void
* @throws \Exception
*/
public function handle()
{
Log::info(sprintf('%s:Start - Delete Chat in Channel [%s] with TS [%s]',static::LOGKEY,$this->cid,$this->ts),['m'=>__METHOD__]);
try {
$this->to->slackAPI()->deleteChat($this->cid,$this->ts);
Log::debug(sprintf('%s:Deleted Slack Message: %s',static::LOGKEY,$this->ts),['m'=>__METHOD__]);
} catch (SlackException $e) {
Log::error(sprintf('%s:Failed to delete slack message [%s] [%s]',static::LOGKEY,$this->ts,$e->getMessage()),['m'=>__METHOD__]);
}
}
}

32
src/Jobs/Job.php Normal file
View File

@ -0,0 +1,32 @@
<?php
namespace Slack\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Arr;
abstract class Job implements ShouldQueue
{
/*
|--------------------------------------------------------------------------
| Queueable Jobs
|--------------------------------------------------------------------------
|
| This job base class provides a central location to place any logic that
| is shared across all of your jobs. The trait included with the class
| provides access to the "queueOn" and "delay" queue helper methods.
|
*/
use InteractsWithQueue, Queueable, SerializesModels;
protected $_data = [];
public function __get($key)
{
return Arr::get($this->_data,$key);
}
}

47
src/Jobs/TeamUpdate.php Normal file
View File

@ -0,0 +1,47 @@
<?php
namespace Slack\Jobs;
use Illuminate\Support\Facades\Log;
use Slack\Exceptions\SlackTokenScopeException;
use Slack\Models\Team;
class TeamUpdate extends Job
{
private const LOGKEY = 'JTU';
/**
* Create a new job instance.
*
* @param Team $to
*/
public function __construct(Team $to)
{
$this->_data['to'] = $to;
}
public function handle()
{
try {
$response = $this->to->slackAPI()->getTeam($this->to->team_id);
} catch (SlackTokenScopeException $e) {
Log::error(sprintf('%s:%s',self::LOGKEY,$e->getMessage()));
return;
}
// We need to refresh the team, in case their status has changed since the job was scheduled.
$this->to->refresh();
$this->to->team_name = $response->domain;
$this->to->description = $response->name;
if ($this->to->isDirty())
Log::debug(sprintf('%s:Updated [%s] (%s)',self::LOGKEY,$this->to->id,$this->to->team_id),['m'=>__METHOD__,'changed'=>$this->to->getDirty()]);
else
Log::debug(sprintf('%s:No Update for [%s] (%s)',self::LOGKEY,$this->to->id,$this->to->user_id),['m'=>__METHOD__]);
$this->to->save();
}
}

308
src/Message.php Normal file
View File

@ -0,0 +1,308 @@
<?php
namespace Slack;
use Carbon\Carbon;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Log;
use Slack\Jobs\DeleteChat;
use Slack\Models\{Channel,User};
use Slack\Blockkit\Block;
use Slack\Exceptions\SlackException;
use Slack\Message\Attachment;
use Slack\Response\Generic;
/**
* This class is used when composing a message to send to Slack.
*/
class Message implements \JsonSerializable
{
protected const LOGKEY = 'SM-';
private $o;
private $attachments;
private $blocks;
/**
* Message constructor.
*
* @param Model|null $o Who the message will be to - Channel or User
*/
public function __construct(Model $o=NULL)
{
$this->_data = collect();
// Message is to a channel
if ($o instanceof Channel) {
$this->setChannel($o);
// Message is to a user
} elseif ($o instanceof User) {
$this->setUser($o);
}
$this->o = $o;
$this->attachments = collect();
$this->blocks = collect();
}
/**
* Add an attachment to a message
*
* @param Attachment $attachment
* @return Message
*/
public function addAttachment(Attachment $attachment): self
{
$this->attachments->push($attachment);
return $this;
}
/**
* Add a block to the message
*
* @param BlockKit $block
* @return $this
*/
public function addBlock(BlockKit $block): self
{
$this->blocks->push($block);
return $this;
}
/**
* Empty the message
*
* @return $this
*/
public function blank(): self
{
$this->_data = collect();
return $this;
}
/*
* @todo This doesnt appear to work
public function ephemeral(): self
{
$this->_data->put('ephemeral',TRUE);
return $this;
}
*/
public function forgetTS(): self
{
$this->_data->forget('ts');
return $this;
}
/**
* Return if this is an empty message
*
* @return bool
*/
public function isEmpty(): bool
{
return $this->jsonSerialize() ? FALSE : TRUE;
}
/**
* When we json_encode this object, this is the data that will be returned
*/
public function jsonSerialize()
{
if ($this->blocks->count()) {
if ($this->_data->has('text'))
throw new \Exception('Messages cannot have text and blocks!');
$this->_data->put('blocks',$this->blocks);
}
if ($this->attachments->count())
$this->_data->put('attachments',$this->attachments);
// For interactive messages that generate a dialog, we need to return NULL
return $this->_data->count() ? $this->_data : NULL;
}
/**
* Post this message to slack
*
* @param Carbon|null $delete
* @return Generic
* @throws \Exception
*/
public function post(Carbon $delete=NULL): Generic
{
if ($this->_data->has('ephemeral'))
abort('500','Cannot post ephemeral messages.');
$api = $this->o->team->slackAPI();
$response = $this->_data->has('ts') ? $api->updateMessage($this) : $api->postMessage($this);
if ($delete) {
Log::debug(sprintf('%s:Scheduling Delete of [%s:%s] on [%s]',static::LOGKEY,object_get($this->o,'channel_id',$this->o->id),$response->ts,$delete->format('Y-m-d')),['m'=>__METHOD__]);
// Queue the delete of the message if requested
dispatch((new DeleteChat($this->o,$response->ts))->onQueue('low')->delay($delete));
}
return $response;
}
public function replace(bool $replace=TRUE): self
{
$this->_data['replace_original'] = $replace ? 'true' : 'false';
return $this;
}
/**
* Post a message to slack using the respond_url
* @note This URL can only be used 5 times in 30 minutes
*
* @param string $url
*/
public function respond(string $url)
{
$request = curl_init();
curl_setopt($request,CURLOPT_URL,$url);
curl_setopt($request,CURLOPT_RETURNTRANSFER,TRUE);
curl_setopt($request,CURLINFO_HEADER_OUT,TRUE);
curl_setopt($request,CURLOPT_HTTPHEADER,['Content-Type: application/json; charset=utf-8']);
curl_setopt($request,CURLOPT_SSL_VERIFYPEER,FALSE);
curl_setopt($request,CURLOPT_POSTFIELDS,json_encode($this));
try {
$result = curl_exec($request);
if (! $result)
throw new \Exception('CURL exec returned an empty response: '.serialize(curl_getinfo($request)));
} catch (\Exception $e) {
Log::error(sprintf('%s:Got an error while posting to [%s] (%s)',static::LOGKEY,$url,$e->getMessage()),['m'=>__METHOD__]);
throw new \Exception($e->getMessage());
}
if ($result !== 'ok') {
switch ($result) {
default:
Log::critical(sprintf('%s:Generic Error',static::LOGKEY),['m'=>__METHOD__,'r'=>$result]);
throw new SlackException($result,curl_getinfo($request,CURLINFO_HTTP_CODE));
}
}
curl_close($request);
return $result;
}
/**
* Make the message self destruct
*
* @param Carbon $time
* @return Generic
* @throws \Exception
*/
public function selfdestruct(Carbon $time): Generic
{
$this->addBlock(
(new Block)->addContext(
collect()
->push((new BlockKit)->text(sprintf('This message will self destruct in %s...',$time->diffForHumans(Carbon::now(),['syntax' => CarbonInterface::DIFF_RELATIVE_TO_NOW]))))));
return $this->post($time);
}
/**
* Set our channel
*
* @param Channel $o
* @return Message
*/
public function setChannel(Channel $o)
{
$this->_data['channel'] = $o->channel_id;
return $this;
}
/**
* Set the icon next to the message
*
* @param string $icon
* @return $this
* @deprecated
*/
public function setIcon(string $icon): self
{
$this->_data->put('icon_emoji',$icon);
return $this;
}
/**
* Option groups are used by the interactive Options controller and hold no other attributes
*
* @param array $array
* @return void
*/
public function setOptionGroup(array $array): void
{
$this->_data = collect();
$this->_data->put('option_groups',$array);
}
/**
* Message text
*
* @param string $string
* @return $this
*/
public function setText(string $string): self
{
$this->_data->put('text',$string);
return $this;
}
public function setTS(string $string): self
{
$this->_data->put('ts',$string);
return $this;
}
public function setThreadTS(string $string): self
{
$this->_data->put('thread_ts',$string);
return $this;
}
/**
* Set our channel
*
* @param User $o
* @return Message
*/
public function setUser(User $o)
{
$this->_data['channel'] = $o->user_id;
return $this;
}
public function setUserName(string $user)
{
$this->_data['username'] = $user;
return $this;
}
}

198
src/Message/Attachment.php Normal file
View File

@ -0,0 +1,198 @@
<?php
namespace Slack\Message;
use Slack\BlockKit;
use Slack\Blockkit\BlockAction;
/**
* Class MessageAttachment - Slack Message Attachments
* Represents an Single Attachment that can be added to a Message
*
* @package Slack\Message
*/
class Attachment implements \JsonSerializable
{
private $_data;
private $actions;
private $blocks;
private $blockactions;
public function __construct()
{
$this->actions = collect();
$this->blocks = collect();
$this->blockactions = collect();
$this->_data = collect();
}
public function jsonSerialize()
{
if ($this->actions->count() AND ! $this->_data->has('callback_id'))
abort(500,'Actions without a callback ID');
if ($this->blockactions->count()) {
$x = collect();
$x->put('type','actions');
$x->put('elements',$this->blockactions);
$this->blocks->push($x);
// Empty out our blockactions, incase we are converted to json a second time.
$this->blockactions = collect();
}
if ($this->actions->count())
$this->_data->put('actions',$this->actions);
if ($this->blocks->count())
$this->_data->put('blocks',$this->blocks);
return $this->_data;
}
/**
* Add an attachment to a message
*
* @param AttachmentAction $action
* @return Attachment
*/
public function addAction(AttachmentAction $action): self
{
$this->actions->push($action);
return $this;
}
/**
* Add a block to message
*
* @param BlockKit $block
* @return Attachment
*/
public function addBlock(BlockKit $block): self
{
$this->blocks->push($block);
return $this;
}
/**
* Add a BlockAction to a Block
*
* @param BlockAction $action
* @return $this
*/
public function addBlockAction(BlockAction $action): self
{
$this->blockactions->push($action);
return $this;
}
public function addField(string $title,string $value,bool $short): self
{
if (! $this->_data->has('fields'))
$this->_data->put('fields',collect());
$this->_data->get('fields')->push([
'title'=>$title,
'value'=>$value,
'short'=>$short
]);
return $this;
}
/**
* Set where markdown should be parsed by slack
*
* @param array $array
* @return $this
*/
public function markdownIn(array $array): self
{
// @todo Add array check to make sure it has valid items
$this->_data->put('mrkdown_in',$array);
return $this;
}
/**
* Configure the attachment color (on the left of the attachment)
*
* @param string $string
* @return $this
*/
public function setCallbackID(string $string): self
{
$this->_data->put('callback_id',$string);
return $this;
}
/**
* Configure the attachment color (on the left of the attachment)
*
* @param string $string
* @return $this
*/
public function setColor(string $string): self
{
$this->_data->put('color',$string);
return $this;
}
/**
* Set the text used in the attachments footer
*
* @param string $string
* @return $this
*/
public function setFooter(string $string): self
{
$this->_data->put('footer',$string);
return $this;
}
/**
* Add the pre-text, displayed after the title.
*
* @param string $string
* @return $this
*/
public function setPretext(string $string): self
{
$this->_data->put('pretext',$string);
return $this;
}
/**
* Set the text used in the attachment
*
* @param string $string
* @return $this
*/
public function setText(string $string): self
{
$this->_data->put('text',$string);
return $this;
}
/**
* Set the Title used in the attachment
*
* @param string $string
* @return $this
*/
public function setTitle(string $string): self
{
$this->_data->put('title',$string);
return $this;
}
}

View File

@ -0,0 +1,133 @@
<?php
namespace Slack\Message;
/**
* Class MessageAttachmentAction - Slack Message Attachments Actions
* Represents an Single Action for a Slack Message Attachment
*
* @package Slack\Message
*/
class AttachmentAction implements \JsonSerializable
{
private $_data;
public function __construct()
{
$this->_data = collect();
}
public function jsonSerialize()
{
return $this->_data;
}
public function minSize(int $int): self
{
$this->_data->put('min_query_length',$int);
return $this;
}
/**
* Set a confirmation diaglog when this action is selected
*
* @param string $title
* @param string $text
* @param string $ok_text
* @param string $dismiss_text
* @return $this
*/
public function setConfirm(string $title,string $text,string $ok_text,string $dismiss_text): self
{
$this->_data->put('confirm',[
'title'=>$title,
'text'=>$text,
'ok_text'=>$ok_text,
'dismiss_text'=>$dismiss_text
]);
return $this;
}
/**
* Set the name of the action
*
* @param string $string
* @return $this
*/
public function setName(string $string): self
{
$this->_data->put('name',$string);
return $this;
}
/**
* Set the text displayed in the action
*
* @param string $type
* @return $this
*/
public function setStyle(string $style): self
{
if (! in_array($style,['danger','primary']))
abort(500,'Style not supported: '.$style);
$this->_data->put('style',$style);
return $this;
}
/**
* Set the text displayed in the action
*
* @param string $string
* @return $this
*/
public function setText(string $string): self
{
$this->_data->put('text',$string);
return $this;
}
/**
* Set the text displayed in the action
*
* @param string $type
* @return $this
*/
public function setType(string $type): self
{
if (! in_array($type,['button','select']))
abort(500,'Type not supported: '.$type);
$this->_data->put('type',$type);
return $this;
}
/**
* Set the value for the action
*
* @param string $string
* @return $this
*/
public function setValue(string $string): self
{
$this->_data->put('value',$string);
return $this;
}
public function source(string $string): self
{
if (! in_array($string,['external']))
abort(500,'Dont know how to handle: '.$string);
$this->_data->put('data_source',$string);
return $this;
}
}

56
src/Models/Channel.php Normal file
View File

@ -0,0 +1,56 @@
<?php
namespace Slack\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
use Leenooks\Traits\ScopeActive;
class Channel extends Model
{
use ScopeActive;
protected $fillable = ['team_id','channel_id','name','active'];
protected $table = 'slack_channels';
/* RELATIONS */
public function team()
{
return $this->belongsTo(Team::class);
}
/* ATTRIBUTES */
/**
* Return if the user is allowed to use this bot
*
* @return bool
*/
public function getIsAllowedAttribute(): bool
{
return $this->active;
}
/**
* Return the channel name
*
* @return string
*/
public function getNameAttribute(): string
{
return Arr::get($this->attributes,'name') ?: $this->channel_id;
}
/* METHODS */
/**
* Is this channel a direct message channel?
*
* @return bool
*/
public function isDirect(): bool
{
return preg_match('/^D/',$this->channel_id) OR $this->name == 'directmessage';
}
}

21
src/Models/Enterprise.php Normal file
View File

@ -0,0 +1,21 @@
<?php
namespace Slack\Models;
use Illuminate\Database\Eloquent\Model;
use Leenooks\Traits\ScopeActive;
class Enterprise extends Model
{
use ScopeActive;
protected $fillable = ['enterprise_id'];
protected $table = 'slack_enterprises';
/* RELATIONS */
public function teams()
{
return $this->hasMany(Team::class);
}
}

87
src/Models/Team.php Normal file
View File

@ -0,0 +1,87 @@
<?php
namespace Slack\Models;
use Illuminate\Database\Eloquent\Model;
use Leenooks\Traits\ScopeActive;
use Slack\API;
class Team extends Model
{
use ScopeActive;
protected $fillable = ['team_id'];
protected $table = 'slack_teams';
/* RELATIONS */
public function admins()
{
return $this->hasMany(User::class,'team_id','id')->where('admin','=',TRUE);
}
public function bot()
{
return $this->hasOne(User::class,'id','bot_id');
}
public function channels()
{
return $this->hasMany(Channel::class);
}
public function owner()
{
return $this->belongsTo(User::class,'admin_id');
}
// Tokens applicable to this team
// @todo team_id can now be null, so we need to get it from the enterprise_id.
public function token()
{
return $this->hasOne(Token::class);
}
public function users()
{
return $this->hasMany(User::class);
}
/* ATTRIBUTES */
/**
* Provide an obfuscated token.
*
* @note Some tokens have 3 fields (separated by a dash), some have 4
* @return string
*/
public function getAppTokenObfuscateAttribute(): string
{
$attrs = explode('-',$this->getAppTokenAttribute()->token);
$items = count($attrs)-1;
$attrs[$items] = '...'.substr($attrs[$items],-5);
return implode('-',$attrs);
}
/* METHODS */
/**
* Join the owner and the admins together.
* @deprecated ?
*/
public function admin_users()
{
return $this->admins->merge($this->owner->get());
}
/**
* Return an instance of the API ready to interact with Slack
*
* @return API
*/
public function slackAPI(): API
{
return new API($this);
}
}

46
src/Models/Token.php Normal file
View File

@ -0,0 +1,46 @@
<?php
namespace Slack\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
use Leenooks\Traits\ScopeActive;
class Token extends Model
{
use ScopeActive;
protected $table = 'slack_tokens';
/* RELATIONS */
public function team()
{
return $this->belongsTo(Team::class);
}
/* ATTRIBUTES */
public function getScopesAttribute(): Collection
{
return collect(explode(',',$this->scope));
}
public function getTokenHiddenAttribute(): string
{
return '...'.substr($this->token,-5);
}
/* METHODS */
/**
* Does this token include a specific scope
*
* @param string|null $scope
* @return bool
*/
public function hasScope(?string $scope): bool
{
return ($scope AND ($this->getScopesAttribute()->search($scope) !== FALSE));
}
}

49
src/Models/User.php Normal file
View File

@ -0,0 +1,49 @@
<?php
namespace Slack\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Log;
use Leenooks\Traits\ScopeActive;
class User extends Model
{
use ScopeActive;
private const LOGKEY = '-MU';
protected $fillable = ['user_id'];
protected $table = 'slack_users';
/* RELATIONS */
public function enterprise()
{
return $this->belongsTo(Enterprise::class);
}
/* ATTRIBUTES */
/**
* Return the user in slack response format
*/
public function getSlackUserAttribute(): string
{
return sprintf('<@%s>',$this->user_id);
}
/**
* Return the team that this user is in - normally required to get the team token
* For enterprise users, any team token will do.
*
* If the integration is not installed in any channels, team will be blank
*
* @return mixed
*/
public function getTeamAttribute(): ?Team
{
Log::debug(sprintf('%s:User [%s]',self::LOGKEY,$this->id),['team'=>$this->team_id,'enterprise'=>$this->enterprise_id,'eo'=>$this->enterprise]);
return $this->team_id ? Team::find($this->team_id) : (($x=$this->enterprise->teams) ? $x->first() : NULL);
}
}

51
src/Options/Base.php Normal file
View File

@ -0,0 +1,51 @@
<?php
namespace Slack\Options;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Slack\Base as SlackBase;
abstract class Base extends SlackBase
{
// Does the event respond with a reply to the HTTP request, or via a post with a trigger
public $respondNow = TRUE;
public function __construct(Request $request)
{
Log::info(sprintf('SOb:Slack INTERACTIVE MESSAGE Initialised [%s]',get_class($this)),['m'=>__METHOD__]);
// Our data is in a payload value
$this->_data = json_decode($request->input('payload'));
}
/**
* Enable getting values for keys in the response
*
* @note: This method is limited to certain values to ensure integrity reasons
* @note: Classes should return:
* + channel_id,
* + team_id,
* + ts,
* + user_id
* @param string $key
* @return mixed|object
*/
public function __get(string $key)
{
switch ($key) {
case 'team_id':
return object_get($this->_data,'team.id');
case 'channel_id':
return object_get($this->_data,'channel.id');
case 'user_id':
return object_get($this->_data,'user.id');
case 'callback_id':
//case 'action_ts':
//case 'message_ts':
case 'type':
return object_get($this->_data,$key);
}
}
}

52
src/Options/Factory.php Normal file
View File

@ -0,0 +1,52 @@
<?php
namespace Slack\Options;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Log;
class Factory {
private const LOGKEY = 'SOf';
/**
* @var array event type to event class mapping
*/
public const map = [
'interactive_message'=>InteractiveMessage::class,
];
/**
* Returns new event instance
*
* @param string $type
* @param Request $request
* @return Base
*/
public static function create(string $type,Request $request)
{
$class = Arr::get(self::map,$type,Unknown::class);
Log::debug(sprintf('%s:Working out Interactive Options Event Class for [%s] as [%s]',static::LOGKEY,$type,$class),['m'=>__METHOD__]);
if (App::environment() == 'dev')
file_put_contents('/tmp/option.'.$type,print_r(json_decode($request->input('payload')),TRUE));
return new $class($request);
}
public static function make(Request $request): Base
{
// During the life of the event, this method is called twice - once during Middleware processing, and finally by the Controller.
static $o = NULL;
static $or = NULL;
if (! $o OR ($or != $request)) {
$data = json_decode($request->input('payload'));
$or = $request;
$o = self::create($data->type,$request);
}
return $o;
}
}

View File

@ -0,0 +1,73 @@
<?php
namespace Slack\Options;
use Illuminate\Support\Facades\Log;
use Slack\Message;
/**
* Class InteractiveMessage
*
* [name] => source
* [value] =>
* [callback_id] => classify|46
* [type] => interactive_message
* [team] => stdClass Object
* (
* [id] => {SLACKTEAM}
* [domain] => {SLACKDOMAIN}
* )
* [channel] => stdClass Object
* (
* [id] => {SLACKCHANNEL}
* [name] => directmessage
* )
* [user] => stdClass Object
* (
* [id] => {SLACKUSER}
* [name] => {SLACKUSER}
* )
* [action_ts] => 1603780652.484943
* [message_ts] => 1601349865.001500
* [attachment_id] => 3
* [token] => Oow8S2EFvrZoS9z8N4nwf9Jo
*/
class InteractiveMessage extends Base
{
private const LOGKEY = 'OIM';
public function __get($key)
{
switch ($key) {
case 'name':
case 'value':
case 'message_ts':
return object_get($this->_data,$key);
default:
return parent::__get($key);
}
}
/**
* Interactive messages can return their output in the incoming HTTP post
*
* @return Message
* @throws \Exception
*/
public function respond(): Message
{
Log::info(sprintf('%s:Interactive Option - Callback [%s] Name [%s] Value [%s]',static::LOGKEY,$this->callback_id,$this->name,$this->value),['m'=>__METHOD__]);
if (preg_match('/^(.*)\|([0-9]+)/',$this->callback_id)) {
[$action,$id] = explode('|',$this->callback_id,2);
} else {
// If we get here, its an action that we dont know about.
Log::notice(sprintf('%s:Unhandled CALLBACK [%s]',static::LOGKEY,$this->callback_id),['m'=>__METHOD__]);
}
return (new Message)->blank();
}
}

21
src/Options/Unknown.php Normal file
View File

@ -0,0 +1,21 @@
<?php
namespace Slack\Options;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
/**
* Catch all unknown slack event that we havent specifically programmed for.
*
* @package Slack\Options
*/
class Unknown extends Base
{
public function __construct(Request $request)
{
Log::notice(sprintf('SOU:UNKNOWN Slack Interaction Option received [%s]',get_class($this)),['m'=>__METHOD__]);
parent::__construct($request);
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace Slack\Providers;
use Illuminate\Support\ServiceProvider;
use Slack\Console\Commands\SlackSocketClient;
class SlackServiceProvider extends ServiceProvider
{
/**
* Bootstrap the application services.
*
* @return void
*/
public function boot()
{
if ($this->app->runningInConsole()) {
if (config('slack.run_migrations')) {
$this->loadMigrationsFrom(__DIR__.'/../database/migrations');
}
$this->commands([
SlackSocketClient::class,
]);
}
}
/**
* Register the application services.
*
* @return void
*/
public function register()
{
$this->mergeConfigFrom(__DIR__.'/../config/slack.php','slack');
$this->loadRoutesFrom(realpath(__DIR__ .'/../routes.php'));
}
}

69
src/Response/Base.php Normal file
View File

@ -0,0 +1,69 @@
<?php
namespace Slack\Response;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Log;
use Slack\Base as SlackBase;
/**
* This parent class handles responses received from Slack
*
* @note: This class is used for events not specifically created.
*/
class Base extends SlackBase implements \JsonSerializable
{
protected const LOGKEY = 'RB-';
/**
* Default Constructor Setup
*
* @param object $response
*/
public function __construct(object $response)
{
$this->_data = $response;
// This is only for child classes
if (get_class($this) == Base::class) {
Log::debug(sprintf('%s:Slack RESPONSE Initialised [%s]',static::LOGKEY,get_class($this)),['m'=>__METHOD__]);
if (App::environment() == 'dev')
file_put_contents('/tmp/response',print_r($this,TRUE),FILE_APPEND);
}
}
/**
* Enable getting values for keys in the response
*
* @note: This method is limited to certain values to ensure integrity reasons
* @note: Classes should return:
* + channel_id,
* + team_id,
* + ts,
* + user_id
*/
public function __get($key)
{
switch ($key) {
case 'channel_id':
// For interactive post responses, the channel ID is "channel"
return object_get($this->_data,$key) ?: object_get($this->_data,'channel');
case 'team_id':
case 'ts':
case 'user_id':
case 'messages': // Used by getMessageHistory()
case 'type': // Needed by URL verification
return object_get($this->_data,$key);
}
}
/**
* When we json_encode this object, this is the data that will be returned
*/
public function jsonSerialize()
{
return $this->_data ? $this->_data : new \stdClass;
}
}

View File

@ -0,0 +1,23 @@
<?php
namespace Slack\Response;
final class ChannelList extends Base
{
protected const LOGKEY = 'RCL';
/**
* Enable getting values for keys in the response
*
* @note: This method is limited to certain values to ensure integrity reasons
*/
public function __get($key)
{
switch ($key) {
case 'havemore':
return object_get($this->_data,'response_metadata.next_cursor');
case 'channels':
return object_get($this->_data,$key);
}
}
}

27
src/Response/Generic.php Normal file
View File

@ -0,0 +1,27 @@
<?php
namespace Slack\Response;
/**
* This is a Generic Slack Response to API calls
*/
class Generic extends Base
{
protected const LOGKEY = 'RGE';
/**
* Enable getting values for keys in the response
*
* @note: This method is limited to certain values to ensure integrity reasons
*/
public function __get($key)
{
switch ($key) {
case 'view_id':
return object_get($this->_data,'view.id');
default:
return parent::__get($key);
}
}
}

30
src/Response/Team.php Normal file
View File

@ -0,0 +1,30 @@
<?php
namespace Slack\Response;
/**
* Class representing the response to a Slack User Query
*
* @package Slack\Response
*/
final class Team extends Base
{
protected const LOGKEY = 'RT_';
/**
* Enable getting values for keys in the response
*
* @note: This method is limited to certain values to ensure integrity reasons
*/
public function __get($key)
{
switch ($key) {
case 'id':
case 'name':
case 'domain':
case 'enterprise_id':
case 'enterprise_name':
return object_get($this->_data,'team.'.$key);
}
}
}

31
src/Response/Test.php Normal file
View File

@ -0,0 +1,31 @@
<?php
namespace Slack\Response;
/**
* This is the Slack Response to an Auth Test API call
*/
class Test extends Base
{
protected const LOGKEY = 'RTE';
/**
* Enable getting values for keys in the response
*
* @note: This method is limited to certain values to ensure integrity reasons
*/
public function __get($key)
{
switch ($key) {
case 'bot_id':
case 'is_enterprise_install':
case 'team':
case 'url':
case 'user':
return object_get($this->_data,$key);
default:
return parent::__get($key);
}
}
}

35
src/Response/User.php Normal file
View File

@ -0,0 +1,35 @@
<?php
namespace Slack\Response;
/**
* Class representing the response to a Slack User Query
*
* @package Slack\Response
*/
final class User extends Base
{
protected const LOGKEY = 'RU_';
/**
* Enable getting values for keys in the response
*
* @note: This method is limited to certain values to ensure integrity reasons
*/
public function __get($key)
{
switch ($key) {
case 'deleted':
case 'is_admin':
case 'is_bot':
case 'is_restricted':
case 'is_ultra_restricted':
case 'is_owner':
case 'enterprise_user':
return object_get($this->_data,'user.'.$key);
case 'user_id':
return object_get($this->_data,'user.id');
}
}
}

8
src/config/slack.php Normal file
View File

@ -0,0 +1,8 @@
<?php
return [
'socket_token' => env('SLACK_SOCKET_TOKEN',NULL),
'client_id' => env('SLACK_CLIENT_ID',NULL),
'client_secret' => env('SLACK_CLIENT_SECRET',NULL),
'signing_secret' => env('SLACK_SIGNING_SECRET',NULL),
];

View File

@ -0,0 +1,106 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class SlackIntegration extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('slack_enterprises', function (Blueprint $table) {
$table->id();
$table->timestamps();
$table->string('enterprise_id', 45)->unique();
$table->string('name')->nullable();
$table->boolean('active');
});
Schema::create('slack_users', function (Blueprint $table) {
$table->id();
$table->timestamps();
$table->string('user_id', 45)->unique();
$table->string('name')->nullable();
$table->boolean('active');
$table->boolean('admin');
$table->integer('enterprise_id')->nullable()->unsigned();
$table->foreign('enterprise_id')->references('id')->on('slack_enterprises');
});
Schema::create('slack_teams', function (Blueprint $table) {
$table->id();
$table->timestamps();
$table->string('team_id', 45)->unique();
$table->string('name')->nullable();
$table->string('description')->nullable();
$table->boolean('active');
$table->integer('bot_id')->nullable()->unsigned();
$table->foreign('bot_id')->references('id')->on('slack_users');
$table->integer('admin_id')->nullable()->unsigned();
$table->foreign('admin_id')->references('id')->on('slack_users');
$table->integer('enterprise_id')->nullable()->unsigned();
$table->foreign('enterprise_id')->references('id')->on('slack_enterprises');
});
Schema::create('slack_channels', function (Blueprint $table) {
$table->id();
$table->timestamps();
$table->string('channel_id', 45)->unique();
$table->string('name')->nullable();
$table->boolean('active');
$table->integer('enterprise_id')->nullable()->unsigned();
$table->foreign('enterprise_id')->references('id')->on('slack_enterprises');
});
Schema::table('slack_users', function (Blueprint $table) {
$table->integer('team_id')->nullable()->unsigned();
$table->foreign('team_id')->references('id')->on('slack_teams');
});
Schema::create('slack_tokens', function (Blueprint $table) {
$table->id();
$table->timestamps();
$table->string('token');
$table->string('scope')->nullable();
$table->boolean('active');
$table->string('description')->nullable();
$table->integer('team_id')->unique()->unsigned();
$table->foreign('team_id')->references('id')->on('slack_teams');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('slack_users', function (Blueprint $table) {
$table->dropForeign(['team_id']);
$table->dropColumn(['team_id']);
});
Schema::dropIfExists('slack_tokens');
Schema::dropIfExists('slack_channels');
Schema::dropIfExists('slack_teams');
Schema::dropIfExists('slack_users');
Schema::dropIfExists('slack_enterprises');
}
}

17
src/routes.php Normal file
View File

@ -0,0 +1,17 @@
<?php
$routeConfig = [
'namespace' => 'Slack\Http\Controllers',
];
app('router')->group($routeConfig, function ($router) {
$router->get('slack-install-button', [
'uses' => 'SlackAppController@button',
'as' => 'slack-install-button',
]);
$router->get('slack-install', [
'uses' => 'SlackAppController@install',
'as' => 'slack-install',
]);
});