mirror of
https://github.com/d0zingcat/alert-message-center.git
synced 2026-05-13 15:09:19 +00:00
Merge pull request #15 from d0zingcat/perf/onboard
This commit is contained in:
26
CHANGELOG.md
26
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。
|
||||
|
||||
121
README.md
121
README.md
@@ -1,50 +1,48 @@
|
||||
# Alert Message Center
|
||||
|
||||
**README** | [简体中文](./README.zh-CN.md)
|
||||
|
||||
[](https://bun.sh)
|
||||
[](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.
|
||||

|
||||
|
||||
除了个人订阅外,您可以将 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.
|
||||

|
||||

|
||||
|
||||
### 3. 管理员看板 (Live Stats)
|
||||
实时追踪全系统的告警负载、分发成功率以及各话题的热度。
|
||||
### 3. Admin Dashboard (Live Stats)
|
||||
Real-time tracking of system alert load, dispatch success rates, and topic popularity.
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 🔥 核心特性
|
||||
## 🔥 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.*
|
||||
|
||||
119
README.zh-CN.md
Normal file
119
README.zh-CN.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# Alert Message Center
|
||||
|
||||
[English](./README.md) | **简体中文**
|
||||
|
||||
[](https://bun.sh)
|
||||
[](https://www.postgresql.org/)
|
||||
|
||||
**Alert Message Center** 是一个现代化、企业级的告警路由与分发中心。它旨在将纷繁复杂的告警源(Prometheus, Grafana, 自建脚本等)与最终接收人解耦,通过 **飞书机器人私聊** 实现告警的精准触达。
|
||||
|
||||
---
|
||||
|
||||
## 📸 界面预览
|
||||
|
||||
### 1. 话题管理与个人信箱
|
||||
支持通过 **Topic (主题)** 订阅模式分发告警,同时也提供 **Personal Inbox (个人信箱)** 功能,无需创建话题即可快速给自己推送消息。
|
||||

|
||||
|
||||
### 2. 群聊告警分发
|
||||
支持将机器人加入飞书群聊,并将话题绑定到群聊中,实现告警的群组广播。
|
||||

|
||||

|
||||
|
||||
### 3. 管理员看板 (Live Stats)
|
||||
实时追踪全系统的告警负载、分发成功率以及各话题的热度。
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 🔥 核心特性
|
||||
|
||||
- **🚀 极简推送 (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.*
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
85
apps/server/src/lib/admin-notifier.ts
Normal file
85
apps/server/src/lib/admin-notifier.ts
Normal file
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ export interface AuthSession {
|
||||
name: string;
|
||||
email: string | null;
|
||||
isAdmin: boolean;
|
||||
isTrusted: boolean;
|
||||
}
|
||||
|
||||
export async function requireAuth(c: Context, next: Next) {
|
||||
|
||||
@@ -82,13 +82,15 @@ webhook.post("/:token/topic/:slug", async (c) => {
|
||||
})
|
||||
.filter((u): u is NonNullable<typeof u> => 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];
|
||||
|
||||
|
||||
@@ -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<GroupBinding[]>([]);
|
||||
const [knownGroups, setKnownGroups] = useState<KnownGroup[]>([]);
|
||||
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<HTMLDivElement>(null);
|
||||
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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<HTMLInputElement>) => {
|
||||
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({
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-2">
|
||||
<h4 className="text-sm font-semibold text-gray-900 mb-3 flex items-center">
|
||||
<MessageCircle className="w-4 h-4 mr-2 text-indigo-500" />
|
||||
Bound Groups
|
||||
</h4>
|
||||
{bindings.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 italic">
|
||||
No groups bound to this topic yet.
|
||||
</p>
|
||||
<div className="text-center py-6 bg-gray-50 rounded-lg border-2 border-dashed border-gray-200">
|
||||
<p className="text-sm text-gray-400">
|
||||
No groups bound to this topic yet.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-gray-200 border rounded-md">
|
||||
<ul className="divide-y divide-gray-100 border rounded-lg overflow-hidden bg-white shadow-sm">
|
||||
{bindings.map((binding) => (
|
||||
<li
|
||||
key={binding.id}
|
||||
className="flex justify-between items-center p-3"
|
||||
className="flex justify-between items-center p-3 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<MessageCircle className="w-4 h-4 text-gray-400 mr-2" />
|
||||
<span className="text-sm text-gray-700">
|
||||
{binding.name}
|
||||
<div className="flex items-center flex-1 min-w-0">
|
||||
<div className="w-8 h-8 rounded-full bg-indigo-50 flex items-center justify-center mr-3 flex-shrink-0">
|
||||
<MessageCircle className="w-4 h-4 text-indigo-600" />
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-sm font-medium text-gray-900 truncate">
|
||||
{binding.name}
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-400 font-mono truncate">
|
||||
{binding.chatId}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={`ml-3 inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold tracking-wider uppercase ${
|
||||
binding.status === "approved"
|
||||
? "bg-green-100 text-green-700"
|
||||
: binding.status === "rejected"
|
||||
? "bg-red-100 text-red-700"
|
||||
: "bg-amber-100 text-amber-700"
|
||||
}`}
|
||||
>
|
||||
{binding.status}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleUnbind(binding.id)}
|
||||
className="text-red-500 hover:text-red-700 p-1 rounded hover:bg-red-50"
|
||||
className="ml-2 text-gray-400 hover:text-red-500 p-1.5 rounded-md hover:bg-red-50 transition-colors"
|
||||
title="Remove binding"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
<Trash2 className="w-4.5 h-4.5" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
@@ -186,45 +263,118 @@ export default function GroupBindingsModal({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 p-4 rounded-md border border-gray-200">
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-3">
|
||||
<div className="bg-indigo-50/50 p-5 rounded-xl border border-indigo-100 shadow-sm">
|
||||
<h4 className="text-sm font-semibold text-gray-900 mb-2 flex items-center">
|
||||
<Plus className="w-4 h-4 mr-2 text-indigo-500" />
|
||||
Add Group Binding
|
||||
</h4>
|
||||
<p className="text-xs text-gray-500 mb-3">
|
||||
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.
|
||||
<p className="text-xs text-gray-500 mb-4 leading-relaxed">
|
||||
Search and select a group where the <strong>Alert Messenger</strong>{" "}
|
||||
bot has been added.
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2 text-gray-900"
|
||||
value={selectedChatId}
|
||||
onChange={(e) => setSelectedChatId(e.target.value)}
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="">Select a group...</option>
|
||||
{availableGroups.map((group) => (
|
||||
<option key={group.chatId} value={group.chatId}>
|
||||
{group.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
{isSearching ? (
|
||||
<div className="w-4 h-4 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<Search className="h-4 w-4 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
className="block w-full pl-10 pr-10 py-2.5 bg-white border border-gray-300 rounded-lg shadow-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm text-gray-900 placeholder-gray-400 transition-all"
|
||||
placeholder="Search for a group name..."
|
||||
value={searchQuery}
|
||||
onChange={handleSearchChange}
|
||||
onFocus={() =>
|
||||
knownGroups.length > 0 && setShowDropdown(true)
|
||||
}
|
||||
disabled={loading}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSearchQuery("");
|
||||
setSelectedChatId("");
|
||||
fetchKnownGroups();
|
||||
}}
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showDropdown && (
|
||||
<div className="absolute z-10 mt-1 w-full bg-white shadow-xl max-h-60 rounded-lg py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm border border-gray-100">
|
||||
{availableGroups.length > 0 ? (
|
||||
availableGroups.map((group) => (
|
||||
<button
|
||||
key={group.chatId}
|
||||
type="button"
|
||||
className="w-full text-left px-4 py-2.5 hover:bg-indigo-50 cursor-pointer flex items-center group transition-colors"
|
||||
onClick={() => handleSelectGroup(group)}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-gray-900 truncate group-hover:text-indigo-700">
|
||||
{group.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 font-mono truncate">
|
||||
{group.chatId}
|
||||
</div>
|
||||
</div>
|
||||
{selectedChatId === group.chatId && (
|
||||
<Plus className="w-4 h-4 text-indigo-600 ml-2" />
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="px-4 py-6 text-center text-gray-500">
|
||||
<p className="text-sm font-medium">No results found</p>
|
||||
<p className="text-xs mt-1">
|
||||
Try a different search term or check if the bot is in
|
||||
the group.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBind}
|
||||
disabled={!selectedChatId || loading}
|
||||
className="inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="w-full inline-flex items-center justify-center px-4 py-2.5 border border-transparent text-sm font-semibold rounded-lg shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed transition-all transform active:scale-[0.98]"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
Add
|
||||
{loading ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin mr-2" />
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Bind This Group
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{status && (
|
||||
<p
|
||||
className={`mt-2 text-xs ${status.type === "success" ? "text-green-600" : "text-red-600"}`}
|
||||
<div
|
||||
className={`mt-4 p-3 rounded-lg flex items-start gap-2 ${
|
||||
status.type === "success"
|
||||
? "bg-green-50 text-green-700 border border-green-100"
|
||||
: "bg-red-50 text-red-700 border border-red-100"
|
||||
}`}
|
||||
>
|
||||
{status.message}
|
||||
</p>
|
||||
<div className="text-sm font-medium">{status.message}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -30,7 +30,7 @@ export default function AdminView() {
|
||||
</div>
|
||||
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<div className="border-b border-gray-200 mb-6">
|
||||
<div className="border-b border-gray-200 mb-6 overflow-x-auto">
|
||||
<nav className="-mb-px flex space-x-8">
|
||||
<button
|
||||
type="button"
|
||||
@@ -54,6 +54,17 @@ export default function AdminView() {
|
||||
>
|
||||
Topic Requests
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("group-requests")}
|
||||
className={`${
|
||||
activeTab === "group-requests"
|
||||
? "border-indigo-500 text-indigo-600"
|
||||
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
||||
} whitespace-nowrap pb-4 px-1 border-b-2 font-medium text-sm`}
|
||||
>
|
||||
Group Bindings
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("topics")}
|
||||
@@ -70,12 +81,122 @@ export default function AdminView() {
|
||||
|
||||
{activeTab === "load" && <SystemLoadView />}
|
||||
{activeTab === "requests" && <TopicRequestsList />}
|
||||
{activeTab === "group-requests" && <GroupRequestsList />}
|
||||
{activeTab === "topics" && <TopicsManagement />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface GroupRequest {
|
||||
id: string;
|
||||
topicId: string;
|
||||
chatId: string;
|
||||
name: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
topic?: Topic;
|
||||
creator?: TopicUser;
|
||||
}
|
||||
|
||||
function GroupRequestsList() {
|
||||
const [requests, setRequests] = useState<GroupRequest[]>([]);
|
||||
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 <div>Loading group requests...</div>;
|
||||
|
||||
if (requests.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
No pending group binding requests.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden">
|
||||
<ul className="divide-y divide-gray-200">
|
||||
{requests.map((req) => (
|
||||
<li key={req.id} className="py-4 flex justify-between items-center">
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">
|
||||
Group: <span className="text-indigo-600">{req.name}</span>
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Topic: <span className="font-semibold">{req.topic?.name}</span>{" "}
|
||||
({req.topic?.slug})
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Requested by: {req.creator?.name || "Unknown"}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">ID: {req.chatId}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAction(req, "approve")}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 text-sm font-medium shadow-sm transition-colors"
|
||||
>
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAction(req, "reject")}
|
||||
className="px-4 py-2 bg-red-100 text-red-700 rounded hover:bg-red-200 text-sm font-medium transition-colors"
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TopicsManagement() {
|
||||
const [topics, setTopics] = useState<Topic[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -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 <div className="p-4">Loading...</div>;
|
||||
|
||||
return (
|
||||
@@ -110,9 +134,16 @@ export default function UsersView() {
|
||||
<div className="px-4 py-4 sm:px-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-indigo-600 truncate">
|
||||
{user.name}
|
||||
</p>
|
||||
<div className="flex items-center space-x-2">
|
||||
<p className="text-sm font-medium text-indigo-600 truncate">
|
||||
{user.name}
|
||||
</p>
|
||||
{user.isAdmin && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800">
|
||||
Admin
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 sm:flex sm:justify-between">
|
||||
<div className="sm:flex flex-col">
|
||||
<p className="flex items-center text-sm text-gray-500">
|
||||
@@ -134,11 +165,25 @@ export default function UsersView() {
|
||||
</div>
|
||||
</div>
|
||||
{currentUser?.isAdmin && (
|
||||
<div className="ml-4 flex items-center space-x-2">
|
||||
<div className="ml-4 flex items-center space-x-4">
|
||||
<div className="flex items-center">
|
||||
<label className="inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
checked={user.isTrusted || false}
|
||||
onChange={() => handleToggleTrusted(user)}
|
||||
/>
|
||||
<div className="relative w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-indigo-300 rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-indigo-600" />
|
||||
<span className="ms-3 text-sm font-medium text-gray-500">
|
||||
Trusted
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(user.id)}
|
||||
className="text-red-600 hover:text-red-900 p-2"
|
||||
className="text-red-400 hover:text-red-600 p-2 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Project Context for GitHub Copilot (v1.2.7)
|
||||
# Project Context for GitHub Copilot (v1.3.2)
|
||||
|
||||
This document provides technical context, architectural decisions, and code conventions for the **Alert Message Center** project. It is intended to help AI assistants understand the codebase.
|
||||
|
||||
@@ -51,6 +51,7 @@ The database schema is defined in `apps/server/src/db/schema.ts`.
|
||||
- `feishuUserId`: The Feishu `open_id`. **Critical** for sending messages.
|
||||
- `email`: Contact info.
|
||||
- `isAdmin`: Boolean flag for administrative privileges (create topics, view all users).
|
||||
- `isTrusted`: Boolean flag for trusted users (topics are auto-approved).
|
||||
|
||||
3. **Subscription** (`subscriptions`)
|
||||
- `topicId`: Foreign Key -> `topics.id`.
|
||||
@@ -62,6 +63,8 @@ The database schema is defined in `apps/server/src/db/schema.ts`.
|
||||
- `topicId`: Foreign Key -> `topics.id`.
|
||||
- `chatId`: The Feishu `chat_id`.
|
||||
- `name`: Group name (snapshot).
|
||||
- `status`: `pending`, `approved`, or `rejected`.
|
||||
- `createdBy`: Foreign Key -> `users.id`.
|
||||
- **Relationship**: Many-to-Many between Topics and Feishu Groups.
|
||||
|
||||
5. **Known Group Chat** (`known_group_chats`)
|
||||
@@ -130,7 +133,12 @@ The database schema is defined in `apps/server/src/db/schema.ts`.
|
||||
- The system listens for `im.chat.member.bot.deleted_v1` events.
|
||||
- When the bot is removed, the cached group is deleted from `known_group_chats`.
|
||||
- **Auto-Unbind**: All bindings in `topic_group_chats` for that `chat_id` are automatically deleted to ensure data consistency.
|
||||
- **Binding**: Admins bind a Topic to a known Feishu Group in the UI.
|
||||
- **Binding**: Users/Admins bind a Topic to a known Feishu Group in the UI.
|
||||
- **Search**: The binding UI supports real-time, server-side debounced search by group name.
|
||||
- **Security**: Only the Topic Creator or an Admin can bind/unbind groups to a Topic.
|
||||
- **Approval**:
|
||||
- Normal users: Binding status is `pending` upon creation. Admins receive notification.
|
||||
- Admins/Trusted Users: Binding status is `approved` immediately.
|
||||
- **Dispatch**: Alerts for the topic are sent to all bound `chat_id`s in addition to individual subscribers.
|
||||
|
||||
### Long Connection (WebSocket)
|
||||
@@ -142,6 +150,12 @@ The database schema is defined in `apps/server/src/db/schema.ts`.
|
||||
- Admins can manage subscriptions for other users globally in `AdminView`.
|
||||
- **Topic Deletion**: Centralized in the **Admin Dashboard (All Topics Tab)** to avoid accidental deletion from the main topic list.
|
||||
- Button logic on frontend toggles between "Subscribe" and "Unsubscribe".
|
||||
- **Topic Approval**:
|
||||
- Normal users: Topic status is `pending` upon creation. Admins receive an interactive Feishu notification.
|
||||
- Admins/Trusted Users: Topic status is `approved` immediately.
|
||||
- Admin notification logic is located in `apps/server/src/lib/admin-notifier.ts`.
|
||||
- **Trusted User System**:
|
||||
- Users with `isTrusted=true` (set by Admin) or `isAdmin=true` have their requests (Topics/Bindings) automatically approved.
|
||||
|
||||
|
||||
## 5. API Endpoints
|
||||
@@ -167,7 +181,7 @@ The database schema is defined in `apps/server/src/db/schema.ts`.
|
||||
- `GET /api/users`: List users (Admin only).
|
||||
|
||||
### Feishu Group Management
|
||||
- `GET /api/groups`: List known groups (cached from bot events).
|
||||
- `GET /api/groups`: List known groups (cached from bot events). Supports `q` for search and `limit` parameters.
|
||||
- `GET /api/topics/:id/groups`: List group bindings for a topic.
|
||||
- `POST /api/topics/:id/groups`: Bind a group to a topic.
|
||||
- `DELETE /api/topics/:id/groups/:bindingId`: Unbind a group.
|
||||
@@ -193,13 +207,15 @@ The database schema is defined in `apps/server/src/db/schema.ts`.
|
||||
- **Imports**: Use relative imports.
|
||||
- **Styling**: Use Tailwind utility classes directly in JSX.
|
||||
- **Async/Await**: Prefer `async/await` over `.then()`.
|
||||
- **Type Safety**: strict TypeScript usage. Backend and Frontend share types via Hono RPC or shared interfaces. **Elimination of `any`** is a priority; use explicit interfaces (e.g., `WebhookBody`, `UserAccessTokenData`) for all externally sourced data.
|
||||
- **Linter & Formatter**:
|
||||
- Framework: [Biome](https://biomejs.dev/).
|
||||
- **Rules**: Strict configuration for `a11y`, `suspicious`, `style`, and `correctness`.
|
||||
- **Tailwind**: `noUnknownAtRules` is configured to ignore Tailwind directives (`@tailwind`, `@apply`, etc.).
|
||||
- **Enforcement**: CI/CD runs `biome check` to ensure compliance. **AI assistants MUST run `bun x biome check --write .` (or equivalent) in the respective app directory after every code modification to verify and fix lint/formatting issues before finalizing.** Avoid Use of `as any` is strictly prohibited except for specialized cases like `import.meta as any` (for Vite env) or very complex JSON spread operations. In those rare cases, use `// biome-ignore` with a clear explanation.
|
||||
- **Vite Env Access**: When accessing Vite environment variables via `import.meta.env` (or casting `import.meta as any`), **always use optional chaining** (e.g., `meta.env?.VITE_...`). This prevents crashes if the environment is not initialized or if the code runs in a non-browser context during pre-rendering/testing.
|
||||
- **Strict Type Safety & `any` Prohibition**:
|
||||
> [!IMPORTANT]
|
||||
> **The usage of `any` is strictly prohibited.** This has been a recurring issue and must be avoided at all costs.
|
||||
- **Explicit Interfaces**: Always define clear interfaces or types for API responses, webhook payloads, and complex objects.
|
||||
- **Type Inference**: Leverage TypeScript's type inference. If a variable is initialized later, provide an explicit type during declaration (e.g., `let whereClause: SQL | undefined;`) instead of leaving it implicit.
|
||||
- **Hono RPC**: Utilize the type-safe client (`client.api...`) to ensure end-to-end type safety between backend and frontend.
|
||||
- **No Type Casting**: Avoid `as any` or `<any>` casts. Use type guards (`if`, `switch`, `instanceof`) or Zod schema validation to narrow types safely.
|
||||
- **AI Responsibility**: AI assistants MUST ensure every new or modified piece of code passes strict TypeScript and Biome checks. If a type is unknown, research the schema rather than defaulting to `any`.
|
||||
- **Vite Env Access**: When accessing Vite environment variables via `import.meta.env` (or casting `import.meta as any`), **always use optional chaining** (e.g., `meta.env?.VITE_...`). This prevents crashes if the environment is not initialized or if the code runs in a non-browser context during pre-rendering/testing.
|
||||
- **Frontend Resilience**:
|
||||
- Always check `res.ok` before attempting to parse or use API responses.
|
||||
- Use `Array.isArray()` to verify that data expected to be a list actually is one, especially when mapping over it in JSX. This prevents "white page" crashes when the backend returns error objects instead of arrays.
|
||||
@@ -227,6 +243,7 @@ The database schema is defined in `apps/server/src/db/schema.ts`.
|
||||
|
||||
## 8. Core Documents
|
||||
|
||||
- **[README.md](file:///Users/lilithgames/Workspace/play/alert-message-center/README.md)**: Main project documentation, including quick start, tech stack overview, and Webhook usage guide.
|
||||
- **[CHANGELOG.md](file:///Users/lilithgames/Workspace/play/alert-message-center/CHANGELOG.md)**: Record of version changes, following the Keep a Changelog specification.
|
||||
- **[todo.md](file:///Users/lilithgames/Workspace/play/alert-message-center/todo.md)**: Task tracking and upcoming features.
|
||||
- **[README.md](file:///Users/lilithgames/Workspace/play/alert-message-center/README.md)**: Main project documentation (English version).
|
||||
- **[README.zh-CN.md](file:///Users/lilithgames/Workspace/play/alert-message-center/README.zh-CN.md)**: Simplified Chinese version of the documentation.
|
||||
- **[CHANGELOG.md](file:///Users/lilithgames/Workspace/play/alert-message-center/CHANGELOG.md)**: Record of version changes.
|
||||
- **[todo.md](file:///Users/lilithgames/Workspace/play/alert-message-center/todo.md)**: Task tracking.
|
||||
|
||||
4
todo.md
4
todo.md
@@ -33,4 +33,8 @@
|
||||
- [x] **User Token Shortening**: Shortened `personalToken` to 8 characters and integrated automated migration into the deployment script.
|
||||
- [x] **Visual Identity**: Added custom logo, favicon and integrated them into the UI (login/navbar).
|
||||
- [x] **Migration Robustness**: Fixed migration failures in Docker by un-ignoring the drizzle meta directory.
|
||||
- [x] **Scalability & Security**: Implemented Trusted User system, ownership-based group binding, and Admin notification for topic requests.
|
||||
- [x] **User Management UI**: Added "Admin" badges and a "Trusted" toggle in the User Management view.
|
||||
- [x] **Searchable Group Binding**: Implemented server-side search and searchable dropdown for smoother group chat management.
|
||||
- [x] **Bilingual Documentation**: Split README into English and Chinese versions for international outreach.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user