diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8b31a8f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,83 @@ +# Agent Guide for Alert Message Center + +This document provides instructions for AI agents working on the Alert Message Center codebase. + +## 🛠 Commands + +### Workspace Operations +- **Install**: `bun install` +- **Root Dev**: `bun run dev` (starts both server and web) +- **Root Build**: `bun run build` +- **Lint & Format**: + - `bun run lint`: Run Biome linter + - `bun run format`: Fix formatting issues + - `bun run check`: Linter + Formatter fix + +### Backend (`apps/server`) +- **Dev**: `bun run dev` +- **Migrations**: + - `bun run db:generate`: Generate migration files + - `bun run db:push`: Push schema changes directly (dev only) + - `bun run db:migrate:deploy`: Run migrations in production/Docker +- **Verification**: `bun run src/verify_permissions.ts` (Manual verification script) + +### Frontend (`apps/web`) +- **Dev**: `bun run dev` +- **Build**: `bun run build` + +### Testing +- No automated tests currently exist in the repository. If adding tests, use **Bun Test**. +- **Run Single Test**: `bun test path/to/file.test.ts` + +--- + +## 📜 Code Style & Conventions + +### 1. General +- **Runtime**: Bun is the primary runtime. Use `node:` protocol for built-in modules (e.g., `import fs from "node:fs"`). +- **Formatting**: Enforced by Biome. Use **tabs** for indentation and **double quotes**. +- **Naming**: + - Variables/Functions: `camelCase` + - Components/Classes/Interfaces: `PascalCase` + - Database Tables/Columns: `snake_case` (e.g., `topic_group_chats`) + - URL Slugs: `kebab-case` + +### 2. TypeScript & Type Safety +- **Strict Mode**: No `any` allowed. Use explicit interfaces or Zod schemas. +- **Interfaces vs Types**: Prefer `interface` for object definitions and `type` for unions/aliases. +- **RPC**: Use `hono/client` for type-safe communication between frontend and backend. +- **Vite Env**: Always use optional chaining when accessing `import.meta.env?.VITE_...`. + +### 3. Backend (Hono + Drizzle) +- **Routing**: Group logic into sub-apps (e.g., `api.ts`, `auth.ts`, `webhook.ts`). +- **Database**: Use Drizzle ORM. Prefer relational queries where possible (`db.query.xxx.findMany`). +- **Validation**: Use `@hono/zod-validator` for request validation. +- **Logging**: Use the structured logger in `src/lib/logger.ts` (Pino). + - **Pattern**: `logger.error({ err, context }, "Message")`. + +### 4. Frontend (React + Tailwind) +- **Components**: Functional components with hooks. +- **Styling**: Tailwind CSS utility classes. Avoid custom CSS files. +- **Icons**: Use `lucide-react`. +- **Resilience**: + - Always check `res.ok` before parsing API responses. + - Provide fallback states (e.g., `[]`) for data fetching. + +### 5. Error Handling +- **Backend**: Wrap critical logic in `try/catch`. Log errors with context. Return meaningful JSON errors with appropriate HTTP status codes. +- **Frontend**: Use `useState` to track error states and display user-friendly messages. + +--- + +## 🏗 Architecture Context +- **Topic Model**: Alerts are sent to "Topics". Users and Group Chats subscribe to Topics. +- **Personal Inbox**: Each user has a `personalToken` (8-character hex) for direct `/dm` alerts. +- **Feishu Integration**: Uses `@larksuiteoapi/node-sdk`. Supports both Webhook and WebSocket modes for events. +- **Auth**: Feishu SSO (OAuth2). Admin status is assigned via `ADMIN_EMAILS` env var on first login. + +## 🤖 Rules for Agents +- **NO `any`**: This is a hard requirement. Research types or define them. +- **Biome First**: Always run `bun run check` before concluding a task. +- **Preserve Patterns**: Follow existing structure in `apps/server/src` and `apps/web/src`. +- **Minimalism**: Fix bugs minimally. Avoid large refactors unless requested. +- **Context**: Agent-specific context is located in `@docs/`. Refer to `docs/copilot-context.md` for additional instructions. diff --git a/CHANGELOG.md b/CHANGELOG.md index ce625b5..718a1f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 **CHANGELOG** | [简体中文](./CHANGELOG.zh-CN.md) +## [1.4.0] - 2026-01-23 + +### Added +- **Global Topics**: Introduced a new topic type that broadcasts alerts to all users automatically. + - **User Requests**: All users can now request a topic to be "Global" during creation. + - **Admin Control**: Admins can promote any topic to "Global" or create new global topics via the Admin Dashboard. + - **Automatic Distribution**: Alerts sent to Global Topics are delivered to every registered user without requiring individual subscriptions. + - **UI Indicators**: Added "Global" badges and specialized management actions in the Topics and Admin views. + ## [1.3.3] - 2026-01-17 ### Added diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md index a404100..097c82a 100644 --- a/CHANGELOG.zh-CN.md +++ b/CHANGELOG.zh-CN.md @@ -7,6 +7,15 @@ **更新日志** | [English](./CHANGELOG.md) +## [1.4.0] - 2026-01-23 + +### 新增 +- **全局话题 (Global Topics)**: 引入了全新的话题类型,可自动向所有用户广播告警。 + - **用户申请**: 现在所有用户在创建话题时都可以选择申请将其设为“全局话题”。 + - **管理员控制**: 管理员可以通过管理员后台将任何话题提升为“全局话题”,或直接创建新的全局话题。 + - **自动分发**: 发送到全局话题的告警将投递给每一位已注册的用户,无需用户手动订阅。 + - **UI 标识**: 在话题列表和管理员视图中增加了“全局”标识和专门的管理操作。 + ## [1.3.3] - 2026-01-17 ### 新增 diff --git a/apps/server/package.json b/apps/server/package.json index 343e5e3..a763c7e 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "@alertmessagecenter/server", - "version": "1.3.3", + "version": "1.4.0", "scripts": { "dev": "bun run --env-file .env --watch src/index.ts", "start": "bun run src/index.ts", diff --git a/apps/server/src/api.ts b/apps/server/src/api.ts index e5adde1..d84574e 100644 --- a/apps/server/src/api.ts +++ b/apps/server/src/api.ts @@ -18,8 +18,15 @@ const api = new Hono<{ Variables: { session: AuthSession } }>(); const topicSchema = z.object({ name: z.string().min(1), - slug: z.string().min(1), + slug: z + .string() + .min(1) + .regex( + /^[a-z0-9-]+$/i, + "Slug must only contain alphanumeric characters and hyphens", + ), description: z.string().optional(), + isGlobal: z.boolean().optional(), }); const groupBindingSchema = z.object({ @@ -143,6 +150,7 @@ api.post("/topics", requireAuth, zValidator("json", topicSchema), async (c) => { .insert(topics) .values({ ...body, + isGlobal: body.isGlobal ?? false, status, createdBy: session.id, approvedBy: session.isAdmin || session.isTrusted ? session.id : null, @@ -170,7 +178,10 @@ api.put( const body = c.req.valid("json"); const result = await db .update(topics) - .set(body) + .set({ + ...body, + isGlobal: body.isGlobal !== undefined ? body.isGlobal : undefined, + }) .where(eq(topics.id, id)) .returning(); return c.json(result[0]); diff --git a/apps/server/src/db/schema.ts b/apps/server/src/db/schema.ts index 2d7b55f..8517914 100644 --- a/apps/server/src/db/schema.ts +++ b/apps/server/src/db/schema.ts @@ -20,6 +20,7 @@ export const topics = pgTable("topics", { status: text("status", { enum: ["pending", "approved", "rejected"] }) .default("approved") .notNull(), + isGlobal: boolean("is_global").default(false).notNull(), createdBy: text("created_by").references(() => users.id), approvedBy: text("approved_by").references(() => users.id), createdAt: timestamp("created_at").defaultNow().notNull(), diff --git a/apps/server/src/webhook.ts b/apps/server/src/webhook.ts index 18d3e5d..4b4c620 100644 --- a/apps/server/src/webhook.ts +++ b/apps/server/src/webhook.ts @@ -17,58 +17,16 @@ interface Recipient { const webhook = new Hono(); -webhook.post("/:token/topic/:slug", async (c) => { - const token = c.req.param("token"); - const slug = c.req.param("slug"); - logger.info({ token, slug }, "[Webhook] Received request"); - - // 0. Find the User by Token - const user = await db.query.users.findFirst({ - where: eq(users.personalToken, token), - }); - - if (!user) { - logger.warn({ token }, "[Webhook] Invalid personal token"); - return c.json({ error: "Invalid personal token" }, 401); - } - // biome-ignore lint/suspicious/noExplicitAny: Webhook body can be any arbitrary JSON - let body: Record; - try { - const rawBody = await c.req.text(); - logger.debug({ bodyLength: rawBody.length }, "[Webhook] Received raw body"); - if (!rawBody || rawBody.trim() === "") { - return c.json({ error: "Empty body" }, 400); - } - body = JSON.parse(rawBody); - } catch (e) { - logger.error({ err: e }, "[Webhook] Failed to parse JSON body"); - return c.json({ error: "Invalid JSON body" }, 400); - } - - // 1. Find the Topic - const topic = await db.query.topics.findFirst({ - where: eq(topics.slug, slug), - with: { - subscriptions: { - with: { - user: true, - }, - }, - groupChats: true, - }, - }); - - if (!topic) { - logger.warn({ slug }, "[Webhook] Topic not found"); - return c.json({ error: "Topic not found" }, 404); - } - - logger.info({ topicName: topic.name }, "[Webhook] Found topic"); - +const dispatchAlert = async ( + c: any, + topic: any, + body: any, + user: any | null, +) => { // 2. Collect recipients - const userRecipients: Recipient[] = topic.subscriptions - .map((sub) => sub.user) - .map((u) => { + const userRecipients: Recipient[] = (topic.subscriptions || []) + .map((sub: any) => sub.user) + .map((u: any) => { if (!u || !u.feishuUserId) return null; return { type: "user" as const, @@ -80,11 +38,11 @@ webhook.post("/:token/topic/:slug", async (c) => { : "user_id") as FeishuReceiveIdType, }; }) - .filter((u): u is NonNullable => u !== null); + .filter((u: any): u is Recipient => u !== null); - const groupRecipients: Recipient[] = topic.groupChats - .filter((g) => g.status === "approved") - .map((g) => ({ + const groupRecipients: Recipient[] = (topic.groupChats || []) + .filter((g: any) => g.status === "approved") + .map((g: any) => ({ type: "group", id: g.id, // Binding ID name: g.name, @@ -98,7 +56,7 @@ webhook.post("/:token/topic/:slug", async (c) => { .insert(alertTasks) .values({ topicSlug: topic.slug, - senderId: user.id, + senderId: user?.id || null, // Global topic might not have a sender status: "processing", recipientCount: allRecipients.length, successCount: 0, @@ -272,7 +230,7 @@ webhook.post("/:token/topic/:slug", async (c) => { taskId: task.id, successCount, totalCount: allRecipients.length, - slug, + slug: topic.slug, }, "[Webhook] Task processed", ); @@ -284,6 +242,115 @@ webhook.post("/:token/topic/:slug", async (c) => { status: "processing", recipientCount: allRecipients.length, }); +}; + +webhook.post("/topic/:slug", async (c) => { + const slug = c.req.param("slug"); + logger.info({ slug }, "[Webhook] Received global request"); + + // 1. Find the Topic + const topic = await db.query.topics.findFirst({ + where: eq(topics.slug, slug), + with: { + subscriptions: { + with: { + user: true, + }, + }, + groupChats: true, + }, + }); + + if (!topic) { + logger.warn({ slug }, "[Webhook] Topic not found"); + return c.json({ error: "Topic not found" }, 404); + } + + if (!topic.isGlobal) { + logger.warn({ slug }, "[Webhook] Topic is not global"); + return c.json( + { error: "This topic requires a personal token to send alerts" }, + 401, + ); + } + + // 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); + } + + return dispatchAlert(c, topic, body, null); +}); + +webhook.post("/:token/topic/:slug", async (c) => { + const token = c.req.param("token"); + const slug = c.req.param("slug"); + logger.info({ token, slug }, "[Webhook] Received request"); + + // 1. Find the Topic + const topic = await db.query.topics.findFirst({ + where: eq(topics.slug, slug), + with: { + subscriptions: { + with: { + user: true, + }, + }, + groupChats: true, + }, + }); + + if (!topic) { + logger.warn({ slug }, "[Webhook] Topic not found"); + return c.json({ error: "Topic not found" }, 404); + } + + let user: any = null; + if (!topic.isGlobal) { + // 0. Find the User by Token + user = await db.query.users.findFirst({ + where: eq(users.personalToken, token), + }); + + if (!user) { + logger.warn({ token }, "[Webhook] Invalid personal token"); + return c.json({ error: "Invalid personal token" }, 401); + } + } + + // 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); + } + + return dispatchAlert(c, topic, body, user); +}); + +webhook.all("/topic/:slug", (c) => { + return c.json( + { + error: "Method not allowed", + message: "Please use POST to send alerts to this webhook", + format: "POST /webhook/topic/:slug", + example: + 'curl -X POST -H "Content-Type: application/json" -d \'{"content":{"text":"Hello"}}\' URL', + }, + 405, + ); }); webhook.post("/:token/dm", async (c) => { diff --git a/apps/web/package.json b/apps/web/package.json index b868989..b799dd4 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@alertmessagecenter/web", - "version": "1.3.3", + "version": "1.4.0", "type": "module", "scripts": { "dev": "bun run --env-file .env vite", diff --git a/apps/web/src/views/AdminView.tsx b/apps/web/src/views/AdminView.tsx index b21b841..f16fdb7 100644 --- a/apps/web/src/views/AdminView.tsx +++ b/apps/web/src/views/AdminView.tsx @@ -1,3 +1,4 @@ +import { Globe, Lock, Trash2 } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import { client } from "../lib/client"; import SystemLoadView from "./SystemLoadView"; @@ -13,6 +14,7 @@ interface Topic { name: string; slug: string; description?: string; + isGlobal?: boolean; status: "pending" | "approved" | "rejected"; subscriptions?: { id: string }[]; creator?: TopicUser; @@ -251,6 +253,21 @@ function TopicsManagement() { } }; + const handleToggleGlobal = async (topic: Topic) => { + try { + await client.api.topics[":id"].$put( + { + param: { id: topic.id }, + json: { isGlobal: !topic.isGlobal }, + }, + { init: { credentials: "include" } }, + ); + fetchAllTopics(); + } catch (error) { + console.error(error); + } + }; + if (loading) return
Loading topics...
; return ( @@ -282,8 +299,21 @@ function TopicsManagement() { {topics.map((topic) => ( -
- {topic.name} +
+
+ {topic.name} +
+ {topic.isGlobal ? ( + + + Global + + ) : ( + + + Private + + )}
{topic.slug} @@ -312,13 +342,38 @@ function TopicsManagement() { {topic.approver?.name || "-"} - +
+ + +
))} @@ -407,7 +462,20 @@ function TopicRequestsList() { {requests.map((req) => (
  • -

    {req.name}

    +
    +

    {req.name}

    + {req.isGlobal ? ( + + + Global + + ) : ( + + + Private + + )} +

    Slug: {req.slug}

    diff --git a/apps/web/src/views/TopicsView.tsx b/apps/web/src/views/TopicsView.tsx index e14f8df..942433b 100644 --- a/apps/web/src/views/TopicsView.tsx +++ b/apps/web/src/views/TopicsView.tsx @@ -1,6 +1,8 @@ import { Check, Copy, + Globe, + Lock, Plus, Settings, ShieldCheck, @@ -35,6 +37,7 @@ interface Topic { creator?: TopicUser; approver?: TopicUser; createdBy?: string; + isGlobal?: boolean; status?: string; createdAt?: string; } @@ -55,6 +58,7 @@ export default function TopicsView() { name: "", slug: "", description: "", + isGlobal: false, }); const [submitStatus, setSubmitStatus] = useState<{ type: "success" | "error"; @@ -137,6 +141,7 @@ export default function TopicsView() { name: string; slug: string; description?: string; + isGlobal?: boolean; }, }, { @@ -151,7 +156,7 @@ export default function TopicsView() { ? "Topic created successfully!" : "Request submitted! Waiting for approval.", }); - setFormData({ name: "", slug: "", description: "" }); + setFormData({ name: "", slug: "", description: "", isGlobal: false }); fetchTopics(); fetchMyRequests(); setTimeout(() => { @@ -294,6 +299,15 @@ export default function TopicsView() { return `${baseUrl}/webhook/${currentUser.personalToken}/topic/${topicSlug}`; }; + const getGlobalWebhookUrl = (topicSlug: string) => { + // biome-ignore lint/suspicious/noExplicitAny: Vite env access + const meta = import.meta as any; + const baseUrl = ( + meta.env?.VITE_WEBHOOK_BASE_URL || window.location.origin + ).replace(/\/$/, ""); + return `${baseUrl}/webhook/topic/${topicSlug}`; + }; + const getDmWebhookUrl = () => { if (!currentUser?.personalToken) return ""; // biome-ignore lint/suspicious/noExplicitAny: Vite env access @@ -422,8 +436,19 @@ export default function TopicsView() {
    -

    +

    {topic.name} + {topic.isGlobal ? ( + + + Global + + ) : ( + + + Private + + )}

    -
    -
    - {getWebhookUrl(topic.slug)} +
    +
    +
    +
    + + Your Personal Webhook + + +
    +
    + {getWebhookUrl(topic.slug)} +
    +
    + {topic.isGlobal && ( +

    + * Requires your personal token to identify you + as the sender. +

    + )}
    + + {topic.isGlobal && ( +
    +
    + +
    +
    +
    + + + Global Webhook (Public) + +
    + +
    +
    + {getGlobalWebhookUrl(topic.slug)} +
    +

    + * Global topics can receive alerts without a + personal token. +

    +
    + )}
    )}
    @@ -665,6 +747,8 @@ export default function TopicsView() { id="topic-slug" type="text" required + pattern="[a-zA-Z0-9-]+" + title="Slug must only contain alphanumeric characters and hyphens" className="mt-1 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={formData.slug} onChange={(e) => @@ -672,6 +756,26 @@ export default function TopicsView() { } />
    +
    + + setFormData({ ...formData, isGlobal: e.target.checked }) + } + /> + +

    + (Broadcast to ALL users. Requires Admin approval) +

    +