Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions functions/snippets/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
config.json
4 changes: 4 additions & 0 deletions functions/snippets/config.slack.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"SLACK_SECRET": "[YOUR_SLACK_SIGNING_SECRET]",
"KG_API_KEY": "[YOUR_KG_API_KEY]"
}
16 changes: 14 additions & 2 deletions functions/snippets/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -26,20 +26,32 @@
</properties>

<dependencies>
<!-- Required to for com.example.functions.ParseContentType -->
<!-- Required for com.example.functions.ParseContentType -->
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.8.6</version>
</dependency>

<!-- Required to for com.example.functions.RetrieveLogs -->
<!-- Required for com.example.functions.RetrieveLogs -->
<dependency>
<groupId>com.google.cloud</groupId>
<artifactId>google-cloud-logging</artifactId>
<version>1.100.0</version>
</dependency>

<!-- Required for com.example.functions.SlackSlashCommand -->
<dependency>
<groupId>com.google.apis</groupId>
<artifactId>google-api-services-kgsearch</artifactId>
<version>v1-rev253-1.25.0</version>
</dependency>
<dependency>
<groupId>com.github.seratch</groupId>
<artifactId>jslack</artifactId>
<version>3.4.1</version>
</dependency>

<!-- The following dependencies are only required for testing -->
<dependency>
<groupId>junit</groupId>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
/*
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.example.functions;

import com.github.seratch.jslack.app_backend.SlackSignature;
import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport;
import com.google.api.client.json.jackson2.JacksonFactory;
import com.google.api.client.util.ArrayMap;
import com.google.api.services.kgsearch.v1.Kgsearch;
import com.google.cloud.functions.HttpFunction;
import com.google.cloud.functions.HttpRequest;
import com.google.cloud.functions.HttpResponse;
import com.google.gson.Gson;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import java.io.BufferedWriter;
import java.io.IOException;
import java.net.HttpURLConnection;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.GeneralSecurityException;
import java.util.HashMap;
import java.util.List;
import java.util.logging.Logger;
import java.util.stream.Collectors;

public class SlackSlashCommand implements HttpFunction {

private Kgsearch kgClient;
private static String API_KEY;
private static String SLACK_SECRET;
private static final Logger LOGGER = Logger.getLogger(HelloHttp.class.getName());
private SlackSignature.Verifier verifier;
private Gson gson = new Gson();

public SlackSlashCommand() throws IOException, GeneralSecurityException {
kgClient = new Kgsearch.Builder(
GoogleNetHttpTransport.newTrustedTransport(), new JacksonFactory(), null).build();

// Read + parse config file
Path configPath = Path.of(System.getProperty("user.dir"), "config.json");
JsonObject configJson = (new Gson()).fromJson(Files.readString(configPath), JsonObject.class);

SLACK_SECRET = configJson.get("SLACK_SECRET").getAsString();
API_KEY = configJson.get("KG_API_KEY").getAsString();

verifier = new SlackSignature.Verifier(new SlackSignature.Generator(SLACK_SECRET));
}

private boolean isValidSlackWebhook(HttpRequest request, String requestBody) throws IOException {

// Check for headers
HashMap<String, List<String>> headers = new HashMap(request.getHeaders());
if (!headers.containsKey("X-Slack-Request-Timestamp")
|| !headers.containsKey("X-Slack-Signature")) {
return false;
}

return verifier.isValid(
headers.get("X-Slack-Request-Timestamp").get(0),
requestBody,
headers.get("X-Slack-Signature").get(0),
1L);
}

private void addPropertyIfPresent(
JsonObject target, String targetName, ArrayMap source, String sourceName) {
if (source.containsKey(sourceName)) {
target.addProperty(targetName, source.get(sourceName).toString());
}
}

private String formatSlackMessage(List<Object> kgResults, String query) {
JsonObject attachmentJson = new JsonObject();
JsonArray attachments = new JsonArray();

JsonObject responseJson = new JsonObject();
responseJson.addProperty("response_type", "in_channel");
responseJson.addProperty("text", String.format("Query: %s", query));

// Extract the first entity from the result list, if any
if (kgResults.size() == 0) {
attachmentJson.addProperty("text","No results match your query...");

attachments.add(attachmentJson);
responseJson.add("attachments", attachmentJson);

return gson.toJson(responseJson);
}

ArrayMap entity = (ArrayMap) ((ArrayMap) kgResults.get(0)).get("result");

// Construct Knowledge Graph response attachment
String title = entity.get("name").toString();
if (entity.containsKey("description")) {
title = String.format("%s: %s", title, entity.get("description").toString());
}
attachmentJson.addProperty("title", title);

if (entity.containsKey("detailedDescription")) {
ArrayMap detailedDescJson = (ArrayMap) entity.get("detailedDescription");
addPropertyIfPresent(attachmentJson, "title_link", detailedDescJson, "url");
addPropertyIfPresent(attachmentJson, "text", detailedDescJson, "articleBody");
}

if (entity.containsKey("image")) {
ArrayMap imageJson = (ArrayMap) entity.get("image");
addPropertyIfPresent(attachmentJson, "image_url", imageJson, "contentUrl");
}

// Construct top level response
attachments.add(attachmentJson);
responseJson.add("attachments", attachmentJson);

return gson.toJson(responseJson);
}

private List<Object> searchKnowledgeGraph(String query) throws IOException {
Kgsearch.Entities.Search kgRequest = kgClient.entities().search();
kgRequest.setQuery(query);
kgRequest.setKey(API_KEY);

return kgRequest.execute().getItemListElement();
}

@Override
public void service(HttpRequest request, HttpResponse response) throws IOException {

// Validate request
if (request.getMethod() != "POST") {
response.setStatusCode(HttpURLConnection.HTTP_BAD_METHOD);
return;
}

// reader can only be read once per request, so we preserve its contents
String bodyString = request.getReader().lines().collect(Collectors.joining());
JsonObject body = (new Gson()).fromJson(bodyString, JsonObject.class);

if (body == null || !body.has("text")) {
response.setStatusCode(HttpURLConnection.HTTP_BAD_REQUEST);
return;
}

if (!isValidSlackWebhook(request, bodyString)) {
response.setStatusCode(HttpURLConnection.HTTP_UNAUTHORIZED);
return;
}

String query = body.get("text").getAsString();

// Call knowledge graph API
List<Object> kgResults = searchKnowledgeGraph(query);

// Format response to Slack
BufferedWriter writer = response.getWriter();
writer.write(formatSlackMessage(kgResults, query));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
/*
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.example.functions;

import static com.google.common.truth.Truth.assertThat;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.powermock.api.mockito.PowerMockito.mock;
import static org.powermock.api.mockito.PowerMockito.when;

import com.github.seratch.jslack.app_backend.SlackSignature;
import com.google.api.client.googleapis.json.GoogleJsonResponseException;
import com.google.cloud.functions.HttpRequest;
import com.google.cloud.functions.HttpResponse;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.StringReader;
import java.io.StringWriter;
import java.net.HttpURLConnection;
import java.security.GeneralSecurityException;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import org.junit.Before;
import org.junit.Test;
import org.mockito.ArgumentMatchers;
import org.mockito.Mock;
import org.powermock.reflect.Whitebox;

public class SlackSlashCommandTest {

private BufferedWriter writerOut;
private StringWriter responseOut;

@Mock private HttpRequest request;
@Mock private HttpResponse response;

@Mock private SlackSignature.Verifier alwaysValidVerifier;

@Before
public void beforeTest() throws IOException {
request = mock(HttpRequest.class);
when(request.getReader()).thenReturn(new BufferedReader(new StringReader("")));

response = mock(HttpResponse.class);

responseOut = new StringWriter();

writerOut = new BufferedWriter(responseOut);
when(response.getWriter()).thenReturn(writerOut);

alwaysValidVerifier = mock(SlackSignature.Verifier.class);
when(alwaysValidVerifier.isValid(
ArgumentMatchers.any(),
ArgumentMatchers.any(),
ArgumentMatchers.any(),
ArgumentMatchers.anyLong())
).thenReturn(true);

// Construct valid header list
HashMap<String, List<String>> validHeaders = new HashMap<String, List<String>>();
String validSlackSignature = System.getenv("SLACK_TEST_SIGNATURE");
String timestamp =
Long.toString(ZonedDateTime.of(1970, 1, 1, 0, 0, 0, 0, ZoneOffset.UTC)
.toInstant().toEpochMilli());

validHeaders.put("X-Slack-Signature", Arrays.asList(validSlackSignature));
validHeaders.put("X-Slack-Request-Timestamp", Arrays.asList(timestamp));

when(request.getHeaders()).thenReturn(validHeaders);
}

@Test
public void onlyAcceptsPostRequestsTest() throws IOException, GeneralSecurityException {
when(request.getMethod()).thenReturn("GET");
new SlackSlashCommand().service(request, response);

writerOut.flush();
verify(response, times(1)).setStatusCode(HttpURLConnection.HTTP_BAD_METHOD);
}

@Test
public void requiresSlackAuthHeadersTest() throws IOException, GeneralSecurityException {
StringReader requestReadable = new StringReader("{ \"text\": \"foo\" }\n");

when(request.getMethod()).thenReturn("POST");
when(request.getReader()).thenReturn(new BufferedReader(requestReadable));

new SlackSlashCommand().service(request, response);

// Do NOT look for HTTP_BAD_REQUEST here (that means the request WAS authorized)!
verify(response, times(1)).setStatusCode(HttpURLConnection.HTTP_UNAUTHORIZED);
}

@Test
public void recognizesValidSlackTokenTest() throws IOException, GeneralSecurityException {
StringReader requestReadable = new StringReader("{}");

when(request.getReader()).thenReturn(new BufferedReader(requestReadable));
when(request.getMethod()).thenReturn("POST");

new SlackSlashCommand().service(request, response);

verify(response, times(1)).setStatusCode(HttpURLConnection.HTTP_BAD_REQUEST);
}

@Test(expected = GoogleJsonResponseException.class)
public void handlesSearchErrorTest() throws IOException, GeneralSecurityException {
StringReader requestReadable = new StringReader("{ \"text\": \"foo\" }\n");

when(request.getReader()).thenReturn(new BufferedReader(requestReadable));
when(request.getMethod()).thenReturn("POST");

SlackSlashCommand functionInstance = new SlackSlashCommand();
Whitebox.setInternalState(functionInstance, "verifier", alwaysValidVerifier);
Whitebox.setInternalState(SlackSlashCommand.class, "API_KEY", "gibberish");

// Should throw a GoogleJsonResponseException (due to invalid API key)
functionInstance.service(request, response);
}

@Test
public void handlesEmptyKgResultsTest() throws IOException, GeneralSecurityException {
StringReader requestReadable = new StringReader("{ \"text\": \"asdfjkl13579\" }\n");

when(request.getReader()).thenReturn(new BufferedReader(requestReadable));
when(request.getMethod()).thenReturn("POST");

SlackSlashCommand functionInstance = new SlackSlashCommand();
Whitebox.setInternalState(functionInstance, "verifier", alwaysValidVerifier);


functionInstance.service(request, response);

writerOut.flush();
assertThat(responseOut.toString()).contains("No results match your query...");
}

@Test
public void handlesPopulatedKgResultsTest() throws IOException, GeneralSecurityException {
StringReader requestReadable = new StringReader("{ \"text\": \"lion\" }\n");

when(request.getReader()).thenReturn(new BufferedReader(requestReadable));
when(request.getMethod()).thenReturn("POST");

SlackSlashCommand functionInstance = new SlackSlashCommand();
Whitebox.setInternalState(functionInstance, "verifier", alwaysValidVerifier);


functionInstance.service(request, response);

writerOut.flush();
assertThat(responseOut.toString()).contains("https://en.wikipedia.org/wiki/Lion");
}
}
Loading