diff --git a/CHANGELOG.md b/CHANGELOG.md index ab1a291..a9fff4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,31 @@ 本文件的格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/), 并且本项目遵循 [语义化版本 (Semantic Versioning)](https://semver.org/lang/zh-CN/spec/v2.0.0.html)。 -## [1.3.0] - 2026-01-15 +## [1.3.2] - 2026-01-17 + +### 新增 +- **群聊搜索功能**: 在绑定群聊时新增了实时搜索功能,解决了群聊过多时难以查找的问题。 + - **后端支持**: `GET /groups` 接口现在支持 `q` 查询参数进行模糊搜索,并提高了默认返回数量。 + - **搜索前端**: 引入了带防抖逻辑的搜索输入框和自定义下拉列表,提升了用户体验。 + +### 变更 +- **UI 优化**: 改进了 `GroupBindingsModal` 的视觉设计,使用了更现代的列表样式、状态图标和加载动画。 +- **文档优化**: 将 `README.md` 拆分为英文版 (`README.md`) 和中文版 (`README.zh-CN.md`),以更好地支持国际化。 + +## [1.3.1] - 2026-01-16 + +### 新增 +- **群聊绑定管理**: 增强了 Topic 与飞书群聊绑定的安全性与流程。 + - **权限控制**: 仅 Topic 创建者或管理员允许执行群聊绑定/解绑操作。 + - **审批流程**: 新增群聊绑定审批机制,非管理员/非信任用户的绑定请求需经过审批(`status` 追踪)。 + - **管理员通知**: 引入 `admin-notifier.ts`,当有新的 Topic 或群聊绑定请求时,通过飞书卡片实时通知管理员。 +- **信任用户系统**: 引入 `isTrusted` 标志。 + - 信任用户创建 Topic 或绑定群聊时将自动通过审批。 + - 管理员由于其特殊权限,默认享受自动审批。 + +### 变更 +- **数据库架构**: `topic_group_chats` 表新增了 `status` 和 `created_by` 字段,以支持审批流和权限校验。 + ### 新增 - **视觉品牌**: 引入了自定义图标和 Favicon。 diff --git a/README.md b/README.md index a63663e..7646bfd 100644 --- a/README.md +++ b/README.md @@ -1,50 +1,48 @@ # Alert Message Center +**README** | [简体中文](./README.zh-CN.md) + [![Tech Stack](https://img.shields.io/badge/Stack-Bun%20%7C%20Hono%20%7C%20React-blue)](https://bun.sh) [![Database](https://img.shields.io/badge/Database-PostgreSQL-blue)](https://www.postgresql.org/) -**Alert Message Center** 是一个现代化、企业级的告警路由与分发中心。它旨在将纷繁复杂的告警源(Prometheus, Grafana, 自建脚本等)与最终接收人解耦,通过 **飞书机器人私聊** 实现告警的精准触达。 +**Alert Message Center** is a modern, enterprise-grade alert routing and dispatching hub. It decouples complex alert sources (Prometheus, Grafana, custom scripts, etc.) from end recipients, ensuring precise alert delivery via **Feishu (Lark) private messages and group chats.** --- -## 📸 界面预览 +## 📸 Preview -### 1. 话题管理与个人信箱 -支持通过 **Topic (主题)** 订阅模式分发告警,同时也提供 **Personal Inbox (个人信箱)** 功能,无需创建话题即可快速给自己推送消息。 +### 1. Topic Management & Personal Inbox +Supports alert distribution through the **Topic** subscription model, and also provides a **Personal Inbox** feature for quick self-notifications without creating a topic. ![Topics View](docs/images/topics_view.png) -除了个人订阅外,您可以将 Topic 绑定至多个**飞书群聊**。 -> [!TIP] -> **群聊发现**:请先将机器人邀请进入目标群聊。机器人入群后会触发自动感应,此时刷新管理页面即可在下拉菜单中看到并绑定该群组。 - -### 2. 群聊告警分发 -支持将机器人加入飞书群聊,并将话题绑定到群聊中,实现告警的群组广播。 +### 2. Group Chat Alert Dispatch +Supports adding the bot to Feishu group chats and binding topics to these groups for group-wide broadcasting. ![Group Binding](docs/images/group_binding.png) ![Group Alert](docs/images/group_alert.png) -### 3. 管理员看板 (Live Stats) -实时追踪全系统的告警负载、分发成功率以及各话题的热度。 +### 3. Admin Dashboard (Live Stats) +Real-time tracking of system alert load, dispatch success rates, and topic popularity. ![Admin Dashboard](docs/images/admin_dashboard.png) --- -## 🔥 核心特性 +## 🔥 Key Features -- **🚀 极简推送 (Personal Inbox)**: 每个用户拥有专属的 Webhook Token,直接向 `/dm` 接口发送即可在飞书收到私聊,零配置成本。 -- **📢 主题订阅 (Topic Model)**: 灵活的“发布-订阅”机制。告警发送至 Topic,系统自动分发给所有订阅成员。 -- **👥 群聊分发 (Group Support)**: 告警可同步分发至绑定的飞书群聊,支持机器人自动发现与解绑。 -- **🛡️ 权限与审计**: - - 话题创建需经过管理员审批。 - - 记录完整的 `Alert Task` 日志,实现发送链路可追溯。 -- **📊 实时看板**: Grafana 风格的监控界面,直观展示系统运行健壮性。 -- **🔌 长连接模式 (WebSocket)**: 支持飞书开放平台长连接,无需公网 IP 或域名即可在内网环境接收事件回调。 -- **⚡ 高性能架构**: 基于 Bun + Hono 的全异步架构,毫秒级分发延迟。 +- **🚀 Zero-Config Personal Inbox**: Every user has a unique Webhook Token. Send directly to the `/dm` interface to receive messages on Feishu with zero configuration. +- **📢 Topic Subscription Model**: Flexible "Publish-Subscribe" mechanism. Alerts sent to a Topic are automatically distributed to all subscribers. +- **👥 Group Chat Distribution**: Alerts can be simultaneously dispatched to bound Feishu group chats, supporting automatic discovery and unbinding. +- **🛡️ Permissions & Auditing**: + - Topic creation requires admin approval. + - 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. +- **⚡ High Performance**: Built on Bun + Hono for millisecond-level dispatch latency. --- -## 🛠️ 技术栈 +## 🛠️ Tech Stack -- **Runtime**: [Bun](https://bun.sh/) (高性能 TS 运行时) +- **Runtime**: [Bun](https://bun.sh/) (High-performance TS runtime) - **Backend**: [Hono](https://hono.dev/) (Web Standards Based) - **Frontend**: [React](https://react.dev/) + [Vite](https://vitejs.dev/) + [Tailwind CSS](https://tailwindcss.com/) - **Database**: [PostgreSQL](https://www.postgresql.org/) + [Drizzle ORM](https://orm.drizzle.team/) @@ -52,84 +50,53 @@ --- -## 🚀 快速开始 +## 🚀 Quick Start -### 1. 飞书应用配置 -1. 登录 [飞书开放平台](https://open.feishu.cn/) 创建一个 **企业自建应用**。 -2. 在“应用能力”中开启 **机器人**。 -3. 在“权限管理”中申请 `im:message:send_as_bot` (以应用身份发送消息)。 -4. 获取 `App ID` 和 `App Secret`。 +### 1. Feishu App Configuration +1. Login to the [Feishu Open Platform](https://open.feishu.cn/) and create an **Enterprise Custom App**. +2. Enable **Bot** capability in "App Capabilities". +3. Apply for `im:message:send_as_bot` permission in "Permission Management". +4. Get your `App ID` and `App Secret`. -### 2. 部署运行 +### 2. Deployment ```bash -# 安装依赖 +# Install dependencies bun install -# 配置环境变量 (apps/server/.env) +# Configure environment variables (apps/server/.env) DATABASE_URL="postgresql://user:pass@localhost:5432/db" FEISHU_APP_ID="cli_xxx" FEISHU_APP_SECRET="xxx" -ADMIN_EMAILS="user1@example.com,user2@example.com" # 管理员列表 +ADMIN_EMAILS="user1@example.com,user2@example.com" -# 数据库推送/迁移 +# Database migration cd apps/server && bun run db:migrate:deploy -# 启动开发环境 +# Start development bun run dev ``` -### 3. Docker 部署 -项目支持使用 Docker Compose 快速部署,且**数据库会自动进行初始化与迁移**: - +### 3. Docker Deployment ```bash -# 复制并填写环境变量 cp apps/server/.env.example .env - -# 启动所有服务 (Postgres + Server + Web) docker-compose up -d ``` --- ## 🏗️ CI/CD +Automatically builds and pushes Docker images to GitHub Container Registry (GHCR) on every push to `main`. -项目通过 GitHub Actions 实现了自动化流水线: +## 📜 Changelog +See [CHANGELOG.md](CHANGELOG.md) for version history. -- **自动化构建**: 每次推送至 `main` 分支或提交 Pull Request 时,会自动触发 Docker 镜像构建。 -- **镜像仓库**: 构建生成的镜像会同步推送到 GitHub Container Registry (GHCR)。 -- **镜像路径**: `ghcr.io/${USER}/alert-message-center` (包含前后端的统一镜像) +## 📡 Webhook Usage +- **Personal Inbox**: `POST /api/webhook/:your_token/dm` +- **Topic**: `POST /api/webhook/:your_token/topic/:topic_slug` ---- - -## 📜 更新日志 - -所有版本的详细变更记录请查看 [CHANGELOG.md](CHANGELOG.md)。 - ---- - -## 📡 Webhook 使用指南 - -### 1. 发送给个人 (Personal Inbox) -**URL**: `POST /api/webhook/:your_token/dm` -**Body**: -```json -{ - "msg_type": "text", - "content": { "text": "这是一条私有告警" } -} -``` - -### 2. 发送到主题 (Topic) -**URL**: `POST /api/webhook/:your_token/topic/:topic_slug` -**Body**: 同上。系统会自动根据该 Topic 的订阅列表进行广播。 - ---- - -## 📂 项目结构 - -- `apps/server`: 核心 API 服务,处理 OAuth、Webhook 解析与飞书分发。 -- `apps/web`: 响应式管理后台。 -- `docs/copilot-context.md`: 为 AI 辅助编程提供的深层架构背景。 +## 📂 Project Structure +- `apps/server`: Core API service +- `apps/web`: Responsive management dashboard --- *Created with ❤️ by the Alert Message Center Team.* diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 0000000..c501190 --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,119 @@ +# Alert Message Center + +[English](./README.md) | **简体中文** + +[![Tech Stack](https://img.shields.io/badge/Stack-Bun%20%7C%20Hono%20%7C%20React-blue)](https://bun.sh) +[![Database](https://img.shields.io/badge/Database-PostgreSQL-blue)](https://www.postgresql.org/) + +**Alert Message Center** 是一个现代化、企业级的告警路由与分发中心。它旨在将纷繁复杂的告警源(Prometheus, Grafana, 自建脚本等)与最终接收人解耦,通过 **飞书机器人私聊** 实现告警的精准触达。 + +--- + +## 📸 界面预览 + +### 1. 话题管理与个人信箱 +支持通过 **Topic (主题)** 订阅模式分发告警,同时也提供 **Personal Inbox (个人信箱)** 功能,无需创建话题即可快速给自己推送消息。 +![Topics View](docs/images/topics_view.png) + +### 2. 群聊告警分发 +支持将机器人加入飞书群聊,并将话题绑定到群聊中,实现告警的群组广播。 +![Group Binding](docs/images/group_binding.png) +![Group Alert](docs/images/group_alert.png) + +### 3. 管理员看板 (Live Stats) +实时追踪全系统的告警负载、分发成功率以及各话题的热度。 +![Admin Dashboard](docs/images/admin_dashboard.png) + +--- + +## 🔥 核心特性 + +- **🚀 极简推送 (Personal Inbox)**: 每个用户拥有专属的 Webhook Token,直接向 `/dm` 接口发送即可在飞书收到私聊,零配置成本。 +- **📢 主题订阅 (Topic Model)**: 灵活的“发布-订阅”机制。告警发送至 Topic,系统自动分发给所有订阅成员。 +- **👥 群聊分发 (Group Support)**: 告警可同步分发至绑定的飞书群聊,支持机器人自动发现与解绑。 +- **🛡️ 权限与审计**: + - 话题创建需经过管理员审批。 + - 记录完整的 `Alert Task` 日志,实现发送链路可追溯。 +- **📊 实时看板**: Grafana 风格的监控界面,直观展示系统运行健壮性。 +- **🔌 长连接模式 (WebSocket)**: 支持飞书开放平台长连接,无需公网 IP 或域名即可在内网环境接收事件回调。 +- **⚡ 高性能架构**: 基于 Bun + Hono 的全异步架构,毫秒级分发延迟。 + +--- + +## 🛠️ 技术栈 + +- **Runtime**: [Bun](https://bun.sh/) (高性能 TS 运行时) +- **Backend**: [Hono](https://hono.dev/) (Web Standards Based) +- **Frontend**: [React](https://react.dev/) + [Vite](https://vitejs.dev/) + [Tailwind CSS](https://tailwindcss.com/) +- **Database**: [PostgreSQL](https://www.postgresql.org/) + [Drizzle ORM](https://orm.drizzle.team/) +- **Messaging**: [Feishu (Lark) Open Platform](https://open.feishu.cn/) + +--- + +## 🚀 快速开始 + +### 1. 飞书应用配置 +1. 登录 [飞书开放平台](https://open.feishu.cn/) 创建一个 **企业自建应用**。 +2. 在“应用能力”中开启 **机器人**。 +3. 在“权限管理”中申请 `im:message:send_as_bot` (以应用身份发送消息)。 +4. 获取 `App ID` 和 `App Secret`。 + +### 2. 部署运行 +```bash +# 安装依赖 +bun install + +# 配置环境变量 (apps/server/.env) +DATABASE_URL="postgresql://user:pass@localhost:5432/db" +FEISHU_APP_ID="cli_xxx" +FEISHU_APP_SECRET="xxx" +ADMIN_EMAILS="user1@example.com,user2@example.com" # 管理员列表 + +# 数据库推送/迁移 +cd apps/server && bun run db:migrate:deploy + +# 启动开发环境 +bun run dev +``` + +### 3. Docker 部署 +项目支持使用 Docker Compose 快速部署,且**数据库会自动进行初始化与迁移**: + +```bash +# 复制并填写环境变量 +cp apps/server/.env.example .env + +# 启动所有服务 (Postgres + Server + Web) +docker-compose up -d +``` + +--- + +## 🏗️ CI/CD + +项目通过 GitHub Actions 实现了自动化流水线。 + +## 📜 更新日志 + +所有版本的详细变更记录请查看 [CHANGELOG.md](CHANGELOG.md)。 + +--- + +## 📡 Webhook 使用指南 + +### 1. 发送给个人 (Personal Inbox) +**URL**: `POST /api/webhook/:your_token/dm` + +### 2. 发送到主题 (Topic) +**URL**: `POST /api/webhook/:your_token/topic/:topic_slug` + +--- + +## 📂 项目结构 + +- `apps/server`: 核心 API 服务,处理 OAuth、Webhook 解析与飞书分发。 +- `apps/web`: 响应式管理后台。 +- `docs/copilot-context.md`: 为 AI 辅助编程提供的深层架构背景。 + +--- +*Created with ❤️ by the Alert Message Center Team.* diff --git a/apps/server/src/api.ts b/apps/server/src/api.ts index 006f9d3..e5adde1 100644 --- a/apps/server/src/api.ts +++ b/apps/server/src/api.ts @@ -11,6 +11,7 @@ import { topics, users, } from "./db/schema"; +import { notifyAdminsOfNewTopic } from "./lib/admin-notifier"; import { type AuthSession, requireAdmin, requireAuth } from "./middleware"; const api = new Hono<{ Variables: { session: AuthSession } }>(); @@ -30,6 +31,7 @@ const userSchema = z.object({ name: z.string().min(1), feishuUserId: z.string().min(1), email: z.string().email().optional().or(z.literal("")), + isTrusted: z.boolean().optional(), }); // --- Topics --- @@ -73,6 +75,17 @@ api.get("/topics/requests", requireAdmin, async (c) => { return c.json(requests); }); +api.get("/topics/groups/requests", requireAdmin, async (c) => { + const requests = await db.query.topicGroupChats.findMany({ + where: eq(topicGroupChats.status, "pending"), + with: { + topic: true, + creator: true, + }, + }); + return c.json(requests); +}); + api.get("/topics/all", requireAdmin, async (c) => { const allTopics = await db.query.topics.findMany({ with: { @@ -124,7 +137,7 @@ api.post("/topics", requireAuth, zValidator("json", topicSchema), async (c) => { const body = c.req.valid("json"); const session = c.get("session"); - const status = session.isAdmin ? "approved" : "pending"; + const status = session.isAdmin || session.isTrusted ? "approved" : "pending"; const result = await db .insert(topics) @@ -132,9 +145,18 @@ api.post("/topics", requireAuth, zValidator("json", topicSchema), async (c) => { ...body, status, createdBy: session.id, - approvedBy: session.isAdmin ? session.id : null, + approvedBy: session.isAdmin || session.isTrusted ? session.id : null, }) .returning(); + if (status === "pending") { + await notifyAdminsOfNewTopic({ + id: result[0].id, + name: result[0].name, + slug: result[0].slug, + createdBy: session.id, + }); + } + return c.json(result[0]); }); @@ -248,12 +270,20 @@ api.delete("/topics/:topicId/subscribe/:userId", requireAuth, async (c) => { // Get list of known groups (for selection) api.get("/groups", requireAuth, async (c) => { + const query = c.req.query("q")?.trim(); + const limit = Math.min(Number(c.req.query("limit") || 100), 200); + + const whereClause = query + ? sql`${knownGroupChats.name} ilike ${`%${query}%`}` + : undefined; + // Return recent active groups const groups = await db .select() .from(knownGroupChats) + .where(whereClause) .orderBy(desc(knownGroupChats.lastActiveAt)) - .limit(50); + .limit(limit); return c.json(groups); }); @@ -278,6 +308,25 @@ api.post( const body = c.req.valid("json"); const session = c.get("session"); + // Check topic ownership or admin + const topic = await db.query.topics.findFirst({ + where: eq(topics.id, topicId), + }); + + if (!topic) { + return c.json({ error: "Topic not found" }, 404); + } + + if (topic.createdBy !== session.id && !session.isAdmin) { + return c.json( + { error: "Only topic owner or admin can bind groups" }, + 403, + ); + } + + const status = + session.isAdmin || session.isTrusted ? "approved" : "pending"; + const result = await db .insert(topicGroupChats) .values({ @@ -285,9 +334,23 @@ api.post( chatId: body.chatId, name: body.name, createdBy: session.id, + status, }) .returning(); + if (status === "pending") { + // Notify admins about the new group binding request + await notifyAdminsOfNewTopic({ + id: topic.id, + name: topic.name, + slug: topic.slug, + createdBy: session.id, + // Metadata passed to notifier for better context + isGroupBinding: true, + groupName: body.name, + }); + } + return c.json(result[0]); }, ); @@ -295,6 +358,23 @@ api.post( // Unbind a group api.delete("/topics/:id/groups/:bindingId", requireAuth, async (c) => { const { id: topicId, bindingId } = c.req.param(); + const session = c.get("session"); + + // Check topic ownership or admin + const topic = await db.query.topics.findFirst({ + where: eq(topics.id, topicId), + }); + + if (!topic) { + return c.json({ error: "Topic not found" }, 404); + } + + if (topic.createdBy !== session.id && !session.isAdmin) { + return c.json( + { error: "Only topic owner or admin can unbind groups" }, + 403, + ); + } await db .delete(topicGroupChats) @@ -308,6 +388,38 @@ api.delete("/topics/:id/groups/:bindingId", requireAuth, async (c) => { return c.json({ success: true }); }); +// Approve a group binding +api.post("/topics/:id/groups/:bindingId/approve", requireAdmin, async (c) => { + const { id: topicId, bindingId } = c.req.param(); + const result = await db + .update(topicGroupChats) + .set({ status: "approved" }) + .where( + and( + eq(topicGroupChats.id, bindingId), + eq(topicGroupChats.topicId, topicId), + ), + ) + .returning(); + return c.json(result[0]); +}); + +// Reject a group binding +api.post("/topics/:id/groups/:bindingId/reject", requireAdmin, async (c) => { + const { id: topicId, bindingId } = c.req.param(); + const result = await db + .update(topicGroupChats) + .set({ status: "rejected" }) + .where( + and( + eq(topicGroupChats.id, bindingId), + eq(topicGroupChats.topicId, topicId), + ), + ) + .returning(); + return c.json(result[0]); +}); + // --- Alert Tasks --- api.get("/alerts/tasks", requireAdmin, async (c) => { diff --git a/apps/server/src/auth.ts b/apps/server/src/auth.ts index cfcc02a..a275abc 100644 --- a/apps/server/src/auth.ts +++ b/apps/server/src/auth.ts @@ -78,13 +78,13 @@ auth.get("/callback", async (c) => { .returning(); user = result[0]; } else { - // Update user info (in case name or admin status changed) + // Update user info (don't overwrite admin/trusted status from feishu logic unless it's a new admin) const result = await db .update(users) .set({ name: userData.name, email: userData.email || user.email, - isAdmin, + isAdmin: user.isAdmin || isAdmin, // Keep admin if already admin or in ADMIN_EMAILS }) .where(eq(users.id, user.id)) .returning(); @@ -100,6 +100,7 @@ auth.get("/callback", async (c) => { name: user.name, email: user.email, isAdmin: user.isAdmin, + isTrusted: user.isTrusted, personalToken: user.personalToken, }), { @@ -117,6 +118,7 @@ auth.get("/callback", async (c) => { name: user.name, email: user.email, isAdmin: user.isAdmin, + isTrusted: user.isTrusted, }, }); } catch (error) { diff --git a/apps/server/src/db/schema.ts b/apps/server/src/db/schema.ts index a503c7d..2d7b55f 100644 --- a/apps/server/src/db/schema.ts +++ b/apps/server/src/db/schema.ts @@ -35,6 +35,9 @@ export const topicGroupChats = pgTable("topic_group_chats", { .references(() => topics.id, { onDelete: "cascade" }), chatId: text("chat_id").notNull(), // 飞书群 chat_id name: text("name").notNull(), // 群名称快照 + status: text("status", { enum: ["pending", "approved", "rejected"] }) + .default("approved") + .notNull(), createdAt: timestamp("created_at").defaultNow().notNull(), createdBy: text("created_by").references(() => users.id), }); @@ -83,6 +86,7 @@ export const users = pgTable("users", { feishuUserId: text("feishu_user_id").notNull(), // 必须有飞书 ID 才能私聊 (open_id 或 user_id) email: text("email").unique(), isAdmin: boolean("is_admin").default(false), + isTrusted: boolean("is_trusted").default(false), personalToken: text("personal_token") .notNull() .unique() diff --git a/apps/server/src/lib/admin-notifier.ts b/apps/server/src/lib/admin-notifier.ts new file mode 100644 index 0000000..ca7185c --- /dev/null +++ b/apps/server/src/lib/admin-notifier.ts @@ -0,0 +1,85 @@ +import { eq } from "drizzle-orm"; +import { db } from "../db"; +import { users } from "../db/schema"; +import { feishuClient } from "../feishu"; +import { logger } from "./logger"; + +export async function notifyAdminsOfNewTopic(topic: { + id: string; + name: string; + slug: string; + createdBy: string | null; + isGroupBinding?: boolean; + groupName?: string; +}) { + try { + // 1. Get all admins + const admins = await db.query.users.findMany({ + where: eq(users.isAdmin, true), + }); + + if (admins.length === 0) { + logger.warn("No admins found to notify"); + return; + } + + // 2. Get creator name + let creatorName = "Unknown"; + if (topic.createdBy) { + const creator = await db.query.users.findFirst({ + where: eq(users.id, topic.createdBy), + }); + if (creator) creatorName = creator.name; + } + + // 3. Prepare message content + const title = topic.isGroupBinding + ? "🔗 新的群聊绑定申请" + : "🆕 新的 Topic 申请"; + const detailContent = topic.isGroupBinding + ? `**Topic:** ${topic.name}\n**群聊:** ${topic.groupName}\n**创建者:** ${creatorName}` + : `**名称:** ${topic.name}\n**Slug:** ${topic.slug}\n**创建者:** ${creatorName}`; + + const content = { + config: { wide_screen_mode: true }, + header: { + template: topic.isGroupBinding ? "blue" : "orange", + title: { content: title, tag: "plain_text" }, + }, + elements: [ + { + tag: "div", + text: { + content: detailContent, + tag: "lark_md", + }, + }, + { + tag: "action", + actions: [ + { + tag: "button", + text: { content: "前往审批", tag: "plain_text" }, + type: "primary", + url: `${process.env.FRONTEND_URL || "http://localhost:5173"}/admin/topics`, + }, + ], + }, + ], + }; + + // 4. Send notification to each admin + for (const admin of admins) { + if (admin.feishuUserId) { + await feishuClient.sendMessage( + admin.feishuUserId, + "open_id", + "interactive", + content, + ); + } + } + } catch (error) { + logger.error({ err: error, topicId: topic.id }, "Failed to notify admins"); + } +} diff --git a/apps/server/src/middleware.ts b/apps/server/src/middleware.ts index 6741dd7..c8e6f78 100644 --- a/apps/server/src/middleware.ts +++ b/apps/server/src/middleware.ts @@ -6,6 +6,7 @@ export interface AuthSession { name: string; email: string | null; isAdmin: boolean; + isTrusted: boolean; } export async function requireAuth(c: Context, next: Next) { diff --git a/apps/server/src/webhook.ts b/apps/server/src/webhook.ts index dfc3eb9..18d3e5d 100644 --- a/apps/server/src/webhook.ts +++ b/apps/server/src/webhook.ts @@ -82,13 +82,15 @@ webhook.post("/:token/topic/:slug", async (c) => { }) .filter((u): u is NonNullable => u !== null); - const groupRecipients: Recipient[] = topic.groupChats.map((g) => ({ - type: "group", - id: g.id, // Binding ID - name: g.name, - feishuId: g.chatId, - idType: "chat_id" as FeishuReceiveIdType, - })); + const groupRecipients: Recipient[] = topic.groupChats + .filter((g) => g.status === "approved") + .map((g) => ({ + type: "group", + id: g.id, // Binding ID + name: g.name, + feishuId: g.chatId, + idType: "chat_id" as FeishuReceiveIdType, + })); const allRecipients: Recipient[] = [...userRecipients, ...groupRecipients]; diff --git a/apps/web/src/components/GroupBindingsModal.tsx b/apps/web/src/components/GroupBindingsModal.tsx index 12d43ab..97eb017 100644 --- a/apps/web/src/components/GroupBindingsModal.tsx +++ b/apps/web/src/components/GroupBindingsModal.tsx @@ -1,5 +1,5 @@ -import { MessageCircle, Plus, Trash2 } from "lucide-react"; -import { useCallback, useEffect, useState } from "react"; +import { MessageCircle, Plus, Search, Trash2, X } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { client } from "../lib/client"; import Modal from "./Modal"; @@ -7,6 +7,7 @@ interface GroupBinding { id: string; chatId: string; name: string; + status: "pending" | "approved" | "rejected"; } interface KnownGroup { @@ -28,16 +29,21 @@ export default function GroupBindingsModal({ topicId, topicName, }: GroupBindingsModalProps) { - // const { user } = useAuth(); // Unused const [bindings, setBindings] = useState([]); const [knownGroups, setKnownGroups] = useState([]); + const [searchQuery, setSearchQuery] = useState(""); const [selectedChatId, setSelectedChatId] = useState(""); const [loading, setLoading] = useState(false); + const [isSearching, setIsSearching] = useState(false); + const [showDropdown, setShowDropdown] = useState(false); const [status, setStatus] = useState<{ type: "success" | "error"; message: string; } | null>(null); + const dropdownRef = useRef(null); + const searchTimeoutRef = useRef | null>(null); + const fetchBindings = useCallback(async () => { try { const res = await client.api.topics[":id"].groups.$get( @@ -57,17 +63,25 @@ export default function GroupBindingsModal({ } }, [topicId]); - const fetchKnownGroups = useCallback(async () => { + const fetchKnownGroups = useCallback(async (q?: string) => { + setIsSearching(true); try { - const res = await client.api.groups.$get(undefined, { - init: { credentials: "include" }, - }); + const res = await client.api.groups.$get( + { + query: q ? { q } : undefined, + }, + { + init: { credentials: "include" }, + }, + ); const data = (await res.json()) as KnownGroup[]; if (Array.isArray(data)) { setKnownGroups(data); } } catch (err) { console.error(err); + } finally { + setIsSearching(false); } }, []); @@ -77,24 +91,60 @@ export default function GroupBindingsModal({ fetchKnownGroups(); setStatus(null); setSelectedChatId(""); + setSearchQuery(""); + setShowDropdown(false); } }, [isOpen, topicId, fetchBindings, fetchKnownGroups]); + // Handle click outside to close dropdown + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) + ) { + setShowDropdown(false); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + const handleSearchChange = (e: React.ChangeEvent) => { + const value = e.target.value; + setSearchQuery(value); + setSelectedChatId(""); + setShowDropdown(true); + + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current); + } + + searchTimeoutRef.current = setTimeout(() => { + fetchKnownGroups(value); + }, 300); + }; + + const handleSelectGroup = (group: KnownGroup) => { + setSelectedChatId(group.chatId); + setSearchQuery(group.name); + setShowDropdown(false); + }; + const handleBind = async () => { if (!selectedChatId) return; setLoading(true); setStatus(null); - const group = knownGroups.find((g) => g.chatId === selectedChatId); - if (!group) return; + const groupName = searchQuery; try { const res = await client.api.topics[":id"].groups.$post( { param: { id: topicId }, json: { - chatId: group.chatId, - name: group.name, + chatId: selectedChatId, + name: groupName, }, }, { @@ -103,15 +153,22 @@ export default function GroupBindingsModal({ ); if (res.ok) { - setStatus({ type: "success", message: "Group bound successfully!" }); + const data = (await res.json()) as GroupBinding; + setStatus({ + type: "success", + message: + data.status === "approved" + ? "Group bound successfully!" + : "Request submitted! Waiting for approval.", + }); fetchBindings(); setSelectedChatId(""); + setSearchQuery(""); } else { - await res.json(); // Consume body + await res.json(); setStatus({ type: "error", message: "Failed to bind group" }); } } catch (_) { - // Ignore error setStatus({ type: "error", message: "An error occurred" }); } finally { setLoading(false); @@ -139,7 +196,6 @@ export default function GroupBindingsModal({ } }; - // Filter out groups that are already bound const availableGroups = knownGroups.filter( (kg) => !bindings.some((b) => b.chatId === kg.chatId), ); @@ -152,33 +208,54 @@ export default function GroupBindingsModal({ >
-

+

+ Bound Groups

{bindings.length === 0 ? ( -

- No groups bound to this topic yet. -

+
+

+ No groups bound to this topic yet. +

+
) : ( -
    +
      {bindings.map((binding) => (
    • -
      - - - {binding.name} +
      +
      + +
      +
      + + {binding.name} + + + {binding.chatId} + +
      + + {binding.status}
    • ))} @@ -186,45 +263,118 @@ export default function GroupBindingsModal({ )}
-
-

+
+

+ Add Group Binding

-

- Select a group where the Feishu Bot has been added. If your group is - not listed, try removing and re-adding the bot to the group. +

+ Search and select a group where the Alert Messenger{" "} + bot has been added.

-
- +
+
+
+
+ {isSearching ? ( +
+ ) : ( + + )} +
+ + knownGroups.length > 0 && setShowDropdown(true) + } + disabled={loading} + /> + {searchQuery && ( + + )} +
+ + {showDropdown && ( +
+ {availableGroups.length > 0 ? ( + availableGroups.map((group) => ( + + )) + ) : ( +
+

No results found

+

+ Try a different search term or check if the bot is in + the group. +

+
+ )} +
+ )} +
+
+ {status && ( -

- {status.message} -

+
{status.message}
+
)}
diff --git a/apps/web/src/views/AdminView.tsx b/apps/web/src/views/AdminView.tsx index 1d88f26..b21b841 100644 --- a/apps/web/src/views/AdminView.tsx +++ b/apps/web/src/views/AdminView.tsx @@ -30,7 +30,7 @@ export default function AdminView() {

-
+
); } +interface GroupRequest { + id: string; + topicId: string; + chatId: string; + name: string; + status: string; + createdAt: string; + topic?: Topic; + creator?: TopicUser; +} + +function GroupRequestsList() { + const [requests, setRequests] = useState([]); + const [loading, setLoading] = useState(true); + + const fetchRequests = useCallback(async () => { + setLoading(true); + try { + const res = await client.api.topics.groups.requests.$get(undefined, { + init: { credentials: "include" }, + }); + if (res.ok) { + const data = await res.json(); + if (Array.isArray(data)) { + setRequests(data as unknown as GroupRequest[]); + } else { + setRequests([]); + } + } else { + setRequests([]); + } + } catch (error) { + console.error(error); + setRequests([]); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchRequests(); + }, [fetchRequests]); + + const handleAction = async ( + req: GroupRequest, + action: "approve" | "reject", + ) => { + try { + await client.api.topics[":id"].groups[":bindingId"][action].$post( + { param: { id: req.topicId, bindingId: req.id } }, + { init: { credentials: "include" } }, + ); + fetchRequests(); + } catch (error) { + console.error(error); + } + }; + + if (loading) return
Loading group requests...
; + + if (requests.length === 0) { + return ( +
+ No pending group binding requests. +
+ ); + } + + return ( +
+
    + {requests.map((req) => ( +
  • +
    +

    + Group: {req.name} +

    +

    + Topic: {req.topic?.name}{" "} + ({req.topic?.slug}) +

    +

    + Requested by: {req.creator?.name || "Unknown"} +

    +

    ID: {req.chatId}

    +
    +
    + + +
    +
  • + ))} +
+
+ ); +} + function TopicsManagement() { const [topics, setTopics] = useState([]); const [loading, setLoading] = useState(true); diff --git a/apps/web/src/views/UsersView.tsx b/apps/web/src/views/UsersView.tsx index f819374..57770f7 100644 --- a/apps/web/src/views/UsersView.tsx +++ b/apps/web/src/views/UsersView.tsx @@ -10,6 +10,8 @@ interface User { feishuUserId?: string; email?: string; personalToken?: string; + isTrusted?: boolean; + isAdmin?: boolean; } export default function UsersView() { @@ -85,6 +87,28 @@ export default function UsersView() { } }; + const handleToggleTrusted = async (user: User) => { + try { + await client.api.users[":id"].$put( + { + param: { id: user.id }, + json: { + name: user.name, + feishuUserId: user.feishuUserId, + email: user.email, + isTrusted: !user.isTrusted, + }, + }, + { + init: { credentials: "include" }, + }, + ); + fetchUsers(); + } catch (error) { + console.error("Error toggling trusted status:", error); + } + }; + if (loading) return
Loading...
; return ( @@ -110,9 +134,16 @@ export default function UsersView() {
-

- {user.name} -

+
+

+ {user.name} +

+ {user.isAdmin && ( + + Admin + + )} +

@@ -134,11 +165,25 @@ export default function UsersView() {

{currentUser?.isAdmin && ( -
+
+
+