feat: enable personal inbox

Signed-off-by: d0zingcat <iamtangli42@gmail.com>
This commit is contained in:
2026-01-12 13:33:43 +08:00
parent 673859929d
commit 4f4d9a6d6a
6 changed files with 186 additions and 17 deletions

View File

@@ -65,7 +65,7 @@ export const subscriptionsRelations = relations(subscriptions, ({ one }) => ({
// API Tasks: 记录 webhook 请求的处理状态
export const alertTasks = pgTable('alert_tasks', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
topicSlug: text('topic_slug').notNull(),
topicSlug: text('topic_slug'),
senderId: text('sender_id').references(() => users.id), // 记录是谁发送的 (通过 personal_token)
status: text('status', { enum: ['pending', 'processing', 'completed', 'failed'] }).default('pending').notNull(),
recipientCount: integer('recipient_count').default(0),

View File

@@ -161,6 +161,106 @@ webhook.post('/:token/topic/:slug', async (c) => {
});
});
webhook.post('/:token/dm', async (c) => {
const token = c.req.param('token');
console.log(`[Webhook] Received DM request for token: ${token}`);
// 0. Find the User by Token
const user = await db.query.users.findFirst({
where: eq(users.personalToken, token),
});
if (!user) {
console.warn(`[Webhook] Invalid personal token: ${token}`);
return c.json({ error: 'Invalid personal token' }, 401);
}
if (!user.feishuUserId) {
return c.json({ error: 'User has no Feishu ID linked' }, 400);
}
let body;
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);
}
// 1. Create Task (topicSlug is null for DM)
const [task] = await db.insert(alertTasks).values({
topicSlug: null,
senderId: user.id,
status: 'processing',
recipientCount: 1,
successCount: 0,
payload: body,
}).returning();
// 2. Send Message
(async () => {
try {
let msgType = body.msg_type || 'text';
let content = body.content;
if (!content) {
msgType = 'text';
content = { text: JSON.stringify(body, null, 2) };
}
// Add metadata
if (msgType === 'text' && content.text) {
content.text = `[Direct Message]\n${content.text}`;
}
if (msgType === 'interactive' && content.header) {
content.header.title.content = `[DM] ${content.header.title.content}`;
}
const idType = user.feishuUserId.startsWith('ou_') ? 'open_id' : 'user_id';
await feishuClient.sendMessage(user.feishuUserId, idType, msgType, content);
// Update Task
await db.update(alertTasks).set({
status: 'completed',
successCount: 1,
updatedAt: new Date(),
}).where(eq(alertTasks.id, task.id));
// Insert Log
await db.insert(alertLogs).values({
taskId: task.id,
userId: user.id,
status: 'sent',
});
} catch (error: any) {
console.error(`Failed to send DM to user ${user.name}:`, error);
await db.update(alertTasks).set({
status: 'failed',
updatedAt: new Date(),
error: error.message,
}).where(eq(alertTasks.id, task.id));
await db.insert(alertLogs).values({
taskId: task.id,
userId: user.id,
status: 'failed',
error: error.message,
});
}
})();
return c.json({
message: 'DM received and processing started',
taskId: task.id,
status: 'processing',
recipientCount: 1
});
});
// Help message for non-POST requests or malformed URLs
webhook.all('/:token/topic/:slug', (c) => {
return c.json({

View File

@@ -135,8 +135,8 @@ export default function SystemLoadView() {
return (
<tr key={topic.topicSlug} className="hover:bg-gray-50 transition-colors">
<td className="px-6 py-4 whitespace-nowrap">
<span className="font-mono text-xs font-bold text-indigo-600 bg-indigo-50 px-2 py-1 rounded-md">
{topic.topicSlug}
<span className={`font-mono text-xs font-bold ${topic.topicSlug ? 'text-indigo-600 bg-indigo-50' : 'text-gray-600 bg-gray-100'} px-2 py-1 rounded-md`}>
{topic.topicSlug || '[Private DM]'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600 font-medium">{topic.totalTasks}</td>
@@ -193,8 +193,8 @@ export default function SystemLoadView() {
{new Date(task.createdAt).toLocaleString()}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="font-mono text-xs font-bold text-indigo-600 bg-indigo-50 px-2 py-1 rounded-md">
{task.topicSlug}
<span className={`font-mono text-xs font-bold ${task.topicSlug ? 'text-indigo-600 bg-indigo-50' : 'text-gray-600 bg-gray-100'} px-2 py-1 rounded-md`}>
{task.topicSlug || '[Private DM]'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">

View File

@@ -197,6 +197,12 @@ export default function TopicsView() {
return `${baseUrl}/webhook/${currentUser.personalToken}/topic/${topicSlug}`;
};
const getDmWebhookUrl = () => {
if (!currentUser?.personalToken) return '';
const baseUrl = ((import.meta as any).env.VITE_WEBHOOK_BASE_URL || window.location.origin).replace(/\/$/, '');
return `${baseUrl}/webhook/${currentUser.personalToken}/dm`;
};
if (loading) return <div className="p-4">Loading...</div>;
return (
@@ -208,7 +214,7 @@ export default function TopicsView() {
<div className="mt-2 text-sm text-indigo-700">
<ul className="list-disc pl-5 space-y-1">
<li><strong>Subscribe:</strong> Click <span className="text-green-700 font-semibold">Subscribe</span> on any topic to start receiving alerts via Feishu private message.</li>
<li><strong>Personal Webhook:</strong> Each topic provides a <span className="font-semibold">unique URL</span> just for you. Send JSON alerts to this URL to notify yourself.</li>
<li><strong>Personal Webhook:</strong> Use topic-specific URLs to notify all subscribers, or use your <span className="font-semibold text-indigo-900">Personal Inbox</span> to notify only yourself.</li>
<li><strong>Need more?</strong> If you can't find a suitable topic, click <span className="font-semibold">Request Topic</span> to ask admins for a new one.</li>
</ul>
</div>
@@ -216,6 +222,53 @@ export default function TopicsView() {
</div>
</div>
<div className="mb-10">
<div className="flex items-center mb-4">
<ShieldCheck className="w-6 h-6 text-indigo-600 mr-2" />
<h2 className="text-xl font-bold text-gray-900">Personal Inbox</h2>
</div>
<div className="bg-gradient-to-br from-indigo-600 to-indigo-700 rounded-xl p-6 text-white shadow-lg border-b-4 border-indigo-800">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
<div className="flex-1">
<p className="text-indigo-100 text-sm mb-2 font-medium">Your private alert endpoint. No topic required.</p>
<div className="bg-indigo-900/40 rounded-lg p-3 border border-indigo-400/30 backdrop-blur-sm">
<div className="flex items-center justify-between mb-2">
<span className="text-[10px] uppercase tracking-widest text-indigo-300 font-bold">Inbox Webhook URL</span>
<button
onClick={() => copyToClipboard(getDmWebhookUrl(), 'personal-dm')}
className="flex items-center text-xs hover:text-indigo-200 transition-colors"
>
{copiedId === 'personal-dm' ? (
<>
<Check className="w-3 h-3 mr-1 text-green-400" />
Copied!
</>
) : (
<>
<Copy className="w-3 h-3 mr-1" />
Copy URL
</>
)}
</button>
</div>
<div className="font-mono text-xs break-all select-all text-indigo-100 leading-relaxed">
{getDmWebhookUrl()}
</div>
</div>
</div>
<div className="flex items-center gap-4 bg-white/10 p-4 rounded-xl backdrop-blur-sm border border-white/10">
<div className="bg-indigo-500/30 p-2.5 rounded-lg border border-white/20">
<Copy className="w-6 h-6" />
</div>
<div className="text-sm">
<div className="font-bold">Direct Push</div>
<div className="text-indigo-200 text-xs">Always delivered to you</div>
</div>
</div>
</div>
</div>
</div>
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-gray-900">Topics</h2>
<div className="flex gap-2">

View File

@@ -6,7 +6,10 @@ This document provides technical context, architectural decisions, and code conv
**Alert Message Center** (formerly Alert Manager) is a centralized alert dispatching system.
- **Goal**: Decouple alert sources from alert recipients.
- **Mechanism**: Alerts are sent to a **Topic**. Users subscribe to Topics. The system dispatches alerts to subscribers via **Feishu (Lark) Private Messages**.
- **Mechanism**:
- **Topics**: Alerts are sent to a **Topic**. Users subscribe to Topics to receive messages.
- **Personal Inbox**: Users can send alerts directly to themselves via a private webhook URL, bypassing Topic creation and approval.
- **Dispatch**: The system sends messages via **Feishu (Lark) Private Messages**.
- **Runtime**: Bun (JavaScript/TypeScript runtime).
## 2. Tech Stack
@@ -15,7 +18,7 @@ This document provides technical context, architectural decisions, and code conv
- **Backend**:
- **Runtime**: Bun.
- **Framework**: Hono (Web Standard based).
- **Database**: SQLite (via `better-sqlite3`).
- **Database**: PostgreSQL.
- **ORM**: Drizzle ORM.
- **Authentication**: Feishu OAuth2 (Session-based with cookies).
- **External API**: Feishu Open Platform (Server-side API).
@@ -37,6 +40,9 @@ The database schema is defined in `apps/server/src/db/schema.ts`.
- `name`: Display name (e.g., "Payment Service Errors").
- `slug`: URL-safe identifier (e.g., `payment-errors`). Used in webhook URLs.
- `description`: Optional text.
- `status`: `pending`, `approved`, or `rejected`.
- `createdBy`: Foreign Key -> `users.id`.
- `approvedBy`: Foreign Key -> `users.id`.
2. **User** (`users`)
- `id`: UUID (Primary Key).
@@ -60,18 +66,26 @@ The database schema is defined in `apps/server/src/db/schema.ts`.
3. Server exchanges code for token, gets user info, creates/updates user in DB.
4. Server sets `session` cookie (httpOnly).
- **Context**: `AuthContext.tsx` manages user state on frontend.
### Personal Inbox (Direct Messaging)
- **Strategy**: Direct delivery to a specific user.
- **Mechanism**:
1. Each user has a `personalToken`.
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.
### Alert Ingestion & Dispatch
**File**: `apps/server/src/webhook.ts`
1. **Ingest**: `POST /api/webhook/:slug` receives a JSON payload.
1. **Ingest**:
- **Topic-based**: `POST /api/webhook/:token/topic/:slug`
- **Direct (Inbox)**: `POST /api/webhook/:token/dm`
2. **Lookup**:
- Find `Topic` by `slug`.
- Fetch all `subscriptions` for this topic, including the associated `user`.
- For Topic-based: Find `Topic` by `slug` and fetch all `subscriptions`.
- For Direct: Identify the user via `token`.
3. **Dispatch**:
- Iterate through subscribers.
- For each user, call `FeishuClient.sendMessage`.
- **Payload**: The `content` and `msg_type` from the request body are passed directly to Feishu.
- Call `FeishuClient.sendMessage` for each recipient.
- **Payload**: Supports `text` and `interactive` (Feishu Card) message types.
### Subscription Management
- Users can subscribe/unsubscribe themselves to any topic.
@@ -103,7 +117,8 @@ The database schema is defined in `apps/server/src/db/schema.ts`.
### Webhook
- `POST /api/webhook/:slug`: Trigger an alert for a topic.
- `POST /api/webhook/:token/topic/:slug`: Trigger an alert for a topic.
- `POST /api/webhook/:token/dm`: Trigger a direct alert to the user's private inbox.
## 6. Future Roadmap (Planned)

View File

@@ -3,7 +3,7 @@
## Phase 1: Core Functionality (Completed)
- [x] Initialize project structure (Bun, Monorepo)
- [x] Setup Backend (Hono + Bun)
- [x] Setup Drizzle (SQLite)
- [x] Setup Drizzle (PostgreSQL)
- [x] **Refactor Schema**: Switch from Bots/Roles to Topics/Users/Subscriptions
- [x] **Feishu Integration**: Implement Tenant Access Token & Private Message sending
- [x] Implement Webhook API (`POST /api/webhook/:slug`)
@@ -18,7 +18,8 @@
- [x] **Global Monitoring Dashboard**: Real-time System Load metrics (Grafana-style).
- [ ] **Message Preview**: Preview Feishu card JSON in the UI.
- [x] **History/Logs**: Basic tracking for sent alerts (Alert Tasks/Logs).
- [x] **Admin Topic Management**: Approve, reject, and delete topics.
- [x] **Admin Topic Management**: Approve, reject, and delete topics (with audit trail).
- [x] **Personal Inbox**: Direct alert delivery bypassing topics.
- [ ] **Retry Mechanism**: Handle Feishu API failures.
- [ ] **Deployment**: Dockerfile and deployment scripts.