Skip to content

Instantly share code, notes, and snippets.

@huytd
Last active July 2, 2025 03:44
Show Gist options
  • Save huytd/f8a9f1ca3b09db4a0ddc4eac52dd61e5 to your computer and use it in GitHub Desktop.
Save huytd/f8a9f1ca3b09db4a0ddc4eac52dd61e5 to your computer and use it in GitHub Desktop.
import { OpenAI } from "https://deno.land/x/[email protected]/mod.ts";
import { WebClient } from "npm:@slack/web-api";
export function parsePermalink(url: string) {
const u = new URL(url);
const [ , , channel, raw ] = u.pathname.split("/");
const ts = raw.startsWith("p")
? raw.slice(1).replace(/(\d{10})(\d{6})/, "$1.$2")
: raw;
return { channel, ts, maybeThread: u.searchParams.get("thread_ts") ?? undefined };
}
const slack = new WebClient(Deno.env.get("SLACK_BOT_TOKEN")!);
export async function getThreadTs(link: string): Promise<any> {
const { channel, ts, maybeThread } = parsePermalink(link);
if (maybeThread) return maybeThread;
const resp = await slack.conversations.history({
channel,
latest: ts,
inclusive: true,
limit: 1,
});
const message = resp.messages?.[0] as { thread_ts?: string };
if (!message) throw new Error("Message not found");
// Slack uses the parent’s timestamp as the thread ID
return {
channel: channel,
thread_ts: message.thread_ts ?? ts
};
}
export function parseTextOrLink(
raw: string,
): { text: string; url?: string } {
const input = raw.trim();
// Does the string start with an http/https URL?
const match = input.match(/^(https?:\/\/\S+)(?:\s+(.*))?$/);
if (match) {
const [, url, text = ""] = match;
return { url, text: text.trim() };
}
// Plain message – no leading URL
return { text: input };
}
// ---------------------------------------------------------------------------
// Environment + Client setup
// ---------------------------------------------------------------------------
const openai = new OpenAI({
baseURL: "https://openrouter.ai/api/v1",
apiKey: Deno.env.get("OPENAI_API_KEY")!,
});
// Simple KV store for conversation state (use a real database in production)
const conversationStore = new Map<string, any[]>();
// ---------------------------------------------------------------------------
// Slack helpers (markdown ⇄ Slack, reactions, messages, history, etc.)
// ---------------------------------------------------------------------------
function slackifyMarkdown(text: string): string {
return text
.replace(/\*\*(.*?)\*\*/g, "*$1*") // Bold
.replace(/\*(.*?)\*/g, "_$1_") // Italic
.replace(/`(.*?)`/g, "`$1`") // Inline code
.replace(/```([\s\S]*?)```/g, "```$1```"); // Code blocks
}
async function askChatCompletion(messages: any[]): Promise<string | null> {
try {
const completion = await openai.chat.completions.create({
model: "qwen/qwen3-32b:free",
messages,
});
return completion.choices[0]?.message?.content ?? null;
} catch (err) {
console.error("OpenAI API error:", err);
return null;
}
}
async function verifySlackRequest(
body: string,
signature: string | null,
timestamp: string | null,
): Promise<boolean> {
if (!signature || !timestamp) return false;
const signingSecret = Deno.env.get("SLACK_SIGNING_SECRET");
if (!signingSecret) return false;
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
encoder.encode(signingSecret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const baseString = `v0:${timestamp}:${body}`;
const signatureBytes = await crypto.subtle.sign(
"HMAC",
key,
encoder.encode(baseString),
);
const expectedSignature = `v0=${
Array.from(new Uint8Array(signatureBytes))
.map((b) => b.toString(16).padStart(2, "0"))
.join("")
}`;
return signature === expectedSignature;
}
async function sendSlackMessage(
channel: string,
text: string,
threadTs?: string,
): Promise<void> {
const token = Deno.env.get("SLACK_BOT_TOKEN");
if (!token) throw new Error("SLACK_BOT_TOKEN not found");
const payload: Record<string, unknown> = {
channel,
text: slackifyMarkdown(text),
};
if (threadTs) payload.thread_ts = threadTs;
const resp = await fetch("https://slack.com/api/chat.postMessage", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!resp.ok) {
throw new Error(`Slack API error: ${resp.status}`);
}
}
async function addReaction(
channel: string,
timestamp: string,
emoji: string,
): Promise<void> {
const token = Deno.env.get("SLACK_BOT_TOKEN");
if (!token) return;
await fetch("https://slack.com/api/reactions.add", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ channel, name: emoji, timestamp }),
});
}
async function removeReaction(
channel: string,
timestamp: string,
emoji: string,
): Promise<void> {
const token = Deno.env.get("SLACK_BOT_TOKEN");
if (!token) return;
await fetch("https://slack.com/api/reactions.remove", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ channel, name: emoji, timestamp }),
});
}
// ---------------------------------------------------------------------------
// History helpers (channels & threads)
// ---------------------------------------------------------------------------
async function fetchChannelHistory(channel: string, limit = 100): Promise<any[]> {
const token = Deno.env.get("SLACK_BOT_TOKEN");
if (!token) throw new Error("SLACK_BOT_TOKEN not found");
const url = new URL("https://slack.com/api/conversations.history");
url.searchParams.set("channel", channel);
url.searchParams.set("limit", String(limit));
const resp = await fetch(url.toString(), {
headers: { Authorization: `Bearer ${token}` },
});
const data = await resp.json();
if (!data.ok) throw new Error(`Slack API error: ${JSON.stringify(data)}`);
return data.messages;
}
async function fetchThreadReplies(
channel: string,
threadTs: string,
limit = 100,
): Promise<any[]> {
const token = Deno.env.get("SLACK_BOT_TOKEN");
if (!token) throw new Error("SLACK_BOT_TOKEN not found");
const url = new URL("https://slack.com/api/conversations.replies");
url.searchParams.set("channel", channel);
url.searchParams.set("ts", threadTs);
url.searchParams.set("limit", String(limit));
const resp = await fetch(url.toString(), {
headers: { Authorization: `Bearer ${token}` },
});
const data = await resp.json();
if (!data.ok) throw new Error(`Slack API error: ${JSON.stringify(data)}`);
return data.messages;
}
async function summarizeMessages(msgs: any[]): Promise<string> {
// Keep only plain messages (no joins / edits) & oldest-to-newest order
const cleaned = msgs
.filter((m) => m.type === "message" && !m.subtype)
.reverse() // Slack returns newest‑first; we want oldest‑first
.map((m) => `${m.user ? `<@${m.user}>` : "bot"}: ${m.text?.replace(/\n/g, " ")}`)
.join("\n");
const prompt = `
<context>${cleaned}</context>
<request>${msgs[0]?.text ?? ""}</request>`;
const summaryPrompt = [
{
role: "system",
content: `Khi bạn nhìn thấy userId kiểu U..., ví dụ U0GFBENSX, U092925T1AS,... luôn dùng cú pháp <@userId> để tag họ đúng cách. Hãy dùng context sau để trả lời request, chỉ tiếng Việt.`,
},
{
role: "user",
content: prompt,
},
];
const summary = await askChatCompletion(summaryPrompt);
if (!summary) throw new Error("OpenAI summary failed");
return summary;
}
// ---------------------------------------------------------------------------
// Event handlers (messages / mentions) – unchanged
// ---------------------------------------------------------------------------
async function handleSlackEvent(event: any): Promise<void> {
const { type, text, channel, ts, thread_ts } = event;
if (type !== "message" && type !== "app_mention") return;
// Remove bot mention if present
let prompt = text?.replace(/(?:\s)<@[^, ]*|(?:^)<@[^, ]*/, "")?.trim() ?? "";
if (!prompt) return;
await addReaction(channel, ts, "eyes");
try {
const msgs = thread_ts
? await fetchThreadReplies(channel, thread_ts)
: await fetchChannelHistory(channel);
const summary = await summarizeMessages(msgs);
await sendSlackMessage(channel, summary, ts);
} catch (err) {
console.error("Error processing message:", err);
await sendSlackMessage(channel, "Chậm chậm thôi. Rate limit rồi.", ts);
} finally {
await removeReaction(channel, ts, "eyes");
}
}
// ---------------------------------------------------------------------------
// NEW ▶ Slash‑command handler ◀
// ---------------------------------------------------------------------------
async function handleSlashCommand(params: URLSearchParams): Promise<Response> {
const token = Deno.env.get("SLACK_BOT_TOKEN");
if (!token) throw new Error("SLACK_BOT_TOKEN not found");
const command = params.get("command");
if (command !== "/batam") {
return new Response("Unknown slash command", { status: 400 });
}
const channel = params.get("channel_id")!;
const rawText = params.get("text") ?? "";
const parsed = parseTextOrLink(rawText);
if (parsed.url) {
// replying
const {channel, thread_ts} = await getThreadTs(parsed.url);
await sendSlackMessage(channel, `:ninjanup: Reply ẩn danh:
${parsed.text}`, thread_ts);
return new Response('', {
status: 200,
headers: {
"content-type": "text/plain; charset=utf-8",
},
});
}
// 2. Post the bot message asynchronously
const message = {
channel,
text: slackifyMarkdown(`🤫 **Có một bạn giấu tên nhờ Tám lên đây tâm sự với mọi người:**
${parsed.text}`),
};
fetch("https://slack.com/api/chat.postMessage", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(message),
}).catch((err) => console.error("Failed to post message", err));
return new Response("", {
status: 200,
headers: {
"content-type": "text/plain; charset=utf-8",
},
});
}
// ---------------------------------------------------------------------------
// HTTP request handler (for Deno Deploy / Edge runtime)
// ---------------------------------------------------------------------------
export default async function handler(req: Request): Promise<Response> {
//--------------------------------------------------------------------------
// Health‑check shortcut
//--------------------------------------------------------------------------
if (req.method === "GET") {
return new Response("Slack bot is running 🚀", {
status: 200,
headers: { "Content-Type": "text/plain" },
});
}
if (req.method !== "POST") {
return new Response("Method not allowed", { status: 405 });
}
// Read raw body as text (needed both for signature and parsing)
const body = await req.text();
const timestamp = req.headers.get("x-slack-request-timestamp");
const signature = req.headers.get("x-slack-signature");
if (!(await verifySlackRequest(body, signature, timestamp))) {
return new Response("Unauthorized", { status: 401 });
}
//--------------------------------------------------------------------------
// Decide whether this is a slash command (form‑encoded) or event payload (JSON)
//--------------------------------------------------------------------------
const contentType = req.headers.get("content-type") ?? "";
try {
if (contentType.startsWith("application/x-www-form-urlencoded")) {
const params = new URLSearchParams(body);
return await handleSlashCommand(params);
}
// Otherwise assume JSON (events API / url verification)
const payload = JSON.parse(body);
if (payload.type === "url_verification") {
return new Response(payload.challenge, {
headers: { "Content-Type": "text/plain" },
});
}
if (payload.type === "event_callback" && payload.event) {
// Fire‑and‑forget so we can immediately return 200 OK
handleSlackEvent(payload.event).catch(console.error);
}
return new Response("OK", { status: 200 });
} catch (err) {
console.error("Unhandled error:", err);
return new Response("Internal Server Error", { status: 500 });
}
}
// For Deno Deploy
Deno.serve(handler);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment