diff --git a/CHANGELOG.md b/CHANGELOG.md index 21f9925..266355c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ### 修复 diff --git a/apps/server/drizzle/0001_bumpy_orphan.sql b/apps/server/drizzle/0001_bumpy_orphan.sql new file mode 100644 index 0000000..039c725 --- /dev/null +++ b/apps/server/drizzle/0001_bumpy_orphan.sql @@ -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"); \ No newline at end of file diff --git a/apps/server/package.json b/apps/server/package.json index d0f4851..29727ec 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -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", diff --git a/apps/server/src/api.ts b/apps/server/src/api.ts index 5c4f41b..b8c2484 100644 --- a/apps/server/src/api.ts +++ b/apps/server/src/api.ts @@ -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) => { diff --git a/apps/server/src/api/feishu-event.ts b/apps/server/src/api/feishu-event.ts new file mode 100644 index 0000000..30630af --- /dev/null +++ b/apps/server/src/api/feishu-event.ts @@ -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 + const headerRecord: Record = {}; + 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; diff --git a/apps/server/src/db/schema.ts b/apps/server/src/db/schema.ts index 51c06bd..f054317 100644 --- a/apps/server/src/db/schema.ts +++ b/apps/server/src/db/schema.ts @@ -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], diff --git a/apps/server/src/event-handler.ts b/apps/server/src/event-handler.ts new file mode 100644 index 0000000..c260038 --- /dev/null +++ b/apps/server/src/event-handler.ts @@ -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(), + } + }); + } + }, +}); diff --git a/apps/server/src/feishu.ts b/apps/server/src/feishu.ts index b5e4b14..96033ae 100644 --- a/apps/server/src/feishu.ts +++ b/apps/server/src/feishu.ts @@ -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 { - 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 { - 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 || '' ); diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 02751a6..ff5cc2b 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -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; diff --git a/apps/server/src/webhook.ts b/apps/server/src/webhook.ts index 1f1ee18..1d54679 100644 --- a/apps/server/src/webhook.ts +++ b/apps/server/src/webhook.ts @@ -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 }); }); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts new file mode 100644 index 0000000..2c8db94 --- /dev/null +++ b/apps/server/src/ws.ts @@ -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); + } +}; diff --git a/apps/web/package.json b/apps/web/package.json index ecec8bd..949cef5 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/src/components/GroupBindingsModal.tsx b/apps/web/src/components/GroupBindingsModal.tsx new file mode 100644 index 0000000..f61421e --- /dev/null +++ b/apps/web/src/components/GroupBindingsModal.tsx @@ -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([]); + const [knownGroups, setKnownGroups] = useState([]); + 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 ( + +
+
+

Bound Groups

+ {bindings.length === 0 ? ( +

No groups bound to this topic yet.

+ ) : ( +
    + {bindings.map(binding => ( +
  • +
    + + {binding.name} +
    + +
  • + ))} +
+ )} +
+ +
+

Add Group Binding

+

+ 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. +

+ +
+ + +
+ {status && ( +

+ {status.message} +

+ )} +
+
+
+ ); +} diff --git a/apps/web/src/views/TopicsView.tsx b/apps/web/src/views/TopicsView.tsx index d73eda7..19b3439 100644 --- a/apps/web/src/views/TopicsView.tsx +++ b/apps/web/src/views/TopicsView.tsx @@ -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(null); + const [isGroupModalOpen, setIsGroupModalOpen] = useState(false); const [copiedId, setCopiedId] = useState(null); const [formData, setFormData] = useState>({ @@ -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() { )} - {currentUser?.isAdmin && ( + {currentUser && (currentUser.isAdmin || currentUser.id === topic.createdBy) && ( <> + {currentUser.isAdmin && ( + + )} )} @@ -537,6 +554,15 @@ export default function TopicsView() { + + {selectedTopic && ( + setIsGroupModalOpen(false)} + topicId={selectedTopic.id} + topicName={selectedTopic.name} + /> + )} ); } diff --git a/bun.lock b/bun.lock index 8b194ba..9eb1200 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/docs/copilot-context.md b/docs/copilot-context.md index ca879b7..7117519 100644 --- a/docs/copilot-context.md +++ b/docs/copilot-context.md @@ -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. diff --git a/todo.md b/todo.md index 353b022..2e5c188 100644 --- a/todo.md +++ b/todo.md @@ -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.