diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1b7c7cd --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +scripts/config/* +!scripts/config/default.config.php + diff --git a/README.md b/README.md index c01006e..1aec3ca 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,61 @@ slack-integration ================= -A place where I put some integration scripts for the popular Slack messaging platform. +Some integration scripts for the popular Slack messaging platform. +## Features -#Rally Bot +### Rallybot Cron +Periodically pushes notifications from Rally into our team's Slack channel. Notifications are sent whenever: -We use this to query our Rally instance for defects, tasks and user stories. I have it configured to respond to a /rallyme slash command in Slack. +1. a comment is added to a ticket +2. a new defect, user story, or test case is created +3. a user story changes state -E.g.: -/rallyme DE12345 -/rallyme US23456 -/rallyme TA34567 +The bot uses a combination of Rally's _state_ and _ready_ fields to track the progress of user stories. When a story's state is set to "In-Progress" and the ready field is checked, rallybot will announce that the story is ready for testing. When the QA team has completed testing, they may either: 1) uncheck the ready flag to have rallybot announce that the story needs work, or 2) set the story to "Completed" to notify the Product Owner that it is ready for acceptance. -Each of these returns a nicely formatted summary of the defect, story or task, including the owner/submitter, creation date, story title, project and description. For documents that have attachments, such as uploaded screenshots, the summary will include a link to the first available attachment. +> **Note**: Rally automatically sets a story's state to "Completed" when all of its tasks are completed, so be sure to leave at least one open task in order to correctly track stories with defects. -#Image Bot / Gif Bot +### Rallybot +We use this to query our Rally instance for defects, tasks and user stories. It can be configured to respond to a Slack [slash command](https://slack.zendesk.com/hc/en-us/articles/201259356-Slash-Commands) or to any messages that start with a Rally ticket's formatted id, e.g.: +``` +/rallyme DE12345 +/rallyme US23456 project +TA34567 owner description +``` +Entering the id of an artifact without any arguments returns a nicely formatted summary of the defect, story or task, including the owner/submitter, creation date, story title, project and description. For documents that have attachments, such as uploaded screenshots, the summary will include a link to the first available attachment. Otherwise, adding one or more field names after the id will filter the summary to just display the requested fields. +### Image Bot / Gif Bot This one is simple. It uses the google image search JSON API to query for images that match a keyword. It is configured to use the Safe Search feature, since we use this tool in the workplace. Being able to add some levity to our work chats with amusing images from the internet makes life more fun. - -E.g. +``` /imageme kittens /gifme roflcopter +``` +### XKCD Bot +As geeks, we are well acquainted with the internet comic XKCD. This bot allows you to post an XKCD comic to the room, including the ALT text, which will be displayed beneath the image. -#XKCD Bot +E.g. `/xkcd 100` will return the "family circus" episode of XKCD. -As geeks, we are well acquainted with the internet comic XKCD. This bot allows you to post an XKCD comic to the room, including the ALT text, which will be displayed beneath the image. +## Installation + +1. Clone this repository to your server + +2. Create your config file from the default template: + + ``` + cd scripts/config + cp default.config.php config.php + ``` + +3. Create a new incoming webhook in Slack + + **Note**: Leave the channel to post to set to "#general", our scripts use channel-overrides to respond wherever the user issues a slash command. + +4. Edit your config file with the incoming webhook's unique webhook URL + +5. Add a slash command to Slack for each feature (for example: "When a user enters /rallyme, POST to http://example.com/rallyme.php") + + **Note**: The scripts will respond to requests over POST or GET. -E.g. -/xkcd 100 will return the "family circus" episode of XKCD. +6. If you are implementing Rally Bot, add your credentials to the config file diff --git a/scripts/config/default.config.php b/scripts/config/default.config.php new file mode 100644 index 0000000..bc05a70 --- /dev/null +++ b/scripts/config/default.config.php @@ -0,0 +1,35 @@ +Text); -$imageSearchJson = get_url_contents('http://ajax.googleapis.com/ajax/services/search/images?v=1.0&safe=active&as_filetype=gif&rsz=8&imgsz=medium&q=animated+'.$enc); +$imageSearchJson = get_url_contents('http://ajax.googleapis.com/ajax/services/search/images?v=1.0&safe=active&as_filetype=gif&rsz=8&imgsz=medium&q=animated+' . $enc); $imageresponse = json_decode($imageSearchJson); -$userlink = "UserName}|{$command->UserName}>"; +$userlink = 'UserName . '|' . $command->UserName . '>'; -if($imageresponse->responseData == null){ +if ($imageresponse->responseData == null) { //{"responseData": null, "responseDetails": "qps rate exceeded", "responseStatus": 503} $details = $imageresponse->responseDetails; $status = $imageresponse->responseStatus; @@ -29,12 +27,11 @@ die; } -$whichImage = rand(0,7); +$whichImage = rand(0, 7); $returnedimageurl = $imageresponse->responseData->results[$whichImage]->url; $payload = "@{$userlink} asked for '{$command->Text}'\n{$returnedimageurl}"; -$ret = slack_incoming_hook_post($hook, "gifbot", $command->ChannelName, $iconurl, $emoji, $payload); -if($ret!="ok") +$ret = slack_incoming_hook_post($SLACK_INCOMING_HOOK_URL, "gifbot", $command->ChannelName, $iconurl, $emoji, $payload); +if ($ret != "ok") print_r("@tdm, gifbot got this response when it tried to post to the incoming hook for /gifme.\n{$ret}"); -?> diff --git a/scripts/imageme.php b/scripts/imageme.php index 0af8dce..819e29a 100644 --- a/scripts/imageme.php +++ b/scripts/imageme.php @@ -1,60 +1,53 @@ -UserName}|{$command->UserName}>"; +$userlink = 'UserName . '|' . $command->UserName . '>'; $maxtries = 2; $tries = 0; - startover: - + $imageresponse = RunImageSearch($command->Text); $tries++; -if($imageresponse->responseData == null){ +if ($imageresponse->responseData == null) { //{"responseData": null, "responseDetails": "qps rate exceeded", "responseStatus": 503} $details = $imageresponse->responseDetails; $status = $imageresponse->responseStatus; - - if($status == 503 && $tries < $maxtries) - { - sleep(1); - goto startover; //yeah, it's a goto. deal with it. http://xkcd.com/292/ + + if ($status == 503 && $tries < $maxtries) { + sleep(1); + goto startover; //yeah, it's a goto. deal with it. http://xkcd.com/292/ } - + print_r("Sorry @{$userlink}, no image for you! [{$details}:{$status}]\n"); //print_r($imageresponse); die; } -$whichImage = rand(0,7); +$whichImage = rand(0, 7); $returnedimageurl = $imageresponse->responseData->results[$whichImage]->url; $payload = "@{$userlink} asked for '{$command->Text}'\n{$returnedimageurl}"; -$ret = slack_incoming_hook_post($hook, "imagebot", $command->ChannelName, $iconurl, $emoji, $payload); -if($ret!="ok") +$ret = slack_incoming_hook_post($SLACK_INCOMING_HOOK_URL, "imagebot", $command->ChannelName, $iconurl, $emoji, $payload); +if ($ret != "ok") print_r("@tdm, gifbot got this response when it tried to post to the incoming hook for /imageme.\n{$ret}"); - - - + function RunImageSearch($text) { $enc = urlencode($text); - $imageSearchJson = get_url_contents('http://ajax.googleapis.com/ajax/services/search/images?v=1.0&safe=active&rsz=8&imgsz=medium&q='.$enc); + $imageSearchJson = get_url_contents('http://ajax.googleapis.com/ajax/services/search/images?v=1.0&safe=active&rsz=8&imgsz=medium&q=' . $enc); + + $imageresponse = json_decode($imageSearchJson); - $imageresponse = json_decode($imageSearchJson); - - return $imageresponse; + return $imageresponse; } -?> diff --git a/scripts/include/curl.php b/scripts/include/curl.php index d3ff09c..ab6050c 100644 --- a/scripts/include/curl.php +++ b/scripts/include/curl.php @@ -1,30 +1,32 @@ - \ No newline at end of file diff --git a/scripts/include/googleimage.php b/scripts/include/googleimage.php index 67b4cec..230f577 100644 --- a/scripts/include/googleimage.php +++ b/scripts/include/googleimage.php @@ -1,39 +1,36 @@ -Size = $size; - $cmd->Query = $query; - $cmd->Count = $maxresults; - $cmd->Safe = $safe; - $cmd->FileType = $filetype; - - return $cmd; + + $cmd = new stdClass(); + $cmd->Size = $size; + $cmd->Query = $query; + $cmd->Count = $maxresults; + $cmd->Safe = $safe; + $cmd->FileType = $filetype; + + return $cmd; } function GetImageSearchResponse($cmd) { - - $googleImageSearch = "http://ajax.googleapis.com/ajax/services/search/images?v=1.0&safe={$cmd->Safe}&as_filetype={$cmd->FileType}&rsz={$cmd->Count}&imgsz={$cmd->Size}&q={$cmd->Query}"; - $result = CallAPI($googleImageSearch); + $googleImageSearch = "http://ajax.googleapis.com/ajax/services/search/images?v=1.0&safe={$cmd->Safe}&as_filetype={$cmd->FileType}&rsz={$cmd->Count}&imgsz={$cmd->Size}&q={$cmd->Query}"; - return $result; + $result = CallAPI($googleImageSearch); + + return $result; } function GetRandomResultFromResponse($n, $result) { - $resultArray = $result->responseData->results; - $count = count($resultArray); - $item = rand(0,$count-1); - return $resultArray[$item]; + $resultArray = $result->responseData->results; + $count = count($resultArray); + $item = rand(0, $count - 1); + return $resultArray[$item]; } - -?> diff --git a/scripts/include/log.php b/scripts/include/log.php index 4de5c15..54c617b 100644 --- a/scripts/include/log.php +++ b/scripts/include/log.php @@ -1,11 +1,11 @@ - diff --git a/scripts/include/memegenerator.php b/scripts/include/memegenerator.php index e994146..4a06a01 100644 --- a/scripts/include/memegenerator.php +++ b/scripts/include/memegenerator.php @@ -1,18 +1,17 @@ - diff --git a/scripts/include/rally.php b/scripts/include/rally.php index 9713410..9890770 100644 --- a/scripts/include/rally.php +++ b/scripts/include/rally.php @@ -1,401 +1,15 @@ -ChannelName); - die; - break; - case "US": - case "TA": - return HandleStory($rallyFormattedId, $slackCommand->ChannelName); - die; - break; - default: - print_r("Sorry, I don't know what kind of rally object {$rallyFormattedId} is. If you need rallyme to work with these, buy a :beer:. I hear he likes IPAs."); - die; - break; - } -} - -function HandleDefect($id, $channel_name) -{ - $defectref = FindDefect($id); - - $payload = GetDefectPayload($defectref); - - $result = postit($channel_name, $payload->text, $payload->attachments); - - if($result=='Invalid channel specified'){ - die("Sorry, the rallyme command can't post messages to your private chat.\n"); - } - - if($result!="ok"){ - print_r($result."\n"); - print_r(json_encode($payload)); - print_r("\n"); - die("Apparently the Rallyme script is having a problem. Ask about it. :frowning:"); - } - return $result; -} - - -function HandleStory($id, $channel_name) -{ - $ref = FindRequirement($id); - - $payload = GetRequirementPayload($ref); - - $result = postit($channel_name, $payload->text, $payload->attachments); - - if($result=='Invalid channel specified'){ - die("Sorry, the rallyme command can't post messages to your private chat.\n"); - } - - if($result!="ok"){ - print_r($result."\n"); - print_r(json_encode($payload)); - print_r("\n"); - die("Apparently the Rallyme script is having a problem. Ask about it. :frowning:"); - } - return $result; -} - -function postit($channel_name, $payload, $attachments){ - global $config, $slackCommand; - - return slack_incoming_hook_post_with_attachments( - $config['slack']['hook'], - $config['rally']['botname'], - $slackCommand->ChannelName, - $config['rally']['boticon'], - $payload, - $attachments); -} - - - -function GetRallyAttachmentLink($attachmentRef) -{ - $attachments = CallAPI($attachmentRef); - $firstattachment = $attachments->QueryResult->Results[0]; - - $attachmentname = $firstattachment->_refObjectName; - $encodedattachmentname = urlencode($attachmentname); - $id = $firstattachment->ObjectID; - - $uri = "https://rally1.rallydev.com/slm/attachment/{$id}/{$encodedattachmentname}"; - $linktxt = "<{$uri}|{$attachmentname}>"; - return $linktxt; -} - -function GetDefectPayload($ref) -{ - global $show,$requesting_user_name; - - $object = CallAPI($ref); - - $defect = $object->Defect; - - $projecturi = $defect->Project->_ref; - - $title = $defect->_refObjectName; - $description = $defect->Description; - $owner = $defect->Owner->_refObjectName; - $submitter = $defect->SubmittedBy->_refObjectName; - $project = $object->Project->_refObjectName; - $created = $defect->_CreatedAt; - $state = $defect->State; - $priority = $defect->Priority; - $severity = $defect->Severity; - $frequency = $defect->c_Frequency; - $foundinbuild = $defect->FoundInBuild; - - $short_description = TruncateText(strip_tags($description), 200); - - $ProjectFull = CallAPI($projecturi); - $projectid = $ProjectFull->Project->ObjectID; - $defectid = $defect->ObjectID; - $projectName = $defect->Project->_refObjectName; - $itemid = $defect->FormattedID; - - $attachmentcount = $defect->Attachments->Count; - - $firstattachment = null; - if($attachmentcount>0) - { - $linktxt = GetRallyAttachmentLink($defect->Attachments->_ref); - $firstattachment = MakeField("attachment",$linktxt,false); - } - - $defecturi = "https://rally1.rallydev.com/#/{$projectid}d/detail/defect/{$defectid}"; - - $enctitle = urlencode($title); - $linktext = "<{$defecturi}|{$enctitle}>"; - - $color = "bad"; - - $clean_description = html_entity_decode(strip_tags($description), ENT_HTML401|ENT_COMPAT, 'UTF-8'); - $short_description = TruncateText($clean_description, 300); - - $fields = array( - MakeField("link",$linktext,false), - - MakeField("id",$itemid,true), - MakeField("owner",$owner,true), - - MakeField("project",$projectName,true), - MakeField("created",$created,true), - - MakeField("submitter",$submitter,true), - MakeField("state",$state,true), - - MakeField("priority",$priority,true), - MakeField("severity",$severity,true), - - MakeField("frequency",$frequency,true), - MakeField("found in",$foundinbuild,true), - - MakeField("description",$short_description,false) - ); - - if($firstattachment!=null) - array_push($fields,$firstattachment); - - global $slackCommand; - - $userlink = BuildUserLink($slackCommand->UserName); - $user_message = "Ok, {$userlink}, here's the defect you requested."; - - $obj = new stdClass; - $obj->text = ""; - $obj->attachments = MakeAttachment($user_message, "", $color, $fields, $storyuri); - return $obj; -} - -function GetRequirementPayload($ref) -{ - $object = CallAPI($ref); - - $requirement = null; - - if($object->HierarchicalRequirement) - { - $requirement = $object->HierarchicalRequirement; - } - elseif($object->Task) - { - $requirement = $object->Task; - } - else - { - $class = get_class($object); - global $slackCommand; - $userlink = BuildUserLink($slackCommand->UserName); - print_r("Sorry {$userlink}, I can't handle a {$class} yet. I'll let @tdm know about it."); - die; - } - - $projecturi = $requirement->Project->_ref; - - $title = $requirement->_refObjectName; - - - $ProjectFull = CallAPI($projecturi); - $projectid = $ProjectFull->Project->ObjectID; - $storyid = $requirement->ObjectID; - $description = $requirement->Description; - $owner = $requirement->Owner->_refObjectName; - $projectName = $requirement->Project->_refObjectName; - $itemid = $requirement->FormattedID; - $created = $requirement->_CreatedAt; - $estimate = $requirement->PlanEstimate; - $hasparent = $requirement->HasParent; - $childcount = $requirement->DirectChildrenCount; - $state = $requirement->ScheduleState; - $blocked = $requirement->Blocked; - $blockedreason = $requirement->BlockedReason; - $ready = $requirement->Ready; - - $attachmentcount = $requirement->Attachments->Count; - - $firstattachment = null; - if($attachmentcount>0) - { - $linktxt = GetRallyAttachmentLink($requirement->Attachments->_ref); - $firstattachment = MakeField("attachment",$linktxt,false); - } - - $parent = null; - if($hasparent) - $parent = $requirement->Parent->_refObjectName; - - $clean_description = html_entity_decode(strip_tags($description), ENT_HTML401|ENT_COMPAT, 'UTF-8'); - $short_description = TruncateText($clean_description, 300); - - $storyuri = "https://rally1.rallydev.com/#/{$projectid}d/detail/userstory/{$storyid}"; - $enctitle = urlencode($title); - $linktext = "<{$storyuri}|{$enctitle}>"; + global $RALLY_USERNAME, $RALLY_PASSWORD; - $dovegray = "#CEC7B8"; - - - - $fields = array( - MakeField("link",$linktext,false), - MakeField("parent",$parent,false), - - MakeField("id",$itemid,true), - MakeField("owner",$owner,true), - - MakeField("project",$projectName,true), - MakeField("created",$created,true), - - MakeField("estimate",$estimate,true), - MakeField("state",$state,true)); - - if($childcount>0) - array_push($fields,MakeField("children",$childcount,true)); - - if($blocked) - array_push($fields, MakeField("blocked",$blockedreason,true)); - - array_push($fields, MakeField("description",$short_description,false)); - - if($firstattachment!=null) - array_push($fields,$firstattachment); - - - global $slackCommand; - $userlink = BuildUserLink($slackCommand->UserName); - $user_message = "Ok {$userlink}, here's the story you requested."; - - $obj = new stdClass; - $obj->text = ""; - $obj->attachments = MakeAttachment($user_message, "", $dovegray, $fields, $storyuri); -// print_r(json_encode($obj));die; - - return $obj; -} - - -function MakeField($title, $value, $short=false) -{ - $attachmentfield = array( - "title" => $title, - "value" => $value, - "short" => $short); - - return $attachmentfield; -} - -function getProjectPayload($projectRefUri) -{ - $project = CallAPI($projectRefUri); -} - -function CallAPI($uri) -{ - global $config; - - $json = get_url_contents_with_basicauth($uri, $config['rally']['username'], $config['rally']['password']); + $json = get_url_contents_with_basicauth($url, $RALLY_USERNAME, $RALLY_PASSWORD); $object = json_decode($json); return $object; } - - -function GetProjectID($projectref) -{ - $ProjectFull = CallAPI($projectref); - $projectid = $ProjectFull->Project->ObjectID; - return $projectid; -} - -function FindRequirement($id) -{ - $query = GetArtifactQueryUri($id); - - $searchresult = CallAPI($query); -// print_r($searchresult);die; - - $count = GetCount($searchresult); - if($count == 0) - NotFound($id); - - return GetFirstObjectFromSearchResult("HierarchicalRequirement", $searchresult); -} - -function BuildUserLink($username) -{ - $userlink = ""; - return $userlink; -} - -function GetArtifactQueryUri($id) -{ - global $config; - return str_replace("[[ID]]", $id, $config['rally']['artifactquery']); -} - -function GetDefectQueryUri($id) -{ - global $config; - return str_replace("[[ID]]", $id, $config['rally']['defectquery']); -} - -function FindDefect($id) -{ - $query = GetDefectQueryUri($id); - $searchresult = CallAPI($query); - - $count = GetCount($searchresult); - if($count == 0) - NotFound($id); - - return GetFirstObjectFromSearchResult("Defect", $searchresult); -} - -function GetCount($searchresult) -{ - return $searchresult->QueryResult->TotalResultCount; -} - -function NotFound($id) -{ - global $slackCommand; - $userlink = BuildUserLink($slackCommand->UserName); - print_r("Sorry {$userlink}, I couldn't find {$id}");die; -} - - -function GetFirstObjectFromSearchResult($objectName, $result) -{ - foreach ($result->QueryResult->Results as $result) - { - if($result->_type == $objectName) - return $result->_ref; - } - global $slackCommand; - $userlink = BuildUserLink($slackCommand->UserName); - print_r("Sorry @{$userlink}, your search for '{$slackCommand->Text}' was ambiguous.:\n"); - print_r("Here's what Rally told me:\n"); - print_r($result); - die; -} - -function TruncateText($text, $len) -{ - if(strlen($text) <= $len) - return $text; - - return substr($text,0,$len)."...[MORE]"; -} - -?> diff --git a/scripts/include/rally.secrets.php b/scripts/include/rally.secrets.php deleted file mode 100644 index 1df5ee6..0000000 --- a/scripts/include/rally.secrets.php +++ /dev/null @@ -1,4 +0,0 @@ - diff --git a/scripts/include/rallycron.inc.php b/scripts/include/rallycron.inc.php new file mode 100644 index 0000000..e338e72 --- /dev/null +++ b/scripts/include/rallycron.inc.php @@ -0,0 +1,235 @@ ++' . $since . '))&fetch=Artifact,Text,User&order=CreationDate+asc'; + + $results = CallAPI($query_url); + $results = $results->QueryResult->Results; + + $project_url = $RALLY_HOST_URL . '#/' . $RALLYCRON_PROJECT_ID; + + $items = array(); + foreach ($results as $Result) { + switch ($type = $Result->Artifact->_type) { + case 'Defect': + $path = '/detail/defect/'; + break; + case 'HierarchicalRequirement': + $type = 'User Story'; + $path = '/detail/userstory/'; + break; + default: + continue 2; //don't display comments attached to other artifact types + } + + $items[] = array( + 'type' => $type, + 'title' => $Result->Artifact->_refObjectName, + 'url' => $project_url . $path . basename($Result->Artifact->_ref) . '/discussion', + 'user' => $Result->User->_refObjectName, + 'text' => $Result->Text + ); + } + + return $items; +} + +function SendRallyCommentNotifications($items) +{ + global $SLACK_INCOMING_HOOK_URL, $RALLYCRON_CHANNEL, $RALLYBOT_NAME, $RALLYBOT_ICON; + $success = TRUE; + + foreach ($items as $item) { + $item['title'] = SanitizeText($item['title']); + $item['title'] = TruncateText($item['title'], 300); + $slug = $item['type'] . ' ' . l($item['title'], $item['url']); + + $item['text'] = SanitizeText($item['text']); + $item['text'] = TruncateText($item['text'], 300); + + //display a preview of the comment as a message attachment + $pretext = em('New comment added to ' . $slug); + $text = ''; + $color = '#CEC7B8'; //dove gray + $fields = array(MakeField($item['user'], $item['text'])); + $fallback = $item['user'] . ' commented on ' . $slug; + + $message = MakeAttachment($pretext, $text, $color, $fields, $fallback); + $success = slack_incoming_hook_post_with_attachments( + $SLACK_INCOMING_HOOK_URL, + $RALLYBOT_NAME, + $RALLYCRON_CHANNEL, + $RALLYBOT_ICON, + '', + $message + ) && $success; + } + + return $success; +} + +function FetchUpdatedRallyArtifacts($since) +{ + global $RALLY_HOST_URL, $RALLYCRON_PROJECT_ID, $RALLY_TIMESTAMP_FORMAT; + + $api_url = $RALLY_HOST_URL . 'slm/webservice/v2.0/'; + $query_url = $api_url . 'artifact?query=((Project.ObjectID+%3D+' . $RALLYCRON_PROJECT_ID . ')AND(LastUpdateDate+>+' . $since . '))&fetch=CreationDate,FormattedID,LastUpdateDate,Owner,Ready,RevisionHistory,ScheduleState,SubmittedBy&order=LastUpdateDate+asc&pagesize=200'; + + $results = CallAPI($query_url); + $results = $results->QueryResult->Results; + + $project_url = $RALLY_HOST_URL . '#/' . $RALLYCRON_PROJECT_ID; + + $items = array(); + foreach ($results as $Artifact) { + + $user = ''; + switch ($type = $Artifact->_type) { + case 'Defect': + $path = '/detail/defect/'; + $user = $Artifact->SubmittedBy->_refObjectName; + break; + case 'HierarchicalRequirement': + $type = 'User Story'; + $path = '/detail/userstory/'; + break; + case 'TestCase': + $type = 'Test Case'; + $path = '/detail/testcase/'; + break; + default: + continue 2; //don't display other artifact types + } + if (empty($user) && isset($Artifact->Owner)) { + $user = $Artifact->Owner->_refObjectName; + } + + //was the artifact just created? + $lastUpDate = date_create_from_format($RALLY_TIMESTAMP_FORMAT, $Artifact->LastUpdateDate)->getTimestamp(); + $creationDate = date_create_from_format($RALLY_TIMESTAMP_FORMAT, $Artifact->CreationDate)->getTimestamp(); + + if (($lastUpDate - $creationDate) < 2) { //assume items updated within 1 sec haven't changed state + $items[] = array( //report newly-created artifacts + 'type' => $type, + 'title' => $Artifact->_refObjectName, + 'url' => $project_url . $path . basename($Artifact->_ref), + 'user' => $user, + 'id' => $Artifact->FormattedID + ); + + } elseif ($type == 'User Story') { //track progress of user stories + switch ($Artifact->ScheduleState) { + case 'Completed': + $fact_table = array(1 => 'SCHEDULE STATE changed'); + $state = 'acceptance-ready'; + break; + case 'In-Progress': + if ($Artifact->Ready) { + $fact_table = array(1 => 'READY changed from [false] to [true]'); + $state = 'verification-ready'; + } else { + $fact_table = array( + 0 => 'SCHEDULE STATE changed', + 1 => 'READY changed from [true] to [false]' + ); + $state = 'needs-work'; + } + break; + default: + continue 2; //don't parse other state changes + } + + //parse latest revision messages to verify state change + $query2_url = $Artifact->RevisionHistory->_ref . '/Revisions?query=(CreationDate+>+' . $since . ')&fetch=CreationDate,Description,User'; + + $results2 = CallAPI($query2_url); + $results2 = $results2->QueryResult->Results; + + $is_verified = FALSE; + foreach ($results2 as $Revision) { + if (isset($fact_table[0]) && (strpos($Revision->Description, $fact_table[0]) !== FALSE)){ + continue 2; //stop parsing if the negative fact has appeared + } + if (strpos($Revision->Description, $fact_table[1]) !== FALSE) { + $is_verified = TRUE; + $user = $Revision->User->_refObjectName; + } + } + if (!$is_verified) { + continue; //skip artifacts with unconfirmed state change + } + + $items[] = array( //report stories that have changed state + 'type' => $type, + 'title' => $Artifact->_refObjectName, + 'url' => $project_url . $path . basename($Artifact->_ref), + 'user' => $user, + 'id' => $Artifact->FormattedID, + 'state' => $state //presence of this key indicates state-change notification + ); + } + } + return $items; +} + +function SendRallyUpdateNotifications($items) +{ + global $SLACK_INCOMING_HOOK_URL, $RALLYCRON_CHANNEL, $RALLYBOT_NAME, $RALLYBOT_ICON; + $success = TRUE; + + foreach ($items as $item) { + $item['title'] = SanitizeText($item['title']); + $item['title'] = TruncateText($item['title'], 300); + $slug = l($item['title'], $item['url']); + + //display a state-change notification as a message attachment + if (isset($item['state'])) { + switch ($item['state']) { + case 'verification-ready': + $item['state'] = ' is ready for QA'; + $color = '#F29513'; //github orange + break; + case 'needs-work': + $item['state'] = ' needs additional work'; + $color = '#D84A63'; //paletton-suggested red + break; + case 'acceptance-ready': + $item['state'] = ' is ready for acceptance'; + $color = '#6CC644'; //github green + } + $item['state'] = $item['id'] . $item['state']; + + $pretext = em($item['type'] . ' updated by ' . $item['user']); + $text = ''; + $fields = array(MakeField($item['state'], $slug)); + $fallback = $item['type'] . ' ' . $item['state']; + + //display a link to the new artifact as a message attachment + } else { + $pretext = em('New ' . $item['type'] . ' added by ' . $item['user']); + $text = ''; + $color = '#6CC644'; //github green + $fields = array(MakeField($item['id'], $slug)); + $fallback = $item['type'] . ' ' . $item['id'] . ' added by ' . $item['user']; + } + + $message = MakeAttachment($pretext, $text, $color, $fields, $fallback); + $success = slack_incoming_hook_post_with_attachments( + $SLACK_INCOMING_HOOK_URL, + $RALLYBOT_NAME, + $RALLYCRON_CHANNEL, + $RALLYBOT_ICON, + '', + $message + ) && $success; + } + + return $success; +} diff --git a/scripts/include/rallyme.config.php b/scripts/include/rallyme.config.php deleted file mode 100644 index 9e0f85d..0000000 --- a/scripts/include/rallyme.config.php +++ /dev/null @@ -1,11 +0,0 @@ - diff --git a/scripts/include/rallyme.inc.php b/scripts/include/rallyme.inc.php new file mode 100644 index 0000000..44781e2 --- /dev/null +++ b/scripts/include/rallyme.inc.php @@ -0,0 +1,467 @@ +QueryResult->TotalResultCount == 0) { //get count + trigger_error('Sorry, @user, I couldn\'t find ' . $formatted_id, E_USER_ERROR); //not found + } + + //generate payload from first query result + foreach ($Results->QueryResult->Results as $Result) { + if ($Result->_type == $artifact_type) { + $payload = call_user_func($func, $Result); + + //filter display of artifact fields + if (!empty($field_filter)) { + if (ctype_upper(key($payload['fields'])[0])) { //match the case of payload field labels + $field_filter = array_map('ucfirst', $field_filter); + } + $payload['fields'] = array_intersect_key($payload['fields'], array_flip($field_filter)); + } + + return $payload; + } + } + trigger_error('Sorry, @user, your search for "' . $formatted_id . '" was ambiguous.', E_USER_ERROR); +} + +/** + * Notifies Slack users of errors either via an incoming webhook or in the body + * of the HTTP response. + * + * @param int $errno + * @param string $errstr + * + * @return void + */ +function _HandleRallyMeErrors($errno, $errstr) +{ + global $config; + + //assume at-mentions are linkified over either transmission channel + $user = '@' . $_REQUEST['user_name']; + $errstr = strtr($errstr, array('@user' => $user)); + + if (isSlashCommand()) { + //use an incoming webhook to report error + slack_incoming_hook_post( + $config['slack']['hook'], + $config['rally']['botname'], + $_REQUEST['channel_name'], + $config['rally']['boticon'], + NULL, + $errstr + ); + } else { + //otherwise return Slack-formatted JSON in the response body + PrintJsonResponse($errstr); + } + + exit(); +} + +/** + * Prepares a table of fields attached to a Rally defect for display. + * + * @param object $Defect + * + * @return string[] + */ +function ParseDefectPayload($Defect) +{ + $header = CompileArtifactHeader($Defect, 'defect'); + + global $RALLYME_DISPLAY_VERSION; + switch ($RALLYME_DISPLAY_VERSION) { + + case 2: + $state = $Defect->State; + if ($state == 'Closed') { + $Date = new DateTime($Defect->ClosedDate); + $state .= ' ' . $Date->format('M j'); + } + + $fields = array( + 'Creator' => $Defect->SubmittedBy->_refObjectName, + 'Created' => $Defect->_CreatedAt, + 'Owner' => $Defect->Owner->_refObjectName, + 'State' => $state, + 'Priority' => $Defect->Priority, + 'Severity' => $Defect->Severity, + 'Description' => $Defect->Description, + ); + if ($Defect->Attachments->Count > 0) { + $fields['Attachment'] = GetAttachmentLinks($Defect->Attachments->_ref); + } + break; + + default: + $fields = array( + 'link' => array($header['title'] => $header['url']), + 'id' => $Defect->FormattedID, + 'owner' => $Defect->Owner->_refObjectName, + 'project' => $Defect->Project->_refObjectName, + 'created' => $Defect->_CreatedAt, + 'submitter' => $Defect->SubmittedBy->_refObjectName, + 'state' => $Defect->State, + 'priority' => $Defect->Priority, + 'severity' => $Defect->Severity, + 'frequency' => $Defect->c_Frequency, + 'found in' => $Defect->FoundInBuild, + 'description' => $Defect->Description, + ); + if ($Defect->Attachments->Count > 0) { + $fields['attachment'] = GetAttachmentLinks($Defect->Attachments->_ref); + } + break; + } + + return array('header' => $header, 'fields' => $fields); +} + +/** + * Prepares a table of fields attached to a Rally task for display. + * + * @param object $Task + * + * @return string[] + */ +function ParseTaskPayload($Task) +{ + $header = CompileArtifactHeader($Task, 'task'); + + global $RALLYME_DISPLAY_VERSION; + switch ($RALLYME_DISPLAY_VERSION) { + + case 2: + $fields = array( + 'Parent' => $Task->WorkProduct->_refObjectName, + 'Owner' => $Task->Owner->_refObjectName, + 'Created' => $Task->_CreatedAt, + 'To Do' => $Task->ToDo, + 'Actual' => $Task->Actuals, + 'State' => $Task->State, + 'Status' => '' + ); + if ($Task->Blocked) { + $fields['Status'] = 'Blocked'; + $fields['Block Description'] = $Task->BlockedReason; + } elseif ($Task->Ready) { + $fields['Status'] = 'Ready'; + } + $fields['Description'] = $Task->Description; + if ($Task->Attachments->Count > 0) { + $fields['Attachment'] = GetAttachmentLinks($Task->Attachments->_ref); + } + break; + + default: + $fields = CompileRequirementFields($Task, $header); + break; + } + + return array('header' => $header, 'fields' => $fields); +} + +/** + * Prepares a table of fields attached to a Rally user story for display. + * + * @param object $Story + * + * @return string[] + */ +function ParseStoryPayload($Story) +{ + $header = CompileArtifactHeader($Story, 'story'); + + global $RALLYME_DISPLAY_VERSION; + switch ($RALLYME_DISPLAY_VERSION) { + + case 2: + $fields = array( + 'Project' => $Story->Project->_refObjectName, + 'Created' => $Story->_CreatedAt, + 'Owner' => $Story->Owner->_refObjectName, + 'Points' => $Story->PlanEstimate, + 'State' => $Story->ScheduleState, + 'Status' => '' + ); + if ($Story->Blocked) { + $fields['Status'] = 'Blocked'; + $fields['Block Description'] = $Story->BlockedReason; + } elseif ($Story->Ready) { + $fields['Status'] = 'Ready'; + } + $fields['Description'] = $Story->Description; + if ($Story->Attachments->Count > 0) { + $fields['Attachment'] = GetAttachmentLinks($Story->Attachments->_ref); + } + break; + + default: + $fields = CompileRequirementFields($Story, $header); + break; + } + + return array('header' => $header, 'fields' => $fields); +} + +/** + * Prepares an array of fields of meta-information common to all artifacts. + * + * @param object $Artifact + * @param string $type + * + * @return string[] + */ +function CompileArtifactHeader($Artifact, $type) +{ + global $RALLY_HOST_URL; + $path_map = array('defect' => 'defect', 'task' => 'task', 'story' => 'userstory'); //associate human-readable names with Rally URL paths + + $item_url = $RALLY_HOST_URL . '#/' . basename($Artifact->Project->_ref) . '/detail/' . $path_map[$type] . '/' . $Artifact->ObjectID; + + return array( + 'type' => $type, + 'id' => $Artifact->FormattedID, + 'title' => $Artifact->_refObjectName, + 'url' => $item_url + ); +} + +/** + * Prepare a table of field values for stories and tasks. + * + * Rally lumps stories and tasks together as types of "Hierarchical Requirements" + * and so the original version of this script rendered the same fields for both. + * + * @param object $Requirement + * @param string[] $header + * + * @return string[] + */ +function CompileRequirementFields($Requirement, $header) +{ + $parent = NULL; + if ($Requirement->HasParent) { + /** + * @todo perform lookup of parent's project ID to make this into + * a link; we can't assume it's in the same project + */ + $parent = $Requirement->Parent->_refObjectName; + } + + $fields = array( + 'link' => array($header['title'] => $header['url']), + 'parent' => $parent, + 'id' => $Requirement->FormattedID, + 'owner' => $Requirement->Owner->_refObjectName, + 'project' => $Requirement->Project->_refObjectName, + 'created' => $Requirement->_CreatedAt, + 'estimate' => $Requirement->PlanEstimate, + 'state' => $Requirement->ScheduleState, + ); + if ($Requirement->DirectChildrenCount > 0) { + $fields['children'] = $Requirement->DirectChildrenCount; + } + if ($Requirement->Blocked) { + $fields['blocked'] = $Requirement->BlockedReason; + } + $fields['description'] = $Requirement->Description; + if ($Requirement->Attachments->Count > 0) { + $fields['attachment'] = GetAttachmentLinks($Requirement->Attachments->_ref); + } + + return $fields; +} + +/** + * Returns an array of file links listed in a Rally attachment object. + * + * @param string $attachment_ref + * + * @return string[] + */ +function GetAttachmentLinks($attachment_ref) +{ + global $RALLY_HOST_URL; + $url = $RALLY_HOST_URL . 'slm/attachment/'; + $links = array(); + + $Attachments = CallAPI($attachment_ref); + + foreach ($Attachments->QueryResult->Results as $Attachment) { + $filename = $Attachment->_refObjectName; + $link_url = $url . $Attachment->ObjectID . '/' . urlencode($filename); + $links[$filename] = $link_url; + } + + return $links; +} + +/** + * Posts artifact details to a Slack channel via an incoming webhook. + * + * @param string[] $payload + * + * @return mixed + */ +function SendArtifactPayload($payload) +{ + global $config; + + $prextext = ArtifactPretext($payload['header']); + $color = '#CEC7B8'; //dove gray + if ($payload['header']['type'] == 'defect') { + $color = 'bad'; //purple + } + + $fields = array(); + foreach ($payload['fields'] as $label => $value) { + $short = TRUE; + switch ($label) { + + case 'Parent': + case 'parent': + if (is_string($value)) { + $short = FALSE; + break; + } + case 'Attachment': + case 'link': + case 'attachment': + $link_url = reset($value); + $value = l(urlencode(key($value)), $link_url); + $short = FALSE; + break; + + case 'Description': + case 'description': + $value = TruncateText(SanitizeText($value), 300, $payload['header']['url']); + $short = FALSE; + break; + } + $fields[] = MakeField($label, $value, $short); + } + + $attachment = MakeAttachment($prextext, '', $color, $fields, $payload['header']['url']); + + return slack_incoming_hook_post_with_attachments( + $config['slack']['hook'], + $config['rally']['botname'], + $_REQUEST['channel_name'], + $config['rally']['boticon'], + '', + $attachment + ); +} + +/** + * Returns artifact details as Slack-formatted JSON in the body of the response. + * + * @param string[] $payload + * + * @return mixed + */ +function ReturnArtifactPayload($payload) +{ + $text = ArtifactPretext($payload['header']); + + foreach ($payload['fields'] as $label => $value) { + switch ($label) { + + case 'Attachment': + case 'Parent': + $link_url = reset($value); + $value = l(key($value), $link_url); + break; + + case 'Block Reason': + $label = ''; + $value = SanitizeText($value); + break; + + case 'Description': + $value = TruncateText(SanitizeText($value), 300, $payload['header']['url']); + $value = '\n> ' . strtr($value, array('\n' => '\n> ')); + break; + } + + if ($label) { + $label .= ':'; + $text .= '\n`' . str_pad($label, 15) . '`\t' . $value; + } else { + $text .= '\n>' . $value; + } + } + + return PrintJsonResponse($text); +} + +/** + * Compiles a short message that Rallybot uses to announce query results. + * + * @param string[] $header + * + * @return string + */ +function ArtifactPretext($header) +{ + global $RALLYME_DISPLAY_VERSION; + switch ($RALLYME_DISPLAY_VERSION) { + + case 2: + return em('Details for ' . $header['id'] . ' ' . l($header['title'], $header['url'])); + + default: + return 'Ok, @' . $_REQUEST['user_name'] . ', here\'s the ' . $header['type'] . ' you requested.'; + } +} diff --git a/scripts/include/slack.config.php b/scripts/include/slack.config.php deleted file mode 100644 index e14cd95..0000000 --- a/scripts/include/slack.config.php +++ /dev/null @@ -1,7 +0,0 @@ - diff --git a/scripts/include/slack.php b/scripts/include/slack.php index 38639a2..5e7024f 100644 --- a/scripts/include/slack.php +++ b/scripts/include/slack.php @@ -1,4 +1,4 @@ - $payload, - "channel" => "#".$channel, - "username"=>$user - ); + return isset($_REQUEST['token']) && $_REQUEST['token'] == $SLACK_OUTGOING_HOOK_TOKEN; +} - if($icon!=null) - { - $data['icon_url'] = $icon; - } - elseif($emoji!=null) - { - $data['icon_emoji'] = $emoji; +/** + * Determine if the incoming request was made via a slash command. + * + * @return boolean + */ +function isSlashCommand() +{ + return isset($_REQUEST['command']) ? $_REQUEST['command'] : FALSE; +} + +//text-formatting functions + +function BuildUserLink($username) +{ + global $SLACK_SUBDOMAIN; + + $userlink = ''; + return $userlink; +} + +function SanitizeText($text) +{ + $text = strtr($text, array('
' => '\n', '
' => '\n', '

' => '\n')); + return html_entity_decode(strip_tags($text), ENT_HTML401 | ENT_COMPAT, 'UTF-8'); +} + +function TruncateText($text, $len, $url = '') +{ + if (strlen($text) <= $len) { + return $text; } + $text = preg_replace('/\s+?(\S+)?$/', '', substr($text, 0, $len)); + $more = ($url) ? l('more', $url) : 'more'; + return $text . '... ' . em($more); +} - $data_string = "payload=" . json_encode($data, JSON_HEX_AMP|JSON_HEX_APOS|JSON_NUMERIC_CHECK|JSON_PRETTY_PRINT); +function l($text, $url) +{ + return '<' . $url . '|' . $text . '>'; +} - mylog('sent.txt',$data_string); - return curl_post($uri, $data_string); +function em($text) +{ + return '_' . $text . '_'; } +function b($text) +{ + return '*' . $text . '*'; +} +//posting functions -function slack_incoming_hook_post_with_attachments($uri, $user, $channel, $icon, $payload, $attachments){ +function slack_incoming_hook_post($url, $user, $channel, $icon, $emoji, $payload) +{ + $data = array( + 'text' => $payload, + 'channel' => '#' . $channel, + 'username' => $user, + 'link_names' => 1 + ); + + if ($icon != null) { + $data['icon_url'] = $icon; + } elseif ($emoji != null) { + $data['icon_emoji'] = $emoji; + } + + return _incoming_hook_post($url, $data); +} + +function slack_incoming_hook_post_with_attachments($url, $user, $channel, $icon, $payload, $attachments) +{ + //allow bot to display formatted attachment text + $attachments->mrkdwn_in = array('pretext', 'text', 'title', 'fields'); $data = array( - "text" => $payload, - "channel" => "#".$channel, - "username"=>$user, - "icon_url"=>$icon, - "attachments"=>array($attachments)); + 'text' => $payload, + 'channel' => '#' . $channel, + 'username' => $user, + 'icon_url' => $icon, + 'attachments' => array($attachments), + 'link_names' => 1 //allow bot to linkify at-mentions in attachments + ); + + return _incoming_hook_post($url, $data); +} - $data_string = "payload=" . json_encode($data, JSON_HEX_AMP|JSON_HEX_APOS|JSON_NUMERIC_CHECK|JSON_PRETTY_PRINT); - mylog('sent.txt',$data_string); - return curl_post($uri, $data_string); +function _incoming_hook_post($url, $data) +{ + $data_string = 'payload=' . json_encode($data, JSON_HEX_AMP | JSON_HEX_APOS | JSON_NUMERIC_CHECK | JSON_PRETTY_PRINT); + $data_string = strtr($data_string, array('\\\\n' => '\n')); //unescape slashes in newline characters + + $result = curl_post($url, $data_string); + switch ($result) { + case 'ok': + mylog('sent.txt', $data_string); + return $result; + case 'Invalid channel specified': + exit('Unable to post messages to a private chat'); + } + if (strpos($url, 'REPLACE')) { + $result = 'Please set your Slack subdomain and incoming webhook token'; + } + exit('Unable to send Incoming WebHook message: ' . $result); } +function PrintJsonResponse($payload) +{ + $data = array('text' => $payload); + + $data_string = json_encode($data, JSON_HEX_AMP | JSON_HEX_APOS | JSON_NUMERIC_CHECK | JSON_PRETTY_PRINT); + $data_string = strtr($data_string, array('\n' => 'n', '\t' => 't')); //fix double-escaped codes + return print_r($data_string); +} /* slack attachment format @@ -86,23 +173,35 @@ function slack_incoming_hook_post_with_attachments($uri, $user, $channel, $icon, "fields": [ { "title": "Required Field Title", // The title may not contain markup and will be escaped for you + "value": "Text value of the field. May contain standard message markup and must be escaped as normal. May be multi-line.", + "short": false // Optional flag indicating whether the `value` is short enough to be displayed side-by-side with other values } ] } */ -function MakeAttachment($pretext, $text, $color, $fields, $fallback){ - +function MakeAttachment($pretext, $text, $color, $fields, $fallback) +{ $obj = new stdClass; $obj->fallback = $fallback; $obj->text = $text; $obj->pretext = $pretext; $obj->color = $color; - if(sizeof($fields)>0) + if (sizeof($fields) > 0) $obj->fields = $fields; return $obj; } -?> + +function MakeField($title, $value, $short = false) +{ + $attachmentfield = array( + "title" => $title, + "value" => $value, + "short" => $short + ); + + return $attachmentfield; +} diff --git a/scripts/include/slack.secrets.php b/scripts/include/slack.secrets.php deleted file mode 100644 index e243c8c..0000000 --- a/scripts/include/slack.secrets.php +++ /dev/null @@ -1,4 +0,0 @@ - diff --git a/scripts/meme.php b/scripts/meme.php index 4c68a81..f34437e 100644 --- a/scripts/meme.php +++ b/scripts/meme.php @@ -1,7 +1,8 @@ -Text; $memetext = str_replace("memebot ", "", $cmdText); @@ -32,22 +32,19 @@ $bottom = urlencode($parts[2]); $meme = CreateNewMeme($gen, $top, $bottom); -mylog('sent.txt',$meme); +mylog('sent.txt', $meme); -$response = slack_incoming_hook_post($config['slack']['hook'], $cmd->UserName, $cmd->ChannelName, null, ":bow:", $meme); +$response = slack_incoming_hook_post($SLACK_INCOMING_HOOK_URL, $cmd->UserName, $cmd->ChannelName, null, ":bow:", $meme); -mylog('sent.txt',$response); +mylog('sent.txt', $response); //str_replace ( mixed $search , mixed $replace , mixed $subject [, int &$count ] ) //print_r($cmd->Text);die; - - //$out = new stdClass(); //$out->text = $meme; //$json = json_encode($out); //mylog('sent.txt',$json); //print_r($json); -?> diff --git a/scripts/rallycron.php b/scripts/rallycron.php new file mode 100644 index 0000000..fd63019 --- /dev/null +++ b/scripts/rallycron.php @@ -0,0 +1,16 @@ +Text); - -$result = HandleItem($slackCommand, $rallyFormattedId); -?> \ No newline at end of file +if (isValidOutgoingHookRequest() && isset($_REQUEST['text'])) { + $payload = FetchArtifactPayload($_REQUEST['text']); + $result = isSlashCommand() ? SendArtifactPayload($payload) : ReturnArtifactPayload($payload); +} diff --git a/scripts/xkcd.php b/scripts/xkcd.php index 971667b..d2f7219 100644 --- a/scripts/xkcd.php +++ b/scripts/xkcd.php @@ -1,12 +1,10 @@ -\n"; -$ret = slack_incoming_hook_post($hook, "xkcdbot", $command->ChannelName, $iconurl, $emoji, $payload); -if($ret!="ok") +$ret = slack_incoming_hook_post($SLACK_INCOMING_HOOK_URL, "xkcdbot", $command->ChannelName, $iconurl, $emoji, $payload); +if ($ret != "ok") print_r("@tdm, gifbot got this response when it tried to post to the incoming hook.\n{$ret}"); -?> \ No newline at end of file