Skip to content

Instantly share code, notes, and snippets.

@huytd
Last active June 21, 2025 06:42
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";
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 database in production)
const conversationStore = new Map<string, any[]>();
// ---------------------------------------------------------------------------
// Slack helpers
// ---------------------------------------------------------------------------
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-14b: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,
timestamp: string,
): Promise<boolean> {
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 }),
});
}
// ---------------------------------------------------------------------------
// NEW: Fetching helpers for history / thread replies
// ---------------------------------------------------------------------------
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)
.map((m) =>
`${m.user ? `<@${m.user}>` : "bot"}: ${m.text?.replace(/\n/g, " ")}`
)
.join("\n");
const summaryPrompt = [
{
role: "system",
content:
"Tóm tắt nội dung cuộc hội thoại theo phong cách của một bà tám nhiều chuyện. Bản tóm tắt nên ngắn gọn, không dài dòng, nhưng phải bám sát nội dung cuộc hội thoại." +
"When you see userId like U..., for example U0GFBENSX, U092925T1AS,... always use the syntax <@userId> for example <@U092925T1AS>, so they can be mentioned properly.",
},
{
role: "user",
content: cleaned,
},
];
const summary = await askChatCompletion(summaryPrompt);
if (!summary) throw new Error("OpenAI summary failed");
return summary;
}
// ---------------------------------------------------------------------------
// Event handler
// ---------------------------------------------------------------------------
async function handleSlackEvent(event: any): Promise<void> {
const { type, text, user, channel, ts, thread_ts } = event;
console.log("App event", type);
if (type !== "message" && type !== "app_mention") return;
// Strip bot mention if present
let prompt =
text?.replace(/(?:\s)<@[^, ]*|(?:^)<@[^, ]*/, "")?.trim() ?? "";
if (!prompt) {
console.log("No prompt found");
return;
}
// -----------------------------------------------------------------------
// NEW: Summarisation keyword check
// -----------------------------------------------------------------------
const lowered = prompt.toLowerCase();
const isSummarise = lowered === "summarize" || lowered === "tóm tắt";
await addReaction(channel, ts, "eyes");
try {
if (isSummarise) {
// Decide whether to summarise a thread or the channel
const msgs = thread_ts
? await fetchThreadReplies(channel, thread_ts)
: await fetchChannelHistory(channel);
const summary = await summarizeMessages(msgs);
await sendSlackMessage(channel, summary, ts);
return; // done, skip normal chat flow
}
// ---- Regular chat completion path -------------------------------
const threadId = thread_ts || ts;
const conversations = conversationStore.get(threadId) ?? [];
conversations.push({ role: "user", content: prompt });
const response = await askChatCompletion(conversations);
if (!response) {
await sendSlackMessage(
channel,
"ERROR: Something went wrong, please try again later.",
ts,
);
return;
}
conversations.push({ role: "assistant", content: response });
conversationStore.set(threadId, conversations);
await sendSlackMessage(channel, response, ts);
} catch (err) {
console.error("Error processing message:", err);
await sendSlackMessage(
channel,
"ERROR: Something went wrong, please try again later.",
ts,
);
} finally {
await removeReaction(channel, ts, "eyes");
}
}
// ---------------------------------------------------------------------------
// Main request handler
// ---------------------------------------------------------------------------
export default async function handler(req: Request): Promise<Response> {
if (req.method === "GET") {
console.log("Health check request received");
return new Response("Slack bot is running 🚀", {
status: 200,
headers: { "Content-Type": "text/plain" },
});
}
if (req.method !== "POST") {
console.log("Received non-POST request");
return new Response("Method not allowed", { status: 405 });
}
const body = await req.text();
const timestamp = req.headers.get("x-slack-request-timestamp");
const signature = req.headers.get("x-slack-signature");
console.log("Incoming Slack Request:");
console.log("Headers:", {
"x-slack-request-timestamp": timestamp,
"x-slack-signature": signature,
});
console.log("Raw Body:", body);
if (
!timestamp ||
!signature ||
!(await verifySlackRequest(body, signature, timestamp))
) {
console.warn("Slack request verification failed");
return new Response("Unauthorized", { status: 401 });
}
try {
const payload = JSON.parse(body);
console.log("Parsed Payload:", payload);
if (payload.type === "url_verification") {
console.log("Handling Slack URL verification challenge");
return new Response(payload.challenge, {
headers: { "Content-Type": "text/plain" },
});
}
if (payload.type === "event_callback" && payload.event) {
console.log("Handling Slack event:", payload.event);
// fire-and-forget – we’ve already validated headers
handleSlackEvent(payload.event).catch(console.error);
}
return new Response("OK", { status: 200 });
} catch (err) {
console.error("Error handling request:", 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