diff --git a/apps/server/package.json b/apps/server/package.json index 443029b..4d2610a 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -25,4 +25,4 @@ "drizzle-kit": "^0.31.8", "pino-pretty": "^13.1.3" } -} \ No newline at end of file +} diff --git a/apps/server/src/feishu.ts b/apps/server/src/feishu.ts index 7b0cd0a..03fa2f3 100644 --- a/apps/server/src/feishu.ts +++ b/apps/server/src/feishu.ts @@ -1,7 +1,7 @@ -import * as lark from "@larksuiteoapi/node-sdk"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; +import * as lark from "@larksuiteoapi/node-sdk"; import { logger } from "./lib/logger"; export interface UserAccessTokenData { @@ -72,7 +72,10 @@ export class FeishuClient { fileName: string, fileBuffer: Buffer, ): Promise { - const tempPath = path.join(os.tmpdir(), `feishu_upload_${Date.now()}_${fileName}`); + const tempPath = path.join( + os.tmpdir(), + `feishu_upload_${Date.now()}_${fileName}`, + ); try { fs.writeFileSync(tempPath, fileBuffer); const response = await this.client.im.file.create({ @@ -85,7 +88,9 @@ export class FeishuClient { if (!response || !response.file_key) { logger.error({ response }, "Feishu upload file error: no file_key"); - throw new Error("Failed to upload file to Feishu: no file_key returned"); + throw new Error( + "Failed to upload file to Feishu: no file_key returned", + ); } return response.file_key; @@ -115,7 +120,9 @@ export class FeishuClient { if (!response || !response.image_key) { logger.error({ response }, "Feishu upload image error: no image_key"); - throw new Error("Failed to upload image to Feishu: no image_key returned"); + throw new Error( + "Failed to upload image to Feishu: no image_key returned", + ); } return response.image_key; diff --git a/apps/server/src/webhook.ts b/apps/server/src/webhook.ts index f042b58..4182b72 100644 --- a/apps/server/src/webhook.ts +++ b/apps/server/src/webhook.ts @@ -1,5 +1,5 @@ import { eq } from "drizzle-orm"; -import { Hono } from "hono"; +import { type Context, Hono } from "hono"; import { db } from "./db"; import { alertLogs, alertTasks, topics, users } from "./db/schema"; import { feishuClient } from "./feishu"; @@ -15,11 +15,43 @@ interface Recipient { idType: FeishuReceiveIdType; } +interface Topic { + slug: string; + name: string; + isGlobal: boolean; + subscriptions?: { user: User }[]; + groupChats?: { id: string; name: string; chatId: string; status: string }[]; +} + +interface User { + id: string; + name: string; + feishuUserId: string; +} + +interface WebhookBody { + msg_type?: string; + content?: any; + card?: any; + post?: any; + image_key?: string; + file_key?: string; + audio_key?: string; + sticker_key?: string; + chat_id?: string; + user_id?: string; + uuid?: string; + token?: string; + file_type?: string; + file_name?: string; + [key: string]: any; +} + const webhook = new Hono(); -const getRequestBody = async (c: any) => { +const getRequestBody = async (c: Context): Promise => { const contentType = c.req.header("Content-Type") || ""; - let body: Record; + let body: WebhookBody; if (contentType.includes("application/json")) { try { @@ -57,10 +89,18 @@ const getRequestBody = async (c: any) => { } // Proxy upload if files are present - const file = Array.isArray(body.file) ? body.file[0] : body.file; + const file = Array.isArray(body.file) ? (body.file[0] as unknown) : body.file; if (file instanceof File) { const buffer = Buffer.from(await file.arrayBuffer()); - const fileType = (body.file_type as any) || "stream"; + const fileType = + (body.file_type as + | "opus" + | "mp4" + | "pdf" + | "doc" + | "xls" + | "ppt" + | "stream") || "stream"; const fileName = (body.file_name as string) || file.name; const fileKey = await feishuClient.uploadFile(fileType, fileName, buffer); body.file_key = fileKey; @@ -79,15 +119,15 @@ const getRequestBody = async (c: any) => { }; const dispatchAlert = async ( - c: any, - topic: any, - body: any, - user: any | null, + c: Context, + topic: Topic, + body: WebhookBody, + user: User | null, ) => { // 2. Collect recipients - const userRecipients: Recipient[] = (topic.subscriptions || []) - .map((sub: any) => sub.user) - .map((u: any) => { + const userRecipients: (Recipient | null)[] = (topic.subscriptions || []) + .map((sub) => sub.user) + .map((u) => { if (!u || !u.feishuUserId) return null; return { type: "user" as const, @@ -98,12 +138,15 @@ const dispatchAlert = async ( ? "open_id" : "user_id") as FeishuReceiveIdType, }; - }) - .filter((u: any): u is Recipient => u !== null); + }); + + const validUserRecipients: Recipient[] = userRecipients.filter( + (u): u is Recipient => u !== null, + ); const groupRecipients: Recipient[] = (topic.groupChats || []) - .filter((g: any) => g.status === "approved") - .map((g: any) => ({ + .filter((g) => g.status === "approved") + .map((g) => ({ type: "group", id: g.id, // Binding ID name: g.name, @@ -111,7 +154,7 @@ const dispatchAlert = async ( idType: "chat_id" as FeishuReceiveIdType, })); - const allRecipients: Recipient[] = [...userRecipients, ...groupRecipients]; + const allRecipients: Recipient[] = [...validUserRecipients, ...groupRecipients]; const [task] = await db .insert(alertTasks) @@ -121,7 +164,7 @@ const dispatchAlert = async ( status: "processing", recipientCount: allRecipients.length, successCount: 0, - payload: body, + payload: body as Record, // Cast to satisfy Drizzle jsonb type }) .returning(); @@ -152,7 +195,10 @@ const dispatchAlert = async ( allRecipients.map(async (recipient) => { try { // Construct messages list - const messagesToSend: { type: string; content: any }[] = []; + const messagesToSend: { + type: string; + content: Record | string; + }[] = []; // 1. Text content if (body.content) { @@ -288,6 +334,7 @@ const dispatchAlert = async ( const recipient = allRecipients[index]; if (r.status === "fulfilled") { const val = r.value as { + recipientId: string; status: "sent" | "failed"; error: string | null; }; @@ -298,14 +345,13 @@ const dispatchAlert = async ( status: val.status as "sent" | "failed", error: val.error, }; - } else { - return { - taskId: task.id, - userId: recipient.type === "user" ? recipient.id : null, - status: "failed" as const, - error: r.status === "rejected" ? String(r.reason) : "Unknown error", - }; } + return { + taskId: task.id, + userId: recipient.type === "user" ? recipient.id : null, + status: "failed" as const, + error: r.status === "rejected" ? String(r.reason) : "Unknown error", + }; }); if (logs.length > 0) { @@ -395,12 +441,12 @@ webhook.post("/:token/topic/:slug", async (c) => { return c.json({ error: "Topic not found" }, 404); } - let user: any = null; + let user: User | null = null; if (!topic.isGlobal) { // 0. Find the User by Token - user = await db.query.users.findFirst({ + user = (await db.query.users.findFirst({ where: eq(users.personalToken, token), - }); + })) || null; if (!user) { logger.warn({ token }, "[Webhook] Invalid personal token"); @@ -474,7 +520,10 @@ webhook.post("/:token/dm", async (c) => { // 2. Send Message (async () => { try { - const messagesToSend: { type: string; content: any }[] = []; + const messagesToSend: { + type: string; + content: Record | string; + }[] = []; // Text content if (body.content) { diff --git a/apps/web/package.json b/apps/web/package.json index d9114be..ba43fa3 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -29,4 +29,4 @@ "@types/node": "^20.0.0", "bun-types": "latest" } -} \ No newline at end of file +} diff --git a/apps/web/src/components/SendAlertForm.tsx b/apps/web/src/components/SendAlertForm.tsx index 7a6e80e..fbdca2a 100644 --- a/apps/web/src/components/SendAlertForm.tsx +++ b/apps/web/src/components/SendAlertForm.tsx @@ -1,194 +1,194 @@ import { - AlertCircle, - CheckCircle2, - Loader2, - Paperclip, - Send, - X, + AlertCircle, + CheckCircle2, + Loader2, + Paperclip, + Send, + X, } from "lucide-react"; import { useRef, useState } from "react"; interface SendAlertFormProps { - webhookUrl: string; - onSuccess?: () => void; - placeholder?: string; - title?: string; + webhookUrl: string; + onSuccess?: () => void; + placeholder?: string; + title?: string; } export default function SendAlertForm({ - webhookUrl, - onSuccess, - placeholder = "Type your message here...", - title = "Send Quick Alert", + webhookUrl, + onSuccess, + placeholder = "Type your message here...", + title = "Send Quick Alert", }: SendAlertFormProps) { - const [content, setContent] = useState(""); - const [file, setFile] = useState(null); - const [isSending, setIsSending] = useState(false); - const [status, setStatus] = useState<{ - type: "success" | "error"; - message: string; - } | null>(null); - const fileInputRef = useRef(null); + const [content, setContent] = useState(""); + const [file, setFile] = useState(null); + const [isSending, setIsSending] = useState(false); + const [status, setStatus] = useState<{ + type: "success" | "error"; + message: string; + } | null>(null); + const fileInputRef = useRef(null); - const handleFileChange = (e: React.ChangeEvent) => { - if (e.target.files && e.target.files[0]) { - setFile(e.target.files[0]); - setStatus(null); - } - }; + const handleFileChange = (e: React.ChangeEvent) => { + if (e.target.files?.[0]) { + setFile(e.target.files[0]); + setStatus(null); + } + }; - const removeFile = () => { - setFile(null); - if (fileInputRef.current) { - fileInputRef.current.value = ""; - } - }; + const removeFile = () => { + setFile(null); + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + }; - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - if (!content.trim() && !file) return; + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!content.trim() && !file) return; - setIsSending(true); - setStatus(null); + setIsSending(true); + setStatus(null); - try { - const formData = new FormData(); - if (content.trim()) { - // We send content as a stringified JSON if it's elaborate, - // but for "Pure Proxy", simple text is just a field. - // Our backend getRequestBody handles both. - formData.append("content", JSON.stringify({ text: content })); - formData.append("msg_type", "text"); - } + try { + const formData = new FormData(); + if (content.trim()) { + // We send content as a stringified JSON if it's elaborate, + // but for "Pure Proxy", simple text is just a field. + // Our backend getRequestBody handles both. + formData.append("content", JSON.stringify({ text: content })); + formData.append("msg_type", "text"); + } - if (file) { - const isImage = file.type.startsWith("image/"); - formData.append(isImage ? "image" : "file", file); - if (!content.trim()) { - formData.append("msg_type", isImage ? "image" : "file"); - } - } + if (file) { + const isImage = file.type.startsWith("image/"); + formData.append(isImage ? "image" : "file", file); + if (!content.trim()) { + formData.append("msg_type", isImage ? "image" : "file"); + } + } - const response = await fetch(webhookUrl, { - method: "POST", - body: formData, - }); + const response = await fetch(webhookUrl, { + method: "POST", + body: formData, + }); - if (response.ok) { - setStatus({ type: "success", message: "Alert sent successfully!" }); - setContent(""); - setFile(null); - if (fileInputRef.current) fileInputRef.current.value = ""; - onSuccess?.(); - setTimeout(() => setStatus(null), 3000); - } else { - const error = await response.json(); - setStatus({ - type: "error", - message: error.error || "Failed to send alert", - }); - } - } catch (err) { - setStatus({ type: "error", message: "Network error. Please try again." }); - } finally { - setIsSending(false); - } - }; + if (response.ok) { + setStatus({ type: "success", message: "Alert sent successfully!" }); + setContent(""); + setFile(null); + if (fileInputRef.current) fileInputRef.current.value = ""; + onSuccess?.(); + setTimeout(() => setStatus(null), 3000); + } else { + const error = await response.json(); + setStatus({ + type: "error", + message: error.error || "Failed to send alert", + }); + } + } catch (_err) { + setStatus({ type: "error", message: "Network error. Please try again." }); + } finally { + setIsSending(false); + } + }; - return ( -
-
-

- {title} -

-
-
-
-