From 03b115b83ece7c0d9a0d80f80b1d4bad4b612a89 Mon Sep 17 00:00:00 2001 From: d0zingcat Date: Thu, 15 Jan 2026 20:51:37 +0800 Subject: [PATCH 1/3] feat: shorten user token Signed-off-by: d0zingcat --- apps/server/src/db/migrate-tokens.ts | 9 +++ apps/server/src/db/migrate.ts | 41 ++++++++++- apps/server/src/db/schema.ts | 2 +- apps/server/src/feishu.ts | 2 + apps/server/src/webhook.ts | 102 +++++++++++++++++++++++++-- 5 files changed, 146 insertions(+), 10 deletions(-) create mode 100644 apps/server/src/db/migrate-tokens.ts diff --git a/apps/server/src/db/migrate-tokens.ts b/apps/server/src/db/migrate-tokens.ts new file mode 100644 index 0000000..0919bfb --- /dev/null +++ b/apps/server/src/db/migrate-tokens.ts @@ -0,0 +1,9 @@ +import { db } from "./index"; +import { migrateUserTokens } from "./migrate"; + +async function main() { + await migrateUserTokens(db); + process.exit(0); +} + +main(); diff --git a/apps/server/src/db/migrate.ts b/apps/server/src/db/migrate.ts index 4d2851d..7dfbaa9 100644 --- a/apps/server/src/db/migrate.ts +++ b/apps/server/src/db/migrate.ts @@ -1,20 +1,52 @@ +import { eq } from "drizzle-orm"; import { drizzle } from "drizzle-orm/postgres-js"; import { migrate } from "drizzle-orm/postgres-js/migrator"; import postgres from "postgres"; import * as schema from "./schema"; +import { users } from "./schema"; const connectionString = process.env.DATABASE_URL || "postgres://postgres:password@localhost:5432/alert_message_center"; +export async function migrateUserTokens(db: any) { + console.log("⏳ Checking for user tokens that need shortening..."); + try { + const allUsers = await db.select().from(users); + let updatedCount = 0; + for (const user of allUsers) { + if (user.personalToken && user.personalToken.length > 8) { + const newToken = user.personalToken.substring(0, 8); + console.log( + `Updating user ${user.name}: ${user.personalToken} -> ${newToken}`, + ); + await db + .update(users) + .set({ personalToken: newToken }) + .where(eq(users.id, user.id)); + updatedCount++; + } + } + if (updatedCount > 0) { + console.log(`✅ Updated ${updatedCount} user tokens.`); + } else { + console.log("ℹ️ No tokens need shortening."); + } + } catch (error) { + console.error("❌ Failed to migrate user tokens:", error); + } +} + async function main() { - console.log("⏳ Running migrations..."); + console.log("⏳ Running database migrations..."); const sql = postgres(connectionString, { max: 1 }); const db = drizzle(sql, { schema }); try { await migrate(db, { migrationsFolder: "./drizzle" }); - console.log("✅ Migrations completed!"); + console.log("✅ Database migrations completed!"); + + await migrateUserTokens(db); } catch (error) { console.error("❌ Migration failed:", error); process.exit(1); @@ -23,4 +55,7 @@ async function main() { } } -main(); +// Only run main if this script is executed directly +if (import.meta.main) { + main(); +} diff --git a/apps/server/src/db/schema.ts b/apps/server/src/db/schema.ts index 36202a3..a503c7d 100644 --- a/apps/server/src/db/schema.ts +++ b/apps/server/src/db/schema.ts @@ -86,7 +86,7 @@ export const users = pgTable("users", { personalToken: text("personal_token") .notNull() .unique() - .$defaultFn(() => crypto.randomUUID().replace(/-/g, "")), + .$defaultFn(() => crypto.randomUUID().split("-")[0]), }); export const usersRelations = relations(users, ({ many }) => ({ diff --git a/apps/server/src/feishu.ts b/apps/server/src/feishu.ts index 86c8332..778fde5 100644 --- a/apps/server/src/feishu.ts +++ b/apps/server/src/feishu.ts @@ -33,6 +33,7 @@ export class FeishuClient { receiveIdType: "open_id" | "user_id" | "email" | "chat_id", msgType: string, content: Record | string, + uuid?: string, ) { // Content needs to be stringified for 'text' type in API, but SDK might handle it differently? // Actually SDK expects 'content' as string JSON for 'im.v1.messages.create' @@ -48,6 +49,7 @@ export class FeishuClient { receive_id: receiveId, msg_type: msgType, content: contentStr, + uuid: uuid, }, }); diff --git a/apps/server/src/webhook.ts b/apps/server/src/webhook.ts index 0c43cf9..c3bcb1c 100644 --- a/apps/server/src/webhook.ts +++ b/apps/server/src/webhook.ts @@ -4,6 +4,7 @@ import { db } from "./db"; import { alertLogs, alertTasks, topics, users } from "./db/schema"; import { feishuClient } from "./feishu"; import { logger } from "./lib/logger"; +import { uuid } from "zod/v4"; type FeishuReceiveIdType = "open_id" | "user_id" | "email" | "chat_id"; @@ -134,10 +135,38 @@ webhook.post("/:token/topic/:slug", async (c) => { let msgType = body.msg_type || "text"; let content = body.content; + // Special handling for incomplete payloads (missing 'content') if (!content) { - msgType = "text"; - content = { text: JSON.stringify(body, null, 2) }; - // Deep copy needed? usually content is new obj if we parsed body + // 1. Special case: Unwrap 'card' if provided (convenience for user) + if (body.card) { + content = body.card; + if (!msgType) msgType = "interactive"; + } else { + // 2. Pass-through strategy: Use rest of body as content + // Exclude keys that are definitely not part of content + // biome-ignore lint/performance/noDelete: usage is limited + const { msg_type, token, ...rest } = body; + content = rest; + + // 3. Infer msgType if missing + if (!msgType) { + if (body.post) msgType = "post"; + else if (body.image_key) msgType = "image"; + else if (body.file_key && body.image_key) msgType = "media"; // Media has both + else if (body.file_key) msgType = "file"; + else if (body.audio_key) msgType = "audio"; + else if (body.sticker_key) msgType = "sticker"; + else if (body.chat_id) msgType = "share_chat"; + else if (body.user_id) msgType = "share_user"; + else if (body.header || body.elements) msgType = "interactive"; // Unwrapped card + else { + // Fallback to text + msgType = "text"; + // For text, content must be simple or stringified + content = { text: JSON.stringify(body, null, 2) }; + } + } + } } else { // Deep clone content to avoid mutating shared object for parallel requests if we modify it content = JSON.parse(JSON.stringify(content)); @@ -145,7 +174,7 @@ webhook.post("/:token/topic/:slug", async (c) => { // Add metadata if (msgType === "text" && content.text) { - content.text = `[Topic: ${topic.name}]\n${content.text}`; + content.text = `[${topic.name}]\n${content.text}`; } if (msgType === "interactive" && content.header) { content.header.title.content = `[${topic.name}] ${content.header.title.content}`; @@ -156,6 +185,7 @@ webhook.post("/:token/topic/:slug", async (c) => { recipient.idType, msgType, content, + body.uuid, ); return { recipientId: recipient.id, status: "sent", error: null }; @@ -303,9 +333,67 @@ webhook.post("/:token/dm", async (c) => { let msgType = body.msg_type || "text"; let content = body.content; + // Special handling for incomplete payloads (missing 'content') if (!content) { - msgType = "text"; - content = { text: JSON.stringify(body, null, 2) }; + // 1. Interactive / Card + if ((msgType === "interactive" || !msgType) && body.card) { + msgType = "interactive"; + content = body.card; + } + // 2. Post (Rich Text) + else if ((msgType === "post" || !msgType) && body.post) { + msgType = "post"; + content = { post: body.post }; + } + // 3. Image + else if ((msgType === "image" || !msgType) && body.image_key) { + msgType = "image"; + content = { image_key: body.image_key }; + } + // 4. File + else if ((msgType === "file" || !msgType) && body.file_key) { + msgType = "file"; + content = { file_key: body.file_key }; + } + // 5. Audio + else if ((msgType === "audio" || !msgType) && body.audio_key) { + msgType = "audio"; + content = { file_key: body.audio_key }; + } + // 6. Media (Video) + else if ( + (msgType === "media" || !msgType) && + body.file_key && + body.image_key + ) { + msgType = "media"; + content = { file_key: body.file_key, image_key: body.image_key }; + } + // 7. Sticker + else if ((msgType === "sticker" || !msgType) && body.sticker_key) { + msgType = "sticker"; + content = { file_key: body.sticker_key }; + } + // 8. Share Chat + else if ((msgType === "share_chat" || !msgType) && body.chat_id) { + msgType = "share_chat"; + content = { chat_id: body.chat_id }; + } + // 9. Share User + else if ((msgType === "share_user" || !msgType) && body.user_id) { + msgType = "share_user"; + content = { user_id: body.user_id }; + } + // Fallback + else { + if (!msgType || msgType === "text") { + msgType = "text"; + content = { text: JSON.stringify(body, null, 2) }; + } + } + } else { + // Deep clone content to avoid mutating shared object for parallel requests if we modify it + content = JSON.parse(JSON.stringify(content)); } // Add metadata @@ -319,11 +407,13 @@ webhook.post("/:token/dm", async (c) => { const idType = user.feishuUserId.startsWith("ou_") ? "open_id" : "user_id"; + const uuid = body.uuid || crypto.randomUUID(); await feishuClient.sendMessage( user.feishuUserId, idType, msgType, content, + uuid, ); // Update Task From 652506352ab5348a0d570d6acacbc45203396cca Mon Sep 17 00:00:00 2001 From: d0zingcat Date: Thu, 15 Jan 2026 20:53:02 +0800 Subject: [PATCH 2/3] feat: update context Signed-off-by: d0zingcat --- CHANGELOG.md | 6 ++++++ docs/copilot-context.md | 5 +++-- todo.md | 1 + 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d420869..e194f08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ 本文件的格式基于 [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.2.6] - 2026-01-15 + +### 变更 +- **用户 Token**:将用户的 `personalToken` 从 32 位 UUID 缩短为 8 位十六进制字符串,提升易用性。 +- **数据库迁移**:完善了数据库迁移流程,在 `db:migrate:deploy` 中集成了存量用户 Token 的自动缩短逻辑,确保线上环境数据的一致性。 ## [1.2.5] - 2026-01-15 diff --git a/docs/copilot-context.md b/docs/copilot-context.md index c2a1df9..0e8c65c 100644 --- a/docs/copilot-context.md +++ b/docs/copilot-context.md @@ -1,4 +1,4 @@ -# Project Context for GitHub Copilot (v1.2.5) +# Project Context for GitHub Copilot (v1.2.6) 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. @@ -101,7 +101,7 @@ The database schema is defined in `apps/server/src/db/schema.ts`. ### Personal Inbox (Direct Messaging) - **Strategy**: Direct delivery to a specific user. - **Mechanism**: - 1. Each user has a `personalToken`. + 1. Each user has a `personalToken` (8-character hex string). 2. Sending to `POST /api/webhook/:token/dm` routes messages directly to the user associated with the token. 3. No Topic or Subscription is required. @@ -222,6 +222,7 @@ The database schema is defined in `apps/server/src/db/schema.ts`. - Image path: `ghcr.io/${USER}/alert-message-center`. - Deployment Architecture: A single container runs the Bun server, which serves API requests and static frontend assets (via `hono/bun`'s `serveStatic`). - **Database Initialization**: The Docker entrypoint automatically runs `bun run db:migrate:deploy` before starting the server to ensure the schema is up-to-date in new environments. + - **Token Migration**: The `db:migrate:deploy` script (defined in `src/db/migrate.ts`) also handles legacy user token shortening to maintain consistency with the 8-character token logic introduced in v1.2.6. ## 8. Core Documents diff --git a/todo.md b/todo.md index 186b70f..85eec4f 100644 --- a/todo.md +++ b/todo.md @@ -30,3 +30,4 @@ - [x] **Automated Migrations**: Automatically initialize database schema on startup (especially in Docker). - [x] **Frontend Resilience**: Hardened API calls to prevent crashes on empty data or env access errors. - [x] **CI & Type Safety**: Resolved all TypeScript errors and Biome formatting issues to ensure a healthy CI pipeline. +- [x] **User Token Shortening**: Shortened `personalToken` to 8 characters and integrated automated migration into the deployment script. From 8c1da28a2f4b8fc5500c794301594bb45e38e183 Mon Sep 17 00:00:00 2001 From: d0zingcat Date: Thu, 15 Jan 2026 20:57:32 +0800 Subject: [PATCH 3/3] docs: add mandatory lint check convention for AI Signed-off-by: d0zingcat --- CHANGELOG.md | 1 + apps/server/src/db/migrate-tokens.ts | 4 ++-- apps/server/src/db/migrate.ts | 2 +- apps/server/src/webhook.ts | 8 ++++---- docs/copilot-context.md | 2 +- 5 files changed, 9 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e194f08..5eaf74a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ ### 变更 - **用户 Token**:将用户的 `personalToken` 从 32 位 UUID 缩短为 8 位十六进制字符串,提升易用性。 - **数据库迁移**:完善了数据库迁移流程,在 `db:migrate:deploy` 中集成了存量用户 Token 的自动缩短逻辑,确保线上环境数据的一致性。 +- **AI 规范**:更新了 `copilot-context.md`,明确要求 AI 在每次修改代码后必须进行代码风格和 Lint 检查。 ## [1.2.5] - 2026-01-15 diff --git a/apps/server/src/db/migrate-tokens.ts b/apps/server/src/db/migrate-tokens.ts index 0919bfb..ea22c21 100644 --- a/apps/server/src/db/migrate-tokens.ts +++ b/apps/server/src/db/migrate-tokens.ts @@ -2,8 +2,8 @@ import { db } from "./index"; import { migrateUserTokens } from "./migrate"; async function main() { - await migrateUserTokens(db); - process.exit(0); + await migrateUserTokens(db); + process.exit(0); } main(); diff --git a/apps/server/src/db/migrate.ts b/apps/server/src/db/migrate.ts index 7dfbaa9..e2281e2 100644 --- a/apps/server/src/db/migrate.ts +++ b/apps/server/src/db/migrate.ts @@ -9,7 +9,7 @@ const connectionString = process.env.DATABASE_URL || "postgres://postgres:password@localhost:5432/alert_message_center"; -export async function migrateUserTokens(db: any) { +export async function migrateUserTokens(db: ReturnType) { console.log("⏳ Checking for user tokens that need shortening..."); try { const allUsers = await db.select().from(users); diff --git a/apps/server/src/webhook.ts b/apps/server/src/webhook.ts index c3bcb1c..dfc3eb9 100644 --- a/apps/server/src/webhook.ts +++ b/apps/server/src/webhook.ts @@ -4,7 +4,6 @@ import { db } from "./db"; import { alertLogs, alertTasks, topics, users } from "./db/schema"; import { feishuClient } from "./feishu"; import { logger } from "./lib/logger"; -import { uuid } from "zod/v4"; type FeishuReceiveIdType = "open_id" | "user_id" | "email" | "chat_id"; @@ -144,21 +143,22 @@ webhook.post("/:token/topic/:slug", async (c) => { } else { // 2. Pass-through strategy: Use rest of body as content // Exclude keys that are definitely not part of content - // biome-ignore lint/performance/noDelete: usage is limited const { msg_type, token, ...rest } = body; content = rest; // 3. Infer msgType if missing if (!msgType) { if (body.post) msgType = "post"; + else if (body.file_key && body.image_key) + msgType = "media"; // Media has both else if (body.image_key) msgType = "image"; - else if (body.file_key && body.image_key) msgType = "media"; // Media has both else if (body.file_key) msgType = "file"; else if (body.audio_key) msgType = "audio"; else if (body.sticker_key) msgType = "sticker"; else if (body.chat_id) msgType = "share_chat"; else if (body.user_id) msgType = "share_user"; - else if (body.header || body.elements) msgType = "interactive"; // Unwrapped card + else if (body.header || body.elements) + msgType = "interactive"; // Unwrapped card else { // Fallback to text msgType = "text"; diff --git a/docs/copilot-context.md b/docs/copilot-context.md index 0e8c65c..a163008 100644 --- a/docs/copilot-context.md +++ b/docs/copilot-context.md @@ -198,7 +198,7 @@ The database schema is defined in `apps/server/src/db/schema.ts`. - 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. 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. + - **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. - **Frontend Resilience**: - Always check `res.ok` before attempting to parse or use API responses.