Skip to content

Commit

Permalink
Slack bot: subscribe slash command (#96)
Browse files Browse the repository at this point in the history
* feat: handle slack command

* refactor: process591QueryUrl

* chore: add inngest dev script

* feat: subscription models and slash command

* style: linting
  • Loading branch information
Yukaii authored Mar 3, 2023
1 parent 30dbaff commit 463ff76
Show file tree
Hide file tree
Showing 11 changed files with 267 additions and 42 deletions.
22 changes: 22 additions & 0 deletions lib/591House/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import url from "url";

export function extractSearchParams(urlString: string) {
const parsedUrl = url.parse(urlString);
return parsedUrl.search || "";
}

export function appendRentalAPIParams(urlString: string) {
const searchParams = new URLSearchParams(urlString);

searchParams.set("is_format_data", "1");
searchParams.set("is_new_list", "1");
searchParams.set("type", "1");

return searchParams.toString();
}

export function process591QueryUrl(urlString: string) {
const search = extractSearchParams(urlString);

return appendRentalAPIParams(search);
}
2 changes: 1 addition & 1 deletion lib/__tests__/config.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { appendRentalAPIParams } from "@/lib/config";
import { appendRentalAPIParams } from "@/lib/591House/utils";

test("Should insert additional query params", () => {
const search = "";
Expand Down
27 changes: 5 additions & 22 deletions lib/config.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,4 @@
import url from "url";

export function extractSearchParams(urlString: string) {
const parsedUrl = url.parse(urlString);
return parsedUrl.search || "";
}

export function appendRentalAPIParams(urlString: string) {
const searchParams = new URLSearchParams(urlString);

searchParams.set("is_format_data", "1");
searchParams.set("is_new_list", "1");
searchParams.set("type", "1");

return searchParams.toString();
}

const targetURL = process.env.TARGET_URL || "";
const search = extractSearchParams(targetURL);

const houseListURL = `https://rent.591.com.tw/home/search/rsList?${appendRentalAPIParams(search)}`;
import { process591QueryUrl } from "./591House/utils";

const apiSecret = process.env.ZUZUGO_API_SECRET;

Expand All @@ -31,7 +11,9 @@ const tokenLine = process.env.LINE_API_TOKEN;
const slackWebhook = process.env.SLACK_WEBHOOK!;

export const config = {
houseListURL,
houseListURL: `https://rent.591.com.tw/home/search/rsList?${process591QueryUrl(
process.env.TARGET_URL || ""
)}`,
// subwayStationFilter: {
// enable: isSubwayStationFilterEnabled,
// station: subwayStation,
Expand All @@ -51,4 +33,5 @@ export const config = {
apiSecret,
tokenLine,
production: process.env.NODE_ENV === "production",
cronEnabled: process.env.DISABLE_CRON !== "true",
};
15 changes: 13 additions & 2 deletions lib/inngest/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,25 @@ type NotifySlack = {

type FetchNewHouses = {
name: "tasks/fetchNewHouses";
data: {}
data: {};
};

type SlashCommand = {
name: "slackCommands/slashHandler";
data: {
channelId: string;
userId: string;
command: string;
args: string;
};
};

type Events = {
"notification/dispatchAll": DispatchAll;
"notification/notifyLine": NotifyLine;
"notification/notifySlack": NotifySlack;
"tasks/fetchNewHouses": FetchNewHouses
"tasks/fetchNewHouses": FetchNewHouses;
"slackCommands/slashHandler": SlashCommand;
};

export const inngest = new Inngest<Events>({
Expand Down
13 changes: 12 additions & 1 deletion lib/inngest/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,15 @@
import { config } from "@/lib/config";
import { dispatchAll, notifyLineNotification, notifySlackNotification } from "@/lib/notification";
import { slashCommand } from "@/lib/slackApp/commands";
import { fetchNewHousesFn, fetchNewHousesEvent } from "@/lib/tasks";

export const fns = [dispatchAll, notifyLineNotification, notifySlackNotification, fetchNewHousesFn, fetchNewHousesEvent];
export const fns = [
dispatchAll,
notifyLineNotification,
notifySlackNotification,
config.cronEnabled ? fetchNewHousesFn : null,
fetchNewHousesEvent,

// slack commands
slashCommand,
].filter(Boolean);
128 changes: 128 additions & 0 deletions lib/slackApp/commands.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { SlackAppInstallation } from "@prisma/client";
import { IncomingWebhook } from "@slack/webhook";

import { process591QueryUrl } from "@/lib/591House/utils";
import { config } from "@/lib/config";
import { inngest } from "@/lib/inngest/client";
import { prisma } from "@/lib/prisma";

type CommandHandler = (args: {
webhook: IncomingWebhook;
args: string;
installation: SlackAppInstallation;
}) => Promise<void>;

const commandHandlers: Record<string, CommandHandler> = {
subscribe: async ({ webhook, args, installation }) => {
const firstNonWhitespace = /^[^\s]+/g;
const match = args.match(firstNonWhitespace);

if (!match) {
throw new Error("No subscribe argument provided");
}

const arg = match[0];

const installationToSubscriptions = await prisma.slackInstallationToSubscription.findMany({
where: {
channelId: installation.incomingWebhookChannelId!,
},
include: {
subscription: true,
},
});

switch (arg) {
case "list": {
const subscriptionUrls = installationToSubscriptions.map(
(record) => record.subscription.query
);

if (subscriptionUrls.length === 0) {
await webhook.send({
text: `You are not subscribed to any queries`,
});
} else {
// TODO: print subscription id to let user unsubscribe
// Prepend 591 url with https://rent.591.com.tw/
await webhook.send({
text: `You are subscribed to the following queries: ${subscriptionUrls.join(", ")}`,
});
}

break;
}
default: {
const query = process591QueryUrl(arg);

// TODO: Check if query is valid 591 url
const record = installationToSubscriptions.find(
(record) => record.subscription.query === query
);

if (!record) {
const subscription = await prisma.houseSubscription.create({
data: {
query,
SlackInstallationToSubscription: {
create: {
channelId: installation.incomingWebhookChannelId!,
},
},
},
});

await webhook.send({
text: `You are now subscribed to ${subscription.query}`,
});
}

break;
}
}
},
unsubscribe: async ({ webhook }) => {},
help: async ({ webhook }) => {},
};

export const availableCommands = Object.keys(commandHandlers);

export const slashCommand = inngest.createFunction(
"Subscribe command",
"slackCommands/slashHandler",
async ({ event }) => {
const { channelId, userId, command, args } = event.data;

const installation = await prisma.slackAppInstallation.findFirst({
where: {
incomingWebhookChannelId: channelId,
userId,
},
});

if (!installation || !installation.incomingWebhookUrl) {
throw new Error("No installation found");
}

const webhook = new IncomingWebhook(installation.incomingWebhookUrl!);

const handler = commandHandlers[command];

if (!handler) {
// TODO: Send help message
throw new Error("No handler found");
}

try {
await handler({ webhook, args, installation });
} catch (error) {
console.error(error);

if (config.slackDevMode) {
await webhook.send({
text: `Error: ${error}`,
});
}
}
}
);
2 changes: 1 addition & 1 deletion lib/slackApp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export function setupSlackApp(setupApp: (app: App) => void) {
clientId: config.slackClientId,
clientSecret: config.slackClientSecret,
stateSecret: config.slackStateSecret,
scopes: ["commands", "chat:write", "incoming-webhook"],
scopes: ["commands", "incoming-webhook", "chat:write", "chat:write.public"],
installerOptions: {
directInstall: true,
callbackOptions: {
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"private": true,
"scripts": {
"dev": "next dev",
"inngest-dev": "inngest-cli dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
Expand Down
59 changes: 44 additions & 15 deletions pages/api/slack/_app.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,53 @@
import { config } from "@/lib/config";
import { inngest } from "@/lib/inngest/client";
import { setupSlackApp } from "@/lib/slackApp";
import { availableCommands } from "@/lib/slackApp/commands";

export { appRunner } from "@/lib/slackApp";

setupSlackApp((app) => {
app.command(config.slackSlashCommand || "/zuzugo", async ({ ack, command, say, client }) => {
console.log(command.text);

await client.chat.postMessage({
blocks: [
{
type: "section",
text: {
type: "mrkdwn",
text: "*Hello*, _World!_",
},
},
],
channel: command.channel_id,
const slashCommand = config.slackSlashCommand || "/zuzugo";

app.command(slashCommand, async ({ ack, command, client }) => {
if (config.slackDevMode) {
console.log("Slash command", command);
}

// TODO: Add show help command
const handleInvalidCommand = async () => {
await client.chat.postEphemeral({
channel: command.channel_id,
text: "Invalid command",
user: command.user_id,
});

ack();
};

const { text } = command;
const regex = /(?<cmd>\w+)(?:\s+(?<args>.*))?/;
const match = text.match(regex);

if (!match) {
return handleInvalidCommand();
}

const { cmd, args } = match.groups as { cmd: string; args: string };
if (!availableCommands.includes(cmd)) {
return handleInvalidCommand();
}

await inngest.send("slackCommands/slashHandler", {
data: {
channelId: command.channel_id,
command: cmd,
args,
userId: command.user_id,
},
});

ack();
await ack({
response_type: "in_channel",
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
-- CreateTable
CREATE TABLE "SlackInstallationToSubscription" (
"id" TEXT NOT NULL,
"channel_id" TEXT NOT NULL,
"houseSubscriptionId" TEXT NOT NULL,

CONSTRAINT "SlackInstallationToSubscription_pkey" PRIMARY KEY ("id")
);

-- CreateTable
CREATE TABLE "HouseSubscription" (
"id" TEXT NOT NULL,
"query" TEXT NOT NULL,

CONSTRAINT "HouseSubscription_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE UNIQUE INDEX "SlackInstallationToSubscription_channel_id_houseSubscriptio_key" ON "SlackInstallationToSubscription"("channel_id", "houseSubscriptionId");

-- AddForeignKey
ALTER TABLE "SlackInstallationToSubscription" ADD CONSTRAINT "SlackInstallationToSubscription_houseSubscriptionId_fkey" FOREIGN KEY ("houseSubscriptionId") REFERENCES "HouseSubscription"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
18 changes: 18 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,21 @@ model SlackAppInstallation {
tokenType String @default("bot") @map("token_type")
installedAt DateTime @default(now()) @map("installed_at")
}

model SlackInstallationToSubscription {
id String @id @default(cuid())
channelId String @map("channel_id")
subscription HouseSubscription @relation(fields: [houseSubscriptionId], references: [id])
houseSubscriptionId String
@@unique([channelId, houseSubscriptionId])
}

model HouseSubscription {
id String @id @default(cuid())
query String
SlackInstallationToSubscription SlackInstallationToSubscription[]
}

1 comment on commit 463ff76

@vercel
Copy link

@vercel vercel bot commented on 463ff76 Mar 3, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

zuzugo – ./

zuzugo-yukaihuangtw.vercel.app
zuzugo.vercel.app
zuzugo-git-main-yukaihuangtw.vercel.app

Please sign in to comment.