a fun bot for the hc slack

feat: add onboarding process

dunkirk.sh 2a39e985 699b0688

verified
Changed files
+467 -182
src
+10
src/features/takes/handlers/help.ts
···
type: "button",
text: {
type: "plain_text",
+
text: "⚙️ Settings",
+
emoji: true,
+
},
+
value: "settings",
+
action_id: "takes_settings",
+
},
+
{
+
type: "button",
+
text: {
+
type: "plain_text",
text: "📋 History",
emoji: true,
},
+10
src/features/takes/handlers/history.ts
···
type: "button",
text: {
type: "plain_text",
+
text: "⚙️ Settings",
+
emoji: true,
+
},
+
value: "settings",
+
action_id: "takes_settings",
+
},
+
{
+
type: "button",
+
text: {
+
type: "plain_text",
text: "🔄 Refresh",
emoji: true,
},
+18 -11
src/features/takes/handlers/home.ts
···
import type { MessageResponse } from "../types";
import { db } from "../../../libs/db";
-
import { takes as takesTable } from "../../../libs/schema";
+
import { takes as takesTable, users as usersTable } from "../../../libs/schema";
import { eq, and, desc } from "drizzle-orm";
import { prettyPrintTime } from "../../../libs/time";
export default async function handleHome(
userId: string,
): Promise<MessageResponse> {
-
const takes = await db
-
.select()
-
.from(takesTable)
-
.where(and(eq(takesTable.userId, userId)))
-
.orderBy(desc(takesTable.createdAt));
+
const userFromDB = (
+
await db
+
.select({ totalTakesTime: usersTable.totalTakesTime })
+
.from(usersTable)
+
.where(eq(usersTable.id, userId))
+
)[0];
-
const takeTimeMs = takes.reduce(
-
(acc, take) => acc + take.elapsedTimeMs * Number(take.multiplier),
-
0,
-
);
-
const takeTime = prettyPrintTime(takeTimeMs);
+
const takeTime = prettyPrintTime(userFromDB?.totalTakesTime || 0);
return {
text: `You have logged ${takeTime} of takes!`,
···
},
value: "history",
action_id: "takes_history",
+
},
+
{
+
type: "button",
+
text: {
+
type: "plain_text",
+
text: "⚙️ Settings",
+
emoji: true,
+
},
+
value: "settings",
+
action_id: "takes_settings",
},
{
type: "button",
+260
src/features/takes/handlers/settings.ts
···
+
import type { UploadedFile } from "slack-edge";
+
import { slackApp, slackClient } from "../../../index";
+
import { db } from "../../../libs/db";
+
import { eq } from "drizzle-orm";
+
import { users as usersTable } from "../../../libs/schema";
+
import {
+
getHackatimeApiUrl,
+
getHackatimeName,
+
getHackatimeVersion,
+
HACKATIME_VERSIONS,
+
type HackatimeVersion,
+
} from "../../../libs/hackatime";
+
+
export async function handleSettings(
+
triggerID: string,
+
user: string,
+
prefill = false,
+
) {
+
let initialValues: {
+
project_name: string;
+
project_description: string;
+
repo_link: string | undefined;
+
demo_link: string | undefined;
+
hackatime_version: string;
+
} = {
+
project_name: "",
+
project_description: "",
+
repo_link: undefined,
+
demo_link: undefined,
+
hackatime_version: "v2",
+
};
+
+
if (prefill) {
+
try {
+
// Check if user already has a project in the database
+
const existingUser = (
+
await db
+
.select()
+
.from(usersTable)
+
.where(eq(usersTable.id, user))
+
)[0];
+
+
if (existingUser) {
+
initialValues = {
+
project_name: existingUser.projectName,
+
project_description: existingUser.projectDescription,
+
repo_link: existingUser.repoLink || undefined,
+
demo_link: existingUser.demoLink || undefined,
+
hackatime_version: getHackatimeVersion(
+
existingUser.hackatimeBaseAPI,
+
),
+
};
+
}
+
} catch (error) {
+
console.error("Error prefilling form:", error);
+
}
+
}
+
+
await slackClient.views.open({
+
trigger_id: triggerID,
+
view: {
+
type: "modal",
+
title: {
+
type: "plain_text",
+
text: "Setup Project",
+
},
+
submit: {
+
type: "plain_text",
+
text: "Submit",
+
},
+
clear_on_close: true,
+
callback_id: "takes_setup_submit",
+
blocks: [
+
{
+
type: "input",
+
block_id: "project_name",
+
label: {
+
type: "plain_text",
+
text: "Project Name",
+
},
+
element: {
+
type: "plain_text_input",
+
action_id: "project_name_input",
+
initial_value: initialValues.project_name || "",
+
placeholder: {
+
type: "plain_text",
+
text: "Enter your project name",
+
},
+
},
+
},
+
{
+
type: "input",
+
block_id: "project_description",
+
label: {
+
type: "plain_text",
+
text: "Project Description",
+
},
+
element: {
+
type: "plain_text_input",
+
action_id: "project_description_input",
+
multiline: true,
+
initial_value: initialValues.project_description || "",
+
placeholder: {
+
type: "plain_text",
+
text: "Describe your project",
+
},
+
},
+
},
+
{
+
type: "input",
+
block_id: "project_banner",
+
label: {
+
type: "plain_text",
+
text: `Banner Image${prefill ? " (this will replace your current banner)" : ""}`,
+
},
+
element: {
+
type: "file_input",
+
action_id: "project_banner_input",
+
},
+
optional: prefill,
+
},
+
{
+
type: "input",
+
block_id: "repo_link",
+
optional: true,
+
label: {
+
type: "plain_text",
+
text: "Repository Link",
+
},
+
element: {
+
type: "plain_text_input",
+
action_id: "repo_link_input",
+
initial_value: initialValues.repo_link || "",
+
placeholder: {
+
type: "plain_text",
+
text: "Optional: Add a link to your repository",
+
},
+
},
+
},
+
{
+
type: "input",
+
block_id: "demo_link",
+
optional: true,
+
label: {
+
type: "plain_text",
+
text: "Demo Link",
+
},
+
element: {
+
type: "plain_text_input",
+
action_id: "demo_link_input",
+
initial_value: initialValues.demo_link || "",
+
placeholder: {
+
type: "plain_text",
+
text: "Optional: Add a link to your demo",
+
},
+
},
+
},
+
{
+
type: "input",
+
block_id: "hackatime_version",
+
label: {
+
type: "plain_text",
+
text: "Hackatime Version",
+
},
+
element: {
+
type: "static_select",
+
action_id: "hackatime_version_input",
+
initial_option: {
+
text: {
+
type: "plain_text",
+
text: getHackatimeName(
+
initialValues.hackatime_version as HackatimeVersion,
+
),
+
},
+
value: initialValues.hackatime_version,
+
},
+
options: Object.values(HACKATIME_VERSIONS).map((v) => ({
+
text: {
+
type: "plain_text",
+
text: getHackatimeName(v.id),
+
},
+
value: v.id,
+
})),
+
},
+
},
+
],
+
},
+
});
+
}
+
+
export async function setupSubmitListener() {
+
slackApp.view("takes_setup_submit", async ({ payload, body }) => {
+
if (payload.type !== "view_submission") return;
+
const values = payload.view.state.values;
+
const userId = body.user.id;
+
+
const file = values.project_banner?.project_banner_input
+
?.files?.[0] as UploadedFile;
+
try {
+
// If file is already public, use it directly
+
const fileData = file.is_public
+
? file
+
: (
+
await slackClient.files.sharedPublicURL({
+
file: file.id,
+
token: process.env.SLACK_USER_TOKEN,
+
})
+
).file;
+
+
const html = await (
+
await fetch(fileData?.permalink_public as string)
+
).text();
+
const projectBannerUrl = html.match(
+
/https:\/\/files.slack.com\/files-pri\/[^"]+pub_secret=([^"&]*)/,
+
)?.[0];
+
+
const hackatimeVersion = values.hackatime_version
+
?.hackatime_version_input?.selected_option
+
?.value as HackatimeVersion;
+
+
await db
+
.insert(usersTable)
+
.values({
+
id: userId,
+
projectName: values.project_name?.project_name_input
+
?.value as string,
+
projectDescription: values.project_description
+
?.project_description_input?.value as string,
+
projectBannerUrl,
+
repoLink: values.project_link?.repo_link?.value as
+
| string
+
| undefined,
+
demoLink: values.project_link?.demo_link?.value as
+
| string
+
| undefined,
+
hackatimeBaseAPI: getHackatimeApiUrl(hackatimeVersion),
+
})
+
.onConflictDoUpdate({
+
target: usersTable.id,
+
set: {
+
projectName: values.project_name?.project_name_input
+
?.value as string,
+
projectDescription: values.project_description
+
?.project_description_input?.value as string,
+
projectBannerUrl,
+
repoLink: values.repo_link?.repo_link_input?.value as
+
| string
+
| undefined,
+
demoLink: values.demo_link?.demo_link_input?.value as
+
| string
+
| undefined,
+
hackatimeBaseAPI: getHackatimeApiUrl(hackatimeVersion),
+
},
+
});
+
} catch (error) {
+
console.error("Error processing file:", error);
+
throw error;
+
}
+
});
+
}
-115
src/features/takes/handlers/setup.ts
···
-
import type { UploadedFile } from "slack-edge";
-
import { slackApp, slackClient } from "../../../index";
-
import { db } from "../../../libs/db";
-
import { users as usersTable } from "../../../libs/schema";
-
-
export async function handleSetup(triggerID: string) {
-
await slackClient.views.open({
-
trigger_id: triggerID,
-
view: {
-
type: "modal",
-
title: {
-
type: "plain_text",
-
text: "Setup Project",
-
},
-
submit: {
-
type: "plain_text",
-
text: "Submit",
-
},
-
clear_on_close: true,
-
callback_id: "takes_setup_submit",
-
blocks: [
-
{
-
type: "input",
-
block_id: "project_name",
-
label: {
-
type: "plain_text",
-
text: "Project Name",
-
},
-
element: {
-
type: "plain_text_input",
-
action_id: "project_name_input",
-
placeholder: {
-
type: "plain_text",
-
text: "Enter your project name",
-
},
-
},
-
},
-
{
-
type: "input",
-
block_id: "project_description",
-
label: {
-
type: "plain_text",
-
text: "Project Description",
-
},
-
element: {
-
type: "plain_text_input",
-
action_id: "project_description_input",
-
multiline: true,
-
placeholder: {
-
type: "plain_text",
-
text: "Describe your project",
-
},
-
},
-
},
-
{
-
type: "input",
-
block_id: "project_banner",
-
label: {
-
type: "plain_text",
-
text: "Project Banner Image",
-
},
-
element: {
-
type: "file_input",
-
action_id: "project_banner_input",
-
},
-
},
-
],
-
},
-
});
-
}
-
-
export async function setupSubmitListener() {
-
slackApp.view(
-
"takes_setup_submit",
-
async () => Promise.resolve(),
-
async ({ payload, body }) => {
-
if (payload.type !== "view_submission") return;
-
const values = payload.view.state.values;
-
const userId = body.user.id;
-
-
const file = values.project_banner?.project_banner_input
-
?.files?.[0] as UploadedFile;
-
try {
-
// If file is already public, use it directly
-
const fileData = file.is_public
-
? file
-
: (
-
await slackClient.files.sharedPublicURL({
-
file: file.id,
-
token: process.env.SLACK_USER_TOKEN,
-
})
-
).file;
-
-
const html = await (
-
await fetch(fileData?.permalink_public as string)
-
).text();
-
const projectBannerUrl = html.match(
-
/https:\/\/files.slack.com\/files-pri\/[^"]+pub_secret=([^"&]*)/,
-
)?.[0];
-
-
await db.insert(usersTable).values({
-
id: userId,
-
projectName: values.project_name?.project_name_input
-
?.value as string,
-
projectDescription: values.project_description
-
?.project_description_input?.value as string,
-
projectBannerUrl,
-
});
-
} catch (error) {
-
console.error("Error processing file:", error);
-
throw error;
-
}
-
},
-
);
-
}
+34 -3
src/features/takes/services/upload.ts src/features/takes/handlers/upload.ts
···
await slackClient.chat.postMessage({
channel: payload.channel,
thread_ts: payload.ts,
-
text: "we don't have a project for you; set one up in the web ui or by running `/takes`",
+
text: "We don't have a project for you; set one up by clicking the button below or by running `/takes`",
+
blocks: [
+
{
+
type: "section",
+
text: {
+
type: "mrkdwn",
+
text: "We don't have a project for you; set one up by clicking the button below or by running `/takes`",
+
},
+
},
+
{
+
type: "actions",
+
elements: [
+
{
+
type: "button",
+
text: {
+
type: "plain_text",
+
text: "setup your project",
+
},
+
action_id: "takes_setup",
+
},
+
],
+
},
+
{
+
type: "context",
+
elements: [
+
{
+
type: "plain_text",
+
text: "don't forget to resend your update after setting up your project!",
+
},
+
],
+
},
+
],
});
return;
}
···
const timeSpentMs = 60000;
await db.insert(takesTable).values({
-
id: payload.ts,
+
id: Bun.randomUUIDv7(),
userId: user,
ts: payload.ts,
notes: markdownText,
···
type: "section",
text: {
type: "mrkdwn",
-
text: `:inbox_tray: saved! ${mediaUrls.length > 0 ? "uploaded media and " : ""}saved your notes`,
+
text: `:inbox_tray: ${mediaUrls.length > 0 ? "uploaded media and " : ""}saved your notes!`,
},
},
],
+68 -49
src/features/takes/setup/actions.ts
···
import handleHelp from "../handlers/help";
import { handleHistory } from "../handlers/history";
import handleHome from "../handlers/home";
-
import { setupSubmitListener } from "../handlers/setup";
-
import upload from "../services/upload";
+
import { handleSettings, setupSubmitListener } from "../handlers/settings";
+
import upload from "../handlers/upload";
import type { MessageResponse } from "../types";
import * as Sentry from "@sentry/bun";
export default function setupActions() {
// Handle button actions
-
slackApp.action(/^takes_(\w+)$/, async ({ payload, context }) => {
-
try {
-
const userId = payload.user.id;
-
const actionId = payload.actions[0]?.action_id as string;
-
const command = actionId.replace("takes_", "");
+
slackApp.action(
+
/^takes_(\w+)$/,
+
async () => Promise.resolve(),
+
async ({ payload, context }) => {
+
try {
+
const userId = payload.user.id;
+
const actionId = payload.actions[0]?.action_id as string;
+
const command = actionId.replace("takes_", "");
-
let response: MessageResponse | undefined;
+
let response: MessageResponse | undefined;
-
// Route to the appropriate handler function
-
switch (command) {
-
case "history":
-
response = await handleHistory(userId);
-
break;
-
case "help":
-
response = await handleHelp();
-
break;
-
case "home":
-
response = await handleHome(userId);
-
break;
-
default:
-
response = await handleHome(userId);
-
break;
-
}
+
// Route to the appropriate handler function
+
switch (command) {
+
case "history":
+
response = await handleHistory(userId);
+
break;
+
case "help":
+
response = await handleHelp();
+
break;
+
case "home":
+
response = await handleHome(userId);
+
break;
+
case "settings":
+
await handleSettings(
+
context.triggerId as string,
+
userId,
+
true,
+
);
+
if (context.respond)
+
await context.respond({ delete_original: true });
+
return;
+
case "setup":
+
await handleSettings(
+
context.triggerId as string,
+
userId,
+
);
+
return;
+
default:
+
response = await handleHome(userId);
+
break;
+
}
-
// Send the response
-
if (response && context.respond) {
-
await context.respond(response);
-
}
-
} catch (error) {
-
if (error instanceof Error)
-
blog(
-
`Error in \`${payload.actions[0]?.action_id}\` action: ${error.message}`,
-
"error",
-
);
-
-
// Capture the error in Sentry
-
Sentry.captureException(error, {
-
extra: {
-
actionId: payload.actions[0]?.action_id,
-
userId: payload.user.id,
-
channelId: context.channelId,
-
},
-
});
+
// Send the response
+
if (response && context.respond) {
+
await context.respond(response);
+
}
+
} catch (error) {
+
if (error instanceof Error)
+
blog(
+
`Error in \`${payload.actions[0]?.action_id}\` action: ${error.message}`,
+
"error",
+
);
-
// Respond with error message to user
-
if (context.respond) {
-
await context.respond({
-
text: "An error occurred while processing your request. Please stand by while we try to put out the fire.",
-
response_type: "ephemeral",
+
// Capture the error in Sentry
+
Sentry.captureException(error, {
+
extra: {
+
actionId: payload.actions[0]?.action_id,
+
userId: payload.user.id,
+
channelId: context.channelId,
+
},
});
+
+
// Respond with error message to user
+
if (context.respond) {
+
await context.respond({
+
text: "An error occurred while processing your request. Please stand by while we try to put out the fire.",
+
response_type: "ephemeral",
+
});
+
}
}
-
}
-
});
+
},
+
);
// setup the upload actions
try {
+10 -2
src/features/takes/setup/commands.ts
···
import { db } from "../../../libs/db";
import { users as usersTable } from "../../../libs/schema";
import { eq } from "drizzle-orm";
-
import { handleSetup } from "../handlers/setup";
+
import { handleSettings } from "../handlers/settings";
export default function setupCommands() {
// Main command handler
slackApp.command(
environment === "dev" ? "/takes-dev" : "/takes",
+
async () => Promise.resolve(),
async ({ payload, context }): Promise<void> => {
try {
const userId = payload.user_id;
···
.where(eq(usersTable.id, userId));
if (userFromDB.length === 0) {
-
await handleSetup(context.triggerId as string);
+
await handleSettings(context.triggerId as string, userId);
return;
}
···
case "help":
response = await handleHelp();
break;
+
case "settings":
+
await handleSettings(
+
context.triggerId as string,
+
userId,
+
true,
+
);
+
return;
default:
response = await handleHome(userId);
break;
+49
src/libs/hackatime.ts
···
+
/**
+
* Maps Hackatime version identifiers to their corresponding data
+
*/
+
export const HACKATIME_VERSIONS = {
+
v1: {
+
id: "v1",
+
name: "Hackatime",
+
apiUrl: "https://waka.hackclub.com/api",
+
},
+
v2: {
+
id: "v2",
+
name: "Hackatime v2",
+
apiUrl: "https://hackatime.hackclub.com/api",
+
},
+
} as const;
+
+
export type HackatimeVersion = keyof typeof HACKATIME_VERSIONS;
+
+
/**
+
* Converts a Hackatime version identifier to its full API URL
+
* @param version The version identifier (v1 or v2)
+
* @returns The corresponding API URL
+
*/
+
export function getHackatimeApiUrl(version: HackatimeVersion): string {
+
return HACKATIME_VERSIONS[version].apiUrl;
+
}
+
+
/**
+
* Gets the fancy name for a Hackatime version
+
* @param version The version identifier (v1 or v2)
+
* @returns The fancy display name for the version
+
*/
+
export function getHackatimeName(version: HackatimeVersion): string {
+
return HACKATIME_VERSIONS[version].name;
+
}
+
+
/**
+
* Determines which Hackatime version is being used based on the API URL
+
* @param apiUrl The full Hackatime API URL
+
* @returns The version identifier (v1 or v2), defaulting to v2 if not recognized
+
*/
+
export function getHackatimeVersion(apiUrl: string): HackatimeVersion {
+
for (const [version, data] of Object.entries(HACKATIME_VERSIONS)) {
+
if (apiUrl === data.apiUrl) {
+
return version as HackatimeVersion;
+
}
+
}
+
return "v2";
+
}
+8 -2
src/libs/schema.ts
···
});
export const users = pgTable("users", {
-
id: text("id").primaryKey(),
+
id: text("id")
+
.primaryKey()
+
.$defaultFn(() => Bun.randomUUIDv7()),
totalTakesTime: integer("total_takes_time").default(0).notNull(),
hackatimeKeys: text("hackatime_keys").notNull().default("[]"),
projectName: text("project_name").notNull().default(""),
projectDescription: text("project_description").notNull().default(""),
projectBannerUrl: text("project_banner_url").notNull().default(""),
-
usingHackatimeV2: boolean().notNull().default(true),
+
hackatimeBaseAPI: text("hackatime_base_api")
+
.notNull()
+
.default("https://hackatime.hackclub.com/api"),
+
repoLink: text("repo_link"),
+
demoLink: text("demo_link"),
});
export async function setupTriggers(pool: Pool) {