commit 9d66b5ed9127e82ba1d5af53091967e6635764e9 Author: Deon George Date: Fri Aug 6 12:22:22 2021 +1000 Initial commit - based from qabot but converted into a composer module diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..4b022bb --- /dev/null +++ b/composer.json @@ -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" +} diff --git a/src/API.php b/src/API.php new file mode 100644 index 0000000..9f20357 --- /dev/null +++ b/src/API.php @@ -0,0 +1,454 @@ +'', // 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; + } +} diff --git a/src/Base.php b/src/Base.php new file mode 100644 index 0000000..33a538c --- /dev/null +++ b/src/Base.php @@ -0,0 +1,111 @@ +_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; + } +} diff --git a/src/BlockKit.php b/src/BlockKit.php new file mode 100644 index 0000000..c26d4e9 --- /dev/null +++ b/src/BlockKit.php @@ -0,0 +1,104 @@ +_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, + ]; + } +} diff --git a/src/Blockkit/Block.php b/src/Blockkit/Block.php new file mode 100644 index 0000000..8035938 --- /dev/null +++ b/src/Blockkit/Block.php @@ -0,0 +1,256 @@ +_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); + } + +} diff --git a/src/Blockkit/BlockAction.php b/src/Blockkit/BlockAction.php new file mode 100644 index 0000000..c6995a7 --- /dev/null +++ b/src/Blockkit/BlockAction.php @@ -0,0 +1,37 @@ +_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; + } +} diff --git a/src/Blockkit/Modal.php b/src/Blockkit/Modal.php new file mode 100644 index 0000000..7db40a2 --- /dev/null +++ b/src/Blockkit/Modal.php @@ -0,0 +1,115 @@ +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; + } +} diff --git a/src/Client/API.php b/src/Client/API.php new file mode 100644 index 0000000..84875d0 --- /dev/null +++ b/src/Client/API.php @@ -0,0 +1,134 @@ +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; + } +} diff --git a/src/Client/ApiException.php b/src/Client/ApiException.php new file mode 100644 index 0000000..160b653 --- /dev/null +++ b/src/Client/ApiException.php @@ -0,0 +1,7 @@ +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(); + } +} diff --git a/src/Client/SocketMode.php b/src/Client/SocketMode.php new file mode 100644 index 0000000..e398967 --- /dev/null +++ b/src/Client/SocketMode.php @@ -0,0 +1,274 @@ +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__]); + } +} diff --git a/src/Command/Base.php b/src/Command/Base.php new file mode 100644 index 0000000..d520a83 --- /dev/null +++ b/src/Command/Base.php @@ -0,0 +1,54 @@ +__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'); + } + } +} diff --git a/src/Command/Factory.php b/src/Command/Factory.php new file mode 100644 index 0000000..4a13d16 --- /dev/null +++ b/src/Command/Factory.php @@ -0,0 +1,52 @@ +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); + } +} diff --git a/src/Command/Help.php b/src/Command/Help.php new file mode 100644 index 0000000..b4c2a47 --- /dev/null +++ b/src/Command/Help.php @@ -0,0 +1,25 @@ +setText('Hi, I am the a *NEW* Bot'); + + // Version + $a = new Attachment; + $a->addField('Version',config('app.version'),TRUE); + $o->addAttachment($a); + + return $o; + } +} diff --git a/src/Command/Unknown.php b/src/Command/Unknown.php new file mode 100644 index 0000000..1b69a6e --- /dev/null +++ b/src/Command/Unknown.php @@ -0,0 +1,31 @@ +__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; + } +} diff --git a/src/Console/Commands/SlackSocketClient.php b/src/Console/Commands/SlackSocketClient.php new file mode 100644 index 0000000..0babf79 --- /dev/null +++ b/src/Console/Commands/SlackSocketClient.php @@ -0,0 +1,60 @@ +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(); + } +} diff --git a/src/Event/AppHomeOpened.php b/src/Event/AppHomeOpened.php new file mode 100644 index 0000000..d0f19d0 --- /dev/null +++ b/src/Event/AppHomeOpened.php @@ -0,0 +1,44 @@ + {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); + } + } +} diff --git a/src/Event/Base.php b/src/Event/Base.php new file mode 100644 index 0000000..a8c4aad --- /dev/null +++ b/src/Event/Base.php @@ -0,0 +1,44 @@ +__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); + } + } +} diff --git a/src/Event/ChannelLeft.php b/src/Event/ChannelLeft.php new file mode 100644 index 0000000..af1e172 --- /dev/null +++ b/src/Event/ChannelLeft.php @@ -0,0 +1,39 @@ + {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); + } + } +} diff --git a/src/Event/Factory.php b/src/Event/Factory.php new file mode 100644 index 0000000..039c1aa --- /dev/null +++ b/src/Event/Factory.php @@ -0,0 +1,58 @@ +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; + } +} diff --git a/src/Event/GroupLeft.php b/src/Event/GroupLeft.php new file mode 100644 index 0000000..315ba04 --- /dev/null +++ b/src/Event/GroupLeft.php @@ -0,0 +1,39 @@ + {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); + } + } +} diff --git a/src/Event/MemberJoinedChannel.php b/src/Event/MemberJoinedChannel.php new file mode 100644 index 0000000..a0cb6a4 --- /dev/null +++ b/src/Event/MemberJoinedChannel.php @@ -0,0 +1,45 @@ + 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); + } + } +} diff --git a/src/Event/Message.php b/src/Event/Message.php new file mode 100644 index 0000000..f7b0dce --- /dev/null +++ b/src/Event/Message.php @@ -0,0 +1,65 @@ + {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); + } + } +} diff --git a/src/Event/PinAdded.php b/src/Event/PinAdded.php new file mode 100644 index 0000000..be32d4b --- /dev/null +++ b/src/Event/PinAdded.php @@ -0,0 +1,78 @@ + {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); + } + } +} diff --git a/src/Event/PinRemoved.php b/src/Event/PinRemoved.php new file mode 100644 index 0000000..1b9a72b --- /dev/null +++ b/src/Event/PinRemoved.php @@ -0,0 +1,75 @@ + {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); + } + } +} diff --git a/src/Event/ReactionAdded.php b/src/Event/ReactionAdded.php new file mode 100644 index 0000000..d3c0139 --- /dev/null +++ b/src/Event/ReactionAdded.php @@ -0,0 +1,55 @@ + {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); + } + } +} diff --git a/src/Event/Unknown.php b/src/Event/Unknown.php new file mode 100644 index 0000000..c0db70a --- /dev/null +++ b/src/Event/Unknown.php @@ -0,0 +1,21 @@ +__METHOD__]); + + parent::__contruct($request); + } +} diff --git a/src/Exceptions/SlackAlreadyPinnedException.php b/src/Exceptions/SlackAlreadyPinnedException.php new file mode 100644 index 0000000..2e60786 --- /dev/null +++ b/src/Exceptions/SlackAlreadyPinnedException.php @@ -0,0 +1,7 @@ +Add to Slack', + 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 @deon.'); + } + + 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 %s."',$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')), + ]; + } +} diff --git a/src/Interactive/Base.php b/src/Interactive/Base.php new file mode 100644 index 0000000..4f9ceaf --- /dev/null +++ b/src/Interactive/Base.php @@ -0,0 +1,65 @@ +__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 = + * + * @param $key + * @param $value + */ + public function __set($key,$value) + { + if ($key == 'index') + $this->{$key} = $value; + } +} diff --git a/src/Interactive/BlockActions.php b/src/Interactive/BlockActions.php new file mode 100644 index 0000000..13c624b --- /dev/null +++ b/src/Interactive/BlockActions.php @@ -0,0 +1,161 @@ + 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; + } +} diff --git a/src/Interactive/Factory.php b/src/Interactive/Factory.php new file mode 100644 index 0000000..6c0b99b --- /dev/null +++ b/src/Interactive/Factory.php @@ -0,0 +1,56 @@ +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; + } +} diff --git a/src/Interactive/InteractiveMessage.php b/src/Interactive/InteractiveMessage.php new file mode 100644 index 0000000..ab082f1 --- /dev/null +++ b/src/Interactive/InteractiveMessage.php @@ -0,0 +1,118 @@ + 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); + } + } +} diff --git a/src/Interactive/Shortcut.php b/src/Interactive/Shortcut.php new file mode 100644 index 0000000..c79d2c1 --- /dev/null +++ b/src/Interactive/Shortcut.php @@ -0,0 +1,38 @@ + 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); + } + } +} diff --git a/src/Interactive/Unknown.php b/src/Interactive/Unknown.php new file mode 100644 index 0000000..cc043c6 --- /dev/null +++ b/src/Interactive/Unknown.php @@ -0,0 +1,21 @@ +__METHOD__]); + + parent::__construct($request); + } +} diff --git a/src/Interactive/ViewClosed.php b/src/Interactive/ViewClosed.php new file mode 100644 index 0000000..da2b333 --- /dev/null +++ b/src/Interactive/ViewClosed.php @@ -0,0 +1,87 @@ + 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); + } + } +} diff --git a/src/Interactive/ViewSubmission.php b/src/Interactive/ViewSubmission.php new file mode 100644 index 0000000..316dfb5 --- /dev/null +++ b/src/Interactive/ViewSubmission.php @@ -0,0 +1,93 @@ +_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); + } +} diff --git a/src/Jobs/DeleteChat.php b/src/Jobs/DeleteChat.php new file mode 100644 index 0000000..c36379f --- /dev/null +++ b/src/Jobs/DeleteChat.php @@ -0,0 +1,55 @@ +_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__]); + } + } +} diff --git a/src/Jobs/Job.php b/src/Jobs/Job.php new file mode 100644 index 0000000..33f98ea --- /dev/null +++ b/src/Jobs/Job.php @@ -0,0 +1,32 @@ +_data,$key); + } +} diff --git a/src/Jobs/TeamUpdate.php b/src/Jobs/TeamUpdate.php new file mode 100644 index 0000000..812890e --- /dev/null +++ b/src/Jobs/TeamUpdate.php @@ -0,0 +1,47 @@ +_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(); + } +} diff --git a/src/Message.php b/src/Message.php new file mode 100644 index 0000000..15a472c --- /dev/null +++ b/src/Message.php @@ -0,0 +1,308 @@ +_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; + } +} diff --git a/src/Message/Attachment.php b/src/Message/Attachment.php new file mode 100644 index 0000000..bc6015a --- /dev/null +++ b/src/Message/Attachment.php @@ -0,0 +1,198 @@ +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; + } +} diff --git a/src/Message/AttachmentAction.php b/src/Message/AttachmentAction.php new file mode 100644 index 0000000..ce73c5d --- /dev/null +++ b/src/Message/AttachmentAction.php @@ -0,0 +1,133 @@ +_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; + } +} diff --git a/src/Models/Channel.php b/src/Models/Channel.php new file mode 100644 index 0000000..2710776 --- /dev/null +++ b/src/Models/Channel.php @@ -0,0 +1,56 @@ +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'; + } +} diff --git a/src/Models/Enterprise.php b/src/Models/Enterprise.php new file mode 100644 index 0000000..e7bb636 --- /dev/null +++ b/src/Models/Enterprise.php @@ -0,0 +1,21 @@ +hasMany(Team::class); + } +} diff --git a/src/Models/Team.php b/src/Models/Team.php new file mode 100644 index 0000000..c8e1ea2 --- /dev/null +++ b/src/Models/Team.php @@ -0,0 +1,87 @@ +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); + } +} diff --git a/src/Models/Token.php b/src/Models/Token.php new file mode 100644 index 0000000..bd49c22 --- /dev/null +++ b/src/Models/Token.php @@ -0,0 +1,46 @@ +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)); + } +} diff --git a/src/Models/User.php b/src/Models/User.php new file mode 100644 index 0000000..2b75468 --- /dev/null +++ b/src/Models/User.php @@ -0,0 +1,49 @@ +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); + } +} diff --git a/src/Options/Base.php b/src/Options/Base.php new file mode 100644 index 0000000..642b0c3 --- /dev/null +++ b/src/Options/Base.php @@ -0,0 +1,51 @@ +__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); + } + } +} diff --git a/src/Options/Factory.php b/src/Options/Factory.php new file mode 100644 index 0000000..fb01893 --- /dev/null +++ b/src/Options/Factory.php @@ -0,0 +1,52 @@ +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; + } +} diff --git a/src/Options/InteractiveMessage.php b/src/Options/InteractiveMessage.php new file mode 100644 index 0000000..b0e4f3e --- /dev/null +++ b/src/Options/InteractiveMessage.php @@ -0,0 +1,73 @@ + 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(); + } + +} diff --git a/src/Options/Unknown.php b/src/Options/Unknown.php new file mode 100644 index 0000000..be77c02 --- /dev/null +++ b/src/Options/Unknown.php @@ -0,0 +1,21 @@ +__METHOD__]); + + parent::__construct($request); + } +} diff --git a/src/Providers/SlackServiceProvider.php b/src/Providers/SlackServiceProvider.php new file mode 100644 index 0000000..6e3f519 --- /dev/null +++ b/src/Providers/SlackServiceProvider.php @@ -0,0 +1,39 @@ +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')); + } +} \ No newline at end of file diff --git a/src/Response/Base.php b/src/Response/Base.php new file mode 100644 index 0000000..5aac955 --- /dev/null +++ b/src/Response/Base.php @@ -0,0 +1,69 @@ +_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; + } +} diff --git a/src/Response/ChannelList.php b/src/Response/ChannelList.php new file mode 100644 index 0000000..112e0f4 --- /dev/null +++ b/src/Response/ChannelList.php @@ -0,0 +1,23 @@ +_data,'response_metadata.next_cursor'); + case 'channels': + return object_get($this->_data,$key); + } + } +} diff --git a/src/Response/Generic.php b/src/Response/Generic.php new file mode 100644 index 0000000..487d94c --- /dev/null +++ b/src/Response/Generic.php @@ -0,0 +1,27 @@ +_data,'view.id'); + + default: + return parent::__get($key); + } + } +} diff --git a/src/Response/Team.php b/src/Response/Team.php new file mode 100644 index 0000000..75c60bb --- /dev/null +++ b/src/Response/Team.php @@ -0,0 +1,30 @@ +_data,'team.'.$key); + } + } +} diff --git a/src/Response/Test.php b/src/Response/Test.php new file mode 100644 index 0000000..419dcf3 --- /dev/null +++ b/src/Response/Test.php @@ -0,0 +1,31 @@ +_data,$key); + + default: + return parent::__get($key); + } + } +} diff --git a/src/Response/User.php b/src/Response/User.php new file mode 100644 index 0000000..e261afe --- /dev/null +++ b/src/Response/User.php @@ -0,0 +1,35 @@ +_data,'user.'.$key); + + case 'user_id': + return object_get($this->_data,'user.id'); + } + } +} diff --git a/src/config/slack.php b/src/config/slack.php new file mode 100644 index 0000000..054093f --- /dev/null +++ b/src/config/slack.php @@ -0,0 +1,8 @@ + 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), +]; diff --git a/src/database/migrations/2021_08_06_002815_slack_integration.php b/src/database/migrations/2021_08_06_002815_slack_integration.php new file mode 100644 index 0000000..3b23431 --- /dev/null +++ b/src/database/migrations/2021_08_06_002815_slack_integration.php @@ -0,0 +1,106 @@ +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'); + } +} diff --git a/src/routes.php b/src/routes.php new file mode 100644 index 0000000..c878215 --- /dev/null +++ b/src/routes.php @@ -0,0 +1,17 @@ + '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', + ]); +}); \ No newline at end of file