diff --git a/packages/comms/src/feishu/adapter.ts b/packages/comms/src/feishu/adapter.ts index 58dcacc7..c84c31ee 100644 --- a/packages/comms/src/feishu/adapter.ts +++ b/packages/comms/src/feishu/adapter.ts @@ -1,12 +1,24 @@ import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; import { createLogger, msgId, type Message } from '@markus/shared'; import type { CommAdapter, CommAdapterConfig, IncomingMessageHandler, SendOptions } from '../adapter.js'; -import { FeishuClient } from './client.js'; +import { FeishuClient, type ReceiveIdType } from './client.js'; import { createHmac, randomBytes, createCipheriv, createDecipheriv, scrypt } from 'node:crypto'; import { promisify } from 'node:util'; const log = createLogger('feishu-adapter'); +/** Extended send options for Feishu adapter */ +export interface FeishuSendOptions extends SendOptions { + /** Target ID type for sending messages — 'chat_id' (default), 'open_id', 'user_id', 'union_id' */ + receiveIdType?: ReceiveIdType; + /** Send as card/interactive message */ + asCard?: boolean; + /** Rich text content (post format) */ + richText?: boolean; + /** Send as image */ + asImage?: boolean; +} + export interface FeishuAdapterConfig extends CommAdapterConfig { platform: 'feishu'; appId: string; @@ -14,6 +26,8 @@ export interface FeishuAdapterConfig extends CommAdapterConfig { verificationToken?: string; encryptKey?: string; webhookPort?: number; + /** Enable WebSocket event subscription instead of webhook */ + wsMode?: boolean; domain?: string; } @@ -50,6 +64,8 @@ export class FeishuAdapter implements CommAdapter { private config?: FeishuAdapterConfig; private handlers: IncomingMessageHandler[] = []; private server?: ReturnType; + private ws?: any; + private wsHeartbeatTimer?: ReturnType; private connected = false; private processedEvents = new Set(); @@ -63,17 +79,22 @@ export class FeishuAdapter implements CommAdapter { await this.client.getTenantToken(); - const port = this.config.webhookPort ?? 9000; - this.server = createServer((req, res) => this.handleWebhook(req, res)); - this.server.listen(port, () => { - log.info(`Feishu webhook server listening on port ${port}`); - }); + if (this.config.wsMode) { + await this.setupWsSubscription(); + } else { + const port = this.config.webhookPort ?? 9000; + this.server = createServer((req, res) => this.handleWebhook(req, res)); + this.server.listen(port, () => { + log.info(`Feishu webhook server listening on port ${port}`); + }); + } this.connected = true; - log.info('Feishu adapter connected'); + log.info(`Feishu adapter connected (mode: ${this.config.wsMode ? 'websocket' : 'webhook'})`); } async disconnect(): Promise { + this.teardownWsSubscription(); if (this.server) { this.server.close(); this.server = undefined; @@ -84,10 +105,19 @@ export class FeishuAdapter implements CommAdapter { async sendMessage(channelId: string, content: string, options?: SendOptions): Promise { if (!this.client) throw new Error('Feishu adapter not connected'); + const feishuOpts = options as FeishuSendOptions | undefined; + const idType = feishuOpts?.receiveIdType ?? 'chat_id'; + + if (feishuOpts?.asCard) { + return this.client.sendInteractiveMessage(channelId, JSON.parse(content), idType); + } if (options?.richText) { - return this.client.sendInteractiveMessage(channelId, JSON.parse(content)); + return this.client.sendInteractiveMessage(channelId, JSON.parse(content), idType); } - return this.client.sendTextMessage(channelId, content); + if (feishuOpts?.asImage) { + return this.client.sendTextMessage(channelId, content, idType); + } + return this.client.sendTextMessage(channelId, content, idType); } async sendCard(channelId: string, card: Record): Promise { @@ -95,9 +125,43 @@ export class FeishuAdapter implements CommAdapter { return this.client.sendInteractiveMessage(channelId, card); } - async sendReply(channelId: string, replyToId: string, content: string): Promise { + async sendReply(channelId: string, replyToId: string, content: string, options?: SendOptions): Promise { + if (!this.client) throw new Error('Feishu adapter not connected'); + const feishuOpts = options as FeishuSendOptions | undefined; + const msgType = feishuOpts?.asCard ? 'interactive' : feishuOpts?.richText ? 'post' : 'text'; + + if (msgType === 'interactive') { + return this.client.replyCard(replyToId, JSON.parse(content)); + } + // For rich text (post) and plain text, content format differs + if (msgType === 'post') { + return this.client.replyMessage(replyToId, content, 'post'); + } + return this.client.replyMessage(replyToId, JSON.stringify({ text: content })); + } + + async updateMessage(channelId: string, messageId: string, content: string): Promise { + if (!this.client) throw new Error('Feishu adapter not connected'); + + try { + await this.client.updateMessage(messageId, JSON.stringify({ text: content })); + log.info(`Feishu message updated in channel ${channelId}: ${messageId}`); + } catch (error) { + log.error(`Failed to update Feishu message ${messageId} in ${channelId}:`, { error }); + throw error; + } + } + + async deleteMessage(channelId: string, messageId: string): Promise { if (!this.client) throw new Error('Feishu adapter not connected'); - return this.client.replyMessage(replyToId, content); + + try { + await this.client.deleteMessage(messageId); + log.info(`Feishu message deleted from channel ${channelId}: ${messageId}`); + } catch (error) { + log.error(`Failed to delete Feishu message ${messageId} from ${channelId}:`, { error }); + throw error; + } } onMessage(handler: IncomingMessageHandler): void { @@ -108,6 +172,112 @@ export class FeishuAdapter implements CommAdapter { return this.connected; } + // ─── WebSocket Event Subscription ──────────────────────────────────────────── + + private async setupWsSubscription(): Promise { + if (!this.client || !this.config) return; + const token = await this.client.getTenantToken(); + + // Step 1: Get WebSocket URL from Feishu API + const res = await fetch(`${this.config.domain ?? 'https://open.feishu.cn'}/open-apis/ws/v1/apps/${this.config.appId}/subscribe`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), + }); + + const data = (await res.json()) as { code: number; data?: { url?: string } }; + if (data.code !== 0) { + throw new Error(`Feishu WS subscribe failed: ${JSON.stringify(data)}`); + } + + const wsUrl = data.data?.url; + if (!wsUrl) { + throw new Error('Feishu WS subscribe returned no URL'); + } + + // Step 2: Connect WebSocket + this.ws = new (globalThis as any).WebSocket(wsUrl); + + this.ws.onopen = () => { + log.info('Feishu WebSocket connected'); + }; + + this.ws.onmessage = (event: { data: Buffer }) => { + try { + const payload = JSON.parse(event.data.toString()) as FeishuEvent; + this.handleWsEvent(payload).catch((err) => { + log.error('Failed to handle WS event', { error: err.message }); + }); + } catch (err) { + log.error('Failed to parse WS message', { error: err instanceof Error ? err.message : String(err) }); + } + }; + + this.ws.onclose = (event: { code: number; reason: Buffer }) => { + log.warn(`Feishu WebSocket closed: code=${event.code}, reason=${event.reason.toString()}`); + // Auto-reconnect after 5 seconds + setTimeout(() => { + if (this.connected) { + log.info('Feishu WebSocket reconnecting...'); + this.setupWsSubscription().catch((err) => { + log.error('Feishu WS reconnect failed', { error: err.message }); + }); + } + }, 5000); + }; + + this.ws.onerror = () => { + log.error('Feishu WebSocket error occurred'); + }; + + // Step 3: Heartbeat at 30s intervals (Feishu WS requirement) + this.wsHeartbeatTimer = setInterval(() => { + if (this.ws?.readyState === (globalThis as any).WebSocket.OPEN) { + this.ws.send(JSON.stringify({ type: 'heartbeat' })); + } + }, 30_000); + } + + private teardownWsSubscription(): void { + if (this.wsHeartbeatTimer) { + clearInterval(this.wsHeartbeatTimer); + this.wsHeartbeatTimer = undefined; + } + if (this.ws) { + this.ws.close(); + this.ws = undefined; + } + } + + private async handleWsEvent(event: FeishuEvent): Promise { + // Handle challenge/pong + if (event.type === 'pong') return; + + // Deduplicate events + const eventId = event.header?.event_id; + if (eventId) { + if (this.processedEvents.has(eventId)) return; + this.processedEvents.add(eventId); + if (this.processedEvents.size > 1000) { + const arr = Array.from(this.processedEvents); + this.processedEvents = new Set(arr.slice(-500)); + } + } + + // Process message events + if (event.header?.event_type === 'im.message.receive_v1') { + await this.processMessageEvent(event); + } + + // Card action callbacks + if ((event as Record)['action']) { + await this.processCardAction(event as Record); + } + } + /** * Decrypt Feishu encrypted webhook payload using AES-256-CBC. * The encryptKey is derived via scrypt with salt='key' (Feishu convention). diff --git a/packages/comms/src/feishu/client.ts b/packages/comms/src/feishu/client.ts index 889122e9..435fed72 100644 --- a/packages/comms/src/feishu/client.ts +++ b/packages/comms/src/feishu/client.ts @@ -8,6 +8,19 @@ export interface FeishuConfig { domain?: string; } +/** Feishu receive ID types for sending messages */ +export type ReceiveIdType = 'chat_id' | 'open_id' | 'user_id' | 'union_id'; + +/** Message content type for sending */ +export type SendMsgType = 'text' | 'post' | 'interactive' | 'image' | 'file' | 'audio' | 'media' | 'sticker'; + +/** Response type for Feishu API operations */ +interface ApiResponse { + code: number; + msg: string; + data?: T; +} + interface TokenResponse { code: number; msg: string; @@ -62,36 +75,38 @@ export class FeishuClient { return this.tenantToken; } - async sendTextMessage(chatId: string, text: string): Promise { - return this.sendMessage(chatId, 'text', JSON.stringify({ text })); + async sendTextMessage(chatId: string, text: string, idType: ReceiveIdType = 'chat_id'): Promise { + return this.sendMessage(chatId, 'text', JSON.stringify({ text }), idType); } - async sendRichTextMessage(chatId: string, title: string, content: Array>>): Promise { + async sendRichTextMessage(chatId: string, title: string, content: Array>>, idType: ReceiveIdType = 'chat_id'): Promise { return this.sendMessage(chatId, 'post', JSON.stringify({ zh_cn: { title, content }, - })); + }), idType); } - async sendInteractiveMessage(chatId: string, card: Record): Promise { - return this.sendMessage(chatId, 'interactive', JSON.stringify(card)); + async sendInteractiveMessage(chatId: string, card: Record, idType: ReceiveIdType = 'chat_id'): Promise { + return this.sendMessage(chatId, 'interactive', JSON.stringify(card), idType); } - async replyMessage(messageId: string, text: string): Promise { + async replyMessage(messageId: string, content: string, msgType: SendMsgType = 'text'): Promise { const token = await this.getTenantToken(); + const body: Record = { + msg_type: msgType, + content, + }; + const res = await fetch(`${this.domain}/open-apis/im/v1/messages/${messageId}/reply`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, }, - body: JSON.stringify({ - msg_type: 'text', - content: JSON.stringify({ text }), - }), + body: JSON.stringify(body), }); - const data = (await res.json()) as SendMessageResponse; + const data = (await res.json()) as ApiResponse<{ message_id: string }>; if (data.code !== 0) { throw new Error(`Feishu reply failed: ${data.msg}`); } @@ -99,6 +114,55 @@ export class FeishuClient { return data.data!.message_id; } + async replyCard(messageId: string, card: Record): Promise { + return this.replyMessage(messageId, JSON.stringify(card), 'interactive'); + } + + async updateMessage(messageId: string, content: string, msgType: SendMsgType = 'text'): Promise { + const token = await this.getTenantToken(); + + const res = await fetch(`${this.domain}/open-apis/im/v1/messages/${messageId}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + msg_type: msgType, + content, + }), + }); + + const data = (await res.json()) as ApiResponse; + if (data.code !== 0) { + throw new Error(`Feishu updateMessage failed: ${data.msg}`); + } + + log.info(`Feishu message updated: ${messageId}`); + } + + async updateInteractiveMessage(messageId: string, card: Record): Promise { + return this.updateMessage(messageId, JSON.stringify(card), 'interactive'); + } + + async deleteMessage(messageId: string): Promise { + const token = await this.getTenantToken(); + + const res = await fetch(`${this.domain}/open-apis/im/v1/messages/${messageId}`, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + const data = (await res.json()) as ApiResponse; + if (data.code !== 0) { + throw new Error(`Feishu deleteMessage failed: ${data.msg}`); + } + + log.info(`Feishu message deleted: ${messageId}`); + } + async getMessageList(chatId: string, pageSize = 20): Promise { const token = await this.getTenantToken(); @@ -191,23 +255,23 @@ export class FeishuClient { })); } - private async sendMessage(receiveIdType: string, msgType: string, content: string): Promise { + private async sendMessage(receiveId: string, msgType: string, content: string, receiveIdType: ReceiveIdType = 'chat_id'): Promise { const token = await this.getTenantToken(); - const res = await fetch(`${this.domain}/open-apis/im/v1/messages?receive_id_type=chat_id`, { + const res = await fetch(`${this.domain}/open-apis/im/v1/messages?receive_id_type=${receiveIdType}`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, }, body: JSON.stringify({ - receive_id: receiveIdType, + receive_id: receiveId, msg_type: msgType, content, }), }); - const data = (await res.json()) as SendMessageResponse; + const data = (await res.json()) as ApiResponse<{ message_id: string }>; if (data.code !== 0) { throw new Error(`Feishu send failed: ${data.msg}`); } diff --git a/packages/comms/test/feishu-adapter.test.ts b/packages/comms/test/feishu-adapter.test.ts new file mode 100644 index 00000000..95257592 --- /dev/null +++ b/packages/comms/test/feishu-adapter.test.ts @@ -0,0 +1,163 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { FeishuAdapter } from '../src/feishu/adapter.js'; +import type { FeishuClient } from '../src/feishu/client.js'; + +// Factory to create a mock FeishuClient +function makeMockClient(): Partial { + return { + getTenantToken: vi.fn().mockResolvedValue('mock-token'), + sendTextMessage: vi.fn().mockResolvedValue('om_mock_sent'), + sendInteractiveMessage: vi.fn().mockResolvedValue('om_mock_card'), + replyMessage: vi.fn().mockResolvedValue('om_mock_reply'), + replyCard: vi.fn().mockResolvedValue('om_mock_reply_card'), + updateMessage: vi.fn().mockResolvedValue(undefined), + deleteMessage: vi.fn().mockResolvedValue(undefined), + }; +} + +describe('FeishuAdapter', () => { + let adapter: FeishuAdapter; + let mockClient: ReturnType; + + beforeEach(() => { + adapter = new FeishuAdapter(); + mockClient = makeMockClient(); + // Inject mock client via property access (adapter's client is private, but we use duck-typing for tests) + (adapter as Record)['client'] = mockClient; + (adapter as Record)['connected'] = true; + }); + + describe('updateMessage', () => { + it('should delegate to client.updateMessage', async () => { + await adapter.updateMessage('oc_channel', 'om_msg', 'updated text'); + + expect(mockClient.updateMessage).toHaveBeenCalledWith( + 'om_msg', + JSON.stringify({ text: 'updated text' }), + ); + }); + + it('should throw if adapter is not connected', async () => { + (adapter as Record)['client'] = undefined; + + await expect( + adapter.updateMessage('oc_channel', 'om_msg', 'text'), + ).rejects.toThrow('Feishu adapter not connected'); + }); + + it('should propagate client errors', async () => { + mockClient.updateMessage = vi.fn().mockRejectedValue(new Error('update failed')); + + await expect( + adapter.updateMessage('oc_channel', 'om_msg', 'text'), + ).rejects.toThrow('update failed'); + }); + }); + + describe('deleteMessage', () => { + it('should delegate to client.deleteMessage', async () => { + await adapter.deleteMessage('oc_channel', 'om_msg'); + + expect(mockClient.deleteMessage).toHaveBeenCalledWith('om_msg'); + }); + + it('should throw if adapter is not connected', async () => { + (adapter as Record)['client'] = undefined; + + await expect( + adapter.deleteMessage('oc_channel', 'om_msg'), + ).rejects.toThrow('Feishu adapter not connected'); + }); + + it('should propagate client errors', async () => { + mockClient.deleteMessage = vi.fn().mockRejectedValue(new Error('delete failed')); + + await expect( + adapter.deleteMessage('oc_channel', 'om_msg'), + ).rejects.toThrow('delete failed'); + }); + }); + + describe('sendReply', () => { + it('should send a text reply via client.replyMessage', async () => { + const result = await adapter.sendReply('oc_channel', 'om_original', 'Hello reply'); + + expect(result).toBe('om_mock_reply'); + expect(mockClient.replyMessage).toHaveBeenCalledWith( + 'om_original', + JSON.stringify({ text: 'Hello reply' }), + ); + }); + + it('should send an interactive card reply via client.replyCard when asCard is set', async () => { + const card = { config: { wide_screen_mode: true } }; + const result = await adapter.sendReply('oc_channel', 'om_original', JSON.stringify(card), { + asCard: true, + }); + + expect(result).toBe('om_mock_reply_card'); + expect(mockClient.replyCard).toHaveBeenCalledWith('om_original', card); + }); + + it('should send a post reply when richText is set', async () => { + const result = await adapter.sendReply('oc_channel', 'om_original', '{"zh_cn":{"title":"Test"}}', { + richText: true, + }); + + expect(result).toBe('om_mock_reply'); + expect(mockClient.replyMessage).toHaveBeenCalledWith( + 'om_original', + '{"zh_cn":{"title":"Test"}}', + 'post', + ); + }); + + it('should throw if adapter is not connected', async () => { + (adapter as Record)['client'] = undefined; + + await expect( + adapter.sendReply('oc_channel', 'om_original', 'text'), + ).rejects.toThrow('Feishu adapter not connected'); + }); + }); + + describe('sendMessage with options', () => { + it('should send as card when asCard is set', async () => { + const cardStr = JSON.stringify({ config: { wide_screen_mode: true } }); + await adapter.sendMessage('oc_channel', cardStr, { asCard: true }); + + expect(mockClient.sendInteractiveMessage).toHaveBeenCalledWith( + 'oc_channel', + JSON.parse(cardStr), + 'chat_id', + ); + }); + + it('should send with custom receiveIdType', async () => { + await adapter.sendMessage('oc_channel', 'Hello', { + receiveIdType: 'open_id', + }); + + expect(mockClient.sendTextMessage).toHaveBeenCalledWith('oc_channel', 'Hello', 'open_id'); + }); + }); + + describe('sendCard', () => { + it('should delegate to client.sendInteractiveMessage', async () => { + const card = { config: { wide_screen_mode: true }, header: { title: { tag: 'plain_text', content: 'Test' } } }; + const result = await adapter.sendCard('oc_channel', card); + + expect(result).toBe('om_mock_card'); + expect(mockClient.sendInteractiveMessage).toHaveBeenCalledWith('oc_channel', card); + }); + }); + + describe('isConnected', () => { + it('should return connection status', () => { + expect(adapter.isConnected()).toBe(true); + + (adapter as Record)['connected'] = false; + expect(adapter.isConnected()).toBe(false); + }); + }); +}); diff --git a/packages/comms/test/feishu-client.test.ts b/packages/comms/test/feishu-client.test.ts new file mode 100644 index 00000000..f917e8c6 --- /dev/null +++ b/packages/comms/test/feishu-client.test.ts @@ -0,0 +1,213 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { FeishuClient } from '../src/feishu/client.js'; + +describe('FeishuClient', () => { + let client: FeishuClient; + let mockFetch: ReturnType; + + beforeEach(() => { + // Reset token state by creating a fresh client each time + client = new FeishuClient({ + appId: 'test-app-id', + appSecret: 'test-secret', + domain: 'https://open.feishu.cn', + }); + + mockFetch = vi.fn(); + vi.stubGlobal('fetch', mockFetch); + + // Mock successful token fetch (called on first API call) + mockFetch.mockResolvedValue({ + json: async () => ({ code: 0, tenant_access_token: 'mock-token', expire: 7200 }), + }); + }); + + describe('sendInteractiveMessage', () => { + it('should send an interactive card message and return message_id', async () => { + const card = { config: { wide_screen_mode: true }, header: { title: { tag: 'plain_text', content: 'Hello' } } }; + + // First call: token fetch, second call: send message + mockFetch + .mockResolvedValueOnce({ + json: async () => ({ code: 0, tenant_access_token: 'mock-token', expire: 7200 }), + }) + .mockResolvedValueOnce({ + json: async () => ({ code: 0, msg: 'ok', data: { message_id: 'om_mock123' } }), + }); + + const result = await client.sendInteractiveMessage('oc_test_chat', card); + + expect(result).toBe('om_mock123'); + + // Verify the send message request + const sendCall = mockFetch.mock.calls[1]; + expect(sendCall[0]).toBe('https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=chat_id'); + expect(sendCall[1].method).toBe('POST'); + const body = JSON.parse(sendCall[1].body); + expect(body.msg_type).toBe('interactive'); + expect(body.receive_id).toBe('oc_test_chat'); + }); + }); + + describe('replyMessage', () => { + it('should reply to a message and return the new message_id', async () => { + mockFetch + .mockResolvedValueOnce({ + json: async () => ({ code: 0, tenant_access_token: 'mock-token', expire: 7200 }), + }) + .mockResolvedValueOnce({ + json: async () => ({ code: 0, msg: 'ok', data: { message_id: 'om_reply_123' } }), + }); + + const result = await client.replyMessage('om_original', JSON.stringify({ text: 'reply text' })); + + expect(result).toBe('om_reply_123'); + + const replyCall = mockFetch.mock.calls[1]; + expect(replyCall[0]).toBe('https://open.feishu.cn/open-apis/im/v1/messages/om_original/reply'); + expect(replyCall[1].method).toBe('POST'); + const body = JSON.parse(replyCall[1].body); + expect(body.msg_type).toBe('text'); + }); + + it('should reply with interactive card type when specified', async () => { + mockFetch + .mockResolvedValueOnce({ + json: async () => ({ code: 0, tenant_access_token: 'mock-token', expire: 7200 }), + }) + .mockResolvedValueOnce({ + json: async () => ({ code: 0, msg: 'ok', data: { message_id: 'om_card_reply' } }), + }); + + const card = { config: { wide_screen_mode: true } }; + const result = await client.replyMessage('om_original', JSON.stringify(card), 'interactive'); + + expect(result).toBe('om_card_reply'); + const replyCall = mockFetch.mock.calls[1]; + const body = JSON.parse(replyCall[1].body); + expect(body.msg_type).toBe('interactive'); + }); + }); + + describe('replyCard', () => { + it('should reply with a card and return the new message_id', async () => { + mockFetch + .mockResolvedValueOnce({ + json: async () => ({ code: 0, tenant_access_token: 'mock-token', expire: 7200 }), + }) + .mockResolvedValueOnce({ + json: async () => ({ code: 0, msg: 'ok', data: { message_id: 'om_card_reply' } }), + }); + + const card = { config: { wide_screen_mode: true }, header: { title: { tag: 'plain_text', content: 'Card Reply' } } }; + const result = await client.replyCard('om_original', card); + + expect(result).toBe('om_card_reply'); + + const replyCall = mockFetch.mock.calls[1]; + expect(replyCall[0]).toBe('https://open.feishu.cn/open-apis/im/v1/messages/om_original/reply'); + expect(replyCall[1].method).toBe('POST'); + const body = JSON.parse(replyCall[1].body); + expect(body.msg_type).toBe('interactive'); + }); + }); + + describe('updateMessage', () => { + it('should update a message with PATCH request', async () => { + mockFetch + .mockResolvedValueOnce({ + json: async () => ({ code: 0, tenant_access_token: 'mock-token', expire: 7200 }), + }) + .mockResolvedValueOnce({ + json: async () => ({ code: 0, msg: 'ok' }), + }); + + await client.updateMessage('om_test_msg', JSON.stringify({ text: 'Updated content' })); + + const updateCall = mockFetch.mock.calls[1]; + expect(updateCall[0]).toBe('https://open.feishu.cn/open-apis/im/v1/messages/om_test_msg'); + expect(updateCall[1].method).toBe('PATCH'); + const body = JSON.parse(updateCall[1].body); + expect(body.msg_type).toBe('text'); + expect(body.content).toBe(JSON.stringify({ text: 'Updated content' })); + }); + + it('should throw error when update fails', async () => { + mockFetch + .mockResolvedValueOnce({ + json: async () => ({ code: 0, tenant_access_token: 'mock-token', expire: 7200 }), + }) + .mockResolvedValueOnce({ + json: async () => ({ code: 10003, msg: 'message not found' }), + }); + + await expect(client.updateMessage('om_nonexistent', 'new content')).rejects.toThrow('Feishu updateMessage failed: message not found'); + }); + + it('should use specified msgType for update', async () => { + mockFetch + .mockResolvedValueOnce({ + json: async () => ({ code: 0, tenant_access_token: 'mock-token', expire: 7200 }), + }) + .mockResolvedValueOnce({ + json: async () => ({ code: 0, msg: 'ok' }), + }); + + await client.updateMessage('om_test', JSON.stringify({}), 'interactive'); + + const updateCall = mockFetch.mock.calls[1]; + const body = JSON.parse(updateCall[1].body); + expect(body.msg_type).toBe('interactive'); + }); + }); + + describe('updateInteractiveMessage', () => { + it('should update with interactive card content', async () => { + mockFetch + .mockResolvedValueOnce({ + json: async () => ({ code: 0, tenant_access_token: 'mock-token', expire: 7200 }), + }) + .mockResolvedValueOnce({ + json: async () => ({ code: 0, msg: 'ok' }), + }); + + const card = { config: { wide_screen_mode: true } }; + await client.updateInteractiveMessage('om_test', card); + + const updateCall = mockFetch.mock.calls[1]; + const body = JSON.parse(updateCall[1].body); + expect(body.msg_type).toBe('interactive'); + expect(body.content).toBe(JSON.stringify(card)); + }); + }); + + describe('deleteMessage', () => { + it('should delete a message with DELETE request', async () => { + mockFetch + .mockResolvedValueOnce({ + json: async () => ({ code: 0, tenant_access_token: 'mock-token', expire: 7200 }), + }) + .mockResolvedValueOnce({ + json: async () => ({ code: 0, msg: 'ok' }), + }); + + await client.deleteMessage('om_test_msg'); + + const deleteCall = mockFetch.mock.calls[1]; + expect(deleteCall[0]).toBe('https://open.feishu.cn/open-apis/im/v1/messages/om_test_msg'); + expect(deleteCall[1].method).toBe('DELETE'); + }); + + it('should throw error when delete fails', async () => { + mockFetch + .mockResolvedValueOnce({ + json: async () => ({ code: 0, tenant_access_token: 'mock-token', expire: 7200 }), + }) + .mockResolvedValueOnce({ + json: async () => ({ code: 10003, msg: 'message not found' }), + }); + + await expect(client.deleteMessage('om_nonexistent')).rejects.toThrow('Feishu deleteMessage failed: message not found'); + }); + }); +}); diff --git a/packages/core/src/agent-manager.ts b/packages/core/src/agent-manager.ts index 291789d3..9e4ae9dd 100644 --- a/packages/core/src/agent-manager.ts +++ b/packages/core/src/agent-manager.ts @@ -2288,7 +2288,7 @@ export class AgentManager { skills: config.skills, status: 'idle', }); - this.eventBus.emit('agent:created', { agentId: id, name: row.name }); + this.eventBus.emit('agent:restored', { agentId: id, name: row.name }); log.info(`Agent restored: ${row.name} (${id})`, { profile: config.profile ? 'yes' : 'no', tokensUsedToday: row.tokensUsedToday ?? 0, diff --git a/packages/org-manager/package.json b/packages/org-manager/package.json index 5ab38da4..b7fb4f10 100644 --- a/packages/org-manager/package.json +++ b/packages/org-manager/package.json @@ -11,6 +11,7 @@ "clean": "rm -rf dist *.tsbuildinfo" }, "dependencies": { + "@larksuiteoapi/node-sdk": "^1.36.0", "@markus/core": "workspace:*", "@markus/shared": "workspace:*", "@markus/storage": "workspace:*", diff --git a/packages/org-manager/src/api-server.ts b/packages/org-manager/src/api-server.ts index 39be921c..8a9a19ce 100644 --- a/packages/org-manager/src/api-server.ts +++ b/packages/org-manager/src/api-server.ts @@ -4,7 +4,7 @@ import { readdirSync, readFileSync, existsSync, writeFileSync, mkdirSync, rmSync import { gzipSync } from 'node:zlib'; import { homedir } from 'node:os'; import { execSync } from 'node:child_process'; -import { createLogger, generateId, userId as genUserId, kebab, saveConfig, getTextContent, stripInternalBlocks, extractThinkBlocks, APP_VERSION, checkForUpdate, buildManifest, manifestFilename, CHANNEL_CONTEXT_MESSAGES, type TaskStatus, type TaskPriority, type TaskSortField, type SortOrder, type PackageType, type RequirementStatus } from '@markus/shared'; +import { createLogger, generateId, userId as genUserId, kebab, saveConfig, getTextContent, stripInternalBlocks, extractThinkBlocks, APP_VERSION, checkForUpdate, buildManifest, manifestFilename, CHANNEL_CONTEXT_MESSAGES, type TaskStatus, type TaskPriority, type TaskSortField, type SortOrder, type PackageType, type RequirementStatus, type IntegrationConfig } from '@markus/shared'; import { GatewayError, WorkflowEngine, @@ -34,6 +34,7 @@ import type { OrganizationService } from './org-service.js'; import { BuilderService } from './builder-service.js'; import type { TaskService } from './task-service.js'; import type { HITLService } from './hitl-service.js'; +import { FeishuNotifier, type FeishuNotifierConfig } from './feishu-notifier.js'; import type { BillingService } from './billing-service.js'; import type { AuditService, AuditEventType } from './audit-service.js'; import type { LicenseService } from './license-service.js'; @@ -185,6 +186,7 @@ export class APIServer { private ws: WSBroadcaster; private skillRegistry?: SkillRegistry; private hitlService?: HITLService; + private feishuNotifier?: FeishuNotifier; private billingService?: BillingService; private auditService?: AuditService; private licenseService?: LicenseService; @@ -210,6 +212,7 @@ export class APIServer { private remoteAgent?: { getStatus(): unknown; start(): Promise; stop(): Promise; onStatus(cb: (s: unknown) => void): () => void }; private remoteAgentFactory?: () => Promise<{ getStatus(): unknown; start(): Promise; stop(): Promise; onStatus(cb: (s: unknown) => void): () => void } | null>; private modelCatalog?: ModelCatalogService; + private feishuRegisterSessions = new Map(); /** Aggregate today's tool calls from all agents' persisted metrics (the single source of truth) */ private getToolCallsTodayFromAgents(): number { try { @@ -595,10 +598,20 @@ export class APIServer { }; this.ws.sendToUser(n.targetUserId, event); }); + this.tryInitFeishuNotifier(); + } + + /** Update the Feishu notifier config at runtime (called when integration settings are saved). */ + updateFeishuConfig(config: FeishuNotifierConfig): void { + if (!this.feishuNotifier) { + return; + } + this.feishuNotifier.updateConfig(config); } setStorage(storage: StorageBridge): void { this.storage = storage; + this.tryInitFeishuNotifier(); } setRemoteAgent(agent: { getStatus(): unknown; start(): Promise; stop(): Promise; onStatus(cb: (s: unknown) => void): () => void }): void { @@ -1450,10 +1463,124 @@ export class APIServer { this.server.listen(this.port, '0.0.0.0', () => { log.info(`API server listening on 0.0.0.0:${this.port} (HTTP + WebSocket)`); }); + this.tryInitFeishuNotifier(); } stop(): void { this.server?.close(); + this.feishuNotifier?.stop(); + } + + /** Initialize FeishuNotifier once all dependencies are available. */ + private async tryInitFeishuNotifier(): Promise { + if (this.feishuNotifier) return; + if (!this.hitlService) return; + if (!this.storage) return; + try { + const agentManager = this.orgService.getAgentManager(); + const eventBus = agentManager.getEventBus(); + + // Credentials from markus.json (single source of truth) + const { loadConfig: loadCfg } = await import('@markus/shared'); + const markusCfg = loadCfg(this.markusConfigPath); + const appId = markusCfg.integrations?.feishu?.appId; + const appSecret = markusCfg.integrations?.feishu?.appSecret; + + // Runtime prefs from SQLite + const rows = this.storage.integrationRepo.listByPlatform('default', 'feishu') as Array>; + const row = rows[0]; + const cfgConfig = row?.['config'] as Record | undefined; + const forwardRules = row?.['forwardRules'] as Array> | undefined; + + let initialConfig: FeishuNotifierConfig | undefined; + if (appId && appSecret) { + initialConfig = { + appId, + appSecret, + domain: cfgConfig?.domain as string | undefined, + locale: (cfgConfig?.locale as 'zh' | 'en' | undefined) ?? 'zh', + notifyChatId: cfgConfig?.notifyChatId as string | undefined, + notifyOpenId: cfgConfig?.notifyOpenId as string | undefined, + notifyOnApproval: (cfgConfig?.notifyOnApproval ?? true) as boolean, + notifyOnNotification: (cfgConfig?.notifyOnNotification ?? false) as boolean, + notifyPriority: (cfgConfig?.notifyPriority ?? ['high', 'urgent']) as string[], + forwardRules: (forwardRules ?? []) as unknown as FeishuNotifierConfig['forwardRules'], + }; + } + + this.feishuNotifier = new FeishuNotifier({ + eventBus, + hitlService: this.hitlService, + orgId: 'default', + agentManager: { + getAgentName: (id: string) => { + try { return agentManager.getAgent(id)?.config?.name ?? id; } catch { return id; } + }, + }, + config: initialConfig, + }); + this.feishuNotifier.start(); + log.info('FeishuNotifier initialized'); + + // Route Feishu user messages to the Secretary agent + eventBus.on('feishu:message_received', (...args: unknown[]) => { + const payload = args[0] as Record; + this.handleFeishuUserMessage(payload).catch((err) => { + log.error('Failed to handle Feishu user message', { error: String(err) }); + }); + }); + } catch (err) { + log.warn('Failed to initialize FeishuNotifier', { error: String(err) }); + } + } + + /** Handle an incoming Feishu user message by routing it to the Secretary agent. */ + private async handleFeishuUserMessage(payload: Record): Promise { + const chatId = payload['chatId'] as string | undefined; + const senderId = payload['senderId'] as string | undefined; + const rawContent = payload['content'] as string | undefined; + const messageType = payload['messageType'] as string | undefined; + if (!chatId || !rawContent) return; + + // Extract text from Feishu message content JSON (e.g. {"text":"hello"}) + let text: string | undefined; + if (messageType === 'text') { + try { + const parsed = JSON.parse(rawContent); + text = parsed.text; + } catch { text = rawContent; } + } else { + text = `[${messageType ?? 'unknown'}] ${rawContent}`; + } + if (!text) return; + + const agentManager = this.orgService.getAgentManager(); + const agentList = agentManager.listAgents(); + const secretaryInfo = agentList.find(a => + a.agentRole === 'secretary' || a.role?.toLowerCase() === 'secretary' + ); + if (!secretaryInfo) { + log.warn('No Secretary agent found to handle Feishu message'); + await this.feishuNotifier?.sendTextToChat(chatId, '暂无可用的秘书 Agent 处理此消息'); + return; + } + + const secretary = agentManager.getAgent(secretaryInfo.id); + const senderName = payload['senderName'] as string ?? senderId ?? 'feishu_user'; + try { + const reply = await secretary.sendMessage( + text, + senderId ?? 'feishu_user', + { name: senderName, role: 'user' }, + { sourceType: 'human_chat' }, + ); + if (reply && this.feishuNotifier) { + await this.feishuNotifier.sendTextToChat(chatId, reply); + } + } catch (err) { + log.error('Secretary agent failed to respond to Feishu message', { error: String(err) }); + await this.feishuNotifier?.sendTextToChat(chatId, `处理消息时出错: ${String(err).slice(0, 200)}`); + } } getWSBroadcaster(): WSBroadcaster { @@ -8907,6 +9034,509 @@ EXPLANATION_END`; return; } + // ── Settings — Integration Config (Feishu) ────────────────────────── + + /** Find feishu integration config for a given org */ + const findFeishuConfig = (orgId: string): Record | undefined => { + const repo = this.storage?.integrationRepo; + if (!repo) return undefined; + const rows = repo.listByPlatform(orgId, 'feishu') as Array>; + return rows[0]; + }; + + if (path === '/api/settings/integrations/feishu' && req.method === 'GET') { + const auth = await this.requireAuth(req, res); + if (!auth) return; + try { + // Credentials from markus.json (single source of truth) + const { loadConfig: loadCfg } = await import('@markus/shared'); + const markusCfg = loadCfg(this.markusConfigPath); + const appId = markusCfg.integrations?.feishu?.appId ?? ''; + const appSecret = markusCfg.integrations?.feishu?.appSecret ?? ''; + + // Runtime prefs from SQLite + const row = findFeishuConfig(auth.orgId); + const cfg = (row?.['config'] as Record) ?? {}; + const connected = !!(this.feishuNotifier?.connected); + this.json(res, 200, { + appId, + appSecret, + enabled: !!(row?.['enabled']), + connected, + notifyChatId: cfg['notifyChatId'] ?? '', + notifyOnApproval: cfg['notifyOnApproval'] ?? true, + notifyOnNotification: cfg['notifyOnNotification'] ?? false, + notifyPriority: cfg['notifyPriority'] ?? ['high', 'urgent'], + }); + } catch (e) { + log.error('Failed to read feishu integration config', { error: String(e) }); + this.json(res, 500, { error: 'Failed to read integration config' }); + } + return; + } + + if (path === '/api/settings/integrations/feishu' && req.method === 'POST') { + const auth = await this.requireAuth(req, res); + if (!auth) return; + const body = await this.readBody(req); + const appId = body['appId'] as string; + const appSecret = body['appSecret'] as string; + if (!appId || !appSecret) { + this.json(res, 400, { error: 'appId and appSecret are required' }); + return; + } + const now = new Date().toISOString(); + const enabled = body['enabled'] !== false; + const payload: Record = { + id: 'feishu_default', + orgId: auth.orgId, + platform: 'feishu', + displayName: body['displayName'] ?? '飞书', + enabled, + config: { + domain: body['domain'] ?? undefined, + connectionMode: 'long_connection', + notifyChatId: body['notifyChatId'] ?? undefined, + notifyOnApproval: body['notifyOnApproval'] ?? true, + notifyOnNotification: body['notifyOnNotification'] ?? false, + notifyPriority: body['notifyPriority'] ?? ['high', 'urgent'], + }, + forwardRules: [], + lastVerifiedAt: null, + lastError: null, + }; + try { + const repo = this.storage?.integrationRepo; + if (!repo) { + this.json(res, 503, { error: 'Storage not available' }); + return; + } + const existing = findFeishuConfig(auth.orgId); + if (existing) { + await repo.update(existing['id'] as string, payload); + } else { + await repo.create(payload); + } + // Credentials go to markus.json (single source of truth, consistent with other API keys) + saveConfig({ integrations: { feishu: { appId, appSecret } } }, this.markusConfigPath); + log.info('Feishu integration config saved', { orgId: auth.orgId }); + this.auditService?.record({ + orgId: auth.orgId, + type: 'settings_changed', + action: 'integration_feishu', + detail: 'Feishu integration config saved', + userId: auth.userId, + success: true, + }); + // Update the FeishuNotifier runtime config + this.updateFeishuConfig({ + appId, + appSecret: appSecret, + domain: body['domain'] as string | undefined, + locale: (body['locale'] as 'zh' | 'en' | undefined) ?? undefined, + notifyChatId: body['notifyChatId'] as string | undefined, + notifyOnApproval: (body['notifyOnApproval'] ?? true) as boolean, + notifyOnNotification: (body['notifyOnNotification'] ?? false) as boolean, + notifyPriority: (body['notifyPriority'] ?? ['high', 'urgent']) as string[], + forwardRules: [], + }); + const connected = !!(this.feishuNotifier?.connected); + this.json(res, 200, { appId, connected, enabled }); + } catch (e) { + log.error('Failed to save feishu integration config', { error: String(e) }); + this.json(res, 500, { error: 'Failed to save integration config' }); + } + return; + } + + if (path === '/api/settings/integrations/feishu' && req.method === 'DELETE') { + const auth = await this.requireAuth(req, res); + if (!auth) return; + try { + const row = findFeishuConfig(auth.orgId); + if (row) { + await this.storage?.integrationRepo?.delete(row['id'] as string); + } + // Clear credentials from markus.json + saveConfig({ integrations: { feishu: {} } }, this.markusConfigPath); + log.info('Feishu integration config deleted', { orgId: auth.orgId }); + this.auditService?.record({ + orgId: auth.orgId, + type: 'settings_changed', + action: 'integration_feishu_delete', + detail: 'Feishu integration config deleted', + userId: auth.userId, + success: true, + }); + this.json(res, 200, { success: true }); + } catch (e) { + log.error('Failed to delete feishu integration config', { error: String(e) }); + this.json(res, 500, { error: 'Failed to delete integration config' }); + } + return; + } + + if (path === '/api/settings/integrations/feishu/test' && req.method === 'POST') { + const auth = await this.requireAuth(req, res); + if (!auth) return; + const body = await this.readBody(req); + const appId = (body['appId'] as string) ?? ''; + const appSecret = (body['appSecret'] as string) ?? ''; + if (!appId || !appSecret) { + this.json(res, 400, { error: 'appId and appSecret are required' }); + return; + } + try { + const resp = 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: appId, app_secret: appSecret }), + }); + const data = await resp.json() as Record; + if (resp.ok && data['tenant_access_token']) { + this.json(res, 200, { success: true, message: 'Credentials verified successfully' }); + } else { + this.json(res, 200, { success: false, message: String(data['msg'] ?? 'Authentication failed') }); + } + } catch (e) { + log.error('Feishu test connection failed', { error: String(e) }); + this.json(res, 200, { success: false, message: `Connection failed: ${String(e)}` }); + } + return; + } + + if (path === '/api/settings/integrations/feishu/chats' && req.method === 'GET') { + const auth = await this.requireAuth(req, res); + if (!auth) return; + try { + // Credentials from markus.json (single source of truth) + const { loadConfig: loadCfg } = await import('@markus/shared'); + const markusCfg = loadCfg(this.markusConfigPath); + const appId = markusCfg.integrations?.feishu?.appId; + const appSecret = markusCfg.integrations?.feishu?.appSecret; + if (!appId || !appSecret) { + this.json(res, 400, { error: 'Feishu integration not configured' }); + return; + } + const { FeishuApiClient } = await import('./feishu-api-client.js'); + const client = new FeishuApiClient({ appId, appSecret }); + const chats = await client.listBotChats(); + this.json(res, 200, { chats }); + } catch (e) { + log.error('Failed to list Feishu bot chats', { error: String(e) }); + this.json(res, 200, { chats: [], error: String(e) }); + } + return; + } + + if (path === '/api/settings/integrations/feishu/test-message' && req.method === 'POST') { + const auth = await this.requireAuth(req, res); + if (!auth) return; + const body = await this.readBody(req); + const chatId = (body['chatId'] as string) ?? ''; + if (!chatId) { + this.json(res, 400, { error: 'chatId is required' }); + return; + } + try { + // Credentials from markus.json (single source of truth) + const { loadConfig: loadCfg } = await import('@markus/shared'); + const markusCfg = loadCfg(this.markusConfigPath); + const appId = markusCfg.integrations?.feishu?.appId; + const appSecret = markusCfg.integrations?.feishu?.appSecret; + if (!appId || !appSecret) { + this.json(res, 400, { error: 'Feishu integration not configured' }); + return; + } + const { FeishuApiClient } = await import('./feishu-api-client.js'); + const client = new FeishuApiClient({ appId, appSecret }); + const card = { + config: { wide_screen_mode: true }, + header: { title: { tag: 'plain_text', content: '🎉 Markus 测试消息' }, template: 'blue' }, + elements: [ + { tag: 'markdown', content: '这是一条来自 **Markus** 的测试消息。\n如果你看到了这条消息,说明飞书集成配置正确!' }, + { tag: 'hr' }, + { tag: 'note', elements: [{ tag: 'plain_text', content: `发送时间: ${new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}` }] }, + ], + }; + await client.sendCard(chatId, card); + this.json(res, 200, { success: true, message: 'Test message sent' }); + } catch (e) { + log.error('Feishu test message failed', { error: String(e) }); + this.json(res, 200, { success: false, message: `Send failed: ${String(e)}` }); + } + return; + } + + if (path === '/api/settings/integrations/feishu/register' && req.method === 'POST') { + const auth = await this.requireAuth(req, res); + if (!auth) return; + try { + const Lark = await import('@larksuiteoapi/node-sdk'); + const controller = new AbortController(); + + // Timeout after 10 minutes + const timeout = setTimeout(() => controller.abort(), 600_000); + + const result = await Lark.registerApp({ + source: 'markus', + signal: controller.signal, + appPreset: { + name: 'Markus 秘书', + desc: 'Markus 智能秘书 — 消息互动、通知转发、审批处理', + }, + onQRCodeReady: (info) => { + // Store QR info for polling — use a simple in-memory store keyed by orgId + this.feishuRegisterSessions.set(auth.orgId, { + url: info.url, + expireIn: info.expireIn, + status: 'pending', + createdAt: Date.now(), + }); + }, + onStatusChange: (info) => { + const session = this.feishuRegisterSessions.get(auth.orgId); + if (session) session.status = info.status; + }, + }); + + clearTimeout(timeout); + this.feishuRegisterSessions.delete(auth.orgId); + + // Auto-save the credentials + const appId = result.client_id; + const appSecret = result.client_secret; + const locale = result.user_info?.tenant_brand === 'lark' ? 'en' : 'zh'; + const registeredOpenId = result.user_info?.open_id; + const payload: Record = { + id: 'feishu_default', + orgId: auth.orgId, + platform: 'feishu', + displayName: locale === 'en' ? 'Lark' : '飞书', + enabled: true, + config: { + locale, + notifyOpenId: registeredOpenId, + connectionMode: 'long_connection', + notifyOnApproval: true, + notifyOnNotification: true, + notifyPriority: ['normal', 'high', 'urgent'], + }, + forwardRules: [], + lastVerifiedAt: new Date().toISOString(), + lastError: null, + }; + const repo = this.storage?.integrationRepo; + if (repo) { + const existing = findFeishuConfig(auth.orgId); + if (existing) { + await repo.update(existing['id'] as string, payload); + } else { + await repo.create(payload); + } + } + + // Credentials go to markus.json (single source of truth) + saveConfig({ integrations: { feishu: { appId, appSecret } } }, this.markusConfigPath); + + // Start the long connection + this.updateFeishuConfig({ + appId, + appSecret, + locale: locale as 'zh' | 'en', + notifyOpenId: registeredOpenId, + notifyOnApproval: true, + notifyOnNotification: true, + notifyPriority: ['normal', 'high', 'urgent'], + forwardRules: [], + }); + + log.info('Feishu app registered via QR scan', { orgId: auth.orgId, appId }); + + // Send welcome message and pending status to the user who scanned + const openId = result.user_info?.open_id; + if (openId) { + try { + const { FeishuApiClient } = await import('./feishu-api-client.js'); + const welcomeClient = new FeishuApiClient({ appId, appSecret }); + + // Gather pending status + let statusLines = ''; + const pendingApprovals = this.hitlService?.listApprovals('pending') ?? []; + const notifCounts = this.hitlService?.countNotifications(auth.userId, true) ?? { total: 0, unread: 0 }; + + if (locale === 'en') { + if (pendingApprovals.length > 0 || notifCounts.unread > 0) { + statusLines += '\n\n---\n📊 **Pending items:**\n'; + if (pendingApprovals.length > 0) { + statusLines += `\n🔔 **${pendingApprovals.length} pending approval(s)**\n`; + for (const a of pendingApprovals.slice(0, 5)) { + statusLines += `• ${a.title} (from ${a.agentName})\n`; + } + if (pendingApprovals.length > 5) { + statusLines += `• ...and ${pendingApprovals.length - 5} more\n`; + } + } + if (notifCounts.unread > 0) { + statusLines += `\n📬 **${notifCounts.unread} unread notification(s)**\n`; + } + statusLines += '\nReply to handle them, e.g. type "approvals" to view details.'; + } + const card = { + config: { wide_screen_mode: true }, + header: { title: { tag: 'plain_text', content: '🎉 Markus Secretary is Online' }, template: 'green' }, + elements: [ + { tag: 'markdown', content: `**Markus Secretary** has been connected to your Lark!\n\nYou can now:\n- 💬 Send me messages and I'll handle them for you\n- 📋 Receive system notifications and approval requests\n- ✅ Approve or reject directly in the conversation\n- 🤖 Interact with the AI team in real time${statusLines}` }, + { tag: 'hr' }, + { tag: 'note', elements: [{ tag: 'plain_text', content: 'Powered by Markus — Your AI Team OS' }] }, + ], + }; + await welcomeClient.sendCardToUser(openId, card); + } else { + if (pendingApprovals.length > 0 || notifCounts.unread > 0) { + statusLines += '\n\n---\n📊 **当前待处理事项:**\n'; + if (pendingApprovals.length > 0) { + statusLines += `\n🔔 **${pendingApprovals.length} 个待审批请求**\n`; + for (const a of pendingApprovals.slice(0, 5)) { + statusLines += `• ${a.title}(来自 ${a.agentName})\n`; + } + if (pendingApprovals.length > 5) { + statusLines += `• ...还有 ${pendingApprovals.length - 5} 项\n`; + } + } + if (notifCounts.unread > 0) { + statusLines += `\n📬 **${notifCounts.unread} 条未读通知**\n`; + } + statusLines += '\n直接回复消息即可处理,例如输入「审批」查看详情。'; + } + const card = { + config: { wide_screen_mode: true }, + header: { title: { tag: 'plain_text', content: '🎉 Markus 秘书已上线' }, template: 'green' }, + elements: [ + { tag: 'markdown', content: `**Markus 秘书** 已成功接入你的飞书!\n\n你现在可以:\n- 💬 直接发消息给我,我来帮你处理\n- 📋 接收系统通知和审批请求\n- ✅ 直接在对话中审批或驳回\n- 🤖 与 AI 团队实时互动${statusLines}` }, + { tag: 'hr' }, + { tag: 'note', elements: [{ tag: 'plain_text', content: 'Powered by Markus — Your AI Team OS' }] }, + ], + }; + await welcomeClient.sendCardToUser(openId, card); + } + log.info('Welcome message sent to user', { openId, locale }); + } catch (welcomeErr) { + log.warn('Failed to send welcome message', { error: String(welcomeErr) }); + } + } + + this.json(res, 200, { + success: true, + appId, + connected: !!(this.feishuNotifier?.connected), + userInfo: result.user_info, + }); + } catch (e: unknown) { + this.feishuRegisterSessions.delete(auth.orgId); + const err = e as { code?: string; description?: string; message?: string }; + if (err.code === 'access_denied') { + this.json(res, 200, { success: false, error: 'user_denied', message: '用户拒绝了授权' }); + } else if (err.code === 'expired_token' || err.code === 'abort') { + this.json(res, 200, { success: false, error: 'expired', message: '二维码已过期或超时' }); + } else { + log.error('Feishu register app failed', { error: String(e) }); + this.json(res, 200, { success: false, error: 'unknown', message: String(err.message || err.description || e) }); + } + } + return; + } + + if (path === '/api/settings/integrations/feishu/register/status' && req.method === 'GET') { + const auth = await this.requireAuth(req, res); + if (!auth) return; + const session = this.feishuRegisterSessions.get(auth.orgId); + if (!session) { + this.json(res, 200, { active: false }); + } else { + this.json(res, 200, { + active: true, + url: session.url, + expireIn: session.expireIn, + status: session.status, + elapsed: Math.floor((Date.now() - session.createdAt) / 1000), + }); + } + return; + } + + if (path === '/api/settings/integrations/feishu/notifications' && req.method === 'GET') { + const auth = await this.requireAuth(req, res); + if (!auth) return; + try { + const row = findFeishuConfig(auth.orgId); + const rules = row?.['forwardRules'] as Array> | undefined; + this.json(res, 200, { rules: rules ?? [] }); + } catch (e) { + log.error('Failed to read notification rules', { error: String(e) }); + this.json(res, 500, { error: 'Failed to read notification rules' }); + } + return; + } + + if (path === '/api/settings/integrations/feishu/notifications' && req.method === 'PUT') { + const auth = await this.requireAuth(req, res); + if (!auth) return; + const body = await this.readBody(req); + const rules = body['rules'] as Array>; + if (!Array.isArray(rules)) { + this.json(res, 400, { error: 'rules must be an array' }); + return; + } + try { + const repo = this.storage?.integrationRepo; + if (!repo) { + this.json(res, 503, { error: 'Storage not available' }); + return; + } + const row = findFeishuConfig(auth.orgId); + if (row) { + await repo.update(row['id'] as string, { + forwardRules: rules, + lastVerifiedAt: row['lastVerifiedAt'] ?? null, + lastError: row['lastError'] ?? null, + }); + // Update the FeishuNotifier runtime config with new rules + if (this.feishuNotifier) { + const { loadConfig: loadCfg } = await import('@markus/shared'); + const markusCfg = loadCfg(this.markusConfigPath); + const cfgAppId = markusCfg.integrations?.feishu?.appId; + const cfgAppSecret = markusCfg.integrations?.feishu?.appSecret; + if (cfgAppId && cfgAppSecret) { + const cfgConfig = row['config'] as Record | undefined; + this.feishuNotifier.updateConfig({ + appId: cfgAppId, + appSecret: cfgAppSecret, + domain: cfgConfig?.domain as string | undefined, + forwardRules: rules as unknown as FeishuNotifierConfig['forwardRules'], + }); + } + } + log.info('Feishu notification rules updated', { orgId: auth.orgId, ruleCount: rules.length }); + this.auditService?.record({ + orgId: auth.orgId, + type: 'settings_changed', + action: 'integration_feishu_notifications', + detail: `Feishu notification rules updated (${rules.length} rules)`, + userId: auth.userId, + success: true, + }); + this.json(res, 200, { rules }); + } else { + this.json(res, 404, { error: 'Feishu integration not configured' }); + } + } catch (e) { + log.error('Failed to update notification rules', { error: String(e) }); + this.json(res, 500, { error: 'Failed to update notification rules' }); + } + return; + } + // Settings — Config Export if (path === '/api/settings/export' && req.method === 'POST') { const auth = await this.requireAuth(req, res); @@ -10800,6 +11430,11 @@ EXPLANATION_END`; regex(/^\/api\/settings\/oauth\/profiles\/[^/]+$/, 'DELETE'), exact('/api/settings/oauth/setup-token', 'POST'), + // ── Integrations ──────────────────────────────────────────────────── + exact('/api/settings/integrations/feishu', 'GET', 'POST', 'DELETE'), + exact('/api/settings/integrations/feishu/test', 'POST'), + exact('/api/settings/integrations/feishu/notifications', 'GET', 'PUT'), + // ── Approvals ──────────────────────────────────────────────────────── exact('/api/approvals', 'GET', 'POST'), startsWith('/api/approvals/', 'POST'), diff --git a/packages/org-manager/src/feishu-api-client.ts b/packages/org-manager/src/feishu-api-client.ts new file mode 100644 index 00000000..3e9715d1 --- /dev/null +++ b/packages/org-manager/src/feishu-api-client.ts @@ -0,0 +1,198 @@ +import * as Lark from '@larksuiteoapi/node-sdk'; +import { createLogger } from '@markus/shared'; + +const log = createLogger('feishu-api-client'); + +/** + * Feishu API client backed by the official @larksuiteoapi/node-sdk. + * Uses the SDK's Client for REST API calls and WSClient for long-connection event receiving. + */ +export class FeishuApiClient { + private client: Lark.Client; + private wsClient: Lark.WSClient | null = null; + private appId: string; + private appSecret: string; + private domain: string; + + constructor(opts: { + appId: string; + appSecret: string; + domain?: string; + }) { + this.appId = opts.appId; + this.appSecret = opts.appSecret; + this.domain = opts.domain ?? 'https://open.feishu.cn'; + this.client = new Lark.Client({ + appId: opts.appId, + appSecret: opts.appSecret, + domain: this.domain, + }); + } + + /** Get the underlying Lark Client instance. */ + getClient(): Lark.Client { + return this.client; + } + + /** + * Start the long-connection WebSocket client to receive events. + * Returns the WSClient instance so the caller can stop it later. + */ + async startWSClient(eventDispatcher: Lark.EventDispatcher): Promise { + if (this.wsClient) { + log.warn('WSClient already running, stopping existing one'); + this.stopWSClient(); + } + this.wsClient = new Lark.WSClient({ + appId: this.appId, + appSecret: this.appSecret, + domain: this.domain, + loggerLevel: Lark.LoggerLevel.debug, + onReady: () => { + log.info('Feishu WSClient onReady callback fired — connection is live'); + }, + onError: (err: Error) => { + log.error('Feishu WSClient onError callback', { error: err.message }); + }, + }); + await this.wsClient.start({ eventDispatcher }); + log.info('Feishu WSClient started (long connection mode)'); + return this.wsClient; + } + + /** Stop the WebSocket client. */ + stopWSClient(): void { + // The SDK's WSClient doesn't expose a clean stop(), but we null it out + // so we can recreate on next start. The GC will close the socket. + this.wsClient = null; + log.info('Feishu WSClient stopped'); + } + + /** Send a text message to a Feishu chat by chat_id. */ + async sendText(chatId: string, text: string): Promise { + try { + await this.client.im.v1.message.create({ + params: { receive_id_type: 'chat_id' }, + data: { + receive_id: chatId, + msg_type: 'text', + content: JSON.stringify({ text }), + }, + }); + } catch (err) { + log.error('Feishu sendText failed', { chatId, error: String(err) }); + throw err; + } + } + + /** Send a text message to a user by open_id (direct/P2P message). */ + async sendTextToUser(openId: string, text: string): Promise { + try { + await this.client.im.v1.message.create({ + params: { receive_id_type: 'open_id' }, + data: { + receive_id: openId, + msg_type: 'text', + content: JSON.stringify({ text }), + }, + }); + } catch (err) { + log.error('Feishu sendTextToUser failed', { openId, error: String(err) }); + throw err; + } + } + + /** Send an interactive card to a user by open_id (direct/P2P message). Returns message_id if available. */ + async sendCardToUser(openId: string, card: Record): Promise { + try { + const resp = await this.client.im.v1.message.create({ + params: { receive_id_type: 'open_id' }, + data: { + receive_id: openId, + msg_type: 'interactive', + content: JSON.stringify(card), + }, + }); + return (resp?.data?.message_id as string) ?? undefined; + } catch (err) { + log.error('Feishu sendCardToUser failed', { openId, error: String(err) }); + throw err; + } + } + + /** Send an interactive card to a Feishu chat by chat_id. Returns message_id if available. */ + async sendCard(chatId: string, card: Record): Promise { + try { + const resp = await this.client.im.v1.message.create({ + params: { receive_id_type: 'chat_id' }, + data: { + receive_id: chatId, + msg_type: 'interactive', + content: JSON.stringify(card), + }, + }); + return (resp?.data?.message_id as string) ?? undefined; + } catch (err) { + log.error('Feishu sendCard failed', { chatId, error: String(err) }); + throw err; + } + } + + /** List groups where the bot is a member. */ + async listBotChats(): Promise> { + const results: Array<{ chatId: string; name: string; description?: string; avatar?: string; ownerIdType?: string; ownerId?: string }> = []; + let pageToken: string | undefined; + try { + do { + const resp = await this.client.im.v1.chat.list({ + params: { page_size: 50, page_token: pageToken }, + }); + const items = (resp?.data?.items ?? []) as Array<{ + chat_id?: string; + name?: string; + description?: string; + avatar?: string; + owner_id_type?: string; + owner_id?: string; + }>; + for (const item of items) { + if (item.chat_id) { + results.push({ + chatId: item.chat_id, + name: item.name ?? '', + description: item.description, + avatar: item.avatar, + ownerIdType: item.owner_id_type, + ownerId: item.owner_id, + }); + } + } + pageToken = resp?.data?.page_token as string | undefined; + } while (pageToken); + } catch (err) { + log.error('Feishu listBotChats failed', { error: String(err) }); + throw err; + } + return results; + } + + /** Verify credentials by fetching a tenant access token. */ + async verifyCredentials(): Promise { + try { + const resp = await fetch(`${this.domain}/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 }), + }); + const data = (await resp.json()) as { tenant_access_token?: string; code?: number }; + return !!(resp.ok && data.tenant_access_token); + } catch { + return false; + } + } + + /** Clear internal state (for config updates). */ + clearToken(): void { + // SDK handles token lifecycle internally; this is a no-op now but kept for API compat. + } +} diff --git a/packages/org-manager/src/feishu-notifier.ts b/packages/org-manager/src/feishu-notifier.ts new file mode 100644 index 00000000..d6a71a06 --- /dev/null +++ b/packages/org-manager/src/feishu-notifier.ts @@ -0,0 +1,873 @@ +import * as Lark from '@larksuiteoapi/node-sdk'; +import { createLogger } from '@markus/shared'; +import type { EventBus } from '@markus/core'; +import type { HITLService, Notification as HITLNotification } from './hitl-service.js'; +import { FeishuApiClient } from './feishu-api-client.js'; + +const log = createLogger('feishu-notifier'); + +// ── Types ─────────────────────────────────────────────────────────── + +export interface ForwardTarget { + channelId: string; + type: 'chat' | 'webhook' | 'open_id'; +} + +export interface NotificationForwardRule { + id: string; + name: string; + enabled: boolean; + type: string; + priorityFilter: string; + targets: ForwardTarget[]; + keywordFilter?: string; + includeApprovalActions?: boolean; +} + +export type FeishuLocale = 'zh' | 'en'; + +export interface FeishuNotifierConfig { + appId: string; + appSecret: string; + domain?: string; + locale?: FeishuLocale; + notifyChatId?: string; + notifyOpenId?: string; + notifyOnApproval?: boolean; + notifyOnNotification?: boolean; + notifyPriority?: string[]; + forwardRules: NotificationForwardRule[]; +} + +// EventBus event → FeishuForwardEventType mapping +const EVENT_MAP: Record = { + 'task:completed': 'task_completed', + 'system:announcement': 'notification', + 'agent:started': 'notification', + 'agent:stopped': 'notification', + 'agent:paused': 'notification', + 'agent:resumed': 'notification', + 'agent:created': 'notification', + 'agent:restored': 'notification', + 'agent:removed': 'notification', +}; + +// ── i18n ───────────────────────────────────────────────────────────── + +const messages: Record> = { + zh: { + 'btn.approve': '✅ 批准', + 'btn.reject': '❌ 驳回', + 'input.comment_placeholder': '输入审批意见(可选)', + 'input.comment_hint': '💡 回复本消息可附带审批意见', + 'toast.approved': '✅ 已批准', + 'toast.rejected': '❌ 已拒绝', + 'toast.option_selected': '✅ 已选择', + 'toast.expired': '该审批已被处理或已过期', + 'toast.error': '处理失败', + 'toast.received': '操作已收到', + 'card.approval': '审批', + 'card.status_approved': '已批准', + 'card.status_rejected': '已驳回', + 'card.selected': '已选择', + 'card.comment': '审批意见', + 'card.processed_at': '处理时间', + 'event.task_completed': '✅ 任务完成', + 'event.system_announcement': '📢 系统公告', + 'event.agent_started': '▶️ Agent 启动', + 'event.agent_stopped': '⏹️ Agent 停止', + 'event.agent_paused': '⏸️ Agent 暂停', + 'event.agent_resumed': '▶️ Agent 恢复', + 'event.agent_created': '🆕 Agent 创建', + 'event.agent_restored': '♻️ Agent 恢复就绪', + 'event.agent_removed': '🗑️ Agent 删除', + 'label.agent': 'Agent', + 'label.task_id': '任务 ID', + 'label.reason': '原因', + 'card.type_label': '类型', + }, + en: { + 'btn.approve': '✅ Approve', + 'btn.reject': '❌ Reject', + 'input.comment_placeholder': 'Enter your comment (optional)', + 'input.comment_hint': '💡 Reply to this message to add a comment', + 'toast.approved': '✅ Approved', + 'toast.rejected': '❌ Rejected', + 'toast.option_selected': '✅ Option selected', + 'toast.expired': 'This approval has already been processed or expired', + 'toast.error': 'Processing failed', + 'toast.received': 'Action received', + 'card.approval': 'Approval', + 'card.status_approved': 'Approved', + 'card.status_rejected': 'Rejected', + 'card.selected': 'Selected', + 'card.comment': 'Comment', + 'card.processed_at': 'Processed at', + 'event.task_completed': '✅ Task Completed', + 'event.system_announcement': '📢 System Announcement', + 'event.agent_started': '▶️ Agent Started', + 'event.agent_stopped': '⏹️ Agent Stopped', + 'event.agent_paused': '⏸️ Agent Paused', + 'event.agent_resumed': '▶️ Agent Resumed', + 'event.agent_created': '🆕 Agent Created', + 'event.agent_restored': '♻️ Agent Restored', + 'event.agent_removed': '🗑️ Agent Removed', + 'label.agent': 'Agent', + 'label.task_id': 'Task ID', + 'label.reason': 'Reason', + 'card.type_label': 'Type', + }, +}; + +function t(locale: FeishuLocale, key: string): string { + return messages[locale]?.[key] ?? messages['zh'][key] ?? key; +} + +// ── Card Building Helpers ─────────────────────────────────────────── + +/** Build a Feishu interactive card from notification data. */ +function buildNotificationCard( + title: string, + body: string, + priority: string, + eventType: string, + locale: FeishuLocale, + metadata?: Record, +): Record { + const color = priority === 'urgent' ? 'red' : priority === 'high' ? 'orange' : 'blue'; + const timeStr = locale === 'en' + ? new Date().toLocaleString('en-US', { timeZone: 'Asia/Shanghai' }) + : new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' }); + const elements: Record[] = [ + { + tag: 'markdown', + content: body, + }, + ]; + + if (metadata?.approvalId) { + const approvalId = metadata.approvalId as string; + const options = metadata.options as Array<{ id: string; label: string; description?: string }> | undefined; + const allowFreeform = metadata.allowFreeform as boolean | undefined; + + if (options && options.length > 0) { + // Multi-option: render each option as a separate button + const actions: Record[] = options.map((opt, idx) => ({ + tag: 'button', + text: { tag: 'plain_text', content: opt.label + (opt.description ? ` — ${opt.description}` : '') }, + type: idx === 0 ? 'primary' : (idx === options.length - 1 && options.length > 2 ? 'danger' : 'default'), + value: { action: 'select_option', approval_id: approvalId, option_id: opt.id }, + })); + elements.push({ tag: 'action', actions }); + } else { + // Default: approve/reject + elements.push({ + tag: 'action', + actions: [ + { + tag: 'button', + text: { tag: 'plain_text', content: t(locale, 'btn.approve') }, + type: 'primary', + value: { action: 'approve', approval_id: approvalId }, + }, + { + tag: 'button', + text: { tag: 'plain_text', content: t(locale, 'btn.reject') }, + type: 'danger', + value: { action: 'reject', approval_id: approvalId }, + }, + ], + }); + } + + if (allowFreeform) { + elements.push({ + tag: 'note', + elements: [{ tag: 'plain_text', content: t(locale, 'input.comment_hint') }], + }); + } + } + + elements.push( + { tag: 'hr' }, + { + tag: 'note', + elements: [ + { + tag: 'plain_text', + content: `${t(locale, 'card.type_label')}: ${eventType} | ${timeStr}`, + }, + ], + }, + ); + + return { + config: { wide_screen_mode: true, update_multi: true }, + header: { title: { tag: 'plain_text', content: title }, template: color }, + elements, + }; +} + +function buildSimpleCard(title: string, body: string, priority: string, eventType: string, locale: FeishuLocale): Record { + return buildNotificationCard(title, body, priority, eventType, locale, undefined); +} + +// ── FeishuNotifier ────────────────────────────────────────────────── + +export class FeishuNotifier { + private eventBus: EventBus; + private hitlService: HITLService; + private orgId: string; + private locale: FeishuLocale = 'zh'; + private agentManager?: { getAgentName?: (id: string) => string | undefined }; + private apiClient: FeishuApiClient | null = null; + private config: FeishuNotifierConfig | null = null; + private unsubscribes: Array<() => void> = []; + private hitlUnsubscribe: (() => void) | null = null; + private wsConnected = false; + /** Map messageId → approvalId for tracking replies as comments */ + private approvalMessageMap = new Map(); + + constructor(opts: { + eventBus: EventBus; + hitlService: HITLService; + orgId: string; + agentManager?: { getAgentName?: (id: string) => string | undefined }; + config?: FeishuNotifierConfig; + }) { + this.eventBus = opts.eventBus; + this.hitlService = opts.hitlService; + this.orgId = opts.orgId; + this.agentManager = opts.agentManager; + if (opts.config?.appId && opts.config?.appSecret) { + this.config = opts.config; + this.locale = opts.config.locale ?? 'zh'; + } + } + + /** Whether the long connection is active. */ + get connected(): boolean { + return this.wsConnected; + } + + /** Start listening — subscribe to EventBus events, HITL notifications, and establish Feishu long connection. */ + start(): void { + for (const [eventName] of Object.entries(EVENT_MAP)) { + const unsub = this.eventBus.on(eventName, (...args: unknown[]) => { + this.handleEventBusEvent(eventName, args).catch((err) => { + log.error('Failed to handle EventBus event', { event: eventName, error: String(err) }); + }); + }); + this.unsubscribes.push(unsub); + } + + this.hitlUnsubscribe = this.hitlService.onNotification((notification: HITLNotification) => { + this.handleHITLNotification(notification).catch((err) => { + log.error('Failed to handle HITL notification', { error: String(err) }); + }); + }); + + if (this.config) { + this.startLongConnection(this.config).then(() => { + if (this.wsConnected) { + this.flushPendingOnConnect().catch((err) => { + log.warn('Failed to flush pending items on connect', { error: String(err) }); + }); + } + }).catch((err) => { + log.error('Failed to start Feishu long connection on init', { error: String(err) }); + }); + } + + log.info('FeishuNotifier started'); + } + + /** On first connection, send all pending approvals and unread notifications. */ + private async flushPendingOnConnect(): Promise { + if (!this.apiClient || !this.config) return; + + const L = this.locale; + const pendingApprovals = this.hitlService.listApprovals('pending'); + + // Send each pending approval as a card with action buttons + for (const approval of pendingApprovals) { + const title = `🔔 ${approval.title}`; + const body = `${t(L, 'label.agent')}: ${approval.agentName}\n\n${approval.description}`; + const metadata: Record = { approvalId: approval.id }; + if (approval.options?.length) metadata['options'] = approval.options; + if (approval.allowFreeform) metadata['allowFreeform'] = true; + await this.routeNotification('approval_requested', 'high', title, body, metadata); + } + + // Send unread notifications summary (batch into one card to avoid spam) + const unreadNotifs = this.hitlService.listNotifications('all', true, { limit: 20 }); + if (unreadNotifs.length > 0) { + const lines = unreadNotifs.slice(0, 10).map(n => `• **${n.title}** — ${n.body.slice(0, 60)}`); + if (unreadNotifs.length > 10) { + lines.push(L === 'en' + ? `• ...and ${unreadNotifs.length - 10} more` + : `• ...还有 ${unreadNotifs.length - 10} 条`); + } + const title = L === 'en' + ? `📬 ${unreadNotifs.length} Unread Notification(s)` + : `📬 ${unreadNotifs.length} 条未读通知`; + const body = lines.join('\n'); + await this.routeNotification('notification', 'normal', title, body); + } + + if (pendingApprovals.length > 0 || unreadNotifs.length > 0) { + log.info('Flushed pending items on Feishu connect', { + approvals: pendingApprovals.length, + notifications: unreadNotifs.length, + }); + } + } + + /** Stop listening and clean up. */ + stop(): void { + for (const unsub of this.unsubscribes) { + try { unsub(); } catch { /* ignore */ } + } + this.unsubscribes = []; + if (this.hitlUnsubscribe) { + try { this.hitlUnsubscribe(); } catch { /* ignore */ } + this.hitlUnsubscribe = null; + } + if (this.apiClient) { + this.apiClient.stopWSClient(); + } + this.apiClient = null; + this.config = null; + this.wsConnected = false; + log.info('FeishuNotifier stopped'); + } + + /** Update the Feishu integration config at runtime (e.g. when settings are saved). */ + updateConfig(config: FeishuNotifierConfig): void { + this.config = config; + this.locale = config.locale ?? 'zh'; + if (config.appId && config.appSecret) { + if (this.apiClient) { + this.apiClient.stopWSClient(); + } + this.apiClient = new FeishuApiClient({ + appId: config.appId, + appSecret: config.appSecret, + domain: config.domain, + }); + this.startLongConnection(config).then(() => { + if (this.wsConnected) { + this.flushPendingOnConnect().catch((err) => { + log.warn('Failed to flush pending on reconnect', { error: String(err) }); + }); + } + }).catch((err) => { + log.error('Failed to restart Feishu long connection', { error: String(err) }); + }); + } else { + if (this.apiClient) { + this.apiClient.stopWSClient(); + } + this.apiClient = null; + this.wsConnected = false; + } + log.info('FeishuNotifier config updated'); + } + + /** Establish the WebSocket long connection to receive Feishu events. */ + private async startLongConnection(config: FeishuNotifierConfig): Promise { + if (!this.apiClient) { + this.apiClient = new FeishuApiClient({ + appId: config.appId, + appSecret: config.appSecret, + domain: config.domain, + }); + } + + const eventDispatcher = new Lark.EventDispatcher({ + loggerLevel: Lark.LoggerLevel.debug, + }).register({ + 'im.message.receive_v1': (data: unknown) => { + log.info('im.message.receive_v1 event fired', { hasData: !!data, dataKeys: data ? Object.keys(data as object).join(',') : 'null' }); + this.handleFeishuMessage(data).catch((err: unknown) => { + log.error('Failed to handle Feishu message', { error: String(err) }); + }); + }, + 'card.action.trigger': (data: unknown) => { + log.info('card.action.trigger event fired', { data: JSON.stringify(data).slice(0, 500) }); + return this.handleCardAction(data); + }, + }); + + // Monkey-patch invoke to log ALL incoming events for debugging + const originalInvoke = eventDispatcher.invoke.bind(eventDispatcher); + eventDispatcher.invoke = async (data: unknown, params?: { needCheck?: boolean }) => { + log.info('EventDispatcher.invoke called', { + dataType: typeof data, + dataKeys: data && typeof data === 'object' ? Object.keys(data).join(',') : 'n/a', + }); + return originalInvoke(data, params); + }; + + try { + await this.apiClient.startWSClient(eventDispatcher); + this.wsConnected = true; + log.info('Feishu long connection established successfully'); + } catch (err) { + this.wsConnected = false; + log.error('Failed to start Feishu long connection', { error: String(err) }); + } + } + + /** Handle incoming Feishu messages (from bot chat). */ + private async handleFeishuMessage(data: unknown): Promise { + const event = data as { + sender?: { + sender_id?: { open_id?: string; user_id?: string; union_id?: string }; + sender_type?: string; + }; + message?: { + chat_id?: string; + message_id?: string; + parent_id?: string; + root_id?: string; + content?: string; + message_type?: string; + chat_type?: string; + }; + }; + + const chatId = event?.message?.chat_id; + const content = event?.message?.content; + if (!chatId || !content) { + log.warn('Feishu message missing chatId or content', { + hasChatId: !!chatId, + hasContent: !!content, + dataKeys: data ? Object.keys(data as object) : [], + }); + return; + } + + const senderId = event.sender?.sender_id?.open_id; + const parentId = event.message?.parent_id ?? event.message?.root_id; + + // Check if this is a reply to an approval card → treat as approval comment + if (parentId && this.approvalMessageMap.has(parentId)) { + const approvalId = this.approvalMessageMap.get(parentId)!; + const approval = this.hitlService.getApproval(approvalId); + if (approval && approval.status === 'pending') { + let textContent = ''; + try { + const parsed = JSON.parse(content); + textContent = parsed.text ?? content; + } catch { textContent = content; } + + // Store comment on the approval without resolving it + if (textContent && approval.allowFreeform) { + approval.responseComment = textContent; + log.info('Approval comment received via reply', { approvalId, comment: textContent.slice(0, 100) }); + // Send confirmation + if (this.apiClient && senderId) { + const confirmText = this.locale === 'zh' + ? `💬 已记录审批意见: "${textContent.slice(0, 50)}"` + : `💬 Comment recorded: "${textContent.slice(0, 50)}"`; + this.apiClient.sendTextToUser(senderId, confirmText).catch(() => {}); + } + return; + } + } + } + + log.info('Received Feishu message', { + chatId, + senderId, + messageType: event.message?.message_type, + chatType: event.message?.chat_type, + }); + + this.eventBus.emit('feishu:message_received', { + chatId, + messageId: event.message?.message_id, + content, + messageType: event.message?.message_type, + senderId, + }); + } + + /** Handle interactive card button actions (approve/reject/select_option). */ + private handleCardAction(data: unknown): Record { + const payload = data as { + action?: { value?: { action?: string; approval_id?: string; option_id?: string }; name?: string; input_value?: string }; + open_id?: string; + form_value?: Record; + }; + + const actionValue = payload?.action?.value; + if (!actionValue?.approval_id || !actionValue?.action) { + return { toast: { type: 'info', content: t(this.locale, 'toast.received') } }; + } + + const approvalId = actionValue.approval_id; + const actionType = actionValue.action; + const respondedBy = payload?.open_id ?? 'feishu_user'; + + // Extract comment: from form_value, action input, or pre-filled reply comment + const existingApproval = this.hitlService.getApproval(approvalId); + const comment = payload?.form_value?.[`comment_${approvalId}`] + ?? payload?.action?.input_value + ?? existingApproval?.responseComment + ?? undefined; + + let approved: boolean; + let selectedOption: string | undefined; + + if (actionType === 'select_option') { + selectedOption = actionValue.option_id; + approved = true; + } else { + approved = actionType === 'approve'; + } + + log.info('Feishu card action: processing approval', { actionType, approvalId, respondedBy, selectedOption, hasComment: !!comment }); + + let result: { status: string; title: string; agentName: string; description: string } | undefined; + try { + result = this.hitlService.respondToApproval( + approvalId, + approved, + respondedBy, + comment ? `${comment}` : `Via Feishu card action`, + selectedOption, + ) as typeof result; + if (result) { + log.info('Approval processed via Feishu card', { approvalId, status: result.status, selectedOption }); + } else { + log.warn('Approval not found or already processed', { approvalId }); + return { + toast: { type: 'warning', content: t(this.locale, 'toast.expired') }, + }; + } + } catch (err) { + log.error('Failed to process approval via Feishu card', { approvalId, error: String(err) }); + return { + toast: { type: 'error', content: `${t(this.locale, 'toast.error')}: ${String(err).slice(0, 100)}` }, + }; + } + + this.eventBus.emit('feishu:card_action', { + action: actionType, + approvalId, + selectedOption, + comment, + openId: payload?.open_id, + }); + + // Return updated card to replace the original (showing processed state) + const updatedCard = this.buildProcessedCard(result, approved, selectedOption, comment); + let toastContent: string; + if (actionType === 'select_option' && selectedOption) { + toastContent = `${t(this.locale, 'toast.option_selected')}: ${selectedOption}`; + } else { + toastContent = approved ? t(this.locale, 'toast.approved') : t(this.locale, 'toast.rejected'); + } + return { + toast: { type: 'success', content: toastContent }, + card: { type: 'raw', data: updatedCard }, + }; + } + + /** Build a card that replaces the original after an approval has been processed. */ + private buildProcessedCard( + approval: { status: string; title: string; agentName: string; description: string }, + approved: boolean, + selectedOption?: string, + comment?: string, + ): Record { + const L = this.locale; + const timeStr = L === 'en' + ? new Date().toLocaleString('en-US', { timeZone: 'Asia/Shanghai' }) + : new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' }); + + const statusIcon = approved ? '✅' : '❌'; + const statusText = selectedOption + ? `${statusIcon} ${t(L, 'card.selected')}: ${selectedOption}` + : (approved ? `${statusIcon} ${t(L, 'card.status_approved')}` : `${statusIcon} ${t(L, 'card.status_rejected')}`); + + const headerColor = approved ? 'green' : 'red'; + const headerTitle = `${statusIcon} ${approval.title}`; + + const elements: Record[] = [ + { + tag: 'markdown', + content: `${t(L, 'label.agent')}: ${approval.agentName}\n\n${approval.description}`, + }, + ]; + + // Status badge + elements.push({ + tag: 'markdown', + content: `**${statusText}**`, + }); + + // Comment if any + if (comment) { + elements.push({ + tag: 'markdown', + content: `💬 ${t(L, 'card.comment')}: ${comment}`, + }); + } + + elements.push( + { tag: 'hr' }, + { + tag: 'note', + elements: [ + { tag: 'plain_text', content: `${t(L, 'card.processed_at')}: ${timeStr}` }, + ], + }, + ); + + return { + config: { wide_screen_mode: true, update_multi: true }, + header: { title: { tag: 'plain_text', content: headerTitle }, template: headerColor }, + elements, + }; + } + + /** Handle an EventBus event. */ + private async handleEventBusEvent(eventName: string, args: unknown[]): Promise { + if (!this.apiClient || !this.config) return; + + const forwardType = EVENT_MAP[eventName]; + if (!forwardType) return; + + const payload = args[0] as Record | undefined; + if (!payload) return; + + let title = ''; + let body = ''; + let priority = 'normal'; + + const L = this.locale; + const resolveAgentName = (id?: string | unknown): string => { + if (!id || typeof id !== 'string') return 'Unknown'; + return this.agentManager?.getAgentName?.(id) ?? id; + }; + + switch (eventName) { + case 'task:completed': { + const agentName = resolveAgentName(payload['agentId']); + const taskTitle = payload['title'] as string | undefined; + title = t(L, 'event.task_completed'); + body = `${t(L, 'label.agent')}: ${agentName}`; + if (taskTitle) body += `\n${taskTitle}`; + else body += `\n${t(L, 'label.task_id')}: ${payload['taskId'] as string}`; + priority = 'normal'; + break; + } + case 'system:announcement': { + title = t(L, 'event.system_announcement'); + body = (payload['content'] as string) ?? payload['message'] as string ?? ''; + priority = payload['priority'] as string ?? 'high'; + break; + } + case 'agent:started': { + const agentName = resolveAgentName(payload['agentId']); + title = t(L, 'event.agent_started'); + body = `${t(L, 'label.agent')}: ${agentName}`; + priority = 'low'; + break; + } + case 'agent:stopped': { + const agentName = resolveAgentName(payload['agentId']); + const reason = payload['reason'] as string | undefined; + title = t(L, 'event.agent_stopped'); + body = `${t(L, 'label.agent')}: ${agentName}`; + if (reason) body += `\n${t(L, 'label.reason')}: ${reason}`; + priority = 'high'; + break; + } + case 'agent:paused': { + const agentName = resolveAgentName(payload['agentId']); + const reason = payload['reason'] as string | undefined; + title = t(L, 'event.agent_paused'); + body = `${t(L, 'label.agent')}: ${agentName}`; + if (reason) body += `\n${t(L, 'label.reason')}: ${reason}`; + priority = 'high'; + break; + } + case 'agent:resumed': { + const agentName = resolveAgentName(payload['agentId']); + title = t(L, 'event.agent_resumed'); + body = `${t(L, 'label.agent')}: ${agentName}`; + priority = 'low'; + break; + } + case 'agent:created': { + const agentName = (payload['name'] as string) ?? resolveAgentName(payload['agentId']); + title = t(L, 'event.agent_created'); + body = `${t(L, 'label.agent')}: ${agentName}`; + priority = 'normal'; + break; + } + case 'agent:restored': { + const agentName = (payload['name'] as string) ?? resolveAgentName(payload['agentId']); + title = t(L, 'event.agent_restored'); + body = `${t(L, 'label.agent')}: ${agentName}`; + priority = 'low'; + break; + } + case 'agent:removed': { + const agentName = resolveAgentName(payload['agentId']); + title = t(L, 'event.agent_removed'); + body = `${t(L, 'label.agent')}: ${agentName}`; + priority = 'high'; + break; + } + default: { + title = eventName; + body = JSON.stringify(payload); + } + } + + await this.routeNotification(forwardType, priority, title, body, payload['metadata'] as Record | undefined); + } + + /** Handle a HITL notification. */ + private async handleHITLNotification(notification: HITLNotification): Promise { + if (!this.apiClient || !this.config) return; + + const typeMap: Record = { + 'approval_request': 'approval_requested', + 'task_created': 'task_assigned', + 'task_completed': 'task_completed', + 'task_review': 'task_assigned', + 'task_failed': 'notification', + 'requirement_created': 'notification', + 'requirement_decision': 'notification', + 'agent_report': 'report_ready', + 'direct_message': 'mention', + 'group_message': 'mention', + 'system': 'notification', + }; + + const forwardType = typeMap[notification.type] ?? 'notification'; + const title = notification.title; + const body = notification.body; + const priority = notification.priority; + + const metadata: Record = {}; + if (notification.type === 'approval_request' && notification.metadata?.approvalId) { + metadata['approvalId'] = notification.metadata.approvalId; + const approval = this.hitlService.getApproval(notification.metadata.approvalId as string); + if (approval) { + if (approval.options?.length) metadata['options'] = approval.options; + if (approval.allowFreeform) metadata['allowFreeform'] = true; + } + } + + await this.routeNotification(forwardType, priority, title, body, metadata); + } + + /** Match rules and send to all matched targets. */ + private async routeNotification( + eventType: string, + priority: string, + title: string, + body: string, + metadata?: Record, + ): Promise { + if (!this.config) return; + + const rules = this.config.forwardRules ?? []; + const matchedTargets: ForwardTarget[] = []; + + for (const rule of rules) { + if (!rule.enabled) continue; + + if (rule.type !== '*' && rule.type !== eventType) continue; + + if (rule.priorityFilter === 'urgent' && priority !== 'urgent') continue; + if (rule.priorityFilter === 'high' && priority !== 'high' && priority !== 'urgent') continue; + + if (rule.keywordFilter) { + const kw = rule.keywordFilter.toLowerCase(); + const haystack = `${title} ${body}`.toLowerCase(); + if (!haystack.includes(kw)) continue; + } + + matchedTargets.push(...rule.targets); + } + + // Fallback: use notifyChatId or notifyOpenId from simplified config + if (matchedTargets.length === 0 && (this.config.notifyChatId || this.config.notifyOpenId)) { + const isApprovalType = ['approval_requested', 'approval_approved', 'approval_rejected'].includes(eventType); + const shouldForward = isApprovalType + ? this.config.notifyOnApproval !== false + : this.config.notifyOnNotification === true; + + if (shouldForward) { + const allowedPriorities = this.config.notifyPriority ?? ['high', 'urgent']; + if (allowedPriorities.includes(priority) || allowedPriorities.includes('*')) { + if (this.config.notifyChatId) { + matchedTargets.push({ type: 'chat', channelId: this.config.notifyChatId }); + } else if (this.config.notifyOpenId) { + matchedTargets.push({ type: 'open_id', channelId: this.config.notifyOpenId }); + } + } + } + } + + // Deduplicate by channelId + const seen = new Set(); + const uniqueTargets = matchedTargets.filter(t => { + if (seen.has(t.channelId)) return false; + seen.add(t.channelId); + return true; + }); + + if (uniqueTargets.length === 0) return; + + const includeActions = !!metadata?.approvalId; + + const card = includeActions + ? buildNotificationCard(title, body, priority, eventType, this.locale, metadata) + : buildSimpleCard(title, body, priority, eventType, this.locale); + + for (const target of uniqueTargets) { + try { + let messageId: string | undefined; + if (target.type === 'webhook') { + const resp = await fetch(target.channelId, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ msg_type: 'interactive', card }), + }); + if (!resp.ok) { + log.warn('Feishu webhook send failed', { channelId: target.channelId, status: resp.status }); + } + } else if (target.type === 'open_id') { + messageId = await this.apiClient!.sendCardToUser(target.channelId, card); + } else { + messageId = await this.apiClient!.sendCard(target.channelId, card); + } + // Track approval card messages for reply-based comments + if (messageId && metadata?.approvalId) { + this.approvalMessageMap.set(messageId, metadata.approvalId as string); + } + } catch (err) { + log.error('Failed to send Feishu notification', { + channelId: target.channelId, + type: target.type, + error: String(err), + }); + } + } + } + + /** Send a text message to a specific Feishu chat. */ + async sendTextToChat(chatId: string, text: string): Promise { + if (!this.apiClient) return; + await this.apiClient.sendText(chatId, text); + } +} diff --git a/packages/org-manager/src/storage-bridge.ts b/packages/org-manager/src/storage-bridge.ts index 55b4594b..ac2c0fc7 100644 --- a/packages/org-manager/src/storage-bridge.ts +++ b/packages/org-manager/src/storage-bridge.ts @@ -40,6 +40,7 @@ export interface StorageBridge { readCursorRepo?: any; workflowRunRepo?: any; workflowScheduleRepo?: any; + integrationRepo?: any; } function resolveSqlitePath(url?: string): string { @@ -92,6 +93,7 @@ async function initSqliteStorage(url?: string): Promise { readCursorRepo: new storage.SqliteReadCursorRepo(db), workflowRunRepo: new storage.SqliteWorkflowRunRepo(db), workflowScheduleRepo: new storage.SqliteWorkflowScheduleRepo(db), + integrationRepo: new storage.SqliteIntegrationRepo(db), }; log.info('SQLite storage initialized', { path: dbPath }); return bridge; diff --git a/packages/org-manager/test/integration-api.test.ts b/packages/org-manager/test/integration-api.test.ts new file mode 100644 index 00000000..145d892a --- /dev/null +++ b/packages/org-manager/test/integration-api.test.ts @@ -0,0 +1,441 @@ +/** + * Integration API: /api/settings/integrations/feishu endpoints + * + * Tests the 6 route handlers for Feishu integration configuration: + * - GET /api/settings/integrations/feishu — read config + * - POST /api/settings/integrations/feishu — save config + * - DELETE /api/settings/integrations/feishu — delete config + * - POST /api/settings/integrations/feishu/test — test credentials + * - GET /api/settings/integrations/feishu/notifications — read rules + * - PUT /api/settings/integrations/feishu/notifications — update rules + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, writeFileSync, rmSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { APIServer } from '../src/api-server.js'; +import type { OrganizationService } from '../src/org-service.js'; +import type { TaskService } from '../src/task-service.js'; +import type { StorageBridge } from '../src/storage-bridge.js'; + +/** Number of milliseconds to wait for the server to be ready */ +const SERVER_WAIT_MS = 300; + +/** Create a mock OrganizationService with minimal stubs */ +function createMockOrgService(): OrganizationService { + const mockAgentManager = { + setGroupChatHandlers: () => {}, + getTemplateRegistry: () => null, + setTemplateRegistry: () => {}, + getAgent: () => null, + listAgents: () => [], + }; + + return { + getAgentManager: () => mockAgentManager, + getTeam: () => null, + listTeamsWithMembers: () => [], + getTeamAgentStatuses: () => [], + isProtectedAgent: () => false, + resolveHumanIdentity: () => null, + getOrg: () => null, + listOrgs: () => [], + listTeams: () => [], + addHumanUser: () => ({ id: '', name: '', role: 'manager', orgId: 'default', createdAt: '' }), + createOrganization: () => Promise.resolve({ id: '', name: '', ownerId: '', createdAt: '', status: 'active' as const }), + } as unknown as OrganizationService; +} + +/** Create a minimal mock TaskService */ +function createMockTaskService(): TaskService { + return {} as unknown as TaskService; +} + +/** Create a mock in-memory IntegrationRepo */ +function createMockIntegrationRepo() { + const store = new Map>(); + + return { + create: async (data: Record) => { + const id = (data['id'] as string) ?? 'test'; + const now = new Date().toISOString(); + const row = { + id, + orgId: data['orgId'] as string, + platform: data['platform'] as string, + displayName: data['displayName'] as string, + enabled: (data['enabled'] as boolean) ? 1 : 0, + config: data['config'] as Record, + forwardRules: (data['forwardRules'] ?? []) as Record[], + lastVerifiedAt: null, + lastError: null, + createdAt: now, + updatedAt: now, + }; + store.set(id, row); + return row; + }, + findById: (id: string) => store.get(id), + listByOrg: (orgId: string) => + Array.from(store.values()).filter(r => r['orgId'] === orgId), + listByPlatform: (orgId: string, platform: string) => + Array.from(store.values()).filter(r => r['orgId'] === orgId && r['platform'] === platform), + update: async (id: string, data: Record) => { + const existing = store.get(id); + if (existing) { + store.set(id, { ...existing, ...data, updatedAt: new Date().toISOString() }); + } + }, + delete: async (id: string) => { + store.delete(id); + }, + }; +} + +/** Create a mock StorageBridge with integrationRepo */ +function createMockStorage(repo?: ReturnType): StorageBridge { + const integrationRepo = repo ?? createMockIntegrationRepo(); + return { + orgRepo: {}, + taskRepo: {}, + integrationRepo, + } as unknown as StorageBridge; +} + +/** Start server and return the port it's listening on */ +async function startServer(server: APIServer): Promise { + server.start(); + await new Promise((resolve) => setTimeout(() => resolve(), SERVER_WAIT_MS)); + const addr = (server as unknown as { server: { address(): { port: number } } }).server?.address(); + return addr?.port ?? 0; +} + +describe('Integration Config API (Feishu)', () => { + // ── Auth Bypass — set AUTH_ENABLED=false so route logic is testable ───────── + // When AUTH_ENABLED is not 'false', getAuthUser checks JWT token cookie. + // We disable it here so tests can exercise the actual route handlers. + const origEnv = process.env['AUTH_ENABLED']; + + let server: APIServer; + let integrationRepo: ReturnType; + let port: number; + const baseHeaders = { 'Content-Type': 'application/json' }; + + beforeEach(() => { + process.env['AUTH_ENABLED'] = 'false'; + }); + + afterEach(() => { + server?.stop(); + process.env['AUTH_ENABLED'] = origEnv; + }); + + // ── 401 auth check tests (auth enabled) ────────────────────────────────────── + describe('auth — 401 without valid token', () => { + beforeEach(() => { + // Restore env so auth is enforced + if (origEnv === undefined) { + delete process.env['AUTH_ENABLED']; + } else { + process.env['AUTH_ENABLED'] = origEnv; + } + }); + + beforeEach(async () => { + integrationRepo = createMockIntegrationRepo(); + const mockStorage = createMockStorage(integrationRepo); + const orgService = createMockOrgService(); + const taskService = createMockTaskService(); + server = new APIServer(orgService, taskService, 0); + server.setStorage(mockStorage); + port = await startServer(server); + }); + + it('GET returns 401 without auth', async () => { + const res = await fetch(`http://localhost:${port}/api/settings/integrations/feishu`, { + method: 'GET', + headers: baseHeaders, + }); + expect(res.status).toBe(401); + }); + + it('POST returns 401 without auth', async () => { + const res = await fetch(`http://localhost:${port}/api/settings/integrations/feishu`, { + method: 'POST', + headers: baseHeaders, + body: JSON.stringify({ appId: 'a', appSecret: 'b' }), + }); + expect(res.status).toBe(401); + }); + + it('DELETE returns 401 without auth', async () => { + const res = await fetch(`http://localhost:${port}/api/settings/integrations/feishu`, { + method: 'DELETE', + headers: baseHeaders, + }); + expect(res.status).toBe(401); + }); + }); + + // ── Actual route handler tests (auth bypassed) ────────────────────────────── + describe('route handlers', () => { + let tmpDir: string; + let configPath: string; + + beforeEach(async () => { + tmpDir = mkdtempSync(join(tmpdir(), 'markus-test-')); + configPath = join(tmpDir, 'markus.json'); + writeFileSync(configPath, JSON.stringify({})); + + integrationRepo = createMockIntegrationRepo(); + const mockStorage = createMockStorage(integrationRepo); + const orgService = createMockOrgService(); + const taskService = createMockTaskService(); + server = new APIServer(orgService, taskService, 0); + server.setStorage(mockStorage); + server.setConfigPath(configPath); + port = await startServer(server); + }); + + afterEach(() => { + try { rmSync(tmpDir, { recursive: true }); } catch { /* ignore */ } + }); + + describe('GET /api/settings/integrations/feishu', () => { + it('returns config when one exists', async () => { + // Credentials in markus.json (single source of truth) + writeFileSync(configPath, JSON.stringify({ integrations: { feishu: { appId: 'cli_a1111', appSecret: 'xxx' } } })); + // Runtime prefs in SQLite + await integrationRepo.create({ + id: 'feishu_default', + orgId: 'default', + platform: 'feishu', + displayName: '飞书', + enabled: true, + config: { notifyChatId: 'oc_123' }, + }); + + const res = await fetch(`http://localhost:${port}/api/settings/integrations/feishu`, { + method: 'GET', + headers: baseHeaders, + }); + expect(res.status).toBe(200); + const body = await res.json() as Record; + expect(body['appId']).toBe('cli_a1111'); + expect(body['appSecret']).toBe('xxx'); + expect(body['enabled']).toBe(true); + expect(body['notifyChatId']).toBe('oc_123'); + }); + + it('returns null config when none exists', async () => { + const res = await fetch(`http://localhost:${port}/api/settings/integrations/feishu`, { + method: 'GET', + headers: baseHeaders, + }); + expect(res.status).toBe(200); + const body = await res.json() as Record; + expect(body['appId']).toBe(''); + expect(body['enabled']).toBe(false); + }); + }); + + describe('POST /api/settings/integrations/feishu', () => { + it('returns 400 when appId or appSecret missing', async () => { + const res = await fetch(`http://localhost:${port}/api/settings/integrations/feishu`, { + method: 'POST', + headers: baseHeaders, + body: JSON.stringify({ appId: '' }), + }); + expect(res.status).toBe(400); + const body = await res.json() as Record; + expect(body['error']).toContain('required'); + }); + + it('creates a new config when none exists', async () => { + const res = await fetch(`http://localhost:${port}/api/settings/integrations/feishu`, { + method: 'POST', + headers: baseHeaders, + body: JSON.stringify({ appId: 'cli_a2222', appSecret: 'secret123' }), + }); + expect(res.status).toBe(200); + // Runtime prefs stored in SQLite (no credentials) + const rows = integrationRepo.listByPlatform('default', 'feishu'); + expect(rows).toHaveLength(1); + expect((rows[0]['config'] as Record)['appId']).toBeUndefined(); + // Credentials stored in markus.json + const { readFileSync } = await import('fs'); + const saved = JSON.parse(readFileSync(configPath, 'utf-8')); + expect(saved.integrations.feishu.appId).toBe('cli_a2222'); + expect(saved.integrations.feishu.appSecret).toBe('secret123'); + }); + + it('updates existing config when already present', async () => { + writeFileSync(configPath, JSON.stringify({ integrations: { feishu: { appId: 'old_id', appSecret: 'old_secret' } } })); + await integrationRepo.create({ + id: 'feishu_default', + orgId: 'default', + platform: 'feishu', + displayName: '飞书', + enabled: true, + config: { connectionMode: 'long_connection' }, + }); + + const res = await fetch(`http://localhost:${port}/api/settings/integrations/feishu`, { + method: 'POST', + headers: baseHeaders, + body: JSON.stringify({ appId: 'new_id', appSecret: 'new_secret', displayName: '飞书新版' }), + }); + expect(res.status).toBe(200); + // SQLite should not have credentials + const rows = integrationRepo.listByPlatform('default', 'feishu'); + expect(rows).toHaveLength(1); + expect((rows[0]['config'] as Record)['appId']).toBeUndefined(); + // markus.json should have updated credentials + const { readFileSync } = await import('fs'); + const saved = JSON.parse(readFileSync(configPath, 'utf-8')); + expect(saved.integrations.feishu.appId).toBe('new_id'); + expect(saved.integrations.feishu.appSecret).toBe('new_secret'); + }); + }); + + describe('DELETE /api/settings/integrations/feishu', () => { + it('deletes an existing config', async () => { + await integrationRepo.create({ + id: 'feishu_default', + orgId: 'default', + platform: 'feishu', + displayName: '飞书', + enabled: true, + config: { appId: 'cli_a3333', appSecret: 'xxx' }, + }); + + const res = await fetch(`http://localhost:${port}/api/settings/integrations/feishu`, { + method: 'DELETE', + headers: baseHeaders, + }); + expect(res.status).toBe(200); + expect(integrationRepo.listByPlatform('default', 'feishu')).toHaveLength(0); + }); + + it('succeeds even when no config exists', async () => { + const res = await fetch(`http://localhost:${port}/api/settings/integrations/feishu`, { + method: 'DELETE', + headers: baseHeaders, + }); + expect(res.status).toBe(200); + }); + }); + + describe('GET /api/settings/integrations/feishu/notifications', () => { + it('returns empty rules when no config exists', async () => { + const res = await fetch(`http://localhost:${port}/api/settings/integrations/feishu/notifications`, { + method: 'GET', + headers: baseHeaders, + }); + expect(res.status).toBe(200); + const body = await res.json() as Record; + expect(body['rules']).toEqual([]); + }); + + it('returns rules from existing config', async () => { + const rules = [{ id: 'rule1', name: 'Test Rule', type: 'all', enabled: true, priorityFilter: 'all', targets: [{ channelId: 'chat_123', enabled: true }] }]; + await integrationRepo.create({ + id: 'feishu_default', + orgId: 'default', + platform: 'feishu', + displayName: '飞书', + enabled: true, + config: { appId: 'cli_a4444', appSecret: 'xxx' }, + forwardRules: rules, + }); + + const res = await fetch(`http://localhost:${port}/api/settings/integrations/feishu/notifications`, { + method: 'GET', + headers: baseHeaders, + }); + expect(res.status).toBe(200); + const body = await res.json() as Record; + expect(body['rules']).toHaveLength(1); + }); + }); + + describe('PUT /api/settings/integrations/feishu/notifications', () => { + it('returns 404 when feishu not configured', async () => { + const res = await fetch(`http://localhost:${port}/api/settings/integrations/feishu/notifications`, { + method: 'PUT', + headers: baseHeaders, + body: JSON.stringify({ rules: [] }), + }); + expect(res.status).toBe(404); + }); + + it('returns 400 when rules is not an array', async () => { + await integrationRepo.create({ + id: 'feishu_default', + orgId: 'default', + platform: 'feishu', + displayName: '飞书', + enabled: true, + config: { appId: 'cli_x', appSecret: 'x' }, + }); + + const res = await fetch(`http://localhost:${port}/api/settings/integrations/feishu/notifications`, { + method: 'PUT', + headers: baseHeaders, + body: JSON.stringify({ rules: 'not-an-array' }), + }); + expect(res.status).toBe(400); + }); + + it('updates rules on existing config', async () => { + await integrationRepo.create({ + id: 'feishu_default', + orgId: 'default', + platform: 'feishu', + displayName: '飞书', + enabled: true, + config: { appId: 'cli_a5555', appSecret: 'xxx' }, + }); + + const newRules = [ + { id: 'rule_a', name: 'Urgent Alerts', type: 'all', enabled: true, priorityFilter: 'urgent', targets: [{ channelId: 'chat_999', enabled: true }] }, + ]; + + const res = await fetch(`http://localhost:${port}/api/settings/integrations/feishu/notifications`, { + method: 'PUT', + headers: baseHeaders, + body: JSON.stringify({ rules: newRules }), + }); + expect(res.status).toBe(200); + const body = await res.json() as Record; + expect(body['rules']).toHaveLength(1); + }); + + it('persists rules in storage', async () => { + await integrationRepo.create({ + id: 'feishu_default', + orgId: 'default', + platform: 'feishu', + displayName: '飞书', + enabled: true, + config: { appId: 'cli_a6666', appSecret: 'xxx' }, + }); + + const newRules = [ + { id: 'rule_a', name: 'Alert Rule', type: 'all', enabled: true, priorityFilter: 'high', targets: [{ channelId: 'chat_777', enabled: true }] }, + ]; + + await fetch(`http://localhost:${port}/api/settings/integrations/feishu/notifications`, { + method: 'PUT', + headers: baseHeaders, + body: JSON.stringify({ rules: newRules }), + }); + + const row = integrationRepo.findById('feishu_default'); + const storedRules = row?.['forwardRules'] as Array> | undefined; + expect(storedRules).toHaveLength(1); + expect(storedRules![0]['name']).toBe('Alert Rule'); + }); + }); + }); +}); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 3243da1e..415b198a 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -8,6 +8,7 @@ export * from './types/package.js'; export * from './types/project.js'; export * from './types/requirement.js'; export * from './types/connector.js'; +export * from './types/integration.js'; export * from './types/file-storage.js'; export * from './types/mailbox.js'; export * from './types/workflow-template.js'; diff --git a/packages/shared/src/types/integration.ts b/packages/shared/src/types/integration.ts new file mode 100644 index 00000000..5824de3a --- /dev/null +++ b/packages/shared/src/types/integration.ts @@ -0,0 +1,119 @@ +/** + * Integration Configuration Types + * + * Defines the shape of external platform integration configurations + * (Feishu, Slack, Telegram, etc.) for persistent storage and API exchange. + */ + +// ─── Platform union ────────────────────────────────────────────────────────── + +/** Supported external IM platforms */ +export type IntegrationPlatform = 'feishu' | 'slack' | 'telegram' | 'whatsapp'; + +/** Integration operational status */ +export type IntegrationStatus = 'active' | 'inactive' | 'error' | 'pending_verify'; + +// ─── Base integration config ───────────────────────────────────────────────── + +/** Generic integration configuration base type */ +export interface IntegrationConfig { + /** Unique identifier (e.g. "feishu_default") */ + id: string; + /** Platform identifier */ + platform: IntegrationPlatform; + /** Human-readable label */ + displayName: string; + /** Whether this integration is enabled */ + enabled: boolean; + /** Operational status (default: 'inactive') */ + status?: IntegrationStatus; + /** Org-scoped — which org owns this config */ + orgId: string; + /** Platform-specific config payload (validated by the consumer) */ + config: Record; + /** Notification forwarding rules */ + forwardRules?: NotificationForwardRule[]; + /** Last verification result */ + lastVerifiedAt?: string | null; + /** Last verification error message */ + lastError?: string | null; + /** Timestamps */ + createdAt: string; + updatedAt: string; +} + +// ─── Feishu-specific config payload ────────────────────────────────────────── + +/** Feishu-specific configuration payload stored inside IntegrationConfig.config */ +export interface FeishuConfigPayload { + /** Feishu App ID */ + appId: string; + /** Feishu App Secret */ + appSecret: string; + /** Verification token for webhook event verification */ + verificationToken?: string; + /** AES encrypt key for webhook payload decryption */ + encryptKey?: string; + /** Webhook listener port (default: 8058) */ + webhookPort?: number; + /** Feishu API base domain (default: "https://open.feishu.cn") */ + domain?: string; +} + +// ─── Notification forwarding rules ─────────────────────────────────────────── + +/** Conditions that trigger a forward */ +export interface ForwardCondition { + /** Match by notification type (e.g. "task_assigned", "mention") */ + type?: string; + /** Match by priority (e.g. "high", "urgent") */ + priority?: string; + /** Match by keyword in notification title/body */ + keyword?: string; +} + +/** Target for a single forward destination */ +export interface ForwardTarget { + /** Feishu chat/webhook URL or channel ID */ + channelId: string; + /** Whether this target is active */ + enabled: boolean; +} + +/** A single notification forwarding rule */ +export interface NotificationForwardRule { + /** Unique rule ID */ + id: string; + /** Human-readable rule name */ + name: string; + /** Whether the rule is active */ + enabled: boolean; + /** Notification type filter (e.g. "approval", "mention", "all") */ + type: string; + /** Priority filter (e.g. "urgent", "high", "all") */ + priorityFilter: string; + /** List of target channels to forward to */ + targets: ForwardTarget[]; + /** Optional keyword filter */ + keywordFilter?: string; + /** Whether to include approval action buttons in the card */ + includeApprovalActions?: boolean; +} + +// ─── Approval event forwarding (for EventBus) ──────────────────────────────── + +/** Event types that can trigger Feishu notification */ +export type FeishuForwardEventType = + | 'notification' + | 'approval_requested' + | 'approval_responded' + | 'task_assigned' + | 'task_completed' + | 'mention' + | 'report_ready'; + +/** Mapping from EventBus event types to forward rules */ +export interface FeishuEventForwardMap { + eventType: FeishuForwardEventType; + ruleIds: string[]; +} diff --git a/packages/storage/src/index.ts b/packages/storage/src/index.ts index 9d021ba7..dfb8406d 100644 --- a/packages/storage/src/index.ts +++ b/packages/storage/src/index.ts @@ -1,6 +1,7 @@ // Type definitions export type { TaskRepo, TaskLogRepo, TaskCommentRepo, RequirementCommentRepo, DeliverableRepo, + IntegrationRepo, TaskLogType, TaskLogRow, TaskCommentRow, RequirementCommentRow, ChannelMsg, ChannelMsgMetadata, @@ -44,6 +45,7 @@ export { SqliteGroupChatRepo, SqliteAuditRepo, SqliteStatusTransitionRepo, + SqliteIntegrationRepo, SqliteReadCursorRepo, SqliteWorkflowRunRepo, SqliteWorkflowScheduleRepo, @@ -54,6 +56,7 @@ export { type ExecutionStreamRow, type MailboxItemRow, type DecisionRow, + type IntegrationRow, type NotificationRow, type ApprovalRow, type StatusTransitionRow, diff --git a/packages/storage/src/sqlite-storage.ts b/packages/storage/src/sqlite-storage.ts index 21d3d608..48f8d25f 100644 --- a/packages/storage/src/sqlite-storage.ts +++ b/packages/storage/src/sqlite-storage.ts @@ -627,6 +627,21 @@ CREATE TABLE IF NOT EXISTS workflow_schedules ( updated_at TEXT NOT NULL DEFAULT (datetime('now')), PRIMARY KEY (team_id, workflow_name) ); + +CREATE TABLE IF NOT EXISTS integrations ( + id TEXT PRIMARY KEY, + org_id TEXT NOT NULL, + platform TEXT NOT NULL, + display_name TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 0, + config TEXT NOT NULL DEFAULT '{}', + forward_rules TEXT DEFAULT '[]', + last_verified_at TEXT, + last_error TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_integrations_org ON integrations(org_id, platform); `; // ─── Open / close ──────────────────────────────────────────────────────────── @@ -4229,7 +4244,7 @@ export class SqliteApprovalRepo { // ─── Group Chat Repo ────────────────────────────────────────────────────────── -import type { GroupChat, GroupChatMember } from './types.ts'; +import type { GroupChat, GroupChatMember, IntegrationRow } from './types.ts'; export class SqliteGroupChatRepo { constructor(private db: DatabaseSync) {} @@ -4427,6 +4442,92 @@ export class SqliteStatusTransitionRepo { } } +// ─── Integration ───────────────────────────────────────────────────────────── + +export type { IntegrationRow } from './types.ts'; + +export class SqliteIntegrationRepo { + constructor(private db: DatabaseSync) {} + + async create(data: Record): Promise { + const id = (data['id'] as string) ?? generateId('int'); + const now = new Date().toISOString(); + this.db.prepare( + `INSERT INTO integrations (id, org_id, platform, display_name, enabled, config, forward_rules, last_verified_at, last_error, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + id, + data['orgId'] as string, + data['platform'] as string, + data['displayName'] as string, + (data['enabled'] as boolean) ? 1 : 0, + toJson(data['config']), + toJson(data['forwardRules'] ?? []), + (data['lastVerifiedAt'] as string) ?? null, + (data['lastError'] as string) ?? null, + now, + now, + ); + return this.findById(id)!; + } + + findById(id: string): IntegrationRow | undefined { + const row = this.db.prepare('SELECT * FROM integrations WHERE id = ?').get(id) as Record | undefined; + return row ? this.mapRow(row) : undefined; + } + + listByOrg(orgId: string): IntegrationRow[] { + const rows = this.db.prepare('SELECT * FROM integrations WHERE org_id = ? ORDER BY platform, display_name').all(orgId) as Record[]; + return rows.map(r => this.mapRow(r)); + } + + listByPlatform(orgId: string, platform: string): IntegrationRow[] { + const rows = this.db.prepare('SELECT * FROM integrations WHERE org_id = ? AND platform = ? ORDER BY display_name').all(orgId, platform) as Record[]; + return rows.map(r => this.mapRow(r)); + } + + async update(id: string, data: Record): Promise { + const now = new Date().toISOString(); + const sets: string[] = []; + const params: SQLInputValue[] = []; + + if (data['displayName'] !== undefined) { sets.push('display_name = ?'); params.push(data['displayName'] as string); } + if (data['enabled'] !== undefined) { sets.push('enabled = ?'); params.push((data['enabled'] as boolean) ? 1 : 0); } + if (data['config'] !== undefined) { sets.push('config = ?'); params.push(toJson(data['config'])); } + if (data['forwardRules'] !== undefined) { sets.push('forward_rules = ?'); params.push(toJson(data['forwardRules'])); } + if (data['lastVerifiedAt'] !== undefined) { sets.push('last_verified_at = ?'); params.push(data['lastVerifiedAt'] as string ?? null); } + if (data['lastError'] !== undefined) { sets.push('last_error = ?'); params.push(data['lastError'] as string ?? null); } + if (data['platform'] !== undefined) { sets.push('platform = ?'); params.push(data['platform'] as string); } + + if (sets.length === 0) return; + sets.push('updated_at = ?'); + params.push(now); + params.push(id); + + this.db.prepare(`UPDATE integrations SET ${sets.join(', ')} WHERE id = ?`).run(...params); + } + + async delete(id: string): Promise { + this.db.prepare('DELETE FROM integrations WHERE id = ?').run(id); + } + + private mapRow(r: Record): IntegrationRow { + return { + id: r['id'] as string, + orgId: r['org_id'] as string, + platform: r['platform'] as string, + displayName: r['display_name'] as string, + enabled: !!(r['enabled'] as number), + config: fromJson>(r['config'] as string), + forwardRules: fromJson[]>(r['forward_rules'] as string), + lastVerifiedAt: r['last_verified_at'] as string | null, + lastError: r['last_error'] as string | null, + createdAt: r['created_at'] as string, + updatedAt: r['updated_at'] as string, + }; + } +} + // ─── Read Cursors (unread tracking) ────────────────────────────────────────── export interface ReadCursorRow { diff --git a/packages/storage/src/types.ts b/packages/storage/src/types.ts index c94cecce..a5075aa4 100644 --- a/packages/storage/src/types.ts +++ b/packages/storage/src/types.ts @@ -295,6 +295,32 @@ export interface GroupChatMember { addedAt: Date; } +// ─── Integration ────────────────────────────────────────────────────────────── + +export interface IntegrationRow { + id: string; + orgId: string; + platform: string; + displayName: string; + enabled: boolean; + config: Record; + forwardRules: Record[]; + lastVerifiedAt: string | null; + lastError: string | null; + createdAt: string; + updatedAt: string; +} + +/** Contract for integration config persistence */ +export interface IntegrationRepo { + create(data: Record): Promise; + findById(id: string): IntegrationRow | undefined; + listByOrg(orgId: string): IntegrationRow[]; + listByPlatform(orgId: string, platform: string): IntegrationRow[]; + update(id: string, data: Record): Promise; + delete(id: string): Promise; +} + // ─── Repo interfaces (structural contracts for dependency injection) ────────── /** Contract for task persistence used by org-manager consumers */ diff --git a/packages/web-ui/src/api.ts b/packages/web-ui/src/api.ts index acb48bc7..ada5ddb4 100644 --- a/packages/web-ui/src/api.ts +++ b/packages/web-ui/src/api.ts @@ -1395,6 +1395,30 @@ export const api = { getRemote: () => request('/settings/remote'), enableRemote: () => request<{ ok: boolean; status: RemoteStatus }>('/settings/remote/enable', { method: 'POST' }), disableRemote: () => request<{ ok: boolean }>('/settings/remote/disable', { method: 'POST' }), + getFeishuIntegration: () => request<{ + appId?: string; appSecret?: string; + enabled: boolean; connected: boolean; + notifyChatId?: string; + notifyOnApproval: boolean; notifyOnNotification: boolean; notifyPriority: string[]; + }>('/settings/integrations/feishu'), + saveFeishuIntegration: (config: { + appId: string; appSecret: string; enabled?: boolean; + notifyChatId?: string; + notifyOnApproval?: boolean; notifyOnNotification?: boolean; notifyPriority?: string[]; + }) => request<{ + appId?: string; connected: boolean; enabled: boolean; + }>('/settings/integrations/feishu', { method: 'POST', body: JSON.stringify(config) }), + testFeishuConnection: (creds: { appId: string; appSecret: string }) => + request<{ success: boolean; message?: string }>('/settings/integrations/feishu/test', { method: 'POST', body: JSON.stringify(creds) }), + sendFeishuTestMessage: (data: { chatId: string }) => + request<{ success: boolean; message?: string }>('/settings/integrations/feishu/test-message', { method: 'POST', body: JSON.stringify(data) }), + deleteFeishuIntegration: () => request<{ ok: boolean }>('/settings/integrations/feishu', { method: 'DELETE' }), + listFeishuChats: () => + request<{ chats: Array<{ chatId: string; name: string; description?: string; avatar?: string }>; error?: string }>('/settings/integrations/feishu/chats'), + registerFeishuApp: () => + request<{ success: boolean; appId?: string; connected?: boolean; userInfo?: { open_id?: string; tenant_brand?: string }; error?: string; message?: string }>('/settings/integrations/feishu/register', { method: 'POST' }), + getFeishuRegisterStatus: () => + request<{ active: boolean; url?: string; expireIn?: number; status?: string; elapsed?: number }>('/settings/integrations/feishu/register/status'), }, modelCatalog: { getByProvider: (provider: string) => request<{ provider: string; models: CatalogModel[] }>(`/models/catalog/${provider}`), diff --git a/packages/web-ui/src/components/FeishuIntegrationSection.tsx b/packages/web-ui/src/components/FeishuIntegrationSection.tsx new file mode 100644 index 00000000..5be139b6 --- /dev/null +++ b/packages/web-ui/src/components/FeishuIntegrationSection.tsx @@ -0,0 +1,784 @@ +import { useEffect, useState, useCallback, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import QRCode from 'qrcode'; +import { api } from '../api.ts'; + +export interface FeishuConfig { + appId: string; + appSecret: string; + enabled: boolean; + connected: boolean; + notifyChatId: string; + notifyOnApproval: boolean; + notifyOnNotification: boolean; + notifyPriority: string[]; +} + +const DEFAULT_CONFIG: FeishuConfig = { + appId: '', + appSecret: '', + enabled: false, + connected: false, + notifyChatId: '', + notifyOnApproval: true, + notifyOnNotification: false, + notifyPriority: ['high', 'urgent'], +}; + +const PRIORITY_OPTIONS = [ + { value: 'normal', color: 'bg-green-400' }, + { value: 'low', color: 'bg-gray-400' }, + { value: 'medium', color: 'bg-blue-400' }, + { value: 'high', color: 'bg-amber-400' }, + { value: 'urgent', color: 'bg-red-400' }, +] as const; + +function StatusBadge({ connected }: { connected: boolean }) { + return ( + + + {connected ? 'Connected' : 'Disconnected'} + + ); +} + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ); +} + +function Msg({ type, text }: { type: 'ok' | 'err'; text: string }) { + return ( +
+ {type === 'ok' ? ( + + ) : ( + + )} + {text} +
+ ); +} + +type RegisterState = 'idle' | 'waiting_qr' | 'scanning' | 'done' | 'error'; + +export function FeishuIntegrationSection() { + const { t } = useTranslation(['settings', 'common']); + + const [config, setConfig] = useState(DEFAULT_CONFIG); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [testing, setTesting] = useState(false); + const [sendingMsg, setSendingMsg] = useState(false); + const [msg, setMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null); + const [testChatId, setTestChatId] = useState(''); + + // Bot chat list for chat selector + const [botChats, setBotChats] = useState>([]); + const [loadingChats, setLoadingChats] = useState(false); + + // QR code registration state + const [registerState, setRegisterState] = useState('idle'); + const [qrUrl, setQrUrl] = useState(null); + const [qrDataUrl, setQrDataUrl] = useState(null); + const [qrExpireIn, setQrExpireIn] = useState(0); + const pollTimer = useRef | null>(null); + const registerAbort = useRef(null); + + // Manual config toggle (advanced) + const [showManualConfig, setShowManualConfig] = useState(false); + const [showAppSecret, setShowAppSecret] = useState(false); + + const saveTimer = useRef | null>(null); + const configRef = useRef(config); + configRef.current = config; + const initialLoadDone = useRef(false); + + const loadBotChats = useCallback(async () => { + setLoadingChats(true); + try { + const data = await api.settings.listFeishuChats(); + setBotChats(data.chats ?? []); + } catch { /* ignore */ } + finally { setLoadingChats(false); } + }, []); + + const loadConfig = useCallback(async () => { + setLoading(true); + try { + const data = await api.settings.getFeishuIntegration(); + if (data) { + setConfig({ + appId: data.appId ?? '', + appSecret: data.appSecret ?? '', + enabled: data.enabled ?? false, + connected: data.connected ?? false, + notifyChatId: data.notifyChatId ?? '', + notifyOnApproval: data.notifyOnApproval ?? true, + notifyOnNotification: data.notifyOnNotification ?? false, + notifyPriority: data.notifyPriority ?? ['high', 'urgent'], + }); + if (data.appId && data.appSecret) { + loadBotChats(); + } + } + } catch { + // Not configured yet + } finally { + setLoading(false); + initialLoadDone.current = true; + } + }, []); + + useEffect(() => { loadConfig(); }, [loadConfig]); + + // Cleanup poll timer on unmount + useEffect(() => { + return () => { + if (pollTimer.current) clearInterval(pollTimer.current); + }; + }, []); + + const doSave = useCallback(async (cfg: FeishuConfig) => { + if (!cfg.appId || !cfg.appSecret) return; + setSaving(true); + try { + const result = await api.settings.saveFeishuIntegration({ + appId: cfg.appId, + appSecret: cfg.appSecret, + enabled: cfg.enabled, + notifyChatId: cfg.notifyChatId || undefined, + notifyOnApproval: cfg.notifyOnApproval, + notifyOnNotification: cfg.notifyOnNotification, + notifyPriority: cfg.notifyPriority, + }); + if (result.connected !== undefined) { + setConfig(prev => ({ ...prev, connected: result.connected })); + } + setMsg({ type: 'ok', text: t('settings:feishu.saved', { defaultValue: 'Saved' }) }); + setTimeout(() => setMsg(prev => prev?.type === 'ok' ? null : prev), 2000); + } catch (err) { + setMsg({ type: 'err', text: String(err instanceof Error ? err.message : err) }); + } finally { + setSaving(false); + } + }, [t]); + + const scheduleSave = useCallback(() => { + if (!initialLoadDone.current) return; + if (saveTimer.current) clearTimeout(saveTimer.current); + saveTimer.current = setTimeout(() => { + doSave(configRef.current); + }, 600); + }, [doSave]); + + const updateField = (key: K, value: FeishuConfig[K]) => { + setConfig(prev => ({ ...prev, [key]: value })); + setMsg(null); + scheduleSave(); + }; + + const updateFieldImmediate = (key: K, value: FeishuConfig[K]) => { + setConfig(prev => { + const next = { ...prev, [key]: value }; + configRef.current = next; + return next; + }); + setMsg(null); + if (saveTimer.current) clearTimeout(saveTimer.current); + doSave({ ...configRef.current, [key]: value }); + }; + + const togglePriority = (priority: string) => { + setConfig(prev => { + const current = prev.notifyPriority; + const next = current.includes(priority) + ? current.filter(p => p !== priority) + : [...current, priority]; + const updated = { ...prev, notifyPriority: next }; + configRef.current = updated; + return updated; + }); + setMsg(null); + if (saveTimer.current) clearTimeout(saveTimer.current); + saveTimer.current = setTimeout(() => { + doSave(configRef.current); + }, 300); + }; + + // --- QR Code Registration Flow --- + const startRegister = async () => { + setRegisterState('waiting_qr'); + setQrUrl(null); + setQrDataUrl(null); + setMsg(null); + + // Start polling for QR code status + pollTimer.current = setInterval(async () => { + try { + const status = await api.settings.getFeishuRegisterStatus(); + if (status.active && status.url) { + setQrUrl(status.url); + setQrExpireIn(status.expireIn ?? 300); + setRegisterState('scanning'); + // Generate QR code data URL from the authorization URL + try { + const dataUrl = await QRCode.toDataURL(status.url, { + width: 200, + margin: 2, + color: { dark: '#000000', light: '#ffffff' }, + }); + setQrDataUrl(dataUrl); + } catch { /* QR generation error */ } + } + } catch { /* ignore poll errors */ } + }, 1000); + + // Trigger the registration (this is a long-poll request that resolves when user scans) + try { + const result = await api.settings.registerFeishuApp(); + if (pollTimer.current) { clearInterval(pollTimer.current); pollTimer.current = null; } + + if (result.success && result.appId) { + setRegisterState('done'); + setMsg({ type: 'ok', text: t('settings:feishu.registerSuccess', { defaultValue: 'App created successfully! Integration is now active.' }) }); + // Reload config to get the new credentials + await loadConfig(); + } else { + setRegisterState('error'); + setMsg({ type: 'err', text: result.message || t('settings:feishu.registerFailed', { defaultValue: 'Registration failed' }) }); + } + } catch (err) { + if (pollTimer.current) { clearInterval(pollTimer.current); pollTimer.current = null; } + setRegisterState('error'); + setMsg({ type: 'err', text: String(err instanceof Error ? err.message : err) }); + } + }; + + const cancelRegister = () => { + if (pollTimer.current) { clearInterval(pollTimer.current); pollTimer.current = null; } + if (registerAbort.current) registerAbort.current.abort(); + setRegisterState('idle'); + setQrUrl(null); + setQrDataUrl(null); + }; + + const handleTest = async () => { + if (!config.appId || !config.appSecret) { + setMsg({ type: 'err', text: t('settings:feishu.fillRequired', { defaultValue: 'Please fill in App ID and App Secret first' }) }); + return; + } + setTesting(true); + setMsg(null); + try { + const result = await api.settings.testFeishuConnection({ + appId: config.appId, + appSecret: config.appSecret, + }); + if (result.success) { + setConfig(prev => ({ ...prev, connected: true })); + setMsg({ type: 'ok', text: result.message || t('settings:feishu.testSuccess', { defaultValue: 'Connection successful' }) }); + } else { + setMsg({ type: 'err', text: result.message || t('settings:feishu.testFailed', { defaultValue: 'Connection failed' }) }); + } + } catch (err) { + setMsg({ type: 'err', text: String(err instanceof Error ? err.message : err) }); + } finally { + setTesting(false); + } + }; + + const handleSendTestMessage = async () => { + if (!testChatId.trim()) { + setMsg({ type: 'err', text: t('settings:feishu.chatIdRequired', { defaultValue: 'Please select a group chat' }) }); + return; + } + setSendingMsg(true); + setMsg(null); + try { + const result = await api.settings.sendFeishuTestMessage({ chatId: testChatId.trim() }); + if (result.success) { + setMsg({ type: 'ok', text: result.message || t('settings:feishu.testMsgSent', { defaultValue: 'Test message sent' }) }); + } else { + setMsg({ type: 'err', text: result.message || t('settings:feishu.testMsgFailed', { defaultValue: 'Failed to send test message' }) }); + } + } catch (err) { + setMsg({ type: 'err', text: String(err instanceof Error ? err.message : err) }); + } finally { + setSendingMsg(false); + } + }; + + const handleDisconnect = async () => { + setSaving(true); + setMsg(null); + try { + await api.settings.deleteFeishuIntegration(); + initialLoadDone.current = false; + setConfig(DEFAULT_CONFIG); + initialLoadDone.current = true; + setShowManualConfig(false); + setMsg({ type: 'ok', text: t('settings:feishu.disconnected', { defaultValue: 'Disconnected from Feishu' }) }); + } catch (err) { + setMsg({ type: 'err', text: String(err instanceof Error ? err.message : err) }); + } finally { + setSaving(false); + } + }; + + if (loading) { + return ( +
+
+
+ {t('common:loading', { defaultValue: 'Loading...' })} +
+
+ ); + } + + const isConfigured = !!(config.appId && config.appSecret); + + return ( +
+ {/* Header */} +
+
+
+ + + +
+
+

飞书 (Feishu / Lark)

+

{t('settings:feishu.description', { defaultValue: 'Configure Feishu integration for notifications and approvals' })}

+
+
+
+ {saving &&
} + +
+
+ + {/* Connection mode indicator */} +
+ + + + + {t('settings:feishu.longConnectionMode', { defaultValue: 'Using long connection mode (WebSocket) — no public IP required' })} + +
+ + {/* One-Click Setup or Connected State */} + {!isConfigured ? ( +
+
+ {registerState === 'idle' || registerState === 'error' || registerState === 'done' ? ( + <> +
+
+ + + +
+
+

+ {t('settings:feishu.oneClickTitle', { defaultValue: 'One-Click Feishu Integration' })} +

+

+ {t('settings:feishu.oneClickDesc', { defaultValue: 'Scan a QR code with Feishu to automatically create and configure the app. No manual setup needed.' })} +

+
+ +
+ +
+

+ {t('settings:feishu.oneClickPermissions', { defaultValue: 'The app will be created with permissions: bot messaging, message receiving, user info reading, and group chat management.' })} +

+
+ + ) : ( + /* QR Code Display */ +
+ {registerState === 'waiting_qr' && !qrUrl && ( +
+
+

+ {t('settings:feishu.generatingQR', { defaultValue: 'Generating QR code...' })} +

+
+ )} + + {qrUrl && ( + <> +
+ {qrDataUrl ? ( + Feishu QR Code + ) : ( +
+
+
+ )} +
+
+

+ {t('settings:feishu.scanWithFeishu', { defaultValue: 'Scan with Feishu app' })} +

+

+ {t('settings:feishu.scanHint', { defaultValue: 'Use your Feishu mobile app to scan and authorize. The app will be created in your organization.' })} +

+ {qrExpireIn > 0 && ( +

+ {t('settings:feishu.qrExpire', { defaultValue: `QR code expires in ${Math.floor(qrExpireIn / 60)} minutes`, minutes: Math.floor(qrExpireIn / 60) })} +

+ )} +
+ + + )} +
+ )} +
+ + {/* Manual Configuration Fallback */} +
+ + + {showManualConfig && ( +
+
+ + updateField('appId', e.target.value)} + onBlur={() => { if (config.appId && config.appSecret) { if (saveTimer.current) clearTimeout(saveTimer.current); doSave(configRef.current); } }} + placeholder="cli_xxxxxxxxxxxxxx" + className="w-full px-3 py-2 text-sm bg-surface-primary border border-border-default rounded-lg text-fg-primary placeholder-fg-tertiary focus:outline-none focus:ring-2 focus:ring-brand-500/40 focus:border-brand-500 transition-colors font-mono" + /> +
+
+ +
+ updateField('appSecret', e.target.value)} + onBlur={() => { if (config.appId && config.appSecret) { if (saveTimer.current) clearTimeout(saveTimer.current); doSave(configRef.current); } }} + placeholder="Enter your Feishu app secret" + className="w-full px-3 py-2 pr-10 text-sm bg-surface-primary border border-border-default rounded-lg text-fg-primary placeholder-fg-tertiary focus:outline-none focus:ring-2 focus:ring-brand-500/40 focus:border-brand-500 transition-colors font-mono" + /> + +
+
+
+ )} +
+
+ ) : ( + /* Already Configured — show app info and actions */ + <> +
+
+
+
+
+ App ID: + {config.appId} +
+
+ App Secret: + + {'•'.repeat(Math.min(config.appSecret.length, 20))} + +
+
+ +
+
+
+ + {/* Enable / Disable Toggle */} +
+
+
+
+
+ {t('settings:feishu.enableIntegration', { defaultValue: 'Enable Feishu Integration' })} +
+
+ {t('settings:feishu.enableHint', { defaultValue: 'When enabled, Markus will establish a long connection to Feishu and start processing events' })} +
+
+ +
+
+
+ + {/* Actions */} +
+
+ +
+ + {/* Send Test Message */} + {config.connected && config.enabled && ( +
+ +
+
+ + + + +
+ +
+
+ )} + + {msg && } +
+ + {/* Notification Forwarding Settings */} +
+
+ {/* Target Chat ID */} +
+
+ + +
+
+ + + + +
+ {!config.notifyChatId && !loadingChats && ( +

+ {t('settings:feishu.noChatFallbackHint', { defaultValue: 'No group selected — notifications will be sent to you as private messages.' })} +

+ )} + {botChats.length === 0 && !loadingChats && config.notifyChatId === '' && ( +

+ {t('settings:feishu.noChatHint', { defaultValue: 'No groups found. Add the bot to a group chat to enable group notifications.' })} +

+ )} + {config.notifyChatId && ( +

ID: {config.notifyChatId}

+ )} +
+ +
+ {/* Approval notifications */} +
+
+
{t('settings:feishu.forwardApprovals', { defaultValue: 'Forward Approval Requests' })}
+
{t('settings:feishu.forwardApprovalsHint', { defaultValue: 'Send approval requests to Feishu as interactive cards' })}
+
+ +
+ + {/* General notifications */} +
+
+
{t('settings:feishu.forwardNotifications', { defaultValue: 'Forward General Notifications' })}
+
{t('settings:feishu.forwardNotificationsHint', { defaultValue: 'Send system notifications to Feishu chat' })}
+
+ +
+
+ + {/* Priority filter */} + {(config.notifyOnApproval || config.notifyOnNotification) && ( +
+ +
+ {PRIORITY_OPTIONS.map(({ value, color }) => { + const selected = config.notifyPriority.includes(value); + return ( + + ); + })} +
+

+ {t('settings:feishu.priorityHint', { defaultValue: 'Only notifications with the selected priority levels will be forwarded' })} +

+
+ )} +
+
+ + )} + + {msg && !isConfigured && } +
+ ); +} diff --git a/packages/web-ui/src/locales/en/settings.json b/packages/web-ui/src/locales/en/settings.json index 3b1a81e8..43db4cd2 100644 --- a/packages/web-ui/src/locales/en/settings.json +++ b/packages/web-ui/src/locales/en/settings.json @@ -9,6 +9,7 @@ "storage": "Data & Storage", "users": "User Management", "remote": "Remote Access", + "integrations": "Integrations", "license": "License", "organization": "Organization", "account": "Organization & License" @@ -600,5 +601,68 @@ "roleUpdated": "Role updated", "yourRole": "Your Role", "changeRole": "Change Role" + }, + "feishu": { + "description": "Configure Feishu integration for notifications and approvals", + "connectionSettings": "Connection Settings", + "longConnectionMode": "Using long connection mode (WebSocket) — no public IP required", + "actions": "Actions", + "testConnection": "Test Connection", + "integrationState": "Integration State", + "enableIntegration": "Enable Feishu Integration", + "enableHint": "When enabled, Markus will establish a long connection to Feishu and start processing events", + "notificationForwarding": "Notification Forwarding", + "notifyChatId": "Send Notifications To", + "notifyChatIdHint": "Select a group chat, or use private messages (default).", + "forwardApprovals": "Forward Approval Requests", + "forwardApprovalsHint": "Send approval requests to Feishu as interactive cards", + "forwardNotifications": "Forward General Notifications", + "forwardNotificationsHint": "Send system notifications to Feishu chat", + "notifyPriority": "Minimum Notification Priority", + "priorityHint": "Only notifications with the selected priority levels will be forwarded", + "setupGuide": "Setup Guide", + "setupGuideDesc": "Follow these steps to set up the Feishu integration:", + "guideStep1": "Go to Feishu Open Platform (open.feishu.cn) and create an enterprise self-built app", + "guideStep2": "Go to App → Features → Bot, enable bot capability", + "guideStep3": "Go to App → Permissions, enable im:message and im:message.receive_v1", + "guideStep4": "Go to App → Events & Callbacks → Subscription mode, select \"Use long connection\"", + "guideStep5": "On the Events & Callbacks page, subscribe to \"Receive messages v2.0\" (im.message.receive_v1)", + "guideStep6": "Go to App → Credentials, copy App ID and App Secret into the fields above", + "guideStep7": "Click \"Test Connection\" to verify credentials, then enable the integration", + "guideStep8": "Add the bot to a group chat (or send a direct message to the bot)", + "longConnectionNote": "Long connection mode requires an enterprise self-built app. Store apps are not supported. No public IP or domain is needed.", + "quickSetup": "Quick Setup", + "oneClickTitle": "One-Click Feishu Integration", + "oneClickDesc": "Scan a QR code with Feishu to automatically create and configure the app. No manual setup needed.", + "scanToCreate": "Scan QR Code to Create App", + "oneClickPermissions": "The app will be created with permissions: bot messaging, message receiving, user info reading, and group chat management.", + "generatingQR": "Generating QR code...", + "scanWithFeishu": "Scan with Feishu app", + "scanHint": "Use your Feishu mobile app to scan and authorize. The app will be created in your organization.", + "qrExpire": "QR code expires in {{minutes}} minutes", + "registerSuccess": "App created successfully! Integration is now active.", + "registerFailed": "Registration failed", + "manualConfig": "Manual configuration (advanced)", + "appInfo": "App Information", + "removeApp": "Remove", + "sendTestMessage": "Send Test Message", + "chatIdPlaceholder": "Enter Chat ID (oc_xxxxxxxx)", + "send": "Send", + "chatIdHint": "Chat ID can be found in the Feishu group chat URL or via the API", + "chatIdRequired": "Please select a group chat", + "selectChat": "— Private message (default) —", + "loadingChats": "Loading groups...", + "refreshChats": "Refresh", + "noChatHint": "No groups found. Add the bot to a group chat to enable group notifications.", + "noChatFallbackHint": "No group selected — notifications will be sent to you as private messages.", + "selectTestChat": "— Select a group chat —", + "testMsgSent": "Test message sent", + "testMsgFailed": "Failed to send test message", + "saved": "Feishu configuration saved", + "fillRequired": "Please fill in App ID and App Secret first", + "testSuccess": "Connection successful", + "testFailed": "Connection failed", + "disconnected": "Disconnected from Feishu", + "disconnect": "Disconnect" } } diff --git a/packages/web-ui/src/locales/zh-CN/settings.json b/packages/web-ui/src/locales/zh-CN/settings.json index ded4da6c..98c7292b 100644 --- a/packages/web-ui/src/locales/zh-CN/settings.json +++ b/packages/web-ui/src/locales/zh-CN/settings.json @@ -9,6 +9,7 @@ "storage": "数据与存储", "users": "用户管理", "remote": "远程访问", + "integrations": "外部IM集成", "license": "许可证", "organization": "组织", "account": "组织与许可证" @@ -600,5 +601,68 @@ "roleUpdated": "角色已更新", "yourRole": "你的角色", "changeRole": "更改角色" + }, + "feishu": { + "description": "配置飞书集成,用于接收通知和审批请求", + "connectionSettings": "连接设置", + "longConnectionMode": "使用长连接模式 (WebSocket) — 无需公网 IP", + "actions": "操作", + "testConnection": "测试连接", + "integrationState": "集成状态", + "enableIntegration": "启用飞书集成", + "enableHint": "启用后,Markus 将通过长连接接入飞书并开始处理事件", + "notificationForwarding": "通知转发", + "notifyChatId": "通知发送到", + "notifyChatIdHint": "选择群聊接收通知,或使用私信(默认)", + "forwardApprovals": "转发审批请求", + "forwardApprovalsHint": "将审批请求以交互卡片形式发送到飞书", + "forwardNotifications": "转发一般通知", + "forwardNotificationsHint": "将系统通知转发到飞书聊天", + "notifyPriority": "最低通知优先级", + "priorityHint": "仅选择优先级级别及以上的通知会被转发", + "setupGuide": "设置指南", + "setupGuideDesc": "按以下步骤设置飞书集成:", + "guideStep1": "前往飞书开放平台 (open.feishu.cn),创建一个「企业自建应用」", + "guideStep2": "进入应用 → 应用功能 → 机器人,启用机器人能力", + "guideStep3": "进入应用 → 权限管理,开通 im:message 和 im:message.receive_v1 权限", + "guideStep4": "进入应用 → 事件与回调 → 订阅方式,选择「使用长连接接收事件」", + "guideStep5": "在事件与回调页面,添加事件订阅「接收消息 v2.0」(im.message.receive_v1)", + "guideStep6": "进入应用 → 凭证与基础信息,复制 App ID 和 App Secret 填入上方", + "guideStep7": "点击「测试连接」验证凭证,然后启用集成", + "guideStep8": "将机器人添加到需要接收消息的群聊中(或直接给机器人发私聊消息)", + "longConnectionNote": "长连接模式仅支持企业自建应用,不支持商店应用。使用长连接无需公网 IP 或域名。", + "quickSetup": "快速设置", + "oneClickTitle": "一键接入飞书", + "oneClickDesc": "使用飞书扫描二维码,自动创建并配置应用。无需手动设置。", + "scanToCreate": "扫码创建应用", + "oneClickPermissions": "应用将自动获取以下权限:机器人消息发送、消息接收、用户信息读取、群聊管理。", + "generatingQR": "正在生成二维码...", + "scanWithFeishu": "使用飞书扫一扫", + "scanHint": "打开飞书手机客户端,扫描二维码完成授权。应用将创建在你的组织中。", + "qrExpire": "二维码将在 {{minutes}} 分钟后过期", + "registerSuccess": "应用创建成功!集成已自动激活。", + "registerFailed": "注册失败", + "manualConfig": "手动配置(高级)", + "appInfo": "应用信息", + "removeApp": "移除", + "sendTestMessage": "发送测试消息", + "chatIdPlaceholder": "输入群聊 ID (oc_xxxxxxxx)", + "send": "发送", + "chatIdHint": "群聊 ID 可在飞书群聊的 URL 中找到,或通过 API 获取", + "chatIdRequired": "请选择一个群聊", + "selectChat": "— 私信通知(默认) —", + "loadingChats": "正在加载群列表…", + "refreshChats": "刷新", + "noChatHint": "暂无可用群聊,将机器人添加到群聊后可选择群聊通知", + "noChatFallbackHint": "未选择群聊 — 通知将以私信形式发送给你", + "selectTestChat": "— 选择一个群聊 —", + "testMsgSent": "测试消息已发送", + "testMsgFailed": "发送测试消息失败", + "saved": "飞书配置已保存", + "fillRequired": "请先填写 App ID 和 App Secret", + "testSuccess": "连接成功", + "testFailed": "连接失败", + "disconnected": "已断开飞书连接", + "disconnect": "断开连接" } } diff --git a/packages/web-ui/src/pages/Settings.tsx b/packages/web-ui/src/pages/Settings.tsx index df1ec522..27933d1d 100644 --- a/packages/web-ui/src/pages/Settings.tsx +++ b/packages/web-ui/src/pages/Settings.tsx @@ -10,6 +10,7 @@ import { useIsMobile } from '../hooks/useIsMobile.ts'; import { BrowserTestPanel } from '../components/BrowserTestPanel.tsx'; import { ModelPicker } from '../components/ModelPicker.tsx'; import { PROVIDER_OPTIONS } from '../constants/providers.ts'; +import { FeishuIntegrationSection } from '../components/FeishuIntegrationSection.tsx'; interface ModelCost { input: number; output: number; cacheRead?: number; cacheWrite?: number } interface ModelDef { id: string; name: string; provider: string; contextWindow: number; maxOutputTokens: number; cost: ModelCost; reasoning?: boolean; inputTypes?: string[] } @@ -39,7 +40,7 @@ interface OllamaDetectResult { models?: Array<{ name: string; fullName: string; size?: number; modifiedAt?: string; parameterSize?: string; family?: string; quantization?: string }>; } -type SettingsTab = 'appearance' | 'providers' | 'execution' | 'browser' | 'search' | 'storage' | 'users' | 'organization' | 'account' | 'remote' | 'license'; +type SettingsTab = 'appearance' | 'providers' | 'execution' | 'browser' | 'search' | 'storage' | 'users' | 'organization' | 'account' | 'remote' | 'integrations' | 'license'; const SETTINGS_TABS: Array<{ id: SettingsTab; labelKey: string; adminOnly?: boolean }> = [ { id: 'appearance', labelKey: 'nav.appearance' }, @@ -50,6 +51,7 @@ const SETTINGS_TABS: Array<{ id: SettingsTab; labelKey: string; adminOnly?: bool { id: 'storage', labelKey: 'nav.storage', adminOnly: true }, { id: 'account', labelKey: 'nav.account' }, { id: 'remote', labelKey: 'nav.remote', adminOnly: true }, + { id: 'integrations', labelKey: 'nav.integrations', adminOnly: true }, ]; const LEGACY_TAB_ALIASES: Record = { users: 'account', organization: 'account', license: 'account' }; @@ -2497,6 +2499,8 @@ export function Settings({ theme, onThemeChange, authUser, onLogout, onUserUpdat {resolvedTab === 'remote' && } + {resolvedTab === 'integrations' && } + )} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a9d0c3da..60068242 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -166,6 +166,9 @@ importers: packages/org-manager: dependencies: + '@larksuiteoapi/node-sdk': + specifier: ^1.36.0 + version: 1.66.1 '@markus/core': specifier: workspace:* version: link:../core @@ -1093,6 +1096,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@larksuiteoapi/node-sdk@1.66.1': + resolution: {integrity: sha512-W1rIAs/8Oc/rEYuWc0sxGvR8iLwd8p5D2RS4ODMqs8htIxK8yIa8sb22EDv/OEBqUpKXZaNLydTz7Oq8HOQROg==} + '@mixmark-io/domino@2.2.0': resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==} @@ -1100,6 +1106,36 @@ packages: resolution: {integrity: sha512-juG5VWh4qAivzTAeMzvY9xs9HY5rAcr2E4I7tiSSCokRFi7XIZCAu92ZkSTsIj1OPceCifL3cpfteP3pDT9/QQ==} engines: {node: '>=14.0.0'} + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.5': + resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==} + + '@protobufjs/eventemitter@1.1.1': + resolution: {integrity: sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==} + + '@protobufjs/fetch@1.1.1': + resolution: {integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/inquire@1.1.2': + resolution: {integrity: sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.1': + resolution: {integrity: sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==} + '@rolldown/pluginutils@1.0.0-rc.3': resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} @@ -1634,6 +1670,10 @@ packages: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + camelcase@5.3.1: resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} engines: {node: '>=6'} @@ -2323,9 +2363,21 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash.identity@3.0.0: + resolution: {integrity: sha512-AupTIzdLQxJS5wIYUQlgGyk2XRTfGXA+MCghDHqZk0pzUNYvd3EESS6dkChNauNYVIutcb0dfHw1ri9Q1yPV8Q==} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lodash.pickby@4.6.0: + resolution: {integrity: sha512-AZV+GsS/6ckvPOVQPXSiFFacKvKB4kOQu6ynt9wz0F3LO4R9Ij4K1ddYsIytDpSgLz88JHd9P+oaLeej5/Sl7Q==} + lodash@4.17.23: resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} @@ -2543,6 +2595,10 @@ packages: nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} @@ -2627,6 +2683,10 @@ packages: property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + protobufjs@7.6.3: + resolution: {integrity: sha512-+k0vdJKNdW+Vu+dYe8tZA/VvQb6XKNWexC6URwBFXxNnjLJz9nQJCemGyNgRAWD+B7+nGNc9qMPGwcD7s4nzUw==} + engines: {node: '>=12.0.0'} + proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} @@ -2642,6 +2702,10 @@ packages: engines: {node: '>=10.13.0'} hasBin: true + qs@6.15.2: + resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} + engines: {node: '>=0.6'} + rc@1.2.8: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true @@ -2767,6 +2831,22 @@ packages: resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} engines: {node: '>= 0.4'} + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -3673,10 +3753,46 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@larksuiteoapi/node-sdk@1.66.1': + dependencies: + axios: 1.13.6 + lodash.identity: 3.0.0 + lodash.merge: 4.6.2 + lodash.pickby: 4.6.0 + protobufjs: 7.6.3 + qs: 6.15.2 + ws: 8.19.0 + transitivePeerDependencies: + - bufferutil + - debug + - utf-8-validate + '@mixmark-io/domino@2.2.0': {} '@mozilla/readability@0.6.0': {} + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.5': {} + + '@protobufjs/eventemitter@1.1.1': {} + + '@protobufjs/fetch@1.1.1': + dependencies: + '@protobufjs/aspromise': 1.1.2 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/inquire@1.1.2': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.1': {} + '@rolldown/pluginutils@1.0.0-rc.3': {} '@rollup/rollup-android-arm-eabi@4.59.0': @@ -4193,6 +4309,11 @@ snapshots: es-errors: 1.3.0 function-bind: 1.1.2 + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + camelcase@5.3.1: {} caniuse-lite@1.0.30001774: {} @@ -4934,8 +5055,16 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash.identity@3.0.0: {} + + lodash.merge@4.6.2: {} + + lodash.pickby@4.6.0: {} + lodash@4.17.23: {} + long@5.3.2: {} + longest-streak@3.1.0: {} lru-cache@5.1.1: @@ -5368,6 +5497,8 @@ snapshots: dependencies: boolbase: 1.0.0 + object-inspect@1.13.4: {} + obug@2.1.1: {} once@1.4.0: @@ -5459,6 +5590,21 @@ snapshots: property-information@7.1.0: {} + protobufjs@7.6.3: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.5 + '@protobufjs/eventemitter': 1.1.1 + '@protobufjs/fetch': 1.1.1 + '@protobufjs/float': 1.0.2 + '@protobufjs/inquire': 1.1.2 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.1 + '@types/node': 25.3.0 + long: 5.3.2 + proxy-from-env@1.1.0: {} pump@3.0.4: @@ -5474,6 +5620,10 @@ snapshots: pngjs: 5.0.0 yargs: 15.4.1 + qs@6.15.2: + dependencies: + side-channel: 1.1.1 + rc@1.2.8: dependencies: deep-extend: 0.6.0 @@ -5681,6 +5831,34 @@ snapshots: shell-quote@1.8.3: {} + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} simple-concat@1.0.1: {}