feat: allow send message to group chat

Signed-off-by: d0zingcat <iamtangli42@gmail.com>
This commit is contained in:
2026-01-13 22:40:38 +08:00
parent 3df151c5eb
commit a1c6141b31
17 changed files with 664 additions and 105 deletions

View File

@@ -5,6 +5,19 @@
本文件的格式基于 [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.0] - 2026-01-13
### 新增
- **飞书群聊通知**: 支持将告警发送到飞书群聊 (App Bot 模式)。
- 自动发现机器人所在的群组。
- 支持在 Topic 中绑定群聊。
- **长连接模式 (WebSocket)**: 引入 `@larksuiteoapi/node-sdk`,支持通过 WebSocket 接收飞书事件,解决内网环境无法使用 Webhook 的问题。
- 可通过 `FEISHU_USE_WS=true` 开启。
- **UI 改进**: 在 Topic 列表页新增了群聊管理入口。
### 变更
- **数据库**: 新增 `topic_group_chats``known_group_chats` 表。
- **底层架构**: 重构了飞书客户端 (`FeishuClient`) 和事件处理逻辑,统一了 Webhook 和 WebSocket 的事件分发。
## [1.1.1] - 2026-01-13
### 修复

View File

@@ -0,0 +1,18 @@
CREATE TABLE "topic_group_webhooks" (
"id" text PRIMARY KEY NOT NULL,
"topic_id" text NOT NULL,
"name" text NOT NULL,
"url" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"created_by" text
);
--> statement-breakpoint
ALTER TABLE "alert_tasks" ALTER COLUMN "topic_slug" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "alert_tasks" ADD COLUMN "sender_id" text;--> statement-breakpoint
ALTER TABLE "topics" ADD COLUMN "approved_by" text;--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "personal_token" text NOT NULL;--> statement-breakpoint
ALTER TABLE "topic_group_webhooks" ADD CONSTRAINT "topic_group_webhooks_topic_id_topics_id_fk" FOREIGN KEY ("topic_id") REFERENCES "public"."topics"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "topic_group_webhooks" ADD CONSTRAINT "topic_group_webhooks_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "alert_tasks" ADD CONSTRAINT "alert_tasks_sender_id_users_id_fk" FOREIGN KEY ("sender_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "topics" ADD CONSTRAINT "topics_approved_by_users_id_fk" FOREIGN KEY ("approved_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "users" ADD CONSTRAINT "users_personal_token_unique" UNIQUE("personal_token");

View File

@@ -1,6 +1,6 @@
{
"name": "@alertmessagecenter/server",
"version": "1.0.0",
"version": "1.2.0",
"scripts": {
"dev": "bun run --env-file .env --watch src/index.ts",
"start": "bun run src/index.ts",
@@ -11,6 +11,7 @@
},
"dependencies": {
"@hono/zod-validator": "^0.7.6",
"@larksuiteoapi/node-sdk": "^1.56.1",
"drizzle-orm": "^0.45.1",
"hono": "^4.11.3",
"postgres": "^3.4.8",

View File

@@ -3,7 +3,7 @@ import { eq, and, desc, sql, gt, sum, count } from 'drizzle-orm';
import { z } from 'zod';
import { zValidator } from '@hono/zod-validator';
import { db } from './db';
import { topics, users, subscriptions, alertTasks } from './db/schema';
import { topics, users, subscriptions, alertTasks, topicGroupChats, knownGroupChats } from './db/schema';
import { requireAuth, requireAdmin, AuthSession } from './middleware';
const api = new Hono<{ Variables: { session: AuthSession } }>();
@@ -14,6 +14,11 @@ const topicSchema = z.object({
description: z.string().optional(),
});
const groupBindingSchema = z.object({
chatId: z.string().min(1),
name: z.string().min(1),
});
const userSchema = z.object({
name: z.string().min(1),
feishuUserId: z.string().min(1),
@@ -168,6 +173,8 @@ api.delete('/users/:id', requireAdmin, async (c) => {
// --- Subscriptions ---
// --- Subscriptions ---
// Users can subscribe themselves or admins can subscribe anyone
api.post('/topics/:topicId/subscribe/:userId', requireAuth, async (c) => {
const { topicId, userId } = c.req.param();
@@ -200,6 +207,55 @@ api.delete('/topics/:topicId/subscribe/:userId', requireAuth, async (c) => {
return c.json({ success: true });
});
// --- Group Bindings (App Bot) ---
// Get list of known groups (for selection)
api.get('/groups', requireAuth, async (c) => {
// Return recent active groups
const groups = await db.select().from(knownGroupChats)
.orderBy(desc(knownGroupChats.lastActiveAt))
.limit(50);
return c.json(groups);
});
// Get bindings for a topic
api.get('/topics/:id/groups', requireAuth, async (c) => {
const topicId = c.req.param('id');
const groups = await db.select().from(topicGroupChats)
.where(eq(topicGroupChats.topicId, topicId))
.orderBy(desc(topicGroupChats.createdAt));
return c.json(groups);
});
// Bind a group to a topic
api.post('/topics/:id/groups', requireAuth, zValidator('json', groupBindingSchema), async (c) => {
const topicId = c.req.param('id');
const body = c.req.valid('json');
const session = c.get('session');
const result = await db.insert(topicGroupChats).values({
topicId,
chatId: body.chatId,
name: body.name,
createdBy: session.id,
}).returning();
return c.json(result[0]);
});
// Unbind a group
api.delete('/topics/:id/groups/:bindingId', requireAuth, async (c) => {
const { id: topicId, bindingId } = c.req.param();
await db.delete(topicGroupChats)
.where(and(
eq(topicGroupChats.id, bindingId),
eq(topicGroupChats.topicId, topicId)
));
return c.json({ success: true });
});
// --- Alert Tasks ---
api.get('/alerts/tasks', requireAdmin, async (c) => {

View File

@@ -0,0 +1,40 @@
import { Hono } from 'hono';
import * as lark from '@larksuiteoapi/node-sdk';
import { eventDispatcher } from '../event-handler';
const feishuEvent = new Hono();
// Helper to adapt Hono request to Lark SDK request
const adaptRequest = async (c: any) => {
const headers = c.req.raw.headers;
// Convert Headers object to Record<string, string>
const headerRecord: Record<string, string> = {};
headers.forEach((value: string, key: string) => {
headerRecord[key] = value;
});
return {
headers: headerRecord,
body: await c.req.json(),
};
};
feishuEvent.post('/', async (c) => {
try {
const req = await adaptRequest(c);
// Use the official SDK to handle the request
// It handles URL verification, encryption, and dispatching
const res = await lark.adaptDefault('/api/feishu/event', req, eventDispatcher, {
autoChallenge: true,
encryptKey: process.env.FEISHU_ENCRYPT_KEY
}) as any;
return c.json(res?.body || {});
} catch (e) {
console.error('[Feishu Event] Error:', e);
return c.json({ error: 'Internal Server Error' }, 500);
}
});
export default feishuEvent;

View File

@@ -1,7 +1,7 @@
import { pgTable, text, integer, primaryKey, boolean, jsonb, timestamp } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
// Topics: 类似于 Kafka 的 Topic 或 告警的 Tag例如 "payment-service", "prod-env"
// Topics: 类似于 Kafka 的 Topic 或 告警的 Tag例如 "payment-service",
export const topics = pgTable('topics', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
slug: text('slug').notNull().unique(), // 告警发送时使用的 key
@@ -13,8 +13,37 @@ export const topics = pgTable('topics', {
createdAt: timestamp('created_at').defaultNow().notNull(),
});
// Group Chats: App Bot 所在的群绑定
export const topicGroupChats = pgTable('topic_group_chats', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
topicId: text('topic_id').notNull().references(() => topics.id, { onDelete: 'cascade' }),
chatId: text('chat_id').notNull(), // 飞书群 chat_id
name: text('name').notNull(), // 群名称快照
createdAt: timestamp('created_at').defaultNow().notNull(),
createdBy: text('created_by').references(() => users.id),
});
export const topicGroupChatsRelations = relations(topicGroupChats, ({ one }) => ({
topic: one(topics, {
fields: [topicGroupChats.topicId],
references: [topics.id],
}),
creator: one(users, {
fields: [topicGroupChats.createdBy],
references: [users.id],
}),
}));
// Known Group Chats: 机器人已知的群 (通过事件发现)
export const knownGroupChats = pgTable('known_group_chats', {
chatId: text('chat_id').primaryKey(), // 飞书 chat_id
name: text('name').notNull(),
lastActiveAt: timestamp('last_active_at').defaultNow(),
});
export const topicsRelations = relations(topics, ({ many, one }) => ({
subscriptions: many(subscriptions),
groupChats: many(topicGroupChats),
creator: one(users, {
fields: [topics.createdBy],
references: [users.id],

View File

@@ -0,0 +1,26 @@
import { db } from './db';
import { knownGroupChats } from './db/schema';
import { eq } from 'drizzle-orm';
import * as lark from '@larksuiteoapi/node-sdk';
export const eventDispatcher = new lark.EventDispatcher({}).register({
'im.chat.member.bot.added_v1': async (data) => {
const payload = data as any;
const { chat_id, name } = payload.chat || payload.message?.chat || {};
console.log(`[Feishu Event] Bot added to group: ${name} (${chat_id})`);
if (chat_id) {
await db.insert(knownGroupChats).values({
chatId: chat_id,
name: name || 'Unknown Group',
lastActiveAt: new Date(),
}).onConflictDoUpdate({
target: knownGroupChats.chatId,
set: {
name: name || 'Unknown Group',
lastActiveAt: new Date(),
}
});
}
},
});

View File

@@ -1,94 +1,69 @@
import * as lark from '@larksuiteoapi/node-sdk';
export class FeishuClient {
public client: lark.Client;
private appId: string;
private appSecret: string;
private token: string | null = null;
private tokenExpireAt: number = 0;
constructor(appId: string, appSecret: string) {
this.appId = appId;
this.appSecret = appSecret;
}
private async getTenantAccessToken(): Promise<string> {
if (this.token && Date.now() < this.tokenExpireAt) {
return this.token;
}
const res = await fetch('https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
app_id: this.appId,
app_secret: this.appSecret,
}),
this.client = new lark.Client({
appId: appId,
appSecret: appSecret,
disableTokenCache: false,
});
const data = await res.json();
if (data.code !== 0) {
throw new Error(`Failed to get tenant access token: ${data.msg}`);
}
this.token = data.tenant_access_token;
// Expire 5 minutes early to be safe
this.tokenExpireAt = Date.now() + (data.expire * 1000) - 300000;
return this.token!;
}
async sendMessage(receiveId: string, receiveIdType: 'open_id' | 'user_id' | 'email', msgType: string, content: any) {
const token = await this.getTenantAccessToken();
// Content needs to be stringified for 'text' type, but might be object for 'interactive'
// Feishu API expects 'content' field to be a JSON string for most types
async sendMessage(receiveId: string, receiveIdType: 'open_id' | 'user_id' | 'email' | 'chat_id', msgType: string, content: any) {
// 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'
const contentStr = typeof content === 'string' ? content : JSON.stringify(content);
const res = await fetch(`https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=${receiveIdType}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json; charset=utf-8',
},
body: JSON.stringify({
receive_id: receiveId,
msg_type: msgType,
content: contentStr,
}),
});
try {
const response = await this.client.im.message.create({
params: {
receive_id_type: receiveIdType,
},
data: {
receive_id: receiveId,
msg_type: msgType,
content: contentStr,
},
});
const data = await res.json();
if (data.code !== 0) {
console.error('Feishu send message error:', data);
throw new Error(`Failed to send message: ${data.msg}`);
if (response.code !== 0) {
console.error('Feishu send message error:', response);
throw new Error(`Failed to send message: ${response.msg}`);
}
return response.data;
} catch (e) {
console.error('Feishu SDK error:', e);
throw e;
}
return data;
}
async getUserAccessToken(code: string): Promise<any> {
const token = await this.getTenantAccessToken();
const res = await fetch('https://open.feishu.cn/open-apis/authen/v1/access_token', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json; charset=utf-8',
},
body: JSON.stringify({
grant_type: 'authorization_code',
code,
}),
});
try {
const response = await this.client.authen.accessToken.create({
data: {
grant_type: 'authorization_code',
code,
},
});
const data = await res.json();
if (data.code !== 0) {
console.error('Feishu get user access token error:', data);
throw new Error(`Failed to get user access token: ${data.msg}`);
if (response.code !== 0) {
console.error('Feishu get user access token error:', response);
throw new Error(`Failed to get user access token: ${response.msg}`);
}
return response.data;
} catch (e) {
console.error('Feishu SDK error:', e);
throw e;
}
return data.data;
}
}
// Singleton instance - replace with env vars in production
// Singleton instance
export const feishuClient = new FeishuClient(
process.env.FEISHU_APP_ID || 'cli_xxx',
process.env.FEISHU_APP_SECRET || 'xxx'
process.env.FEISHU_APP_ID || '',
process.env.FEISHU_APP_SECRET || ''
);

View File

@@ -15,9 +15,14 @@ app.use('/*', cors({
credentials: true,
}));
import feishuEvent from './api/feishu-event';
// ...
// API Routes
const routes = app.route('/api/auth', auth)
.route('/api', api)
.route('/api/feishu/event', feishuEvent)
.route('/webhook', webhook);
// Serve static files (Frontend)
@@ -34,5 +39,9 @@ app.get('/topics', async (c) => {
return c.json(allTopics);
});
// Start WebSocket if enabled
import { startWebSocket } from './ws';
startWebSocket();
export type AppType = typeof routes;
export default app;

View File

@@ -41,7 +41,8 @@ webhook.post('/:token/topic/:slug', async (c) => {
with: {
user: true
}
}
},
groupChats: true
}
});
@@ -50,23 +51,40 @@ webhook.post('/:token/topic/:slug', async (c) => {
return c.json({ error: 'Topic not found' }, 404);
}
console.log(`[Webhook] Found topic: ${topic.name}, subscribers: ${topic.subscriptions.length}`);
console.log(`[Webhook] Found topic: ${topic.name}`);
// 2. Collect subscribers
const subscribers = topic.subscriptions
// 2. Collect recipients
const userRecipients = topic.subscriptions
.map(sub => sub.user)
.filter(u => !!u && !!u.feishuUserId);
.filter(u => !!u && !!u.feishuUserId)
.map(u => ({
type: 'user',
id: u.id,
name: u.name,
feishuId: u.feishuUserId,
idType: u.feishuUserId.startsWith('ou_') ? 'open_id' : 'user_id'
}));
const groupRecipients = topic.groupChats.map(g => ({
type: 'group',
id: g.id, // Binding ID
name: g.name,
feishuId: g.chatId,
idType: 'chat_id'
}));
const allRecipients = [...userRecipients, ...groupRecipients];
const [task] = await db.insert(alertTasks).values({
topicSlug: topic.slug,
senderId: user.id,
status: 'processing',
recipientCount: subscribers.length,
recipientCount: allRecipients.length,
successCount: 0,
payload: body,
}).returning();
if (subscribers.length === 0) {
if (allRecipients.length === 0) {
await db.update(alertTasks)
.set({ status: 'completed', updatedAt: new Date() })
.where(eq(alertTasks.id, task.id));
@@ -78,8 +96,10 @@ webhook.post('/:token/topic/:slug', async (c) => {
});
}
console.log(`[Webhook] Task ${task.id}: Dispatching to ${userRecipients.length} users and ${groupRecipients.length} groups`);
// 4. Send Private Messages asynchronously
Promise.allSettled(subscribers.map(async (user) => {
Promise.allSettled(allRecipients.map(async (recipient) => {
try {
// Construct message content
let msgType = body.msg_type || 'text';
@@ -88,6 +108,10 @@ webhook.post('/:token/topic/:slug', async (c) => {
if (!content) {
msgType = 'text';
content = { text: JSON.stringify(body, null, 2) };
// Deep copy needed? usually content is new obj if we parsed body
} else {
// Deep clone content to avoid mutating shared object for parallel requests if we modify it
content = JSON.parse(JSON.stringify(content));
}
// Add metadata
@@ -98,13 +122,12 @@ webhook.post('/:token/topic/:slug', async (c) => {
content.header.title.content = `[${topic.name}] ${content.header.title.content}`;
}
const idType = user.feishuUserId.startsWith('ou_') ? 'open_id' : 'user_id';
await feishuClient.sendMessage(user.feishuUserId, idType, msgType, content);
await feishuClient.sendMessage(recipient.feishuId, recipient.idType as any, msgType, content);
return { userId: user.id, status: 'sent', error: null };
return { recipientId: recipient.id, status: 'sent', error: null };
} catch (error: any) {
console.error(`Failed to send to user ${user.name}:`, error);
return { userId: user.id, status: 'failed', error: error.message };
console.error(`Failed to send to ${recipient.type} ${recipient.name}:`, error);
return { recipientId: recipient.id, status: 'failed', error: error.message };
}
})).then(async (results) => {
const successCount = results.filter(r => r.status === 'fulfilled' && (r.value as any).status === 'sent').length;
@@ -124,22 +147,22 @@ webhook.post('/:token/topic/:slug', async (c) => {
error: failures > 0 ? `Failed to send to ${failures} recipients` : null,
}).where(eq(alertTasks.id, task.id));
// Insert Logs (Optional: insert only failures to save space, or all for audit)
// Let's insert all for now
// Insert Logs
const logs = results.map((r, index) => {
const user = subscribers[index];
const recipient = allRecipients[index];
if (r.status === 'fulfilled') {
const val = r.value as any;
return {
taskId: task.id,
userId: user.id,
userId: recipient.type === 'user' ? recipient.id : null, // Only link users
// We could add connection to group binding if we altered schema, but for now log it
status: val.status,
error: val.error,
};
} else {
return {
taskId: task.id,
userId: user.id,
userId: recipient.type === 'user' ? recipient.id : null,
status: 'failed',
error: r.reason ? String(r.reason) : 'Unknown error',
};
@@ -150,14 +173,14 @@ webhook.post('/:token/topic/:slug', async (c) => {
await db.insert(alertLogs).values(logs as any);
}
console.log(`[Webhook] Task ${task.id}: Sent ${successCount}/${subscribers.length} alerts for topic ${slug}`);
console.log(`[Webhook] Task ${task.id}: Sent ${successCount}/${allRecipients.length} alerts for topic ${slug}`);
});
return c.json({
message: 'Alert received and processing started',
taskId: task.id,
status: 'processing',
recipientCount: subscribers.length
recipientCount: allRecipients.length
});
});

16
apps/server/src/ws.ts Normal file
View File

@@ -0,0 +1,16 @@
import { feishuClient } from './feishu';
import { eventDispatcher } from './event-handler';
export const startWebSocket = async () => {
if (process.env.FEISHU_USE_WS !== 'true') {
return;
}
console.log('[Feishu WS] Starting WebSocket connection...');
try {
await (feishuClient.client as any).ws.start(eventDispatcher);
console.log('[Feishu WS] Connected successfully');
} catch (e) {
console.error('[Feishu WS] Connection failed:', e);
}
};

View File

@@ -1,6 +1,6 @@
{
"name": "@alertmessagecenter/web",
"version": "1.0.0",
"version": "1.2.0",
"type": "module",
"scripts": {
"dev": "bun run --env-file .env vite",

View File

@@ -0,0 +1,196 @@
import { useState, useEffect } from 'react';
import { Trash2, Plus, MessageCircle } from 'lucide-react';
import Modal from './Modal';
import { client } from '../lib/client';
interface GroupBinding {
id: string;
chatId: string;
name: string;
}
interface KnownGroup {
chatId: string;
name: string;
lastActiveAt: string;
}
interface GroupBindingsModalProps {
isOpen: boolean;
onClose: () => void;
topicId: string;
topicName: string;
}
export default function GroupBindingsModal({ isOpen, onClose, topicId, topicName }: GroupBindingsModalProps) {
// const { user } = useAuth(); // Unused
const [bindings, setBindings] = useState<GroupBinding[]>([]);
const [knownGroups, setKnownGroups] = useState<KnownGroup[]>([]);
const [selectedChatId, setSelectedChatId] = useState('');
const [loading, setLoading] = useState(false);
const [status, setStatus] = useState<{ type: 'success' | 'error', message: string } | null>(null);
useEffect(() => {
if (isOpen && topicId) {
fetchBindings();
fetchKnownGroups();
setStatus(null);
setSelectedChatId('');
}
}, [isOpen, topicId]);
const fetchBindings = async () => {
try {
const res = await client.api.topics[':id'].groups.$get({
param: { id: topicId }
}, {
init: { credentials: 'include' }
});
const data = await res.json();
setBindings(data as any);
} catch (err) {
console.error(err);
}
};
const fetchKnownGroups = async () => {
try {
const res = await client.api.groups.$get(undefined, {
init: { credentials: 'include' }
});
const data = await res.json();
// Only verify uniqueness if needed, but here we just list what server returns
setKnownGroups(data as any);
} catch (err) {
console.error(err);
}
};
const handleBind = async () => {
if (!selectedChatId) return;
setLoading(true);
setStatus(null);
const group = knownGroups.find(g => g.chatId === selectedChatId);
if (!group) return;
try {
const res = await client.api.topics[':id'].groups.$post({
param: { id: topicId },
json: {
chatId: group.chatId,
name: group.name,
}
}, {
init: { credentials: 'include' }
});
if (res.ok) {
setStatus({ type: 'success', message: 'Group bound successfully!' });
fetchBindings();
setSelectedChatId('');
} else {
await res.json(); // Consume body
setStatus({ type: 'error', message: 'Failed to bind group' });
}
} catch (_) { // Ignore error
setStatus({ type: 'error', message: 'An error occurred' });
} finally {
setLoading(false);
}
};
const handleUnbind = async (bindingId: string) => {
if (!confirm('Are you sure you want to remove this group binding?')) return;
try {
const res = await client.api.topics[':id'].groups[':bindingId'].$delete({
param: { id: topicId, bindingId }
}, {
init: { credentials: 'include' }
});
if (res.ok) {
setBindings(prev => prev.filter(b => b.id !== bindingId));
}
} catch (err) {
console.error(err);
}
};
// Filter out groups that are already bound
const availableGroups = knownGroups.filter(
kg => !bindings.some(b => b.chatId === kg.chatId)
);
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={`Manage Group Chats for ${topicName}`}
>
<div className="space-y-6">
<div>
<h4 className="text-sm font-medium text-gray-900 mb-2">Bound Groups</h4>
{bindings.length === 0 ? (
<p className="text-sm text-gray-500 italic">No groups bound to this topic yet.</p>
) : (
<ul className="divide-y divide-gray-200 border rounded-md">
{bindings.map(binding => (
<li key={binding.id} className="flex justify-between items-center p-3">
<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}</span>
</div>
<button
onClick={() => handleUnbind(binding.id)}
className="text-red-500 hover:text-red-700 p-1 rounded hover:bg-red-50"
title="Remove binding"
>
<Trash2 className="w-4 h-4" />
</button>
</li>
))}
</ul>
)}
</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">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>
<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>
<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"
>
<Plus className="w-4 h-4 mr-1" />
Add
</button>
</div>
{status && (
<p className={`mt-2 text-xs ${status.type === 'success' ? 'text-green-600' : 'text-red-600'}`}>
{status.message}
</p>
)}
</div>
</div>
</Modal>
);
}

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
import { Plus, Settings, UserPlus, UserMinus, Copy, Check, User, ShieldCheck } from 'lucide-react';
import { Plus, Settings, UserPlus, UserMinus, Copy, Check, User, ShieldCheck, Users } from 'lucide-react';
import Modal from '../components/Modal';
import GroupBindingsModal from '../components/GroupBindingsModal';
import { useAuth } from '../contexts/AuthContext';
import { client } from '../lib/client';
@@ -23,6 +24,7 @@ interface Topic {
subscriptions: Subscription[];
creator?: User;
approver?: User;
createdBy?: string;
}
export default function TopicsView() {
@@ -34,6 +36,7 @@ export default function TopicsView() {
const [isModalOpen, setIsModalOpen] = useState(false);
const [isSubModalOpen, setIsSubModalOpen] = useState(false);
const [selectedTopic, setSelectedTopic] = useState<Topic | null>(null);
const [isGroupModalOpen, setIsGroupModalOpen] = useState(false);
const [copiedId, setCopiedId] = useState<string | null>(null);
const [formData, setFormData] = useState<Partial<Topic>>({
@@ -129,6 +132,11 @@ export default function TopicsView() {
setIsSubModalOpen(true);
};
const handleGroupClick = (topic: Topic) => {
setSelectedTopic(topic);
setIsGroupModalOpen(true);
};
const toggleSubscription = async (topicId: string, userId: string, isSubscribed: boolean) => {
try {
console.log('Toggling subscription:', { topicId, userId, isSubscribed });
@@ -315,14 +323,23 @@ export default function TopicsView() {
</>
)}
</button>
{currentUser?.isAdmin && (
{currentUser && (currentUser.isAdmin || currentUser.id === topic.createdBy) && (
<>
{currentUser.isAdmin && (
<button
onClick={() => handleSubscriptionClick(topic)}
className="text-gray-400 hover:text-gray-500"
title="Manage Subscriptions"
>
<Settings className="w-5 h-5" />
</button>
)}
<button
onClick={() => handleSubscriptionClick(topic)}
onClick={() => handleGroupClick(topic)}
className="text-gray-400 hover:text-gray-500"
title="Manage Subscriptions"
title="Manage Group Chats"
>
<Settings className="w-5 h-5" />
<Users className="w-5 h-5" />
</button>
</>
)}
@@ -537,6 +554,15 @@ export default function TopicsView() {
</div>
</div>
</Modal>
{selectedTopic && (
<GroupBindingsModal
isOpen={isGroupModalOpen}
onClose={() => setIsGroupModalOpen(false)}
topicId={selectedTopic.id}
topicName={selectedTopic.name}
/>
)}
</div >
);
}

View File

@@ -12,6 +12,7 @@
"version": "1.0.0",
"dependencies": {
"@hono/zod-validator": "^0.7.6",
"@larksuiteoapi/node-sdk": "^1.56.1",
"drizzle-orm": "^0.45.1",
"hono": "^4.11.3",
"postgres": "^3.4.8",
@@ -165,12 +166,34 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@larksuiteoapi/node-sdk": ["@larksuiteoapi/node-sdk@1.56.1", "", { "dependencies": { "axios": "0.27.2", "lodash.identity": "^3.0.0", "lodash.merge": "^4.6.2", "lodash.pickby": "^4.6.0", "protobufjs": "^7.2.6", "qs": "^6.13.0", "ws": "^8.16.0" } }, "sha512-/ixtyJnWOmcupKgDXz+6G6qTLMi3cNrR+LGOuq2PMwcJ6hhXTUJNyAF+ADY7ah9OoeDniGU/UJwMb2gqKdxwcA=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
"@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
"@protobufjs/codegen": ["@protobufjs/codegen@2.0.4", "", {}, "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg=="],
"@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="],
"@protobufjs/fetch": ["@protobufjs/fetch@1.1.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" } }, "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ=="],
"@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="],
"@protobufjs/inquire": ["@protobufjs/inquire@1.1.0", "", {}, "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q=="],
"@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="],
"@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="],
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.54.0", "", { "os": "android", "cpu": "arm" }, "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng=="],
@@ -243,8 +266,12 @@
"arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
"autoprefixer": ["autoprefixer@10.4.23", "", { "dependencies": { "browserslist": "^4.28.1", "caniuse-lite": "^1.0.30001760", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA=="],
"axios": ["axios@0.27.2", "", { "dependencies": { "follow-redirects": "^1.14.9", "form-data": "^4.0.0" } }, "sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ=="],
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.11", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ=="],
@@ -267,6 +294,10 @@
"bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
"camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="],
"caniuse-lite": ["caniuse-lite@1.0.30001762", "", {}, "sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw=="],
@@ -277,6 +308,8 @@
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
"commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
@@ -291,6 +324,8 @@
"deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="],
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
@@ -301,10 +336,20 @@
"drizzle-orm": ["drizzle-orm@0.45.1", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"electron-to-chromium": ["electron-to-chromium@1.5.267", "", {}, "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw=="],
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
"esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="],
@@ -323,6 +368,10 @@
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="],
"form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
"fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
"fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="],
@@ -333,12 +382,22 @@
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="],
"github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"hono": ["hono@4.11.3", "", {}, "sha512-PmQi306+M/ct/m5s66Hrg+adPnkD5jiO6IjA7WhWw0gSBSo1EcRegwuI1deZ+wd5pzCGynCcn2DprnE4/yEV4w=="],
@@ -371,16 +430,30 @@
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
"lodash.identity": ["lodash.identity@3.0.0", "", {}, "sha512-AupTIzdLQxJS5wIYUQlgGyk2XRTfGXA+MCghDHqZk0pzUNYvd3EESS6dkChNauNYVIutcb0dfHw1ri9Q1yPV8Q=="],
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
"lodash.pickby": ["lodash.pickby@4.6.0", "", {}, "sha512-AZV+GsS/6ckvPOVQPXSiFFacKvKB4kOQu6ynt9wz0F3LO4R9Ij4K1ddYsIytDpSgLz88JHd9P+oaLeej5/Sl7Q=="],
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"lucide-react": ["lucide-react@0.300.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0" } }, "sha512-rQxUUCmWAvNLoAsMZ5j04b2+OJv6UuNLYMY7VK0eVlm4aTwUEjEEHc09/DipkNIlhXUSDn2xoyIzVT0uh7dRsg=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
@@ -405,6 +478,8 @@
"object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="],
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
@@ -435,8 +510,12 @@
"prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="],
"protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="],
"pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="],
"qs": ["qs@6.14.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ=="],
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
"rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="],
@@ -469,6 +548,14 @@
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
"side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
"simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="],
"simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="],
@@ -519,10 +606,14 @@
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@alertmessagecenter/server/bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="],
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],

View File

@@ -1,4 +1,4 @@
# Project Context for GitHub Copilot (v1.1.1)
# Project Context for GitHub Copilot (v1.1.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.
@@ -9,7 +9,8 @@ This document provides technical context, architectural decisions, and code conv
- **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**.
- **Group Chat**: Alerts can be dispatched to Feishu Group Chats where the App Bot is a member.
- **Dispatch**: The system sends messages via **Feishu (Lark) Private Messages** or **Group Messages**.
- **Runtime**: Bun (JavaScript/TypeScript runtime).
## 2. Tech Stack
@@ -21,7 +22,7 @@ This document provides technical context, architectural decisions, and code conv
- **Database**: PostgreSQL.
- **ORM**: Drizzle ORM.
- **Authentication**: Feishu OAuth2 (Session-based with cookies).
- **External API**: Feishu Open Platform (Server-side API).
- **External API**: Feishu Open Platform (Server-side API via `@larksuiteoapi/node-sdk`).
- **Frontend**:
- **Build Tool**: Vite.
- **Framework**: React.
@@ -56,6 +57,19 @@ The database schema is defined in `apps/server/src/db/schema.ts`.
- `userId`: Foreign Key -> `users.id`.
- **Relationship**: Many-to-Many between Topics and Users.
4. **Topic Group Chat** (`topic_group_chats`)
- `id`: UUID (Primary Key).
- `topicId`: Foreign Key -> `topics.id`.
- `chatId`: The Feishu `chat_id`.
- `name`: Group name (snapshot).
- **Relationship**: Many-to-Many between Topics and Feishu Groups.
5. **Known Group Chat** (`known_group_chats`)
- `chatId`: Feishu `chat_id` (Primary Key).
- `name`: Group name.
- `lastActiveAt`: Timestamp of last event from this group.
- **Purpose**: Caches groups the bot has been added to, facilitating easy selection in the UI.
## 4. Key Workflows
### Authentication
@@ -87,7 +101,22 @@ The database schema is defined in `apps/server/src/db/schema.ts`.
- Call `FeishuClient.sendMessage` for each recipient.
- **Payload**: Supports `text` and `interactive` (Feishu Card) message types.
### Subscription Management
- Call `FeishuClient.sendMessage` for each recipient.
- **Payload**: Supports `text` and `interactive` (Feishu Card) message types.
### Feishu Group Chat Integration
- **Strategy**: App Bot in Group.
- **Discovery**:
- The system listens for `im.chat.member.bot.added_v1` events (via Webhook or WebSocket).
- When the bot is added to a group, the group details are cached in `known_group_chats`.
- **Binding**: Admins bind a Topic to a known Feishu Group in the UI.
- **Dispatch**: Alerts for the topic are sent to all bound `chat_id`s in addition to individual subscribers.
### Long Connection (WebSocket)
- **Problem**: Intranet deployments cannot receive public Webhook callbacks from Feishu.
- **Solution**: Use Feishu Open Platform's WebSocket mode.
- **Configuration**: Set `FEISHU_USE_WS=true` in `.env`.
- **Implementation**: Uses `@larksuiteoapi/node-sdk` to establish a persistent connection and receive events like `im.chat.member.bot.added_v1`.
- Users can subscribe/unsubscribe themselves to any topic.
- 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.
@@ -114,7 +143,16 @@ The database schema is defined in `apps/server/src/db/schema.ts`.
- `POST /api/topics/:id/subscribe/:userId`: Subscribe.
- `DELETE /api/topics/:id/subscribe/:userId`: Unsubscribe.
- `GET /api/users`: List users (Admin only).
- `GET /api/users`: List users (Admin only).
### Feishu Group Management
- `GET /api/groups`: List known groups (cached from bot events).
- `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.
### Feishu Event
- `POST /api/feishu/event`: Endpoint for receiving Feishu events (Webhook mode).
### Webhook
- `POST /api/webhook/:token/topic/:slug`: Trigger an alert for a topic.

View File

@@ -22,4 +22,6 @@
- [x] **Personal Inbox**: Direct alert delivery bypassing topics.
- [ ] **Retry Mechanism**: Handle Feishu API failures.
- [x] **Deployment**: Dockerfile and CI/CD (GitHub Actions + GHCR).
- [x] **Feishu Group Chat**: Event-based group discovery and alerting (App Bot).
- [x] **Long Connection**: WebSocket support for intranet deployments.