Skip to content
Closed
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
19 changes: 10 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,16 @@ The central monorepo for club operations and digital infrastructure.

## Workspace layout

| Path | Contents |
| --- | --- |
| `sites/mainweb` | Public club site |
| `sites/hacklytics2027` | Hacklytics 2027 event site (static export) |
| `packages/db` | Drizzle schema, client, seed script |
| `packages/api` | tRPC routers |
| `packages/auth` | NextAuth configuration |
| `packages/ui`, `packages/consts` | Shared components and constants |
| `tooling/*` | Shared eslint / tailwind / tsconfig |
| Path | Contents |
| -------------------------------- | ------------------------------------------ |
| `apps/dsgt-slack` | Custom Slack Bolt bot (`@dsgt`) |
| `sites/mainweb` | Public club site |
| `sites/hacklytics2027` | Hacklytics 2027 event site (static export) |
| `packages/db` | Drizzle schema, client, seed script |
| `packages/api` | tRPC routers |
| `packages/auth` | NextAuth configuration |
| `packages/ui`, `packages/consts` | Shared components and constants |
| `tooling/*` | Shared eslint / tailwind / tsconfig |

## Database

Expand Down
19 changes: 19 additions & 0 deletions apps/dsgt-slack/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Copy to .env and fill in values from https://api.slack.com/apps
# Never commit .env or real tokens.

# Bot User OAuth Token (xoxb-...) from OAuth & Permissions after install
SLACK_BOT_TOKEN=xoxb-your-bot-token

# Signing Secret from Basic Information
SLACK_SIGNING_SECRET=your-signing-secret

# App-level token (xapp-...) from Basic Information → App-Level Tokens
# Required for Socket Mode. Create a token with the connections:write scope.
SLACK_APP_TOKEN=xapp-your-app-token

# true (default) uses Socket Mode for local development.
# Set to false to listen on HTTP for the Events API (Request URL: /slack/events).
SLACK_SOCKET_MODE=true

# Used only when SLACK_SOCKET_MODE=false
PORT=3000
116 changes: 116 additions & 0 deletions apps/dsgt-slack/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# dsgt Slack bot

Bolt for JavaScript app for **Data Science @ Georgia Tech (DS@GT)**. In Slack it
appears as **@dsgt**. This is a custom Slack app owned by the club, not the
Cursor Slack integration.

The bot answers `@dsgt` mentions, direct messages, and the `/dsgt` slash
command. It can confirm that it is online (`ping`) and tell people how to join:
the first event is **August 26 (8/26)**, further details will be announced
there, and the club site is datasciencegt.org.

## Create the Slack app from the manifest

1. Open [https://api.slack.com/apps](https://api.slack.com/apps) and sign in to
the workspace that should host the bot (typically the DS@GT Slack).
2. Click **Create New App** → **From an app manifest**.
3. Select the workspace, then paste the contents of
[`manifest.yaml`](./manifest.yaml) (YAML is accepted).
4. Confirm the summary. The app name and bot user display name should both be
`dsgt`. Socket Mode should be enabled.
5. Click **Create**.

You now have a real Slack app whose bot user is **dsgt**.

## Install into a workspace and collect tokens

1. In the app settings sidebar, open **OAuth & Permissions** and click
**Install to Workspace**. Approve the requested bot scopes.
2. Copy the **Bot User OAuth Token** (`xoxb-...`) into `.env` as
`SLACK_BOT_TOKEN`.
3. Open **Basic Information** and copy the **Signing Secret** into `.env` as
`SLACK_SIGNING_SECRET`.
4. On **Basic Information**, under **App-Level Tokens**, click
**Generate Token and Scopes**. Name it something like `socket-mode`, add the
`connections:write` scope, and generate it. Copy the `xapp-...` value into
`.env` as `SLACK_APP_TOKEN`.

Socket Mode is already on in the manifest, so you do not need a public URL for
local development.

## Run locally (Socket Mode)

From the monorepo root:

```bash
cp apps/dsgt-slack/.env.example apps/dsgt-slack/.env
# edit apps/dsgt-slack/.env with the three tokens above

pnpm install
pnpm --filter @query/dsgt-slack dev
```

`pnpm dev` at the repo root also starts this app (turbo `dev`), alongside the
sites.

When the process logs that the bot is running, it is connected to Slack.

### Invite @dsgt to a channel

In the channel:

```text
/invite @dsgt
```

Then mention it (`@dsgt ping`) or run `/dsgt help`. Direct messages work from
the app's **Messages** tab without an invite.

### Quick checks after install

| Action | Expected reply |
| ----------------------------------------- | ----------------------------------------------- |
| `@dsgt ping` or `/dsgt ping` | Health check confirming the bot is online |
| `/dsgt help` | List of topics |
| “How do I join?” in a DM, or `/dsgt join` | August 26 first-event FAQ and datasciencegt.org |

## Host later over HTTP (Events API)

The same process can listen for Slack’s HTTP Events API instead of Socket Mode.
Bolt serves Slack at **`/slack/events`**.

1. Deploy the app with a public HTTPS URL.
2. Set `SLACK_SOCKET_MODE=false` and `PORT` (default `3000`) in the environment.
`SLACK_APP_TOKEN` is not required in this mode.
3. In the Slack app settings:
- Turn **Socket Mode** off.
- **Event Subscriptions** → Request URL:
`https://<your-host>/slack/events`
- **Slash Commands** → `/dsgt` → Request URL:
`https://<your-host>/slack/events`
- **Interactivity & Shortcuts** → Request URL:
`https://<your-host>/slack/events`
4. Start with `pnpm --filter @query/dsgt-slack start`.
5. `GET https://<your-host>/health` should return `ok`.

Reinstall the app if Slack asks you to after changing URLs or scopes.

## Scripts

| Script | Command |
| ----------- | ------------------------------------------- |
| Dev (watch) | `pnpm --filter @query/dsgt-slack dev` |
| Start | `pnpm --filter @query/dsgt-slack start` |
| Lint | `pnpm --filter @query/dsgt-slack lint` |
| Typecheck | `pnpm --filter @query/dsgt-slack typecheck` |
| Test | `pnpm --filter @query/dsgt-slack test` |

Repo-wide `pnpm lint`, `pnpm typecheck`, `pnpm build`, and `pnpm test` include
this package (`pnpm test` runs the join-FAQ unit test along with the existing
vitest targets).

## Environment

See [`.env.example`](./.env.example). Do not commit `.env` or real tokens.
`SLACK_BOT_TOKEN`, `SLACK_SIGNING_SECRET`, and `SLACK_APP_TOKEN` (Socket Mode)
are required to start the process.
12 changes: 12 additions & 0 deletions apps/dsgt-slack/eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { config } from "@query/eslint-config/base";

/** @type {import("eslint").Linter.Config[]} */
export default [
...config,
{
files: ["src/index.ts"],
rules: {
"no-console": "off",
},
},
];
39 changes: 39 additions & 0 deletions apps/dsgt-slack/manifest.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
_metadata:
major_version: 1
minor_version: 1
display_information:
name: dsgt
description: Official Slack bot for Data Science @ Georgia Tech (DS@GT).
background_color: "#003057"
features:
app_home:
home_tab_enabled: false
messages_tab_enabled: true
messages_tab_read_only_enabled: false
bot_user:
display_name: dsgt
always_online: true
slash_commands:
- command: /dsgt
description: Ask the Data Science @ Georgia Tech bot for help.
usage_hint: "[help | ping | join]"
should_escape: false
oauth_config:
scopes:
bot:
- app_mentions:read
- chat:write
- commands
- im:history
- im:read
- im:write
settings:
event_subscriptions:
bot_events:
- app_mention
- message.im
interactivity:
is_enabled: true
org_deploy_enabled: false
socket_mode_enabled: true
token_rotation_enabled: false
30 changes: 30 additions & 0 deletions apps/dsgt-slack/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"name": "@query/dsgt-slack",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"start": "tsx src/index.ts",
"build": "tsc --noEmit",
"lint": "eslint . --max-warnings 0",
"typecheck": "tsc --noEmit",
"test": "vitest run --config vitest.config.ts"
},
"dependencies": {
"@slack/bolt": "^5.0.0",
"dotenv": "^16.6.1",
"undici": "^6.27.0",
"zod": "3.25.53"
},
"devDependencies": {
"@query/eslint-config": "workspace:*",
"@query/tsconfig": "workspace:*",
"@types/express": "^5.0.0",
"@types/node": "^22.15.32",
"eslint": "10.1.0",
"tsx": "^4.21.0",
"typescript": "6.0.2",
"vitest": "^4.1.8"
}
}
69 changes: 69 additions & 0 deletions apps/dsgt-slack/src/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { App } from "@slack/bolt";

import type { SlackEnv } from "./env";
import { replyForSlashCommand, replyForText } from "./replies";

function isDirectMessage(channelType: string | undefined): boolean {
return channelType === "im";
}

export function registerListeners(app: App): void {
app.event("app_mention", async ({ event, say }) => {
await say({
text: replyForText(event.text),
thread_ts: event.thread_ts,
});
});

app.message(async ({ message, say }) => {
if (message.subtype !== undefined) {
return;
}
if (!("text" in message) || typeof message.text !== "string") {
return;
}
if ("bot_id" in message && message.bot_id) {
return;
}
if (!isDirectMessage(message.channel_type)) {
return;
}

await say(replyForText(message.text));
});

app.command("/dsgt", async ({ command, ack, respond }) => {
await ack();
await respond({
text: replyForSlashCommand(command.text),
response_type: "ephemeral",
});
});
}

export function createBoltApp(env: SlackEnv): App {
const app = env.socketMode
? new App({
token: env.botToken,
signingSecret: env.signingSecret,
socketMode: true,
appToken: env.appToken,
})
: new App({
token: env.botToken,
signingSecret: env.signingSecret,
customRoutes: [
{
path: "/health",
method: ["GET"],
handler: (_req, res) => {
res.writeHead(200, { "Content-Type": "text/plain" });
res.end("ok");
},
},
],
});

registerListeners(app);
return app;
}
51 changes: 51 additions & 0 deletions apps/dsgt-slack/src/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { z } from "zod";

const truthy = (value: string | undefined) =>
value === undefined || value.toLowerCase() !== "false";

const envSchema = z
.object({
SLACK_BOT_TOKEN: z.string().min(1),
SLACK_SIGNING_SECRET: z.string().min(1),
SLACK_APP_TOKEN: z.string().min(1).optional(),
SLACK_SOCKET_MODE: z.string().optional(),
PORT: z.coerce.number().int().positive().default(3000),
})
.superRefine((value, ctx) => {
if (truthy(value.SLACK_SOCKET_MODE) && !value.SLACK_APP_TOKEN) {
ctx.addIssue({
code: "custom",
path: ["SLACK_APP_TOKEN"],
message:
"SLACK_APP_TOKEN is required when Socket Mode is enabled (default).",
});
}
});

export type SlackEnv = {
botToken: string;
signingSecret: string;
appToken: string | undefined;
socketMode: boolean;
port: number;
};

export function loadEnv(
source: Record<string, string | undefined> = process.env,
): SlackEnv {
const parsed = envSchema.parse({
SLACK_BOT_TOKEN: source.SLACK_BOT_TOKEN,
SLACK_SIGNING_SECRET: source.SLACK_SIGNING_SECRET,
SLACK_APP_TOKEN: source.SLACK_APP_TOKEN || undefined,
SLACK_SOCKET_MODE: source.SLACK_SOCKET_MODE,
PORT: source.PORT,
});

return {
botToken: parsed.SLACK_BOT_TOKEN,
signingSecret: parsed.SLACK_SIGNING_SECRET,
appToken: parsed.SLACK_APP_TOKEN,
socketMode: truthy(parsed.SLACK_SOCKET_MODE),
port: parsed.PORT,
};
}
25 changes: 25 additions & 0 deletions apps/dsgt-slack/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import "dotenv/config";

import { createBoltApp } from "./app";
import { loadEnv } from "./env";

async function main(): Promise<void> {
const env = loadEnv();
const app = createBoltApp(env);

if (env.socketMode) {
await app.start();
app.logger.info("dsgt Slack bot is running (Socket Mode)");
return;
}

await app.start(env.port);
app.logger.info(
`dsgt Slack bot is running (HTTP Events API on port ${String(env.port)}; Request URL path is /slack/events)`,
);
}

main().catch((error: unknown) => {
console.error("Failed to start the dsgt Slack bot.", error);
process.exit(1);
});
Loading
Loading