diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..ea5a55b --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +bunx lint-staged diff --git a/CHANGELOG.md b/CHANGELOG.md index 718a1f9..6463656 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 **CHANGELOG** | [简体中文](./CHANGELOG.zh-CN.md) +## [1.5.0] - 2026-02-09 + +### Added +- **File Attachment Support**: Users can now send files and images via the dashboard and API. +- **Multipart Form-Data Support**: Webhook endpoint now handles `multipart/form-data` for file uploads. +- **Git Pre-commit Hooks**: Automated linting and formatting on staged files using Husky and Biome. +- **Sequential Message Dispatch**: Support for sending text and attachments as multiple sequential messages in a single request. +- **SendAlertForm Component**: New UI component for sending alerts with attachments directly from the topic view. + +### Fixed +- **Feishu SDK Bun Compatibility**: Resolved a crash when uploading files in the Bun environment by using temporary files and `fs.ReadStream`. +- **Drizzle ORM Prototype Error**: Fixed a `null is not an object` crash during database insertion by normalizing request bodies. +- **Attachment Precedence**: Fixed an issue where attachments were ignored if text content was also present. + ## [1.4.0] - 2026-01-23 ### Added diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md index 097c82a..f09801d 100644 --- a/CHANGELOG.zh-CN.md +++ b/CHANGELOG.zh-CN.md @@ -2,44 +2,57 @@ 本项目的所有显著变更都将记录在此文件中。 -本文件的格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/), -并且本项目遵循 [语义化版本 (Semantic Versioning)](https://semver.org/lang/zh-CN/spec/v2.0.0.html)。 +本项目遵循 [Semantic Versioning](https://semver.org/spec/v2.0.0.html) 规范。 + +[English](./CHANGELOG.md) | **简体中文** + +## [1.5.0] - 2026-02-09 + +### 新增 +- **文件附件支持**:用户现在可以通过控制面板和 API 发送文件和图片。 +- **Git Pre-commit 钩子**:使用 Husky 和 Biome 自动检查和格式化暂存代码。 +- **Multipart Form-Data 支持**:Webhook 端点现在支持处理用于文件上传的 `multipart/form-data`。 +- **顺序消息分发**:支持在单个请求中将文本和附件作为多个顺序消息发送。 +- **SendAlertForm 组件**:新的 UI 组件,支持直接从话题视图发送带有附件的告警。 + +### 修复 +- **飞书 SDK Bun 兼容性**:通过使用临时文件和 `fs.ReadStream`,解决了在 Bun 环境下上传文件时的崩溃问题。 +- **Drizzle ORM 原型错误**:通过标准化请求体,修复了数据库插入过程中的 `null is not an object` 崩溃。 +- **附件优先级**:修复了当同时存在文本内容时附件被忽略的问题。 -**更新日志** | [English](./CHANGELOG.md) - ## [1.4.0] - 2026-01-23 ### 新增 -- **全局话题 (Global Topics)**: 引入了全新的话题类型,可自动向所有用户广播告警。 - - **用户申请**: 现在所有用户在创建话题时都可以选择申请将其设为“全局话题”。 - - **管理员控制**: 管理员可以通过管理员后台将任何话题提升为“全局话题”,或直接创建新的全局话题。 - - **自动分发**: 发送到全局话题的告警将投递给每一位已注册的用户,无需用户手动订阅。 - - **UI 标识**: 在话题列表和管理员视图中增加了“全局”标识和专门的管理操作。 +- **全量话题 (Global Topics)**:引入了一种自动向所有用户广播告警的新话题类型。 + - **用户申请**:所有用户在创建话题时均可申请将其标记为“全量”。 + - **管理员控制**:管理员可以通过后台将任何话题提升为“全量”模式。 + - **自动分发**:发送至全量话题的告警将自动推送给每一位注册用户,无需手动订阅。 + - **UI 标识**:在话题列表和管理视图中增加了“全量”徽章及专属管理操作。 ## [1.3.3] - 2026-01-17 ### 新增 -- **多副本部署支持**: 增强了在负载均衡/多实例环境下的稳定性。 - - **数据库锁机制**: 在 `db:migrate:deploy` 脚本中引入了 **Postgres Advisory Locks**,防止多个实例同时执行数据库迁移导致竞态问题。 - - **幂等性增强**: 验证并确保了飞书事件处理逻辑的幂等性,支持多副本安全接收重复事件。 +- **多副本部署支持**:增强了在负载均衡/多实例环境下的稳定性。 + - **数据库锁**: 在 `db:migrate:deploy` 脚本中引入了 **Postgres Advisory Locks**,防止多个实例同时执行数据库迁移时产生竞态条件。 + - **幂等性增强**: 验证并确保了飞书事件处理逻辑的幂等性,支持在多副本环境下安全地多次接收相同事件。 ## [1.3.2] - 2026-01-17 ### 新增 -- **群聊搜索功能**: 在绑定群聊时新增了实时搜索功能,解决了群聊过多时难以查找的问题。 - - **后端支持**: `GET /groups` 接口现在支持 `q` 查询参数进行模糊搜索,并提高了默认返回数量。 - - **搜索前端**: 引入了带防抖逻辑的搜索输入框和自定义下拉列表,提升了用户体验。 +- **群聊搜索**: 在绑定群聊时增加了实时搜索功能,解决了群组过多时难以定位的问题。 + - **后端支持**: `GET /groups` 接口现在支持 `q` 查询参数,并提升了默认返回数量。 + - **搜索前端**: 引入了带有防抖逻辑的搜索输入框和自定义下拉列表,提升了交互体验。 ### 变更 -- **UI 优化**: 改进了 `GroupBindingsModal` 的视觉设计,使用了更现代的列表样式、状态图标和加载动画。 -- **文档优化**: 将 `README.md` 拆分为英文版 (`README.md`) 和中文版 (`README.zh-CN.md`),以更好地支持国际化。 +- **UI 优化**: 改进了 `GroupBindingsModal` 的视觉设计,采用了更现代的列表样式、状态图标和加载动画。 +- **文档**: 将 `README.md` 拆分为英文版 (`README.md`) 和中文版 (`README.zh-CN.md`),以更好地支持国际化。 ## [1.3.1] - 2026-01-16 ### 新增 -- **群聊绑定管理**: 增强了 Topic 与飞书群聊绑定的安全性与流程。 - - **权限控制**: 仅 Topic 创建者或管理员允许执行群聊绑定/解绑操作。 - - **审批流程**: 新增群聊绑定审批机制,非管理员/非信任用户的绑定请求需经过审批(`status` 追踪)。 +- **群聊绑定管理**: 增强了将 Topic 绑定到飞书群聊的安全性和流程。 + - **权限校验**: 仅限 Topic 创建者或管理员执行绑定/解绑操作。 + - **审批流程**: 为非管理员/非信任用户的群聊绑定请求引入了审批机制(通过 `status` 字段追踪)。 - **管理员通知**: 引入 `admin-notifier.ts`,当有新的 Topic 或群聊绑定请求时,通过飞书卡片实时通知管理员。 - **信任用户系统**: 引入 `isTrusted` 标志。 - 信任用户创建 Topic 或绑定群聊时将自动通过审批。 @@ -48,6 +61,7 @@ ### 变更 - **数据库架构**: `topic_group_chats` 表新增了 `status` 和 `created_by` 字段,以支持审批流和权限校验。 +## [1.3.0] - 2026-01-16 ### 新增 - **视觉品牌**: 引入了自定义图标和 Favicon。 @@ -69,7 +83,7 @@ - **用户 Token**:将用户的 `personalToken` 从 32 位 UUID 缩短为 8 位十六进制字符串,提升易用性。 - **数据库迁移**:完善了数据库迁移流程,在 `db:migrate:deploy` 中集成了存量用户 Token 的自动缩短逻辑,确保线上环境数据的一致性。 - **AI 规范**:更新了 `copilot-context.md`,明确要求 AI 在每次修改代码后必须进行代码风格和 Lint 检查。 - + ## [1.2.5] - 2026-01-15 ### 修复 @@ -143,6 +157,7 @@ ### 变更 - **数据库**: 新增 `topic_group_chats` 和 `known_group_chats` 表。 - **底层架构**: 重构了飞书客户端 (`FeishuClient`) 和事件处理逻辑,统一了 Webhook 和 WebSocket 的事件分发。 + ## [1.1.1] - 2026-01-13 ### 修复 diff --git a/README.md b/README.md index 7646bfd..fcd671d 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,8 @@ Real-time tracking of system alert load, dispatch success rates, and topic popul - Full `Alert Task` logs for end-to-end traceability. - **📊 Real-time Dashboard**: Grafana-style monitoring interface for system health visualization. - **🔌 WebSocket Mode**: Supports Feishu Open Platform WebSocket for intranet deployments without public IP or domain. +- **📎 Attachment Support**: Send files and images directly via dashboard or API. +- **🏗️ Git Pre-commit Hooks**: Automated linting and formatting using Husky and Biome to ensure code quality. - **⚡ High Performance**: Built on Bun + Hono for millisecond-level dispatch latency. --- @@ -91,8 +93,37 @@ Automatically builds and pushes Docker images to GitHub Container Registry (GHCR See [CHANGELOG.md](CHANGELOG.md) for version history. ## 📡 Webhook Usage -- **Personal Inbox**: `POST /api/webhook/:your_token/dm` -- **Topic**: `POST /api/webhook/:your_token/topic/:topic_slug` + +### 1. Send to Personal Inbox (DM) +- **URL**: `POST /api/webhook/:your_token/dm` +- **Format**: JSON or Multipart Form-Data + +### 2. Send to Topic +- **URL**: `POST /api/webhook/:your_token/topic/:topic_slug` + +### Examples (using curl) + +**Send Text (JSON):** +```bash +curl -X POST -H "Content-Type: application/json" \ + -d '{"content":{"text":"Hello World"}}' \ + http://localhost:3000/api/webhook/YOUR_TOKEN/dm +``` + +**Send File (Multipart):** +```bash +curl -X POST \ + -F "content={\"text\":\"Check this file\"}" \ + -F "file=@/path/to/report.pdf" \ + http://localhost:3000/api/webhook/YOUR_TOKEN/dm +``` + +**Send Image:** +```bash +curl -X POST \ + -F "image=@/path/to/screenshot.png" \ + http://localhost:3000/api/webhook/YOUR_TOKEN/dm +``` ## 📂 Project Structure - `apps/server`: Core API service diff --git a/README.zh-CN.md b/README.zh-CN.md index 6883c88..539b07d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -31,12 +31,14 @@ - **🚀 极简推送 (Personal Inbox)**: 每个用户拥有专属的 Webhook Token,直接向 `/dm` 接口发送即可在飞书收到私聊,零配置成本。 - **📢 主题订阅 (Topic Model)**: 灵活的“发布-订阅”机制。告警发送至 Topic,系统自动分发给所有订阅成员。 - **👥 群聊分发 (Group Support)**: 告警可同步分发至绑定的飞书群聊,支持机器人自动发现与解绑。 -- **🛡️ 权限与审计**: +- **🛡️ 权限与审计**: - 话题创建需经过管理员审批。 - 记录完整的 `Alert Task` 日志,实现发送链路可追溯。 -- **📊 实时看板**: Grafana 风格的监控界面,直观展示系统运行健壮性。 -- **🔌 长连接模式 (WebSocket)**: 支持飞书开放平台长连接,无需公网 IP 或域名即可在内网环境接收事件回调。 -- **⚡ 高性能架构**: 基于 Bun + Hono 的全异步架构,毫秒级分发延迟。 +- **📊 实时看板**:Grafana 风格的监控界面,直观展示系统运行健壮性。 +- **🔌 长连接模式 (WebSocket)**:支持飞书开放平台长连接,无需公网 IP 或域名即可在内网环境接收事件回调。 +- **📎 附件支持**:支持通过控制面板或 API 直接发送文件和图片。 +- **🏗️ Git Pre-commit 钩子**:使用 Husky 和 Biome 自动进行代码检查和格式化,确保代码质量。 +- **⚡ 高性能**:基于 Bun + Hono 构建,毫秒级分发延迟。 --- @@ -102,10 +104,35 @@ docker-compose up -d ## 📡 Webhook 使用指南 ### 1. 发送给个人 (Personal Inbox) -**URL**: `POST /api/webhook/:your_token/dm` +- **URL**: `POST /api/webhook/:your_token/dm` +- **格式**: JSON 或 Multipart Form-Data ### 2. 发送到主题 (Topic) -**URL**: `POST /api/webhook/:your_token/topic/:topic_slug` +- **URL**: `POST /api/webhook/:your_token/topic/:topic_slug` + +### 使用示例 (curl) + +**发送纯文字 (JSON):** +```bash +curl -X POST -H "Content-Type: application/json" \ + -d '{"content":{"text":"你好,世界"}}' \ + http://localhost:3000/api/webhook/YOUR_TOKEN/dm +``` + +**发送文件 (Multipart):** +```bash +curl -X POST \ + -F "content={\"text\":\"请查看附件\"}" \ + -F "file=@/path/to/report.pdf" \ + http://localhost:3000/api/webhook/YOUR_TOKEN/dm +``` + +**发送图片:** +```bash +curl -X POST \ + -F "image=@/path/to/screenshot.png" \ + http://localhost:3000/api/webhook/YOUR_TOKEN/dm +``` --- diff --git a/apps/server/package.json b/apps/server/package.json index a763c7e..4d2610a 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "@alertmessagecenter/server", - "version": "1.4.0", + "version": "1.5.0", "scripts": { "dev": "bun run --env-file .env --watch src/index.ts", "start": "bun run src/index.ts", diff --git a/apps/server/src/feishu.ts b/apps/server/src/feishu.ts index 778fde5..03fa2f3 100644 --- a/apps/server/src/feishu.ts +++ b/apps/server/src/feishu.ts @@ -1,3 +1,6 @@ +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"; @@ -64,6 +67,77 @@ export class FeishuClient { } } + async uploadFile( + fileType: "opus" | "mp4" | "pdf" | "doc" | "xls" | "ppt" | "stream", + fileName: string, + fileBuffer: Buffer, + ): Promise { + const tempPath = path.join( + os.tmpdir(), + `feishu_upload_${Date.now()}_${fileName}`, + ); + try { + fs.writeFileSync(tempPath, fileBuffer); + const response = await this.client.im.file.create({ + data: { + file_type: fileType, + file_name: fileName, + file: fs.createReadStream(tempPath), + }, + }); + + 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", + ); + } + + return response.file_key; + } catch (e) { + console.error("Feishu upload file SDK error:", e); + throw e; + } finally { + // Clean up after a short delay to ensure stream is processed + setTimeout(() => { + if (fs.existsSync(tempPath)) { + fs.unlinkSync(tempPath); + } + }, 10000); + } + } + + async uploadImage(imageBuffer: Buffer): Promise { + const tempPath = path.join(os.tmpdir(), `feishu_upload_img_${Date.now()}`); + try { + fs.writeFileSync(tempPath, imageBuffer); + const response = await this.client.im.image.create({ + data: { + image_type: "message", + image: fs.createReadStream(tempPath), + }, + }); + + 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", + ); + } + + return response.image_key; + } catch (e) { + console.error("Feishu upload image SDK error:", e); + throw e; + } finally { + setTimeout(() => { + if (fs.existsSync(tempPath)) { + fs.unlinkSync(tempPath); + } + }, 10000); + } + } + async getUserAccessToken( code: string, ): Promise { diff --git a/apps/server/src/webhook.ts b/apps/server/src/webhook.ts index 4b4c620..6ae5cfe 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,18 +15,122 @@ 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?: unknown; + card?: unknown; + post?: unknown; + 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]: unknown; +} + const webhook = new Hono(); +const getRequestBody = async (c: Context): Promise => { + const contentType = c.req.header("Content-Type") || ""; + let body: WebhookBody; + + if (contentType.includes("application/json")) { + try { + body = await c.req.json(); + } catch (_e) { + throw new Error("Invalid JSON body"); + } + } else if ( + contentType.includes("multipart/form-data") || + contentType.includes("application/x-www-form-urlencoded") + ) { + body = (await c.req.parseBody()) as unknown as WebhookBody; + // Handle stringified JSON fields in multipart + const complexFields: (keyof WebhookBody)[] = ["content", "card", "post"]; + for (const field of complexFields) { + const val = body[field]; + if (typeof val === "string") { + try { + body[field] = JSON.parse(val); + } catch { + // Not JSON, leave as is + } + } + } + } else { + // Fallback: try parsing as JSON + try { + const text = await c.req.text(); + if (!text || text.trim() === "") { + throw new Error("Empty body"); + } + body = JSON.parse(text); + } catch (_e) { + throw new Error("Invalid or missing request body"); + } + } + + // Proxy upload if files are present + 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 + | "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; + delete body.file; + } + + const image = Array.isArray(body.image) + ? (body.image[0] as unknown) + : body.image; + if (image instanceof File) { + const buffer = Buffer.from(await image.arrayBuffer()); + const imageKey = await feishuClient.uploadImage(buffer); + body.image_key = imageKey; + delete body.image; + } + + return { ...body }; +}; + 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, @@ -37,12 +141,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, @@ -50,7 +157,10 @@ const dispatchAlert = async ( idType: "chat_id" as FeishuReceiveIdType, })); - const allRecipients: Recipient[] = [...userRecipients, ...groupRecipients]; + const allRecipients: Recipient[] = [ + ...validUserRecipients, + ...groupRecipients, + ]; const [task] = await db .insert(alertTasks) @@ -60,7 +170,8 @@ const dispatchAlert = async ( status: "processing", recipientCount: allRecipients.length, successCount: 0, - payload: body, + // biome-ignore lint/suspicious/noExplicitAny: Drizzle expects specific jsonb type + payload: body as any, }) .returning(); @@ -80,7 +191,8 @@ const dispatchAlert = async ( logger.info( { taskId: task.id, - userCount: userRecipients.length, + slug: topic.slug, + userCount: validUserRecipients.length, groupCount: groupRecipients.length, }, "[Webhook] Dispatching alerts", @@ -90,65 +202,127 @@ const dispatchAlert = async ( Promise.allSettled( allRecipients.map(async (recipient) => { try { - // Construct message content - let msgType = body.msg_type || "text"; - let content = body.content; + // Construct messages list + const messagesToSend: { + type: string; + content: Record | string; + }[] = []; + + // 1. Text content + if (body.content) { + const content = JSON.parse(JSON.stringify(body.content)); + const msgType = body.msg_type || "text"; + // Add prefix for text + if ( + msgType === "text" && + content && + typeof content === "object" && + "text" in content + ) { + (content as Record).text = `[Direct Message]\n${ + (content as Record).text + }`; + } + // Add prefix for interactive + if ( + msgType === "interactive" && + content && + typeof content === "object" && + "header" in content + ) { + const c = content as Record< + string, + Record> + >; + if (c.header?.title?.content) { + c.header.title.content = `[${topic.slug || topic.name}] ${ + c.header.title.content + }`; + } + } + messagesToSend.push({ + type: msgType, + content: content as Record | string, + }); + } + + // 2. Image + if (body.image_key) { + messagesToSend.push({ + type: "image", + content: { image_key: body.image_key }, + }); + } + + // 3. File + if (body.file_key) { + messagesToSend.push({ + type: "file", + content: { file_key: body.file_key }, + }); + } + + // 4. Fallback for no explicit content/attachment keys + if (messagesToSend.length === 0) { + let msgType = body.msg_type || "text"; + let content: unknown = body.content; - // Special handling for incomplete payloads (missing 'content') - if (!content) { - // 1. Special case: Unwrap 'card' if provided (convenience for user) if (body.card) { content = body.card; if (!msgType) msgType = "interactive"; } else { - // 2. Pass-through strategy: Use rest of body as content - // Exclude keys that are definitely not part of content - const { msg_type, token, ...rest } = body; + const { msg_type: _msg_type, token: _token, ...rest } = body; content = rest; - - // 3. Infer msgType if missing if (!msgType) { if (body.post) msgType = "post"; - else if (body.file_key && body.image_key) - msgType = "media"; // Media has both + else if (body.file_key && body.image_key) msgType = "media"; else if (body.image_key) msgType = "image"; else if (body.file_key) msgType = "file"; else if (body.audio_key) msgType = "audio"; else if (body.sticker_key) msgType = "sticker"; else if (body.chat_id) msgType = "share_chat"; else if (body.user_id) msgType = "share_user"; - else if (body.header || body.elements) - msgType = "interactive"; // Unwrapped card + else if (body.header || body.elements) msgType = "interactive"; else { - // Fallback to text msgType = "text"; - // For text, content must be simple or stringified content = { text: JSON.stringify(body, null, 2) }; } } } - } else { - // Deep clone content to avoid mutating shared object for parallel requests if we modify it - content = JSON.parse(JSON.stringify(content)); + // Add prefix for inferred types + if ( + msgType === "text" && + content && + typeof content === "object" && + "text" in content + ) { + (content as Record).text = `[${topic.name}]\n${ + (content as Record).text + }`; + } + messagesToSend.push({ + type: msgType, + content: content as Record | string, + }); } - // Add metadata - if (msgType === "text" && content.text) { - content.text = `[${topic.name}]\n${content.text}`; - } - if (msgType === "interactive" && content.header) { - content.header.title.content = `[${topic.name}] ${content.header.title.content}`; + let successCount = 0; + for (const msg of messagesToSend) { + await feishuClient.sendMessage( + recipient.feishuId, + recipient.idType, + msg.type, + msg.content, + body.uuid, + ); + successCount++; } - await feishuClient.sendMessage( - recipient.feishuId, - recipient.idType, - msgType, - content, - body.uuid, - ); - - return { recipientId: recipient.id, status: "sent", error: null }; + return { + recipientId: recipient.id, + status: successCount > 0 ? "sent" : "failed", + error: null, + }; } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : String(error); @@ -201,6 +375,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; }; @@ -211,14 +386,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) { @@ -277,13 +451,9 @@ webhook.post("/topic/:slug", async (c) => { // biome-ignore lint/suspicious/noExplicitAny: Webhook body can be any arbitrary JSON let body: Record; try { - const rawBody = await c.req.text(); - if (!rawBody || rawBody.trim() === "") { - return c.json({ error: "Empty body" }, 400); - } - body = JSON.parse(rawBody); - } catch (_e) { - return c.json({ error: "Invalid JSON body" }, 400); + body = await getRequestBody(c); + } catch (e) { + return c.json({ error: (e as Error).message }, 400); } return dispatchAlert(c, topic, body, null); @@ -312,12 +482,13 @@ 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({ - where: eq(users.personalToken, token), - }); + user = + (await db.query.users.findFirst({ + where: eq(users.personalToken, token), + })) || null; if (!user) { logger.warn({ token }, "[Webhook] Invalid personal token"); @@ -328,13 +499,9 @@ webhook.post("/:token/topic/:slug", async (c) => { // biome-ignore lint/suspicious/noExplicitAny: Webhook body can be any arbitrary JSON let body: Record; try { - const rawBody = await c.req.text(); - if (!rawBody || rawBody.trim() === "") { - return c.json({ error: "Empty body" }, 400); - } - body = JSON.parse(rawBody); - } catch (_e) { - return c.json({ error: "Invalid JSON body" }, 400); + body = await getRequestBody(c); + } catch (e) { + return c.json({ error: (e as Error).message }, 400); } return dispatchAlert(c, topic, body, user); @@ -374,13 +541,9 @@ webhook.post("/:token/dm", async (c) => { // biome-ignore lint/suspicious/noExplicitAny: Webhook body can be any arbitrary JSON let body: Record; try { - const rawBody = await c.req.text(); - if (!rawBody || rawBody.trim() === "") { - return c.json({ error: "Empty body" }, 400); - } - body = JSON.parse(rawBody); - } catch (_e) { - return c.json({ error: "Invalid JSON body" }, 400); + body = await getRequestBody(c); + } catch (e) { + return c.json({ error: (e as Error).message }, 400); } // 1. Create Task (topicSlug is null for DM) @@ -399,107 +562,103 @@ webhook.post("/:token/dm", async (c) => { // 2. Send Message (async () => { try { - let msgType = body.msg_type || "text"; - let content = body.content; + const messagesToSend: { + type: string; + content: Record | string; + }[] = []; + + // Text content + if (body.content) { + const content = JSON.parse(JSON.stringify(body.content)); + messagesToSend.push({ type: body.msg_type || "text", content }); + } + + // Image + if (body.image_key) { + messagesToSend.push({ + type: "image", + content: { image_key: body.image_key }, + }); + } + + // File + if (body.file_key) { + messagesToSend.push({ + type: "file", + content: { file_key: body.file_key }, + }); + } + + // Fallback: if no explicit content/attachment keys, check other fields + if (messagesToSend.length === 0) { + let msgType = body.msg_type || "text"; + let content = body.content; - // Special handling for incomplete payloads (missing 'content') - if (!content) { - // 1. Interactive / Card if ((msgType === "interactive" || !msgType) && body.card) { msgType = "interactive"; content = body.card; - } - // 2. Post (Rich Text) - else if ((msgType === "post" || !msgType) && body.post) { + } else if ((msgType === "post" || !msgType) && body.post) { msgType = "post"; content = { post: body.post }; - } - // 3. Image - else if ((msgType === "image" || !msgType) && body.image_key) { - msgType = "image"; - content = { image_key: body.image_key }; - } - // 4. File - else if ((msgType === "file" || !msgType) && body.file_key) { - msgType = "file"; - content = { file_key: body.file_key }; - } - // 5. Audio - else if ((msgType === "audio" || !msgType) && body.audio_key) { + } else if ((msgType === "audio" || !msgType) && body.audio_key) { msgType = "audio"; content = { file_key: body.audio_key }; - } - // 6. Media (Video) - else if ( + } else if ( (msgType === "media" || !msgType) && body.file_key && body.image_key ) { msgType = "media"; content = { file_key: body.file_key, image_key: body.image_key }; - } - // 7. Sticker - else if ((msgType === "sticker" || !msgType) && body.sticker_key) { + } else if ((msgType === "sticker" || !msgType) && body.sticker_key) { msgType = "sticker"; content = { file_key: body.sticker_key }; - } - // 8. Share Chat - else if ((msgType === "share_chat" || !msgType) && body.chat_id) { + } else if ((msgType === "share_chat" || !msgType) && body.chat_id) { msgType = "share_chat"; content = { chat_id: body.chat_id }; - } - // 9. Share User - else if ((msgType === "share_user" || !msgType) && body.user_id) { + } else if ((msgType === "share_user" || !msgType) && body.user_id) { msgType = "share_user"; content = { user_id: body.user_id }; - } - // Fallback - else { + } else { + const { msg_type: _msg_type, token: _token, ...rest } = body; + content = rest; if (!msgType || msgType === "text") { msgType = "text"; - content = { text: JSON.stringify(body, null, 2) }; + content = { text: JSON.stringify(rest, null, 2) }; } } - } else { - // Deep clone content to avoid mutating shared object for parallel requests if we modify it - content = JSON.parse(JSON.stringify(content)); + messagesToSend.push({ type: msgType, content }); } - // Add metadata - if (msgType === "text" && content.text) { - content.text = `[Direct Message]\n${content.text}`; - } - if (msgType === "interactive" && content.header) { - content.header.title.content = `[DM] ${content.header.title.content}`; + let totalSuccess = 0; + for (const msg of messagesToSend) { + await feishuClient.sendMessage( + user.feishuUserId, + "open_id", + msg.type, + msg.content, + body.uuid, + ); + totalSuccess++; } - const idType = user.feishuUserId.startsWith("ou_") - ? "open_id" - : "user_id"; - const uuid = body.uuid || crypto.randomUUID(); - await feishuClient.sendMessage( - user.feishuUserId, - idType, - msgType, - content, - uuid, - ); + const finalStatus = totalSuccess > 0 ? "completed" : "failed"; // Update Task await db .update(alertTasks) .set({ - status: "completed", - successCount: 1, + status: finalStatus, + successCount: totalSuccess === messagesToSend.length ? 1 : 0, // In DM case, 1 recipient updatedAt: new Date(), }) .where(eq(alertTasks.id, task.id)); - // Insert Log + // Log Sent await db.insert(alertLogs).values({ taskId: task.id, userId: user.id, - status: "sent" as const, + status: totalSuccess > 0 ? "sent" : "failed", }); } catch (error: unknown) { const errorMessage = diff --git a/apps/web/package.json b/apps/web/package.json index b799dd4..ba43fa3 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@alertmessagecenter/web", - "version": "1.4.0", + "version": "1.5.0", "type": "module", "scripts": { "dev": "bun run --env-file .env vite", diff --git a/apps/web/src/components/SendAlertForm.tsx b/apps/web/src/components/SendAlertForm.tsx new file mode 100644 index 0000000..34fd6d8 --- /dev/null +++ b/apps/web/src/components/SendAlertForm.tsx @@ -0,0 +1,195 @@ +import { + AlertCircle, + CheckCircle2, + Loader2, + Paperclip, + Send, + X, +} from "lucide-react"; +import { useRef, useState } from "react"; + +interface SendAlertFormProps { + webhookUrl: string; + onSuccess?: () => void; + placeholder?: string; + title?: string; +} + +export default function SendAlertForm({ + 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 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 handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!content.trim() && !file) return; + + 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"); + } + + 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, + }); + + 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} +

+
+
+
+