Skip to content

Commit

Permalink
Initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
farski committed Mar 6, 2024
0 parents commit 5ea734a
Show file tree
Hide file tree
Showing 19 changed files with 3,600 additions and 0 deletions.
11 changes: 11 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
root = true

[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true

[Dockerfile]
indent_size = 4
11 changes: 11 additions & 0 deletions .eslintrc.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
extends: [airbnb-base, plugin:prettier/recommended]
env:
es2023: true
node: true
parserOptions:
ecmaVersion: latest
sourceType: module
rules:
no-console: off
import/prefer-default-export: off
import/extensions: [error, always]
7 changes: 7 additions & 0 deletions .github/workflows/check-project-std.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
name: Check project standards

on: push

jobs:
check-javascript:
uses: prx/.github/.github/workflows/check-project-std-javascript.yml@main
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.aws-sam
node_modules
1 change: 1 addition & 0 deletions .tool-versions
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
nodejs 20.9.0 # Should match the AWS Lambda runtime being used
11 changes: 11 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.formatOnSaveMode": "file"
},
"[yaml]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
}
}
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Zendesk Toolkit

A collection of utilities that integrate with [Zendesk](https://www.zendesk.com/) to provide additional functionality or conveniences.

## Components

### Suspended Tickets Poller

Periodically queries the [suspended tickets API](https://developer.zendesk.com/api-reference/ticketing/tickets/suspended_tickets/). When new suspended tickets are found, an [interactive message](https://api.slack.com/messaging/interactivity) is sent to [Slack](https://slack.com/). This is intended to increase the visibility of suspended tickets, and make management (deleting or recovering) easier, faster, and more likely.

The poller is a [Lambda](https://aws.amazon.com/lambda/) function that in invoked periodically. There is also a [DynamoDB](https://aws.amazon.com/dynamodb/) table that is used as a lightweight cache, to keep track of which tickets the poller has already seen, and prevent sending multiple messages to Slack for the same ticket.

The cache keeps a record of each ticket for 7 days from the first time it sees the ticket. Suspended tickets are automatically deleted from Zendesk if they remain suspended for 14 days. This means that a ticket should generate two messages to Slack if it's not otherwise dealt with during that 14 day window.

### Slack Interactivity Endpoint

A Lambda function with a [function URL](https://docs.aws.amazon.com/lambda/latest/dg/lambda-urls.html) that handles interactivity requests coming from a Slack App. The interactive messages are generated elsewhere. For example, the **Suspended Tickets Poller** creates messages for suspended tickets with buttons to _delete_ or _recover_ those tickets. This endpoint is respondible for perfoming those actions in response to a button being selected.

This function should handle **all** interactivity requests related to the Zendesk Toolkit. Do not create new interactivity endpoints for various message sources.

## Deployment

The entire Zendesk Toolkit is deployed using [AWS SAM](), generally by running `sam build && sam deploy --resolve-s3` locally. See `samconfig.toml` for additional deployment details. If stack parameters need to be added or changed, use the `parameter_overrides` section of `samconfig.toml`. Include (uncommment) only the parameters that are being affected; SAM will use existing values for all other parameters.

Only a single instance of Zendesk Toolkit should be deployed. All new functionality should be added to the existing template and deployed to the existing stack. This is true even if some component is being duplicated, such as if a **Suspended Tickets Poller** is being spun up for a new Zendesk instance. **Do not**, for example, create separate Zendesk Toolkits for both the PRX and PRX Accounting Zendesk instances.

A single Slack App exists in the PRX Slack workspace to handle all integration between Slack and the Zendesk Toolkit.
3 changes: 3 additions & 0 deletions lib/nodejs-slack-sdk/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
build-NodejsSlackSdkLambdaLayer:
mkdir -p "$(ARTIFACTS_DIR)/nodejs"
npm install --prefix "$(ARTIFACTS_DIR)/nodejs" @slack/web-api
39 changes: 39 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
{
"name": "zendesk-toolkit",
"version": "1.0.0",
"description": "A collection of utilities that integrate with Zendesk",
"type": "module",
"engines": {
"node": ">= 20.0.0"
},
"repository": {
"type": "git",
"url": "git+https://github.com/PRX/zendesk-toolkit.git"
},
"keywords": [],
"author": "",
"license": "AGPL-3.0",
"private": "true",
"bugs": {
"url": "https://github.com/PRX/zendesk-toolkit/issues"
},
"homepage": "https://github.com/PRX/zendesk-toolkit#readme",
"dependencies": {
"@aws-sdk/client-dynamodb": "*",
"@aws-sdk/util-dynamodb": "*",
"@slack/web-api": "*"
},
"devDependencies": {
"@types/node": "*",
"eslint": "*",
"eslint-config-airbnb-base": "*",
"eslint-config-prettier": "*",
"eslint-plugin-import": "*",
"eslint-plugin-jest": "*",
"eslint-plugin-prettier": "*",
"jsdoc": "*",
"prettier": "*",
"typescript": "*",
"yarn": "*"
}
}
19 changes: 19 additions & 0 deletions samconfig.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# sam build && sam deploy --resolve-s3

version = 0.1

[default.deploy.parameters]
profile = "prx-it-services"
stack_name = "zendesk-toolkit"
s3_prefix = "zendesk-toolkit"
confirm_changeset = false
capabilities = "CAPABILITY_IAM"
region = "us-east-2"
# Parameter overrides only need to be included when a parameter is changing
# parameter_overrides = [
# "PrxZendeskApiUsername=",
# "PrxZendeskApiToken=",
# "SlackAccessToken=",
# "SlackSigningSecret=",
# "SlackChannelId="
# ]
38 changes: 38 additions & 0 deletions src/slack-interactivity-endpoint/delete.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { WebClient } from "@slack/web-api";

const web = new WebClient(process.env.SLACK_ACCESS_TOKEN);

const zendeskApiCreds = `${process.env.ZENDESK_API_USERNAME}:${process.env.ZENDESK_API_TOKEN}`;
const zendeskApiAuthHeader = `Basic ${btoa(zendeskApiCreds)}`;

export default async function del(payload) {
const suspensionId = +payload.actions[0].value;

await fetch(
`https://prx.zendesk.com/api/v2/suspended_tickets/${suspensionId}`,
{
method: "DELETE",
headers: new Headers({
Authorization: zendeskApiAuthHeader,
}),
},
);

console.info(`Deleted suspended ticket ${suspensionId}`);

const ticketSubject = payload.message.blocks[0].text.text;

await web.chat.update({
channel: payload.channel.id,
ts: payload.message.ts,
blocks: [
{
type: "section",
text: {
type: "mrkdwn",
text: `*${ticketSubject}* was :x: deleted by <@${payload.user.id}>`,
},
},
],
});
}
24 changes: 24 additions & 0 deletions src/slack-interactivity-endpoint/ignore.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { WebClient } from "@slack/web-api";

const web = new WebClient(process.env.SLACK_ACCESS_TOKEN);

export default async function ignore(payload) {
const ticketSubject = payload.message.blocks[0].text.text;
const suspensionId = +payload.actions[0].value;

console.info(`Ignored suspended ticket ${suspensionId}`);

await web.chat.update({
channel: payload.channel.id,
ts: payload.message.ts,
blocks: [
{
type: "section",
text: {
type: "mrkdwn",
text: `*${ticketSubject}* was ignored by <@${payload.user.id}>`,
},
},
],
});
}
79 changes: 79 additions & 0 deletions src/slack-interactivity-endpoint/index.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { createHmac } from "node:crypto";
import deleteTicket from "./delete.mjs";
import recoverTicket from "./recover.mjs";
import ignoreTicket from "./ignore.mjs";

export const handler = async (event) => {
console.info("Received Slack interaction");

const retryNum = event.headers["x-slack-retry-num"];
const retryReason = event.headers["x-slack-retry-reason"];

if (retryNum || retryReason) {
console.debug(
JSON.stringify({
slack_retry_count: retryNum,
slack_retry_reason: retryReason,
}),
);
}

// All Slack requests will have a signature that should be verified
const slackSignature = event.headers["x-slack-signature"];

if (!slackSignature) {
console.warn("Missing Slack signature");
return { statusCode: 400 };
}

const body = event.isBase64Encoded
? Buffer.from(event.body, "base64").toString("utf-8")
: event.body;

const slackRequestTimestamp = event.headers["x-slack-request-timestamp"];
const basestring = ["v0", slackRequestTimestamp, body].join(":");
const signingSecret = process.env.SLACK_SIGNING_SECRET;
const requestSignature = `v0=${createHmac("sha256", signingSecret)
.update(basestring)
.digest("hex")}`;

if (requestSignature !== slackSignature) {
console.warn("Invalid Slack signature");
return { statusCode: 400 };
}

const params = new URLSearchParams(body);

let payload;

try {
payload = JSON.parse(params.get("payload"));
} catch (error) {
return { statusCode: 200 };
}

console.debug(JSON.stringify(payload));

const choice = payload.actions[0].action_id;
const suspensionId = +payload.actions[0].value;

console.info(
`User ${payload.user.id} requested to ${choice} suspended ticket ${suspensionId}`,
);

switch (choice) {
case "IGNORE":
await ignoreTicket(payload);
break;
case "DELETE":
await deleteTicket(payload);
break;
case "RECOVER":
await recoverTicket(payload);
break;
default:
break;
}

return { statusCode: 200 };
};
44 changes: 44 additions & 0 deletions src/slack-interactivity-endpoint/recover.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { WebClient } from "@slack/web-api";

const web = new WebClient(process.env.SLACK_ACCESS_TOKEN);

const zendeskApiCreds = `${process.env.ZENDESK_API_USERNAME}:${process.env.ZENDESK_API_TOKEN}`;
const zendeskApiAuthHeader = `Basic ${btoa(zendeskApiCreds)}`;

export default async function recover(payload) {
const suspensionId = +payload.actions[0].value;

const resp = await fetch(
`https://prx.zendesk.com/api/v2/suspended_tickets/recover_many?ids=${suspensionId}`,
{
method: "PUT",
headers: new Headers({
Authorization: zendeskApiAuthHeader,
}),
},
);

const respPayload = await resp.json();
const { tickets } = respPayload;
const ticketId = tickets[0].id;

console.info(
`Recovered suspended ticket ${suspensionId} as ticket ${ticketId}`,
);

const ticketSubject = payload.message.blocks[0].text.text;

await web.chat.update({
channel: payload.channel.id,
ts: payload.message.ts,
blocks: [
{
type: "section",
text: {
type: "mrkdwn",
text: `*${ticketSubject}* was :white_check_mark: <https://prx.zendesk.com/agent/tickets/${ticketId}|recovered> by <@${payload.user.id}>`,
},
},
],
});
}
Loading

0 comments on commit 5ea734a

Please sign in to comment.