Last active
July 2, 2025 03:44
-
-
Save huytd/f8a9f1ca3b09db4a0ddc4eac52dd61e5 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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