Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
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
10 changes: 10 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,16 @@ If **Lockbot** responds then congratulations, it's working!

If **Slackbot** responds with an error, take a look in your **AWS Console > Lambda > Cloudwatch Logs**

### (Optional) Configure Lockbot to send reminders

Lockbot can also send users a reminder when they've held a lock for longer than a configured amount of time.

The default reminder threshold is 4 hours, and reminders are sent twice per day (Mon-Fri).
- The reminder threshold can be configured via the `LOCK_REMINDER_THRESHOLD_MINUTES` environment variable.
- The reminder schedule can be configured via the `self:custom.lockReminderScheduleExpression` serverless variable.

To enable this feature, set the `BOT_OAUTH_TOKEN` environment variable to the value of your Bot User OAuth Token (see **Settings > Features > OAuth & Permissions**).

### Finished

Now that everything has been configured in Slack and AWS you can make changes to Lockbot code and deploy it easily with one command.
Expand Down
11 changes: 8 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"lint:tsc": "tsc --noEmit",
"lint:eslint": "eslint \"**/*.{js,ts}\"",
"lint:openapi": "openapi lint",
"db:install": "serverless plugin install --name serverless-dynamodb-local && serverless dynamodb install",
"db:install": "serverless plugin install --name serverless-dynamodb && serverless dynamodb install",
"db:start": "serverless dynamodb start",
"dev": "serverless offline",
"wait": "npm-run-all wait:*",
Expand Down Expand Up @@ -43,7 +43,7 @@
"prettier": "2.3.1",
"serverless": "^2.72.2",
"serverless-api-gateway-throttling": "^1.2.2",
"serverless-dynamodb-local": "^0.2.40",
"serverless-dynamodb": "^0.2.54",
"serverless-offline": "^8.4.0",
"serverless-webpack": "^5.6.1",
"supertest": "^6.1.3",
Expand All @@ -55,6 +55,7 @@
},
"dependencies": {
"@slack/bolt": "^3.9.0",
"@slack/web-api": "6",
"@types/basic-auth": "^1.1.3",
"@types/bcryptjs": "^2.4.2",
"@vendia/serverless-express": "^4.5.3",
Expand All @@ -65,5 +66,9 @@
"express": "^4.17.2",
"fp-ts": "^2.11.8",
"io-ts": "^2.2.16"
},
"volta": {
"node": "16.20.2",
"yarn": "1.22.22"
}
}
}
17 changes: 14 additions & 3 deletions serverless.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,19 @@ useDotenv: true

plugins:
- serverless-webpack
- serverless-dynamodb-local
- serverless-dynamodb
- serverless-offline
- serverless-api-gateway-throttling
provider:
name: aws
runtime: nodejs14.x
runtime: nodejs16.x
lambdaHashingVersion: 20201221
environment:
SLACK_SIGNING_SECRET: ${env:SLACK_SIGNING_SECRET}
SLACK_CLIENT_ID: ${env:SLACK_CLIENT_ID}
SLACK_CLIENT_SECRET: ${env:SLACK_CLIENT_SECRET}
STATE_SECRET: ${env:STATE_SECRET}
LOCK_REMINDER_THRESHOLD_MINUTES: ${env:LOCK_REMINDER_THRESHOLD_MINUTES}
RESOURCES_TABLE_NAME: ${self:custom.resourcesTableName}
INSTALLATIONS_TABLE_NAME: ${self:custom.installationsTableName}
ACCESS_TOKENS_TABLE_NAME: ${self:custom.accessTokensTableName}
Expand Down Expand Up @@ -80,6 +81,11 @@ functions:
- http:
method: get
path: /api-docs/openapi.json
reminder:
handler: src/handlers/reminder/index.handler
events:
- schedule:
rate: ${self:custom.lockReminderScheduleExpression.${self:custom.stage}, self:custom.lockReminderScheduleExpression.default}

resources:
Resources:
Expand Down Expand Up @@ -133,9 +139,14 @@ custom:
resourcesTableName: ${self:custom.stage}-lockbot-resources
installationsTableName: ${self:custom.stage}-lockbot-installations
accessTokensTableName: ${self:custom.stage}-lockbot-tokens
dynamodb:
serverless-dynamodb:
stages:
- dev # https://bit.ly/35WR0TT
apiGatewayThrottling:
maxRequestsPerSecond: 200
maxConcurrentRequests: 100
lockReminderScheduleExpression:
dev: rate(1 minute)
default:
- cron(30 1 ? * MON-FRI *)
- cron(30 5 ? * MON-FRI *)
44 changes: 44 additions & 0 deletions src/handlers/reminder/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/*
# reminder

This is a Lambda function that reminds users periodically if they have any active Lockbot locks.

It works by getting the list of active locks from the Lockbot database, and parsing the data to
determine if a user has had an active lock for more than a configured amount of time; for each such
user, it sends them a private reminder message on Slack.

This function is triggered by a timer so that it runs at a regular interval.

*NOTE: This function does not store any record of whether a user has already been reminded, so it
must be scheduled to run at an interval that is frequent enough to notify a user within a reasonable
amount of time of the configured threshold, while also not sending reminders too frequently.*

## Configuration

- `reminder_threshold_in_minutes`
- The number of minutes that a user has held a lock before they start receiving reminder messages
*/
import { EventBridgeHandler, EventBridgeEvent, Context } from "aws-lambda";
import * as env from "env-var";
import sendReminders from "./reminder";

const defaultReminderThresholdMinutes: number = 240;

type ScheduledEvent = EventBridgeEvent<"Scheduled Event", string>;

export const handler: EventBridgeHandler<any, string, void> = async (
event: ScheduledEvent,
context: Context
) => {
const reminderThresholdMinutes: number =
env.get("LOCK_REMINDER_THRESHOLD_MINUTES").asIntPositive() ??
defaultReminderThresholdMinutes;

console.log("event:", { event });
console.log("context:", { context });
console.log("reminderThresholdMinutes:", { reminderThresholdMinutes });

await sendReminders(reminderThresholdMinutes);
};

export default handler;
52 changes: 52 additions & 0 deletions src/handlers/reminder/reminder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import * as env from "env-var";
import { WebClient, ErrorCode } from "@slack/web-api";
import { lockRepo } from "../api/infra";
import { Lock } from "../../lock-bot";

// TODO: Read this from `installations` table?
const botOauthToken = env.get("BOT_OAUTH_TOKEN").required().asString();

const slack = new WebClient(botOauthToken);

async function sendReminderMessage(lock: Lock) {
console.log("Sending reminder", { lock });
try {
await slack.chat.postEphemeral({
text: `You've had a lock on \`${lock.name}\` for a while now. Please remember to release it when you're done.`,
channel: lock.channel,
user: lock.owner,
});
} catch (error: any) {
if (
error.code === ErrorCode.HTTPError ||
error.code === ErrorCode.PlatformError ||
error.code === ErrorCode.RequestError ||
error.code === ErrorCode.RateLimitedError
) {
console.error("Failed to send reminder", { lock, error });
} else {
console.error("An unexpected error occurred", { lock, error });
}
}
}

const sendReminders = async (
reminderThresholdMinutes: number
): Promise<void> => {
const millisecondsPerMinute = 60000;
const dateThreshold = new Date(
Date.now() - reminderThresholdMinutes * millisecondsPerMinute
);
console.log("dateThreshold:", { dateThreshold });
const locks = await lockRepo.getAllGlobal();
await Promise.all(
locks.map(async (lock) => {
console.log("Checking lock:", { lock });
if (lock.created < dateThreshold) {
await sendReminderMessage(lock);
}
})
);
};

export default sendReminders;
2 changes: 1 addition & 1 deletion src/handlers/slack/infra.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export const expressReceiver = new ExpressReceiver({
clientId: env.get("SLACK_CLIENT_ID").required().asString(),
clientSecret: env.get("SLACK_CLIENT_SECRET").required().asString(),
stateSecret: env.get("STATE_SECRET").required().asString(),
scopes: ["commands"],
scopes: ["commands", "chat:write"],
processBeforeResponse: true,
installationStore: {
storeInstallation: async (installation, logger) => {
Expand Down
9 changes: 9 additions & 0 deletions src/lock-bot.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
import TokenAuthorizer from "./token-authorizer";

export type Lock = {
channel: string;
team: string;
name: string;
owner: string;
created: Date;
};

export interface LockRepo {
delete(resource: string, channel: string, team: string): Promise<void>;
getAllGlobal(): Promise<Lock[]>;
getAll(
channel: string,
team: string
Expand Down
27 changes: 26 additions & 1 deletion src/storage/dynamodb-lock-repo.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { DocumentClient } from "aws-sdk/clients/dynamodb";
import { LockRepo } from "../lock-bot";
import { Lock, LockRepo } from "../lock-bot";

export default class DynamoDBLockRepo implements LockRepo {
constructor(
Expand All @@ -16,6 +16,31 @@ export default class DynamoDBLockRepo implements LockRepo {
.promise();
}

async getAllGlobal(): Promise<Lock[]> {
const locks: Lock[] = [];
const params: DocumentClient.ScanInput = {
TableName: this.resourcesTableName,
ExclusiveStartKey: undefined,
};
let results;
do {
// eslint-disable-next-line no-await-in-loop
results = await this.documentClient.scan(params).promise();
results.Items?.forEach((item) => {
const [team, channel] = item.Group.split("#");
locks.push({
channel,
team,
name: item.Resource,
owner: item.Owner,
created: new Date(item.Created),
});
});
params.ExclusiveStartKey = results.LastEvaluatedKey;
} while (results.LastEvaluatedKey);
return locks;
}

async getAll(
channel: string,
team: string
Expand Down
21 changes: 20 additions & 1 deletion src/storage/in-memory-lock-repo.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { LockRepo } from "../lock-bot";
import { Lock, LockRepo } from "../lock-bot";

export default class InMemoryLockRepo implements LockRepo {
private readonly lockMap: Map<string, { owner: string; created: Date }> =
Expand All @@ -22,6 +22,25 @@ export default class InMemoryLockRepo implements LockRepo {
this.lockMap.delete(InMemoryLockRepo.toKey(resource, channel, team));
}

async getAllGlobal(): Promise<Lock[]> {
const locks: Lock[] = [];
this.lockMap.forEach((value, key) => {
const {
resource,
channel: resourceChannel,
team: resourceTeam,
} = InMemoryLockRepo.fromKey(key);
locks.push({
channel: resourceChannel,
team: resourceTeam,
name: resource,
owner: value.owner,
created: new Date(value.created),
});
});
return locks;
}

async getAll(
channel: string,
team: string
Expand Down
Loading