mirror of
https://github.com/d0zingcat/alert-message-center.git
synced 2026-05-13 15:09:19 +00:00
Merge pull request #10 from d0zingcat/feature/rich_text_message
This commit is contained in:
@@ -4,6 +4,13 @@
|
||||
|
||||
本文件的格式基于 [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 的自动缩短逻辑,确保线上环境数据的一致性。
|
||||
- **AI 规范**:更新了 `copilot-context.md`,明确要求 AI 在每次修改代码后必须进行代码风格和 Lint 检查。
|
||||
|
||||
## [1.2.5] - 2026-01-15
|
||||
|
||||
|
||||
9
apps/server/src/db/migrate-tokens.ts
Normal file
9
apps/server/src/db/migrate-tokens.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { db } from "./index";
|
||||
import { migrateUserTokens } from "./migrate";
|
||||
|
||||
async function main() {
|
||||
await migrateUserTokens(db);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -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: ReturnType<typeof drizzle>) {
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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 }) => ({
|
||||
|
||||
@@ -33,6 +33,7 @@ export class FeishuClient {
|
||||
receiveIdType: "open_id" | "user_id" | "email" | "chat_id",
|
||||
msgType: string,
|
||||
content: Record<string, unknown> | 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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -134,10 +134,39 @@ 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
|
||||
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) 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
1
todo.md
1
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.
|
||||
|
||||
Reference in New Issue
Block a user