a fun bot for the hc slack

feat: add basic slackbot

dunkirk.sh 81da9860 7ee6700a

verified
+16
bun.lock
···
"workspaces": {
"": {
"name": "takes",
+
"dependencies": {
+
"bottleneck": "^2.19.5",
+
"colors": "^1.4.0",
+
"slack-edge": "^1.3.7",
+
"yaml": "^2.7.1",
+
},
"devDependencies": {
"@types/bun": "latest",
},
···
"@types/node": ["@types/node@22.13.17", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-nAJuQXoyPj04uLgu+obZcSmsfOenUg6DxPKogeUy6yNCFwWaj5sBF8/G/pNo8EtBJjAfSVgfIlugR/BCOleO+g=="],
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
+
+
"bottleneck": ["bottleneck@2.19.5", "", {}, "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw=="],
"bun-types": ["bun-types@1.2.7", "", { "dependencies": { "@types/node": "*", "@types/ws": "*" } }, "sha512-P4hHhk7kjF99acXqKvltyuMQ2kf/rzIw3ylEDpCxDS9Xa0X0Yp/gJu/vDCucmWpiur5qJ0lwB2bWzOXa2GlHqA=="],
+
"colors": ["colors@1.4.0", "", {}, "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA=="],
+
+
"slack-edge": ["slack-edge@1.3.7", "", { "dependencies": { "slack-web-api-client": "^1.1.5" } }, "sha512-BI+V8WTlaMQmUkBmyJoJ8PDykf6GoJQiCeExkfJ1H6l8Za4Wuv0sM+oV4sOjLgS06+AvOKvya9FgBpcuAKGoAA=="],
+
+
"slack-web-api-client": ["slack-web-api-client@1.1.5", "", {}, "sha512-YmGGg3uU7tgW8djO2yn+xXgnkq5M1XeWYGODuDCwMbtr6OOJ5ys08Ju68XzadCSZNFqDKKSs31VSZKWJqb4KhA=="],
+
"typescript": ["typescript@5.8.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ=="],
"undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
+
+
"yaml": ["yaml@2.7.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ=="],
}
}
+31
manifest.yaml
···
+
display_information:
+
name: Smokey
+
description: Only you can complete your takes
+
background_color: "#617c68"
+
features:
+
app_home:
+
home_tab_enabled: true
+
messages_tab_enabled: false
+
messages_tab_read_only_enabled: true
+
bot_user:
+
display_name: smokey
+
always_online: false
+
slash_commands:
+
- command: /takes
+
url: https://casual-renewing-reptile.ngrok-free.app/slack
+
description: Start a takes session
+
should_escape: true
+
oauth_config:
+
scopes:
+
bot:
+
- commands
+
- users:read
+
- chat:write.public
+
- chat:write
+
settings:
+
interactivity:
+
is_enabled: true
+
request_url: https://casual-renewing-reptile.ngrok-free.app/slack
+
org_deploy_enabled: false
+
socket_mode_enabled: false
+
token_rotation_enabled: false
+12
package.json
···
{
"name": "takes",
+
"description": "smokey says hi!",
+
"version": "0.0.0",
"module": "src/index.ts",
"type": "module",
"private": true,
+
"scripts": {
+
"dev": "bun run --watch src/index.ts",
+
"ngrok": "ngrok http 3000 --domain=casual-renewing-reptile.ngrok-free.app"
+
},
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5"
+
},
+
"dependencies": {
+
"bottleneck": "^2.19.5",
+
"colors": "^1.4.0",
+
"slack-edge": "^1.3.7",
+
"yaml": "^2.7.1"
}
}
+9
src/features/example.ts
···
+
import { slackApp } from "../index";
+
+
const example = async () => {
+
slackApp.action("example_action", async ({ context, payload }) => {
+
console.log("Example Action", payload);
+
});
+
};
+
+
export default example;
+1
src/features/index.ts
···
+
export { default as example } from "./example";
+82 -1
src/index.ts
···
-
console.log("Hello via Bun!");
+
import { SlackApp } from "slack-edge";
+
+
import * as features from "./features/index";
+
+
import { t, t_fetch } from "./libs/template";
+
import { blog } from "./libs/Logger";
+
import { version, name } from "../package.json";
+
const environment = process.env.NODE_ENV;
+
+
// Check required environment variables
+
const requiredVars = ["SLACK_BOT_TOKEN", "SLACK_SIGNING_SECRET"] as const;
+
const missingVars = requiredVars.filter((varName) => !process.env[varName]);
+
+
if (missingVars.length > 0) {
+
throw new Error(
+
`Missing required environment variables: ${missingVars.join(", ")}`,
+
);
+
}
+
+
console.log(
+
`----------------------------------\n${name} Server\n----------------------------------\n`,
+
);
+
console.log(`🏗️ Starting ${name}...`);
+
console.log("📦 Loading Slack App...");
+
console.log("🔑 Loading environment variables...");
+
+
const slackApp = new SlackApp({
+
env: {
+
SLACK_BOT_TOKEN: process.env.SLACK_BOT_TOKEN!,
+
SLACK_SIGNING_SECRET: process.env.SLACK_SIGNING_SECRET!,
+
SLACK_LOGGING_LEVEL: "INFO",
+
},
+
startLazyListenerAfterAck: true,
+
});
+
const slackClient = slackApp.client;
+
+
console.log(`⚒️ Loading ${Object.entries(features).length} features...`);
+
for (const [feature, handler] of Object.entries(features)) {
+
console.log(`📦 ${feature} loaded`);
+
if (typeof handler === "function") {
+
handler();
+
}
+
}
+
+
export default {
+
port: process.env.PORT || 3000,
+
async fetch(request: Request) {
+
const url = new URL(request.url);
+
const path = url.pathname;
+
+
switch (path) {
+
case "/":
+
return new Response(`Hello World from ${name}@${version}`);
+
case "/health":
+
return new Response("OK");
+
case "/slack":
+
return slackApp.run(request);
+
default:
+
return new Response("404 Not Found", { status: 404 });
+
}
+
},
+
};
+
+
console.log(
+
`🚀 Server Started in ${
+
Bun.nanoseconds() / 1000000
+
} milliseconds on version: ${version}!\n\n----------------------------------\n`,
+
);
+
+
blog(
+
t("app.startup", {
+
environment,
+
}),
+
"start",
+
{
+
channel: process.env.SLACK_SPAM_CHANNEL || "",
+
},
+
);
+
+
console.log("\n----------------------------------\n");
+
+
export { slackApp, slackClient, version, name, environment };
+99
src/libs/Logger.ts
···
+
import { slackClient } from "../index";
+
+
import Bottleneck from "bottleneck";
+
import Queue from "./queue";
+
+
import colors from "colors";
+
import type {
+
ChatPostMessageRequest,
+
ChatPostMessageResponse,
+
} from "slack-edge";
+
+
// Create a rate limiter with Bottleneck
+
const limiter = new Bottleneck({
+
minTime: 1000, // 1 second between each request
+
});
+
+
const messageQueue = new Queue();
+
+
function sendMessage(
+
message: ChatPostMessageRequest,
+
): Promise<ChatPostMessageResponse> {
+
return limiter.schedule(() => slackClient.chat.postMessage(message));
+
}
+
+
async function slog(
+
logMessage: string,
+
location?: {
+
thread_ts?: string;
+
channel: string;
+
},
+
): Promise<void> {
+
const message: ChatPostMessageRequest = {
+
channel: location?.channel || process.env.SLACK_LOG_CHANNEL || "",
+
thread_ts: location?.thread_ts,
+
text: logMessage.substring(0, 2500),
+
blocks: [
+
{
+
type: "section",
+
text: {
+
type: "mrkdwn",
+
text: logMessage
+
.split("\n")
+
.map((a) => `> ${a}`)
+
.join("\n"),
+
},
+
},
+
{
+
type: "context",
+
elements: [
+
{
+
type: "mrkdwn",
+
text: `${new Date().toString()}`,
+
},
+
],
+
},
+
],
+
};
+
+
messageQueue.enqueue(() => sendMessage(message));
+
}
+
+
type LogType = "info" | "start" | "cron" | "error";
+
+
export async function clog(logMessage: string, type: LogType): Promise<void> {
+
switch (type) {
+
case "info":
+
console.log(colors.blue(logMessage));
+
break;
+
case "start":
+
console.log(colors.green(logMessage));
+
break;
+
case "cron":
+
console.log(colors.magenta(`[CRON]: ${logMessage}`));
+
break;
+
case "error":
+
console.error(
+
colors.red.bold(
+
`Yo <@S0790GPRA48> deres an error \n\n [ERROR]: ${logMessage}`,
+
),
+
);
+
break;
+
default:
+
console.log(logMessage);
+
}
+
}
+
+
export async function blog(
+
logMessage: string,
+
type: LogType,
+
location?: {
+
thread_ts?: string;
+
channel: string;
+
},
+
): Promise<void> {
+
slog(logMessage, location);
+
clog(logMessage, type);
+
}
+
+
export { clog as default, slog };
+23
src/libs/queue.ts
···
+
export default class Queue {
+
private jobs: (() => void)[] = [];
+
private isProcessing = false;
+
+
enqueue(job: () => void) {
+
this.jobs.push(job);
+
if (!this.isProcessing) {
+
this.processQueue();
+
}
+
}
+
+
private processQueue() {
+
if (this.jobs.length > 0) {
+
const job = this.jobs.shift();
+
if (job) {
+
this.isProcessing = true;
+
job();
+
this.isProcessing = false;
+
this.processQueue();
+
}
+
}
+
}
+
}
+56
src/libs/template.ts
···
+
import { parse } from "yaml";
+
+
type template = "app.startup";
+
+
interface data {
+
environment?: string;
+
}
+
+
const file = await Bun.file("src/libs/templates.yaml").text();
+
const templatesRaw = parse(file);
+
+
function flatten(obj: Record<string, unknown>, prefix = "") {
+
let result: Record<string, unknown> = {};
+
+
for (const key in obj) {
+
if (typeof obj[key] === "object" && Array.isArray(obj[key]) === false) {
+
result = {
+
...result,
+
...flatten(
+
obj[key] as Record<string, unknown>,
+
`${prefix}${key}.`,
+
),
+
};
+
} else {
+
result[`${prefix}${key}`] = obj[key];
+
}
+
}
+
+
return result;
+
}
+
+
const templates = flatten(templatesRaw);
+
+
export function t(template: template, data: data) {
+
return t_format(t_fetch(template), data);
+
}
+
+
export function t_fetch(template: template) {
+
return Array.isArray(templates[template])
+
? (randomChoice(templates[template]) as string)
+
: (templates[template] as string);
+
}
+
+
export function t_format(template: string, data: data) {
+
return template.replace(
+
/\${(.*?)}/g,
+
(_, key) => data[key as keyof data] ?? "",
+
);
+
}
+
+
export function randomChoice<T>(arr: T[]): T {
+
if (arr.length === 0) {
+
throw new Error("Cannot get random choice from empty array");
+
}
+
return arr[Math.floor(Math.random() * arr.length)]!;
+
}
+12
src/libs/templates.yaml
···
+
app:
+
startup:
+
- "Remember friends, only YOU can prevent server outages! :bear: The environment *${environment}* is safe and secure! :evergreen_tree:"
+
- "Howdy campers! Your friendly forest guardian here, keeping watch over environment *${environment}*! :camping:"
+
- "Time to pitch our tents in *${environment}*! All systems operational! :tent:"
+
- "Trail status for *${environment}*: Clear skies ahead! :sunny:"
+
- "Forest ranger checking in! *${environment}* is looking mighty fine today! :mountain:"
+
- "The campfire is lit and *${environment}* is warming up nicely! :fire:"
+
- "Happy trails! Your *${environment}* environment is ready for adventure! :hiking_boot:"
+
- "Welcome to *${environment}* National Park! All systems are go! :national_park:"
+
- "Ranger station report: *${environment}* is operating at peak performance! :mountain_snow:"
+
- "Good morning from the wilderness of *${environment}*! Everything's running smoothly! :sunrise_over_mountains:"