From 69488ec2383fb1028bf9e420beaebc2051d1de7e Mon Sep 17 00:00:00 2001 From: Jason Carter Date: Wed, 10 Jun 2026 19:28:26 +0800 Subject: [PATCH 01/12] [TASK-tsk_c356a0bab60eb6df7121dc1f] feat: IntegrationConfig + IntegrationRepo types and persistence layer - @markus/shared: Add integration.ts with FeishuConfigPayload, IntegrationConfig, NotificationForwardRule types - @markus/storage: Add IntegrationRepo interface + SqliteIntegrationRepo implementation - Add integrations table DDL with org+platform index - Export all new types and classes PR: https://github.com/markus-global/markus/pull/NNN --- packages/shared/src/index.ts | 1 + packages/shared/src/types/integration.ts | 114 +++++++++++++++++++++++ packages/storage/src/index.ts | 3 + packages/storage/src/sqlite-storage.ts | 113 ++++++++++++++++++++++ packages/storage/src/types.ts | 26 ++++++ 5 files changed, 257 insertions(+) create mode 100644 packages/shared/src/types/integration.ts 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..c9e486a3 --- /dev/null +++ b/packages/shared/src/types/integration.ts @@ -0,0 +1,114 @@ +/** + * 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'; + +// ─── 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; + /** 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..8edffd16 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 ──────────────────────────────────────────────────────────── @@ -4427,6 +4442,104 @@ export class SqliteStatusTransitionRepo { } } +// ─── 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; +} + +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 */ From 977d143b59db5616824ff27bd03fd95504037e36 Mon Sep 17 00:00:00 2001 From: Jason Carter Date: Wed, 10 Jun 2026 19:30:52 +0800 Subject: [PATCH 02/12] [TASK-tsk_c356a0bab60eb6df7121dc1f] fix: add IntegrationStatus type, deduplicate IntegrationRow --- packages/shared/src/types/integration.ts | 5 +++++ packages/storage/src/sqlite-storage.ts | 16 ++-------------- 2 files changed, 7 insertions(+), 14 deletions(-) diff --git a/packages/shared/src/types/integration.ts b/packages/shared/src/types/integration.ts index c9e486a3..5824de3a 100644 --- a/packages/shared/src/types/integration.ts +++ b/packages/shared/src/types/integration.ts @@ -10,6 +10,9 @@ /** 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 */ @@ -22,6 +25,8 @@ export interface IntegrationConfig { 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) */ diff --git a/packages/storage/src/sqlite-storage.ts b/packages/storage/src/sqlite-storage.ts index 8edffd16..48f8d25f 100644 --- a/packages/storage/src/sqlite-storage.ts +++ b/packages/storage/src/sqlite-storage.ts @@ -4244,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) {} @@ -4444,19 +4444,7 @@ export class SqliteStatusTransitionRepo { // ─── 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; -} +export type { IntegrationRow } from './types.ts'; export class SqliteIntegrationRepo { constructor(private db: DatabaseSync) {} From 0e91a93390b35565525f64b2cc9b8b8ba18f32a1 Mon Sep 17 00:00:00 2001 From: Jason Carter Date: Wed, 10 Jun 2026 21:27:09 +0800 Subject: [PATCH 03/12] [TASK-tsk_6d5ec60931afbe73609ac3a0] feat: add updateMessage/deleteMessage/sendReply to Feishu adapter + WebSocket enhancement --- packages/comms/src/feishu/adapter.ts | 192 +++++++++++++++++-- packages/comms/src/feishu/client.ts | 96 ++++++++-- packages/comms/test/feishu-adapter.test.ts | 163 ++++++++++++++++ packages/comms/test/feishu-client.test.ts | 213 +++++++++++++++++++++ 4 files changed, 637 insertions(+), 27 deletions(-) create mode 100644 packages/comms/test/feishu-adapter.test.ts create mode 100644 packages/comms/test/feishu-client.test.ts diff --git a/packages/comms/src/feishu/adapter.ts b/packages/comms/src/feishu/adapter.ts index 58dcacc7..6436bf49 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.on('open', () => { + log.info('Feishu WebSocket connected'); + }); + + this.ws.on('message', (raw: Buffer) => { + try { + const payload = JSON.parse(raw.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.on('close', (code: number, reason: Buffer) => { + log.warn(`Feishu WebSocket closed: code=${code}, reason=${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.on('error', (err: Error) => { + log.error('Feishu WebSocket error', { error: err.message }); + }); + + // 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'); + }); + }); +}); From e670cd6bf8f06dc435ddf81f9a8dfe83ee61d5a6 Mon Sep 17 00:00:00 2001 From: Jason Carter Date: Wed, 10 Jun 2026 21:42:56 +0800 Subject: [PATCH 04/12] [TASK-tsk_7bc948cb2a431312563622b0] feat: add Feishu integration config API endpoints (/api/settings/integrations/feishu) --- packages/org-manager/src/api-server.ts | 198 ++++++++- packages/org-manager/src/storage-bridge.ts | 2 + .../org-manager/test/integration-api.test.ts | 407 ++++++++++++++++++ 3 files changed, 606 insertions(+), 1 deletion(-) create mode 100644 packages/org-manager/test/integration-api.test.ts diff --git a/packages/org-manager/src/api-server.ts b/packages/org-manager/src/api-server.ts index 39be921c..d9c69d89 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, @@ -8907,6 +8907,197 @@ 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 { + const row = findFeishuConfig(auth.orgId); + this.json(res, 200, { config: row ?? null }); + } 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 payload: Record = { + id: 'feishu_default', + orgId: auth.orgId, + platform: 'feishu', + displayName: body['displayName'] ?? '飞书', + enabled: body['enabled'] !== false, + config: { + appId, + appSecret, + verificationToken: body['verificationToken'] ?? undefined, + encryptKey: body['encryptKey'] ?? undefined, + webhookPort: body['webhookPort'] ?? undefined, + domain: body['domain'] ?? undefined, + }, + 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); + } + 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, + }); + this.json(res, 200, { config: payload }); + } 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) { + this.storage?.integrationRepo?.delete(row['id'] as string); + } + 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/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, + }); + 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 +10991,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/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..11e2917b --- /dev/null +++ b/packages/org-manager/test/integration-api.test.ts @@ -0,0 +1,407 @@ +/** + * 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 { 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', () => { + 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); + }); + + describe('GET /api/settings/integrations/feishu', () => { + it('returns config when one exists', async () => { + await integrationRepo.create({ + id: 'feishu_default', + orgId: 'default', + platform: 'feishu', + displayName: '飞书', + enabled: true, + config: { appId: 'cli_a1111', appSecret: 'xxx' }, + }); + + 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['config']).toBeTruthy(); + expect((body['config'] as Record)['platform']).toBe('feishu'); + }); + + 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['config']).toBeNull(); + }); + }); + + 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); + const rows = integrationRepo.listByPlatform('default', 'feishu'); + expect(rows).toHaveLength(1); + expect((rows[0]['config'] as Record)['appId']).toBe('cli_a2222'); + }); + + it('updates existing config when already present', async () => { + await integrationRepo.create({ + id: 'feishu_default', + orgId: 'default', + platform: 'feishu', + displayName: '飞书', + enabled: true, + config: { appId: 'old_id', appSecret: 'old_secret' }, + }); + + 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); + const rows = integrationRepo.listByPlatform('default', 'feishu'); + expect(rows).toHaveLength(1); + expect((rows[0]['config'] as Record)['appId']).toBe('new_id'); + }); + }); + + 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'); + }); + }); + }); +}); From be09c758720e6b67c4fc59ae4bbcb482c63b7d9e Mon Sep 17 00:00:00 2001 From: Jason Carter Date: Wed, 10 Jun 2026 23:09:12 +0800 Subject: [PATCH 05/12] =?UTF-8?q?[TASK-tsk=5F150ac7d54c5ca577a77816bc]=20f?= =?UTF-8?q?ix:=20=E4=BF=AE=E5=A4=8D=202=20=E4=B8=AA=20CRITICAL=20=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C1: WebSocket 事件 API 不匹配 (adapter.ts:202) - Node 内置 WebSocket 使用 onopen/onmessage/onclose/onerror 属性 API - 将 EventEmitter .on('event', handler) 改为属性赋值模式 C2: DELETE 路由未 await (api-server.ts:9039) - integrationRepo?.delete() 返回 Promise 但未 await - 添加 await 确保删除完成后再返回 200 --- packages/comms/src/feishu/adapter.ts | 22 +++++++++++----------- packages/org-manager/src/api-server.ts | 2 +- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/comms/src/feishu/adapter.ts b/packages/comms/src/feishu/adapter.ts index 6436bf49..c84c31ee 100644 --- a/packages/comms/src/feishu/adapter.ts +++ b/packages/comms/src/feishu/adapter.ts @@ -201,23 +201,23 @@ export class FeishuAdapter implements CommAdapter { // Step 2: Connect WebSocket this.ws = new (globalThis as any).WebSocket(wsUrl); - this.ws.on('open', () => { + this.ws.onopen = () => { log.info('Feishu WebSocket connected'); - }); + }; - this.ws.on('message', (raw: Buffer) => { + this.ws.onmessage = (event: { data: Buffer }) => { try { - const payload = JSON.parse(raw.toString()) as FeishuEvent; + 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.on('close', (code: number, reason: Buffer) => { - log.warn(`Feishu WebSocket closed: code=${code}, reason=${reason.toString()}`); + 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) { @@ -227,11 +227,11 @@ export class FeishuAdapter implements CommAdapter { }); } }, 5000); - }); + }; - this.ws.on('error', (err: Error) => { - log.error('Feishu WebSocket error', { error: err.message }); - }); + this.ws.onerror = () => { + log.error('Feishu WebSocket error occurred'); + }; // Step 3: Heartbeat at 30s intervals (Feishu WS requirement) this.wsHeartbeatTimer = setInterval(() => { diff --git a/packages/org-manager/src/api-server.ts b/packages/org-manager/src/api-server.ts index d9c69d89..b57bcad6 100644 --- a/packages/org-manager/src/api-server.ts +++ b/packages/org-manager/src/api-server.ts @@ -8994,7 +8994,7 @@ EXPLANATION_END`; try { const row = findFeishuConfig(auth.orgId); if (row) { - this.storage?.integrationRepo?.delete(row['id'] as string); + await this.storage?.integrationRepo?.delete(row['id'] as string); } log.info('Feishu integration config deleted', { orgId: auth.orgId }); this.auditService?.record({ From f0e743bd057e2eee65c2b61a075a22a3bdcfb489 Mon Sep 17 00:00:00 2001 From: Jason Carter Date: Wed, 10 Jun 2026 23:08:14 +0800 Subject: [PATCH 06/12] =?UTF-8?q?[TASK-tsk=5F5f8d433055b23f2d1bf1891e][Fro?= =?UTF-8?q?ntend]=20fix:=20rename=20verifyToken=20=E2=86=92=20verification?= =?UTF-8?q?Token=20in=20feishu=20integration=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix UI-API field name mismatch: API expects 'verificationToken' but UI was sending 'verifyToken'. Renamed across all 4 files: - api.ts: type definitions in getFeishuIntegration + saveFeishuIntegration - FeishuIntegrationSection.tsx: interface, state, form field, i18n key - en/settings.json + zh-CN/settings.json: i18n key renamed --- packages/web-ui/src/api.ts | 15 + .../components/FeishuIntegrationSection.tsx | 515 ++++++++++++++++++ packages/web-ui/src/locales/en/settings.json | 34 ++ .../web-ui/src/locales/zh-CN/settings.json | 34 ++ 4 files changed, 598 insertions(+) create mode 100644 packages/web-ui/src/components/FeishuIntegrationSection.tsx diff --git a/packages/web-ui/src/api.ts b/packages/web-ui/src/api.ts index acb48bc7..dbac606e 100644 --- a/packages/web-ui/src/api.ts +++ b/packages/web-ui/src/api.ts @@ -1395,6 +1395,21 @@ 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; verificationToken?: string; encryptKey?: string; + webhookPath?: string; enabled: boolean; connected: boolean; + notifyOnApproval: boolean; notifyOnNotification: boolean; notifyPriority: string[]; + }>('/settings/integrations/feishu'), + saveFeishuIntegration: (config: { + appId: string; appSecret: string; verificationToken?: string; encryptKey?: string; + webhookPath?: string; enabled?: boolean; + 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) }), + deleteFeishuIntegration: () => request<{ ok: boolean }>('/settings/integrations/feishu', { method: 'DELETE' }), }, 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..fc64c265 --- /dev/null +++ b/packages/web-ui/src/components/FeishuIntegrationSection.tsx @@ -0,0 +1,515 @@ +import { useEffect, useState, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { api } from '../api.ts'; + +export interface FeishuConfig { + appId: string; + appSecret: string; + verificationToken?: string; + encryptKey?: string; + webhookPath?: string; + enabled: boolean; + connected: boolean; + notifyOnApproval: boolean; + notifyOnNotification: boolean; + notifyPriority: string[]; +} + +const DEFAULT_CONFIG: FeishuConfig = { + appId: '', + appSecret: '', + verificationToken: '', + encryptKey: '', + webhookPath: '/webhook/feishu', + enabled: false, + connected: false, + notifyOnApproval: true, + notifyOnNotification: false, + notifyPriority: ['high', 'urgent'], +}; + +const PRIORITY_OPTIONS = [ + { 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} +
+ ); +} + +export function FeishuIntegrationSection() { + const { t } = useTranslation(['settings', 'common']); + + const [config, setConfig] = useState(DEFAULT_CONFIG); + const [dirty, setDirty] = useState(false); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [testing, setTesting] = useState(false); + const [msg, setMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null); + + // Show/hide secret values + const [showAppSecret, setShowAppSecret] = useState(false); + const [showEncryptKey, setShowEncryptKey] = useState(false); + + const loadConfig = useCallback(async () => { + setLoading(true); + try { + const data = await api.settings.getFeishuIntegration(); + if (data) { + setConfig({ + appId: data.appId ?? '', + appSecret: data.appSecret ?? '', + verificationToken: data.verificationToken ?? '', + encryptKey: data.encryptKey ?? '', + webhookPath: data.webhookPath ?? '/webhook/feishu', + enabled: data.enabled ?? false, + connected: data.connected ?? false, + notifyOnApproval: data.notifyOnApproval ?? true, + notifyOnNotification: data.notifyOnNotification ?? false, + notifyPriority: data.notifyPriority ?? ['high', 'urgent'], + }); + } + } catch { + // Not configured yet — use defaults + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { loadConfig(); }, [loadConfig]); + + const updateField = (key: K, value: FeishuConfig[K]) => { + setConfig(prev => ({ ...prev, [key]: value })); + setDirty(true); + setMsg(null); + }; + + const togglePriority = (priority: string) => { + setConfig(prev => { + const current = prev.notifyPriority; + const next = current.includes(priority) + ? current.filter(p => p !== priority) + : [...current, priority]; + return { ...prev, notifyPriority: next }; + }); + setDirty(true); + setMsg(null); + }; + + const handleSave = async () => { + setSaving(true); + setMsg(null); + try { + const result = await api.settings.saveFeishuIntegration({ + appId: config.appId, + appSecret: config.appSecret, + verificationToken: config.verificationToken || undefined, + encryptKey: config.encryptKey || undefined, + webhookPath: config.webhookPath || '/webhook/feishu', + enabled: config.enabled, + notifyOnApproval: config.notifyOnApproval, + notifyOnNotification: config.notifyOnNotification, + notifyPriority: config.notifyPriority, + }); + if (result.connected !== undefined) { + setConfig(prev => ({ ...prev, connected: result.connected })); + } + setDirty(false); + setMsg({ type: 'ok', text: t('settings:feishu.saved', { defaultValue: 'Feishu configuration saved' }) }); + } catch (err) { + setMsg({ type: 'err', text: String(err instanceof Error ? err.message : err) }); + } finally { + setSaving(false); + } + }; + + 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 handleDisconnect = async () => { + setSaving(true); + setMsg(null); + try { + await api.settings.deleteFeishuIntegration(); + setConfig(DEFAULT_CONFIG); + setDirty(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...' })} +
+
+ ); + } + + return ( +
+ {/* Header — Connection status + Feishu branding */} +
+
+
+ + + +
+
+

飞书 (Feishu / Lark)

+

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

+
+
+ +
+ + {/* Connection Settings */} +
+
+ {/* App ID */} +
+ + updateField('appId', e.target.value)} + 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" + /> +
+ + {/* App Secret */} +
+ +
+ updateField('appSecret', e.target.value)} + 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" + /> + +
+
+ + {/* Verify Token */} +
+ + updateField('verificationToken', e.target.value)} + placeholder="Event verification token from Feishu" + 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" + /> +
+ + {/* Encrypt Key */} +
+ +
+ updateField('encryptKey', e.target.value)} + placeholder="AES encryption key for event decryption" + 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" + /> + +
+
+ + {/* Webhook Path */} +
+ + updateField('webhookPath', e.target.value)} + placeholder="/webhook/feishu" + 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" + /> +

+ {t('settings:feishu.webhookPathHint', { defaultValue: 'Set this path in your Feishu app event subscription' })} +

+
+
+
+ + {/* Test & Save Actions */} +
+
+ + + + + {config.connected && config.enabled && ( + + )} +
+ + {msg && } +
+ + {/* Enable / Disable Toggle */} +
+
+
+
+
+ {t('settings:feishu.enableIntegration', { defaultValue: 'Enable Feishu Integration' })} +
+
+ {t('settings:feishu.enableHint', { defaultValue: 'When enabled, Markus will connect to Feishu and start processing events' })} +
+
+ +
+
+
+ + {/* Notification Forwarding Settings */} +
+
+ {/* Toggle switches */} +
+ {/* 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' })} +

+
+ )} +
+
+ + {/* Setup Guide */} +
+
+

+ {t('settings:feishu.setupGuideDesc', { defaultValue: 'Follow these steps to set up the Feishu integration:' })} +

+
    +
  1. {t('settings:feishu.guideStep1', { defaultValue: 'Go to Feishu Open Platform (open.feishu.cn) and create a new app' })}
  2. +
  3. {t('settings:feishu.guideStep2', { defaultValue: 'Enable bot capabilities and configure event subscriptions' })}
  4. +
  5. {t('settings:feishu.guideStep3', { defaultValue: 'Copy the App ID and App Secret into the fields above' })}
  6. +
  7. {t('settings:feishu.guideStep4', { defaultValue: 'Set the webhook URL in your Feishu app event subscription' })}
  8. +
  9. {t('settings:feishu.guideStep5', { defaultValue: 'Click "Test Connection" to verify the setup' })}
  10. +
+
+
+
+ ); +} diff --git a/packages/web-ui/src/locales/en/settings.json b/packages/web-ui/src/locales/en/settings.json index 3b1a81e8..76e842cb 100644 --- a/packages/web-ui/src/locales/en/settings.json +++ b/packages/web-ui/src/locales/en/settings.json @@ -600,5 +600,39 @@ "roleUpdated": "Role updated", "yourRole": "Your Role", "changeRole": "Change Role" + }, + "feishu": { + "description": "Configure Feishu integration for notifications and approvals", + "connectionSettings": "Connection Settings", + "verificationToken": "Verify Token", + "encryptKey": "Encrypt Key", + "optional": "optional", + "webhookPath": "Webhook Path", + "webhookPathHint": "Set this path in your Feishu app event subscription", + "actions": "Actions", + "testConnection": "Test Connection", + "integrationState": "Integration State", + "enableIntegration": "Enable Feishu Integration", + "enableHint": "When enabled, Markus will connect to Feishu and start processing events", + "notificationForwarding": "Notification Forwarding", + "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 a new app", + "guideStep2": "Enable bot capabilities and configure event subscriptions", + "guideStep3": "Copy the App ID and App Secret into the fields above", + "guideStep4": "Set the webhook URL in your Feishu app event subscription", + "guideStep5": "Click \"Test Connection\" to verify the setup", + "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..3825c152 100644 --- a/packages/web-ui/src/locales/zh-CN/settings.json +++ b/packages/web-ui/src/locales/zh-CN/settings.json @@ -600,5 +600,39 @@ "roleUpdated": "角色已更新", "yourRole": "你的角色", "changeRole": "更改角色" + }, + "feishu": { + "description": "配置飞书集成,用于接收通知和审批请求", + "connectionSettings": "连接设置", + "verificationToken": "验证令牌", + "encryptKey": "加密密钥", + "optional": "可选", + "webhookPath": "Webhook 路径", + "webhookPathHint": "在飞书应用的事件订阅中设置此路径", + "actions": "操作", + "testConnection": "测试连接", + "integrationState": "集成状态", + "enableIntegration": "启用飞书集成", + "enableHint": "启用后,Markus 将连接到飞书并开始处理事件", + "notificationForwarding": "通知转发", + "forwardApprovals": "转发审批请求", + "forwardApprovalsHint": "将审批请求以交互卡片形式发送到飞书", + "forwardNotifications": "转发一般通知", + "forwardNotificationsHint": "将系统通知转发到飞书聊天", + "notifyPriority": "最低通知优先级", + "priorityHint": "仅选择优先级级别及以上的通知会被转发", + "setupGuide": "设置指南", + "setupGuideDesc": "按以下步骤设置飞书集成:", + "guideStep1": "前往飞书开放平台 (open.feishu.cn) 创建新应用", + "guideStep2": "启用机器人能力并配置事件订阅", + "guideStep3": "将 App ID 和 App Secret 填入上方字段", + "guideStep4": "在飞书应用事件订阅中设置 Webhook URL", + "guideStep5": "点击「测试连接」验证设置", + "saved": "飞书配置已保存", + "fillRequired": "请先填写 App ID 和 App Secret", + "testSuccess": "连接成功", + "testFailed": "连接失败", + "disconnected": "已断开飞书连接", + "disconnect": "断开连接" } } From 6a56854eb60a8a8fd0432f8ba86323728e34a9e0 Mon Sep 17 00:00:00 2001 From: Jason Carter Date: Wed, 10 Jun 2026 23:42:28 +0800 Subject: [PATCH 07/12] [tsk_c687d596] feat: re-implement FeishuNotifier with api-server integration - Add FeishuApiClient: minimal HTTP client for Feishu Open API - Add FeishuNotifier: subscribes to EventBus + HITL notifications, routes via configurable forward rules, sends Feishu cards - Wire FeishuNotifier into api-server: - tryInitFeishuNotifier() loads config from integrations table - updateFeishuConfig() enables runtime config updates - Integrated with setHITLService, setStorage, start, stop - POST integration endpoint updates notifier config - PUT notification rules endpoint updates notifier rules - All 722 tests pass, typecheck clean --- packages/org-manager/src/api-server.ts | 74 ++++ packages/org-manager/src/feishu-api-client.ts | 100 +++++ packages/org-manager/src/feishu-notifier.ts | 365 ++++++++++++++++++ 3 files changed, 539 insertions(+) create mode 100644 packages/org-manager/src/feishu-api-client.ts create mode 100644 packages/org-manager/src/feishu-notifier.ts diff --git a/packages/org-manager/src/api-server.ts b/packages/org-manager/src/api-server.ts index b57bcad6..c20d29e3 100644 --- a/packages/org-manager/src/api-server.ts +++ b/packages/org-manager/src/api-server.ts @@ -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; @@ -595,10 +597,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 +1462,55 @@ 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(); + + // Load Feishu integration config from storage (integrations table) + 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 (cfgConfig?.appId && cfgConfig?.appSecret) { + initialConfig = { + appId: cfgConfig.appId as string, + appSecret: cfgConfig.appSecret as string, + domain: cfgConfig.domain as string | undefined, + 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'); + } catch (err) { + log.warn('Failed to initialize FeishuNotifier', { error: String(err) }); + } } getWSBroadcaster(): WSBroadcaster { @@ -8980,6 +9037,13 @@ EXPLANATION_END`; userId: auth.userId, success: true, }); + // Update the FeishuNotifier runtime config + this.updateFeishuConfig({ + appId, + appSecret: appSecret, + domain: body['domain'] as string | undefined, + forwardRules: [], + }); this.json(res, 200, { config: payload }); } catch (e) { log.error('Failed to save feishu integration config', { error: String(e) }); @@ -9078,6 +9142,16 @@ EXPLANATION_END`; lastVerifiedAt: row['lastVerifiedAt'] ?? null, lastError: row['lastError'] ?? null, }); + // Update the FeishuNotifier runtime config with new rules + const cfgConfig = row['config'] as Record | undefined; + if (cfgConfig?.appId && cfgConfig?.appSecret && this.feishuNotifier) { + this.feishuNotifier.updateConfig({ + appId: cfgConfig.appId as string, + appSecret: cfgConfig.appSecret as string, + 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, 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..ab319f3e --- /dev/null +++ b/packages/org-manager/src/feishu-api-client.ts @@ -0,0 +1,100 @@ +import { createLogger } from '@markus/shared'; + +const log = createLogger('feishu-api-client'); + +/** Minimal HTTP client for the Feishu Open API — used by FeishuNotifier. */ +export class FeishuApiClient { + private token: string | null = null; + private tokenExpiresAt = 0; + 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'; + } + + /** Ensure a valid tenant access token is cached. */ + private async ensureToken(): Promise { + // 5 min buffer before expiry + if (this.token && Date.now() < this.tokenExpiresAt - 300_000) { + return this.token; + } + 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 }), + }); + if (!resp.ok) { + const text = await resp.text(); + throw new Error(`Feishu auth failed (${resp.status}): ${text}`); + } + const data = (await resp.json()) as { tenant_access_token: string; expire: number }; + this.token = data.tenant_access_token; + this.tokenExpiresAt = Date.now() + data.expire * 1000; + log.info('Feishu tenant token refreshed'); + return this.token!; + } + + /** Send a text message to a Feishu chat by chat_id. */ + async sendText(chatId: string, text: string): Promise { + const token = await this.ensureToken(); + const body = { + receive_id: chatId, + msg_type: 'text', + content: JSON.stringify({ text }), + }; + const resp = await fetch( + `${this.domain}/open-apis/im/v1/messages?receive_id_type=chat_id`, + { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }, + ); + if (!resp.ok) { + const text = await resp.text(); + throw new Error(`Feishu sendText failed (${resp.status}): ${text}`); + } + } + + /** Send an interactive card to a Feishu chat by chat_id. */ + async sendCard(chatId: string, card: Record): Promise { + const token = await this.ensureToken(); + const body = { + receive_id: chatId, + msg_type: 'interactive', + content: JSON.stringify(card), + }; + const resp = await fetch( + `${this.domain}/open-apis/im/v1/messages?receive_id_type=chat_id`, + { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }, + ); + if (!resp.ok) { + const text = await resp.text(); + throw new Error(`Feishu sendCard failed (${resp.status}): ${text}`); + } + } + + /** Clear the cached token (e.g. on config update). */ + clearToken(): void { + this.token = null; + this.tokenExpiresAt = 0; + } +} diff --git a/packages/org-manager/src/feishu-notifier.ts b/packages/org-manager/src/feishu-notifier.ts new file mode 100644 index 00000000..288fc21a --- /dev/null +++ b/packages/org-manager/src/feishu-notifier.ts @@ -0,0 +1,365 @@ +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'; +} + +export interface NotificationForwardRule { + id: string; + name: string; + enabled: boolean; + type: string; + priorityFilter: string; + targets: ForwardTarget[]; + keywordFilter?: string; + includeApprovalActions?: boolean; +} + +export interface FeishuNotifierConfig { + appId: string; + appSecret: string; + domain?: 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:removed': 'notification', +}; + +// ── Card Building Helpers ─────────────────────────────────────────── + +/** Build a Feishu interactive card from notification data. */ +function buildNotificationCard( + title: string, + body: string, + priority: string, + eventType: string, + metadata?: Record, +): Record { + const color = priority === 'urgent' ? 'red' : priority === 'high' ? 'orange' : 'blue'; + const elements: Record[] = [ + { + tag: 'markdown', + content: body, + }, + { + tag: 'hr', + }, + { + tag: 'note', + elements: [ + { + tag: 'plain_text', + content: `Type: ${eventType} | ${new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}`, + }, + ], + }, + ]; + + // Add action buttons for approval events + if (metadata?.approvalId) { + elements.push({ + tag: 'action', + actions: [ + { + tag: 'button', + text: { tag: 'plain_text', content: '✅ Approve' }, + type: 'primary', + value: { action: 'approve', approval_id: metadata.approvalId as string }, + }, + { + tag: 'button', + text: { tag: 'plain_text', content: '❌ Reject' }, + type: 'danger', + value: { action: 'reject', approval_id: metadata.approvalId as string }, + }, + ], + }); + } + + return { + config: { wide_screen_mode: true }, + header: { title: { tag: 'plain_text', content: title }, template: color }, + elements, + }; +} + +function buildSimpleCard(title: string, body: string, priority: string, eventType: string): Record { + return buildNotificationCard(title, body, priority, eventType, undefined); +} + +// ── FeishuNotifier ────────────────────────────────────────────────── + +export class FeishuNotifier { + private eventBus: EventBus; + private hitlService: HITLService; + private orgId: string; + 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; + + constructor(opts: { + eventBus: EventBus; + hitlService: HITLService; + orgId: string; + agentManager?: { getAgentName?: (id: string) => string | undefined }; + /** Optional initial config — can be set later via updateConfig(). */ + 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; + } + } + + /** Start listening — subscribe to EventBus events and HITL notifications. */ + start(): void { + // Subscribe to EventBus events + 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); + } + + // Subscribe to HITL notifications + this.hitlUnsubscribe = this.hitlService.onNotification((notification: HITLNotification) => { + this.handleHITLNotification(notification).catch((err) => { + log.error('Failed to handle HITL notification', { error: String(err) }); + }); + }); + + log.info('FeishuNotifier started'); + } + + /** 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; + } + this.apiClient = null; + this.config = null; + log.info('FeishuNotifier stopped'); + } + + /** Update the Feishu integration config at runtime (e.g. when settings are saved). */ + updateConfig(config: FeishuNotifierConfig): void { + this.config = config; + if (config.appId && config.appSecret) { + if (!this.apiClient) { + this.apiClient = new FeishuApiClient({ + appId: config.appId, + appSecret: config.appSecret, + domain: config.domain, + }); + } else { + this.apiClient.clearToken(); + } + } else { + this.apiClient = null; + } + log.info('FeishuNotifier config updated'); + } + + /** 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'; + + switch (eventName) { + case 'task:completed': { + const agentName = payload['agentId'] + ? this.agentManager?.getAgentName?.(payload['agentId'] as string) ?? payload['agentId'] as string + : 'Unknown'; + title = '✅ 任务完成'; + body = `Agent: ${agentName}\nTask ID: ${payload['taskId'] as string}`; + break; + } + case 'system:announcement': { + title = '📢 系统公告'; + body = (payload['content'] as string) ?? payload['message'] as string ?? ''; + priority = payload['priority'] as string ?? 'high'; + break; + } + case 'agent:started': { + title = '▶️ Agent 启动'; + body = `Agent: ${payload['agentId'] as string}`; + break; + } + case 'agent:stopped': { + title = '⏹️ Agent 停止'; + body = `Agent: ${payload['agentId'] as string}`; + priority = 'high'; + break; + } + case 'agent:paused': { + title = '⏸️ Agent 暂停'; + const reason = payload['reason'] as string | undefined; + body = `Agent: ${payload['agentId'] as string}${reason ? `\n原因: ${reason}` : ''}`; + priority = 'high'; + break; + } + case 'agent:resumed': { + title = '▶️ Agent 恢复'; + body = `Agent: ${payload['agentId'] as string}`; + break; + } + case 'agent:created': { + title = '🆕 Agent 创建'; + body = `Agent: ${(payload['name'] as string) ?? payload['agentId'] as string}`; + break; + } + case 'agent:removed': { + title = '🗑️ Agent 删除'; + body = `Agent ID: ${payload['agentId'] as string}`; + 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; + } + + 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); + } + + // 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 = rules.some( + r => r.type === eventType && r.enabled && r.includeApprovalActions && metadata?.approvalId, + ); + + const card = includeActions + ? buildNotificationCard(title, body, priority, eventType, metadata) + : buildSimpleCard(title, body, priority, eventType); + + for (const target of uniqueTargets) { + try { + 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 { + await this.apiClient!.sendCard(target.channelId, card); + } + } catch (err) { + log.error('Failed to send Feishu notification', { + channelId: target.channelId, + error: String(err), + }); + } + } + } +} From 40273a10159c7332a6a1803944fcd91b98092e1d Mon Sep 17 00:00:00 2001 From: Jason Carter Date: Thu, 11 Jun 2026 18:34:42 +0800 Subject: [PATCH 08/12] feat(feishu): route messages to Secretary agent, fix notification forwarding, improve setup guide - Wire feishu:message_received event to Secretary agent with reply-back - Fix notification forwarding: add notifyChatId fallback when no forwardRules configured - Fix WSClient.start() not being awaited (async connection) - Fix sender ID extraction from correct top-level position in event data - Remove unsupported card.action.trigger from EventDispatcher - Add notifyChatId field to config, API, and frontend UI - Add diagnostic logging for event debugging - Improve setup guide with detailed 8-step instructions - Add normal priority level to notification filter options Co-authored-by: Cursor --- packages/org-manager/package.json | 1 + packages/org-manager/src/api-server.ts | 134 +++++++- packages/org-manager/src/feishu-api-client.ts | 148 ++++---- packages/org-manager/src/feishu-notifier.ts | 186 +++++++++- packages/web-ui/src/api.ts | 11 +- .../components/FeishuIntegrationSection.tsx | 320 ++++++++++-------- packages/web-ui/src/locales/en/settings.json | 25 +- .../web-ui/src/locales/zh-CN/settings.json | 25 +- packages/web-ui/src/pages/Settings.tsx | 6 +- pnpm-lock.yaml | 178 ++++++++++ 10 files changed, 785 insertions(+), 249 deletions(-) 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 c20d29e3..7628e7be 100644 --- a/packages/org-manager/src/api-server.ts +++ b/packages/org-manager/src/api-server.ts @@ -1491,6 +1491,10 @@ export class APIServer { appId: cfgConfig.appId as string, appSecret: cfgConfig.appSecret as string, domain: cfgConfig.domain as string | undefined, + notifyChatId: cfgConfig.notifyChatId 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'], }; } @@ -1508,11 +1512,68 @@ export class APIServer { }); 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 { return this.ws; } @@ -8979,7 +9040,22 @@ EXPLANATION_END`; if (!auth) return; try { const row = findFeishuConfig(auth.orgId); - this.json(res, 200, { config: row ?? null }); + if (!row) { + this.json(res, 200, { appId: '', appSecret: '', enabled: false, connected: false, notifyChatId: '', notifyOnApproval: true, notifyOnNotification: false, notifyPriority: ['high', 'urgent'] }); + return; + } + const cfg = (row['config'] as Record) ?? {}; + const connected = !!(this.feishuNotifier?.connected); + this.json(res, 200, { + appId: cfg['appId'] ?? '', + appSecret: cfg['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' }); @@ -8998,19 +9074,22 @@ EXPLANATION_END`; 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: body['enabled'] !== false, + enabled, config: { appId, appSecret, - verificationToken: body['verificationToken'] ?? undefined, - encryptKey: body['encryptKey'] ?? undefined, - webhookPort: body['webhookPort'] ?? undefined, 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, @@ -9042,9 +9121,14 @@ EXPLANATION_END`; appId, appSecret: appSecret, domain: body['domain'] as string | 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: [], }); - this.json(res, 200, { config: payload }); + 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' }); @@ -9106,6 +9190,44 @@ EXPLANATION_END`; 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 { + const row = findFeishuConfig(auth.orgId); + const cfg = (row?.['config'] as Record) ?? {}; + const appId = cfg['appId'] as string; + const appSecret = cfg['appSecret'] as string; + 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/notifications' && req.method === 'GET') { const auth = await this.requireAuth(req, res); if (!auth) return; diff --git a/packages/org-manager/src/feishu-api-client.ts b/packages/org-manager/src/feishu-api-client.ts index ab319f3e..a636f609 100644 --- a/packages/org-manager/src/feishu-api-client.ts +++ b/packages/org-manager/src/feishu-api-client.ts @@ -1,11 +1,15 @@ +import * as Lark from '@larksuiteoapi/node-sdk'; import { createLogger } from '@markus/shared'; const log = createLogger('feishu-api-client'); -/** Minimal HTTP client for the Feishu Open API — used by FeishuNotifier. */ +/** + * 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 token: string | null = null; - private tokenExpiresAt = 0; + private client: Lark.Client; + private wsClient: Lark.WSClient | null = null; private appId: string; private appSecret: string; private domain: string; @@ -18,83 +22,103 @@ export class FeishuApiClient { 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; } - /** Ensure a valid tenant access token is cached. */ - private async ensureToken(): Promise { - // 5 min buffer before expiry - if (this.token && Date.now() < this.tokenExpiresAt - 300_000) { - return this.token; + /** + * 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(); } - 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 }), + 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 }); + }, }); - if (!resp.ok) { - const text = await resp.text(); - throw new Error(`Feishu auth failed (${resp.status}): ${text}`); - } - const data = (await resp.json()) as { tenant_access_token: string; expire: number }; - this.token = data.tenant_access_token; - this.tokenExpiresAt = Date.now() + data.expire * 1000; - log.info('Feishu tenant token refreshed'); - return this.token!; + 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 { - const token = await this.ensureToken(); - const body = { - receive_id: chatId, - msg_type: 'text', - content: JSON.stringify({ text }), - }; - const resp = await fetch( - `${this.domain}/open-apis/im/v1/messages?receive_id_type=chat_id`, - { - method: 'POST', - headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json', + 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 }), }, - body: JSON.stringify(body), - }, - ); - if (!resp.ok) { - const text = await resp.text(); - throw new Error(`Feishu sendText failed (${resp.status}): ${text}`); + }); + } catch (err) { + log.error('Feishu sendText failed', { chatId, error: String(err) }); + throw err; } } /** Send an interactive card to a Feishu chat by chat_id. */ async sendCard(chatId: string, card: Record): Promise { - const token = await this.ensureToken(); - const body = { - receive_id: chatId, - msg_type: 'interactive', - content: JSON.stringify(card), - }; - const resp = await fetch( - `${this.domain}/open-apis/im/v1/messages?receive_id_type=chat_id`, - { - method: 'POST', - headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json', + try { + await this.client.im.v1.message.create({ + params: { receive_id_type: 'chat_id' }, + data: { + receive_id: chatId, + msg_type: 'interactive', + content: JSON.stringify(card), }, - body: JSON.stringify(body), - }, - ); - if (!resp.ok) { - const text = await resp.text(); - throw new Error(`Feishu sendCard failed (${resp.status}): ${text}`); + }); + } catch (err) { + log.error('Feishu sendCard failed', { chatId, error: String(err) }); + throw err; + } + } + + /** 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 the cached token (e.g. on config update). */ + /** Clear internal state (for config updates). */ clearToken(): void { - this.token = null; - this.tokenExpiresAt = 0; + // 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 index 288fc21a..407a0a84 100644 --- a/packages/org-manager/src/feishu-notifier.ts +++ b/packages/org-manager/src/feishu-notifier.ts @@ -1,3 +1,4 @@ +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'; @@ -27,6 +28,10 @@ export interface FeishuNotifierConfig { appId: string; appSecret: string; domain?: string; + notifyChatId?: string; + notifyOnApproval?: boolean; + notifyOnNotification?: boolean; + notifyPriority?: string[]; forwardRules: NotificationForwardRule[]; } @@ -72,7 +77,6 @@ function buildNotificationCard( }, ]; - // Add action buttons for approval events if (metadata?.approvalId) { elements.push({ tag: 'action', @@ -115,13 +119,13 @@ export class FeishuNotifier { private config: FeishuNotifierConfig | null = null; private unsubscribes: Array<() => void> = []; private hitlUnsubscribe: (() => void) | null = null; + private wsConnected = false; constructor(opts: { eventBus: EventBus; hitlService: HITLService; orgId: string; agentManager?: { getAgentName?: (id: string) => string | undefined }; - /** Optional initial config — can be set later via updateConfig(). */ config?: FeishuNotifierConfig; }) { this.eventBus = opts.eventBus; @@ -133,9 +137,13 @@ export class FeishuNotifier { } } - /** Start listening — subscribe to EventBus events and HITL notifications. */ + /** 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 { - // Subscribe to EventBus events for (const [eventName] of Object.entries(EVENT_MAP)) { const unsub = this.eventBus.on(eventName, (...args: unknown[]) => { this.handleEventBusEvent(eventName, args).catch((err) => { @@ -145,13 +153,18 @@ export class FeishuNotifier { this.unsubscribes.push(unsub); } - // Subscribe to HITL notifications 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).catch((err) => { + log.error('Failed to start Feishu long connection on init', { error: String(err) }); + }); + } + log.info('FeishuNotifier started'); } @@ -165,8 +178,12 @@ export class FeishuNotifier { 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'); } @@ -174,21 +191,137 @@ export class FeishuNotifier { updateConfig(config: FeishuNotifierConfig): void { this.config = config; if (config.appId && config.appSecret) { - if (!this.apiClient) { - this.apiClient = new FeishuApiClient({ - appId: config.appId, - appSecret: config.appSecret, - domain: config.domain, - }); - } else { - this.apiClient.clearToken(); + if (this.apiClient) { + this.apiClient.stopWSClient(); } + this.apiClient = new FeishuApiClient({ + appId: config.appId, + appSecret: config.appSecret, + domain: config.domain, + }); + this.startLongConnection(config).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) }); + }); + }, + }); + + // 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; + 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; + 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). */ + private handleCardAction(data: unknown): Record { + const action = data as { + action?: { value?: { action?: string; approval_id?: string } }; + open_id?: string; + }; + + const actionValue = action?.action?.value; + if (actionValue?.approval_id) { + log.info('Feishu card action', { action: actionValue.action, approvalId: actionValue.approval_id }); + this.eventBus.emit('feishu:card_action', { + action: actionValue.action, + approvalId: actionValue.approval_id, + openId: action?.open_id, + }); + } + + return { + toast: { + type: 'success', + content: actionValue?.action === 'approve' ? '已批准' : '已拒绝', + }, + }; + } + /** Handle an EventBus event. */ private async handleEventBusEvent(eventName: string, args: unknown[]): Promise { if (!this.apiClient || !this.config) return; @@ -322,6 +455,21 @@ export class FeishuNotifier { matchedTargets.push(...rule.targets); } + // Fallback: use notifyChatId from simplified config if no explicit rules matched + if (matchedTargets.length === 0 && this.config.notifyChatId) { + const isApprovalType = ['approval_request', '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('*')) { + matchedTargets.push({ type: 'chat', channelId: this.config.notifyChatId }); + } + } + } + // Deduplicate by channelId const seen = new Set(); const uniqueTargets = matchedTargets.filter(t => { @@ -332,9 +480,9 @@ export class FeishuNotifier { if (uniqueTargets.length === 0) return; - const includeActions = rules.some( - r => r.type === eventType && r.enabled && r.includeApprovalActions && metadata?.approvalId, - ); + const includeActions = metadata?.approvalId + ? rules.some(r => r.type === eventType && r.enabled && r.includeApprovalActions) || true + : false; const card = includeActions ? buildNotificationCard(title, body, priority, eventType, metadata) @@ -362,4 +510,10 @@ export class FeishuNotifier { } } } + + /** 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/web-ui/src/api.ts b/packages/web-ui/src/api.ts index dbac606e..2b00c4bf 100644 --- a/packages/web-ui/src/api.ts +++ b/packages/web-ui/src/api.ts @@ -1396,19 +1396,22 @@ export const api = { 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; verificationToken?: string; encryptKey?: string; - webhookPath?: string; enabled: boolean; connected: boolean; + 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; verificationToken?: string; encryptKey?: string; - webhookPath?: string; enabled?: boolean; + 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' }), }, modelCatalog: { diff --git a/packages/web-ui/src/components/FeishuIntegrationSection.tsx b/packages/web-ui/src/components/FeishuIntegrationSection.tsx index fc64c265..81d02622 100644 --- a/packages/web-ui/src/components/FeishuIntegrationSection.tsx +++ b/packages/web-ui/src/components/FeishuIntegrationSection.tsx @@ -1,15 +1,13 @@ -import { useEffect, useState, useCallback } from 'react'; +import { useEffect, useState, useCallback, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { api } from '../api.ts'; export interface FeishuConfig { appId: string; appSecret: string; - verificationToken?: string; - encryptKey?: string; - webhookPath?: string; enabled: boolean; connected: boolean; + notifyChatId: string; notifyOnApproval: boolean; notifyOnNotification: boolean; notifyPriority: string[]; @@ -18,17 +16,16 @@ export interface FeishuConfig { const DEFAULT_CONFIG: FeishuConfig = { appId: '', appSecret: '', - verificationToken: '', - encryptKey: '', - webhookPath: '/webhook/feishu', 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' }, @@ -86,15 +83,18 @@ export function FeishuIntegrationSection() { const { t } = useTranslation(['settings', 'common']); const [config, setConfig] = useState(DEFAULT_CONFIG); - const [dirty, setDirty] = useState(false); 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); - - // Show/hide secret values const [showAppSecret, setShowAppSecret] = useState(false); - const [showEncryptKey, setShowEncryptKey] = useState(false); + const [testChatId, setTestChatId] = useState(''); + + const saveTimer = useRef | null>(null); + const configRef = useRef(config); + configRef.current = config; + const initialLoadDone = useRef(false); const loadConfig = useCallback(async () => { setLoading(true); @@ -104,11 +104,9 @@ export function FeishuIntegrationSection() { setConfig({ appId: data.appId ?? '', appSecret: data.appSecret ?? '', - verificationToken: data.verificationToken ?? '', - encryptKey: data.encryptKey ?? '', - webhookPath: data.webhookPath ?? '/webhook/feishu', enabled: data.enabled ?? false, connected: data.connected ?? false, + notifyChatId: data.notifyChatId ?? '', notifyOnApproval: data.notifyOnApproval ?? true, notifyOnNotification: data.notifyOnNotification ?? false, notifyPriority: data.notifyPriority ?? ['high', 'urgent'], @@ -118,15 +116,60 @@ export function FeishuIntegrationSection() { // Not configured yet — use defaults } finally { setLoading(false); + initialLoadDone.current = true; } }, []); useEffect(() => { loadConfig(); }, [loadConfig]); + 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 })); - setDirty(true); 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) => { @@ -135,37 +178,15 @@ export function FeishuIntegrationSection() { const next = current.includes(priority) ? current.filter(p => p !== priority) : [...current, priority]; - return { ...prev, notifyPriority: next }; + const updated = { ...prev, notifyPriority: next }; + configRef.current = updated; + return updated; }); - setDirty(true); setMsg(null); - }; - - const handleSave = async () => { - setSaving(true); - setMsg(null); - try { - const result = await api.settings.saveFeishuIntegration({ - appId: config.appId, - appSecret: config.appSecret, - verificationToken: config.verificationToken || undefined, - encryptKey: config.encryptKey || undefined, - webhookPath: config.webhookPath || '/webhook/feishu', - enabled: config.enabled, - notifyOnApproval: config.notifyOnApproval, - notifyOnNotification: config.notifyOnNotification, - notifyPriority: config.notifyPriority, - }); - if (result.connected !== undefined) { - setConfig(prev => ({ ...prev, connected: result.connected })); - } - setDirty(false); - setMsg({ type: 'ok', text: t('settings:feishu.saved', { defaultValue: 'Feishu configuration saved' }) }); - } catch (err) { - setMsg({ type: 'err', text: String(err instanceof Error ? err.message : err) }); - } finally { - setSaving(false); - } + if (saveTimer.current) clearTimeout(saveTimer.current); + saveTimer.current = setTimeout(() => { + doSave(configRef.current); + }, 300); }; const handleTest = async () => { @@ -193,13 +214,35 @@ export function FeishuIntegrationSection() { } }; + const handleSendTestMessage = async () => { + if (!testChatId.trim()) { + setMsg({ type: 'err', text: t('settings:feishu.chatIdRequired', { defaultValue: 'Please enter a Chat ID' }) }); + 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); - setDirty(false); + initialLoadDone.current = true; 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) }); @@ -234,7 +277,20 @@ export function FeishuIntegrationSection() {

{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' })} +
{/* Connection Settings */} @@ -249,6 +305,7 @@ export function FeishuIntegrationSection() { type="text" value={config.appId} onChange={e => 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" /> @@ -264,6 +321,7 @@ export function FeishuIntegrationSection() { type={showAppSecret ? 'text' : 'password'} value={config.appSecret} onChange={e => 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" /> @@ -280,70 +338,10 @@ export function FeishuIntegrationSection() { - - {/* Verify Token */} -
- - updateField('verificationToken', e.target.value)} - placeholder="Event verification token from Feishu" - 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" - /> -
- - {/* Encrypt Key */} -
- -
- updateField('encryptKey', e.target.value)} - placeholder="AES encryption key for event decryption" - 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" - /> - -
-
- - {/* Webhook Path */} -
- - updateField('webhookPath', e.target.value)} - placeholder="/webhook/feishu" - 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" - /> -

- {t('settings:feishu.webhookPathHint', { defaultValue: 'Set this path in your Feishu app event subscription' })} -

-
- {/* Test & Save Actions */} + {/* Actions */}
- {config.connected && config.enabled && ( +
+

+ {t('settings:feishu.chatIdHint', { defaultValue: 'Chat ID can be found in the Feishu group chat URL or via the API' })} +

+ + )} + {msg && }
@@ -396,17 +414,11 @@ export function FeishuIntegrationSection() { {t('settings:feishu.enableIntegration', { defaultValue: 'Enable Feishu Integration' })}
- {t('settings:feishu.enableHint', { defaultValue: 'When enabled, Markus will connect to Feishu and start processing events' })} + {t('settings:feishu.enableHint', { defaultValue: 'When enabled, Markus will establish a long connection to Feishu and start processing events' })}
+ + +
+

+ {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...' })} +

+
+ )} - {/* App Secret */} -
- -
- 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" - /> - + )} - -
-
-
- - - {/* Actions */} -
-
- +
- {config.connected && config.enabled && ( + {/* Manual Configuration Fallback */} +
- )} -
- {/* Send Test Message */} - {config.connected && config.enabled && ( -
- -
- setTestChatId(e.target.value)} - placeholder={t('settings:feishu.chatIdPlaceholder', { defaultValue: 'Enter Chat ID (oc_xxxxxxxx)' })} - className="flex-1 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" - /> + {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 */} +
+
-

- {t('settings:feishu.chatIdHint', { defaultValue: 'Chat ID can be found in the Feishu group chat URL or via the API' })} -

-
- )} - - {msg && } - - - {/* 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' })} + + {/* Send Test Message */} + {config.connected && config.enabled && ( +
+ +
+ setTestChatId(e.target.value)} + placeholder={t('settings:feishu.chatIdPlaceholder', { defaultValue: 'Enter Chat ID (oc_xxxxxxxx)' })} + className="flex-1 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" + /> + +
+

+ {t('settings:feishu.chatIdHint', { defaultValue: 'Chat ID can be found in the Feishu group chat URL or via the API' })} +

-
- -
-
-
+ )} - {/* Notification Forwarding Settings */} -
-
- {/* Target Chat ID */} -
- - updateField('notifyChatId', e.target.value)} - onBlur={() => { if (config.appId && config.appSecret) { if (saveTimer.current) clearTimeout(saveTimer.current); doSave(configRef.current); } }} - placeholder="oc_xxxxxxxxxxxxxxxx" - 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" - /> -

- {t('settings:feishu.notifyChatIdHint', { defaultValue: 'The Feishu group chat ID to receive notifications. Required for notification forwarding.' })} -

-
+ {msg && } +
-
- {/* Approval notifications */} -
+ {/* Notification Forwarding Settings */} +
+
+ {/* Target Chat ID */}
-
{t('settings:feishu.forwardApprovals', { defaultValue: 'Forward Approval Requests' })}
-
{t('settings:feishu.forwardApprovalsHint', { defaultValue: 'Send approval requests to Feishu as interactive cards' })}
+ + updateField('notifyChatId', e.target.value)} + onBlur={() => { if (config.appId && config.appSecret) { if (saveTimer.current) clearTimeout(saveTimer.current); doSave(configRef.current); } }} + placeholder="oc_xxxxxxxxxxxxxxxx" + 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" + /> +

+ {t('settings:feishu.notifyChatIdHint', { defaultValue: 'The Feishu group chat ID to receive notifications. Required for notification forwarding.' })} +

- -
- {/* General notifications */} -
-
-
{t('settings:feishu.forwardNotifications', { defaultValue: 'Forward General Notifications' })}
-
{t('settings:feishu.forwardNotificationsHint', { defaultValue: 'Send system notifications to Feishu chat' })}
+
+ {/* 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' })} -

+ {/* 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' })} +

+
+ )}
- )} -
- - - {/* Setup Guide */} -
-
-

- {t('settings:feishu.setupGuideDesc', { defaultValue: 'Follow these steps to set up the Feishu integration:' })} -

-
    - {[ - { key: 'guideStep1', icon: '1️⃣', defaultValue: 'Go to Feishu Open Platform (open.feishu.cn) and create an enterprise self-built app' }, - { key: 'guideStep2', icon: '2️⃣', defaultValue: 'Go to App → Features → Bot, enable bot capability' }, - { key: 'guideStep3', icon: '3️⃣', defaultValue: 'Go to App → Permissions, enable im:message and im:message.receive_v1' }, - { key: 'guideStep4', icon: '4️⃣', defaultValue: 'Go to App → Events & Callbacks → Subscription mode, select "Use long connection"' }, - { key: 'guideStep5', icon: '5️⃣', defaultValue: 'On the Events & Callbacks page, subscribe to "Receive messages v2.0" (im.message.receive_v1)' }, - { key: 'guideStep6', icon: '6️⃣', defaultValue: 'Go to App → Credentials, copy App ID and App Secret into the fields above' }, - { key: 'guideStep7', icon: '7️⃣', defaultValue: 'Click "Test Connection" to verify credentials, then enable the integration' }, - { key: 'guideStep8', icon: '8️⃣', defaultValue: 'Add the bot to a group chat (or send a direct message to the bot)' }, - ].map(({ key, icon, defaultValue }) => ( -
  1. - {icon} - {t(`settings:feishu.${key}`, { defaultValue })} -
  2. - ))} -
-
-

- {t('settings:feishu.longConnectionNote', { defaultValue: 'Long connection mode requires an enterprise self-built app. Store apps are not supported. No public IP or domain is needed.' })} -

-
-
-
+ + + )} + + {msg && !isConfigured && }
); } diff --git a/packages/web-ui/src/locales/en/settings.json b/packages/web-ui/src/locales/en/settings.json index 55736cf5..a97c0f0a 100644 --- a/packages/web-ui/src/locales/en/settings.json +++ b/packages/web-ui/src/locales/en/settings.json @@ -631,6 +631,27 @@ "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 enter a Chat ID", + "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", diff --git a/packages/web-ui/src/locales/zh-CN/settings.json b/packages/web-ui/src/locales/zh-CN/settings.json index 8404cd97..25f4c3af 100644 --- a/packages/web-ui/src/locales/zh-CN/settings.json +++ b/packages/web-ui/src/locales/zh-CN/settings.json @@ -9,7 +9,7 @@ "storage": "数据与存储", "users": "用户管理", "remote": "远程访问", - "integrations": "集成", + "integrations": "外部IM集成", "license": "许可证", "organization": "组织", "account": "组织与许可证" @@ -631,6 +631,27 @@ "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": "请输入群聊 ID", + "testMsgSent": "测试消息已发送", + "testMsgFailed": "发送测试消息失败", "saved": "飞书配置已保存", "fillRequired": "请先填写 App ID 和 App Secret", "testSuccess": "连接成功", From 1804b69a721d134ae617946514301300c20daae8 Mon Sep 17 00:00:00 2001 From: Jason Carter Date: Fri, 12 Jun 2026 12:17:38 +0800 Subject: [PATCH 10/12] feat(feishu): make markus.json single source of truth for credentials Move appId/appSecret storage exclusively to markus.json (consistent with other API keys). SQLite now only stores runtime preferences (notifyChatId, locale, priority settings, etc.). Also includes chat list selector UI, multi-option approval cards, agent event refinements, and i18n improvements. Co-authored-by: Cursor --- packages/core/src/agent-manager.ts | 2 +- packages/org-manager/src/api-server.ts | 194 +++++-- packages/org-manager/src/feishu-api-client.ts | 52 +- packages/org-manager/src/feishu-notifier.ts | 500 +++++++++++++++--- .../org-manager/test/integration-api.test.ts | 8 +- packages/web-ui/src/api.ts | 2 + .../components/FeishuIntegrationSection.tsx | 103 +++- packages/web-ui/src/locales/en/settings.json | 10 +- .../web-ui/src/locales/zh-CN/settings.json | 10 +- 9 files changed, 710 insertions(+), 171 deletions(-) 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/src/api-server.ts b/packages/org-manager/src/api-server.ts index 20902505..8a9a19ce 100644 --- a/packages/org-manager/src/api-server.ts +++ b/packages/org-manager/src/api-server.ts @@ -1480,22 +1480,30 @@ export class APIServer { const agentManager = this.orgService.getAgentManager(); const eventBus = agentManager.getEventBus(); - // Load Feishu integration config from storage (integrations table) + // 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 (cfgConfig?.appId && cfgConfig?.appSecret) { + if (appId && appSecret) { initialConfig = { - appId: cfgConfig.appId as string, - appSecret: cfgConfig.appSecret as string, - domain: cfgConfig.domain as string | undefined, - notifyChatId: cfgConfig.notifyChatId as string | undefined, - notifyOnApproval: (cfgConfig.notifyOnApproval ?? true) as boolean, - notifyOnNotification: (cfgConfig.notifyOnNotification ?? false) as boolean, - notifyPriority: (cfgConfig.notifyPriority ?? ['high', 'urgent']) as string[], + 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'], }; } @@ -9040,17 +9048,20 @@ EXPLANATION_END`; 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); - if (!row) { - this.json(res, 200, { appId: '', appSecret: '', enabled: false, connected: false, notifyChatId: '', notifyOnApproval: true, notifyOnNotification: false, notifyPriority: ['high', 'urgent'] }); - return; - } - const cfg = (row['config'] as Record) ?? {}; + const cfg = (row?.['config'] as Record) ?? {}; const connected = !!(this.feishuNotifier?.connected); this.json(res, 200, { - appId: cfg['appId'] ?? '', - appSecret: cfg['appSecret'] ?? '', - enabled: !!(row['enabled']), + appId, + appSecret, + enabled: !!(row?.['enabled']), connected, notifyChatId: cfg['notifyChatId'] ?? '', notifyOnApproval: cfg['notifyOnApproval'] ?? true, @@ -9083,8 +9094,6 @@ EXPLANATION_END`; displayName: body['displayName'] ?? '飞书', enabled, config: { - appId, - appSecret, domain: body['domain'] ?? undefined, connectionMode: 'long_connection', notifyChatId: body['notifyChatId'] ?? undefined, @@ -9108,6 +9117,8 @@ EXPLANATION_END`; } 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, @@ -9122,6 +9133,7 @@ EXPLANATION_END`; 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, @@ -9145,6 +9157,8 @@ EXPLANATION_END`; 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, @@ -9191,6 +9205,30 @@ EXPLANATION_END`; 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; @@ -9201,10 +9239,11 @@ EXPLANATION_END`; return; } try { - const row = findFeishuConfig(auth.orgId); - const cfg = (row?.['config'] as Record) ?? {}; - const appId = cfg['appId'] as string; - const appSecret = cfg['appSecret'] as string; + // 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; @@ -9267,15 +9306,17 @@ EXPLANATION_END`; // 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: '飞书', + displayName: locale === 'en' ? 'Lark' : '飞书', enabled: true, config: { - appId, - appSecret, + locale, + notifyOpenId: registeredOpenId, connectionMode: 'long_connection', notifyOnApproval: true, notifyOnNotification: true, @@ -9295,10 +9336,15 @@ EXPLANATION_END`; } } + // 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'], @@ -9319,34 +9365,62 @@ EXPLANATION_END`; const pendingApprovals = this.hitlService?.listApprovals('pending') ?? []; const notifCounts = this.hitlService?.countNotifications(auth.userId, true) ?? { total: 0, unread: 0 }; - 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 (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 (pendingApprovals.length > 5) { - statusLines += `• ...还有 ${pendingApprovals.length - 5} 项\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.'; } - if (notifCounts.unread > 0) { - statusLines += `\n📬 **${notifCounts.unread} 条未读通知**\n`; + 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直接回复消息即可处理,例如输入「审批」查看详情。'; } - 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); } - - 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 }); + log.info('Welcome message sent to user', { openId, locale }); } catch (welcomeErr) { log.warn('Failed to send welcome message', { error: String(welcomeErr) }); } @@ -9428,14 +9502,20 @@ EXPLANATION_END`; lastError: row['lastError'] ?? null, }); // Update the FeishuNotifier runtime config with new rules - const cfgConfig = row['config'] as Record | undefined; - if (cfgConfig?.appId && cfgConfig?.appSecret && this.feishuNotifier) { - this.feishuNotifier.updateConfig({ - appId: cfgConfig.appId as string, - appSecret: cfgConfig.appSecret as string, - domain: cfgConfig.domain as string | undefined, - forwardRules: rules as unknown as FeishuNotifierConfig['forwardRules'], - }); + 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({ diff --git a/packages/org-manager/src/feishu-api-client.ts b/packages/org-manager/src/feishu-api-client.ts index 5d063bbb..3e9715d1 100644 --- a/packages/org-manager/src/feishu-api-client.ts +++ b/packages/org-manager/src/feishu-api-client.ts @@ -102,10 +102,10 @@ export class FeishuApiClient { } } - /** Send an interactive card to a user by open_id (direct/P2P message). */ - async sendCardToUser(openId: string, card: Record): Promise { + /** 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 { - await this.client.im.v1.message.create({ + const resp = await this.client.im.v1.message.create({ params: { receive_id_type: 'open_id' }, data: { receive_id: openId, @@ -113,16 +113,17 @@ export class FeishuApiClient { 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. */ - async sendCard(chatId: string, card: Record): Promise { + /** Send an interactive card to a Feishu chat by chat_id. Returns message_id if available. */ + async sendCard(chatId: string, card: Record): Promise { try { - await this.client.im.v1.message.create({ + const resp = await this.client.im.v1.message.create({ params: { receive_id_type: 'chat_id' }, data: { receive_id: chatId, @@ -130,12 +131,51 @@ export class FeishuApiClient { 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 { diff --git a/packages/org-manager/src/feishu-notifier.ts b/packages/org-manager/src/feishu-notifier.ts index 407a0a84..d6a71a06 100644 --- a/packages/org-manager/src/feishu-notifier.ts +++ b/packages/org-manager/src/feishu-notifier.ts @@ -10,7 +10,7 @@ const log = createLogger('feishu-notifier'); export interface ForwardTarget { channelId: string; - type: 'chat' | 'webhook'; + type: 'chat' | 'webhook' | 'open_id'; } export interface NotificationForwardRule { @@ -24,11 +24,15 @@ export interface NotificationForwardRule { 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[]; @@ -44,9 +48,81 @@ const EVENT_MAP: Record = { '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. */ @@ -55,57 +131,85 @@ function buildNotificationCard( 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, }, - { - tag: 'hr', - }, + ]; + + 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: `Type: ${eventType} | ${new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}`, + content: `${t(locale, 'card.type_label')}: ${eventType} | ${timeStr}`, }, ], }, - ]; - - if (metadata?.approvalId) { - elements.push({ - tag: 'action', - actions: [ - { - tag: 'button', - text: { tag: 'plain_text', content: '✅ Approve' }, - type: 'primary', - value: { action: 'approve', approval_id: metadata.approvalId as string }, - }, - { - tag: 'button', - text: { tag: 'plain_text', content: '❌ Reject' }, - type: 'danger', - value: { action: 'reject', approval_id: metadata.approvalId as string }, - }, - ], - }); - } + ); return { - config: { wide_screen_mode: true }, + 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): Record { - return buildNotificationCard(title, body, priority, eventType, undefined); +function buildSimpleCard(title: string, body: string, priority: string, eventType: string, locale: FeishuLocale): Record { + return buildNotificationCard(title, body, priority, eventType, locale, undefined); } // ── FeishuNotifier ────────────────────────────────────────────────── @@ -114,12 +218,15 @@ 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; @@ -134,6 +241,7 @@ export class FeishuNotifier { this.agentManager = opts.agentManager; if (opts.config?.appId && opts.config?.appSecret) { this.config = opts.config; + this.locale = opts.config.locale ?? 'zh'; } } @@ -160,7 +268,13 @@ export class FeishuNotifier { }); if (this.config) { - this.startLongConnection(this.config).catch((err) => { + 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) }); }); } @@ -168,6 +282,47 @@ export class FeishuNotifier { 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) { @@ -190,6 +345,7 @@ export class FeishuNotifier { /** 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(); @@ -199,7 +355,13 @@ export class FeishuNotifier { appSecret: config.appSecret, domain: config.domain, }); - this.startLongConnection(config).catch((err) => { + 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 { @@ -231,6 +393,10 @@ export class FeishuNotifier { 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 @@ -263,6 +429,8 @@ export class FeishuNotifier { message?: { chat_id?: string; message_id?: string; + parent_id?: string; + root_id?: string; content?: string; message_type?: string; chat_type?: string; @@ -281,6 +449,35 @@ export class FeishuNotifier { } 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, @@ -297,28 +494,143 @@ export class FeishuNotifier { }); } - /** Handle interactive card button actions (approve/reject). */ + /** Handle interactive card button actions (approve/reject/select_option). */ private handleCardAction(data: unknown): Record { - const action = data as { - action?: { value?: { action?: string; approval_id?: string } }; + 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 = action?.action?.value; - if (actionValue?.approval_id) { - log.info('Feishu card action', { action: actionValue.action, approvalId: actionValue.approval_id }); - this.eventBus.emit('feishu:card_action', { - action: actionValue.action, - approvalId: actionValue.approval_id, - openId: action?.open_id, - }); + 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: actionValue?.action === 'approve' ? '已批准' : '已拒绝', + 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, }; } @@ -336,52 +648,79 @@ export class FeishuNotifier { 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 = payload['agentId'] - ? this.agentManager?.getAgentName?.(payload['agentId'] as string) ?? payload['agentId'] as string - : 'Unknown'; - title = '✅ 任务完成'; - body = `Agent: ${agentName}\nTask ID: ${payload['taskId'] as string}`; + 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 = '📢 系统公告'; + 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': { - title = '▶️ Agent 启动'; - body = `Agent: ${payload['agentId'] as string}`; + const agentName = resolveAgentName(payload['agentId']); + title = t(L, 'event.agent_started'); + body = `${t(L, 'label.agent')}: ${agentName}`; + priority = 'low'; break; } case 'agent:stopped': { - title = '⏹️ Agent 停止'; - body = `Agent: ${payload['agentId'] as string}`; + 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': { - title = '⏸️ Agent 暂停'; + const agentName = resolveAgentName(payload['agentId']); const reason = payload['reason'] as string | undefined; - body = `Agent: ${payload['agentId'] as string}${reason ? `\n原因: ${reason}` : ''}`; + 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': { - title = '▶️ Agent 恢复'; - body = `Agent: ${payload['agentId'] as string}`; + const agentName = resolveAgentName(payload['agentId']); + title = t(L, 'event.agent_resumed'); + body = `${t(L, 'label.agent')}: ${agentName}`; + priority = 'low'; break; } case 'agent:created': { - title = '🆕 Agent 创建'; - body = `Agent: ${(payload['name'] as string) ?? payload['agentId'] as string}`; + 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': { - title = '🗑️ Agent 删除'; - body = `Agent ID: ${payload['agentId'] as string}`; + const agentName = resolveAgentName(payload['agentId']); + title = t(L, 'event.agent_removed'); + body = `${t(L, 'label.agent')}: ${agentName}`; priority = 'high'; break; } @@ -420,6 +759,11 @@ export class FeishuNotifier { 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); @@ -455,9 +799,9 @@ export class FeishuNotifier { matchedTargets.push(...rule.targets); } - // Fallback: use notifyChatId from simplified config if no explicit rules matched - if (matchedTargets.length === 0 && this.config.notifyChatId) { - const isApprovalType = ['approval_request', 'approval_approved', 'approval_rejected'].includes(eventType); + // 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; @@ -465,7 +809,11 @@ export class FeishuNotifier { if (shouldForward) { const allowedPriorities = this.config.notifyPriority ?? ['high', 'urgent']; if (allowedPriorities.includes(priority) || allowedPriorities.includes('*')) { - matchedTargets.push({ type: 'chat', channelId: this.config.notifyChatId }); + 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 }); + } } } } @@ -480,16 +828,15 @@ export class FeishuNotifier { if (uniqueTargets.length === 0) return; - const includeActions = metadata?.approvalId - ? rules.some(r => r.type === eventType && r.enabled && r.includeApprovalActions) || true - : false; + const includeActions = !!metadata?.approvalId; const card = includeActions - ? buildNotificationCard(title, body, priority, eventType, metadata) - : buildSimpleCard(title, body, priority, eventType); + ? 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', @@ -499,12 +846,19 @@ export class FeishuNotifier { 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 { - await this.apiClient!.sendCard(target.channelId, card); + 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), }); } diff --git a/packages/org-manager/test/integration-api.test.ts b/packages/org-manager/test/integration-api.test.ts index 11e2917b..89a52f32 100644 --- a/packages/org-manager/test/integration-api.test.ts +++ b/packages/org-manager/test/integration-api.test.ts @@ -203,8 +203,9 @@ describe('Integration Config API (Feishu)', () => { }); expect(res.status).toBe(200); const body = await res.json() as Record; - expect(body['config']).toBeTruthy(); - expect((body['config'] as Record)['platform']).toBe('feishu'); + expect(body['appId']).toBe('cli_a1111'); + expect(body['appSecret']).toBe('xxx'); + expect(body['enabled']).toBe(true); }); it('returns null config when none exists', async () => { @@ -214,7 +215,8 @@ describe('Integration Config API (Feishu)', () => { }); expect(res.status).toBe(200); const body = await res.json() as Record; - expect(body['config']).toBeNull(); + expect(body['appId']).toBe(''); + expect(body['enabled']).toBe(false); }); }); diff --git a/packages/web-ui/src/api.ts b/packages/web-ui/src/api.ts index 0697a624..ada5ddb4 100644 --- a/packages/web-ui/src/api.ts +++ b/packages/web-ui/src/api.ts @@ -1413,6 +1413,8 @@ export const api = { 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: () => diff --git a/packages/web-ui/src/components/FeishuIntegrationSection.tsx b/packages/web-ui/src/components/FeishuIntegrationSection.tsx index f925fbf5..1d06d6e4 100644 --- a/packages/web-ui/src/components/FeishuIntegrationSection.tsx +++ b/packages/web-ui/src/components/FeishuIntegrationSection.tsx @@ -93,6 +93,10 @@ export function FeishuIntegrationSection() { 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); @@ -110,6 +114,15 @@ export function FeishuIntegrationSection() { 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 { @@ -125,6 +138,9 @@ export function FeishuIntegrationSection() { notifyOnNotification: data.notifyOnNotification ?? false, notifyPriority: data.notifyPriority ?? ['high', 'urgent'], }); + if (data.appId && data.appSecret) { + loadBotChats(); + } } } catch { // Not configured yet @@ -294,7 +310,7 @@ export function FeishuIntegrationSection() { const handleSendTestMessage = async () => { if (!testChatId.trim()) { - setMsg({ type: 'err', text: t('settings:feishu.chatIdRequired', { defaultValue: 'Please enter a Chat ID' }) }); + setMsg({ type: 'err', text: t('settings:feishu.chatIdRequired', { defaultValue: 'Please select a group chat' }) }); return; } setSendingMsg(true); @@ -597,13 +613,23 @@ export function FeishuIntegrationSection() { {t('settings:feishu.sendTestMessage', { defaultValue: 'Send Test Message' })}
- setTestChatId(e.target.value)} - placeholder={t('settings:feishu.chatIdPlaceholder', { defaultValue: 'Enter Chat ID (oc_xxxxxxxx)' })} - className="flex-1 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" - /> +
+ + + + +
-

- {t('settings:feishu.chatIdHint', { defaultValue: 'Chat ID can be found in the Feishu group chat URL or via the API' })} -

)} @@ -631,20 +654,50 @@ export function FeishuIntegrationSection() {
{/* Target Chat ID */}
- - updateField('notifyChatId', e.target.value)} - onBlur={() => { if (config.appId && config.appSecret) { if (saveTimer.current) clearTimeout(saveTimer.current); doSave(configRef.current); } }} - placeholder="oc_xxxxxxxxxxxxxxxx" - 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" - /> -

- {t('settings:feishu.notifyChatIdHint', { defaultValue: 'The Feishu group chat ID to receive notifications. Required for notification forwarding.' })} -

+
+ + +
+
+ + + + +
+ {botChats.length === 0 && !loadingChats && ( +

+ {t('settings:feishu.noChatHint', { defaultValue: 'No groups found. Please add the bot to a group chat first.' })} +

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

ID: {config.notifyChatId}

+ )}
diff --git a/packages/web-ui/src/locales/en/settings.json b/packages/web-ui/src/locales/en/settings.json index a97c0f0a..42d81347 100644 --- a/packages/web-ui/src/locales/en/settings.json +++ b/packages/web-ui/src/locales/en/settings.json @@ -612,8 +612,8 @@ "enableIntegration": "Enable Feishu Integration", "enableHint": "When enabled, Markus will establish a long connection to Feishu and start processing events", "notificationForwarding": "Notification Forwarding", - "notifyChatId": "Notification Target Chat ID", - "notifyChatIdHint": "The Feishu group chat ID to receive notifications. Required for notification forwarding.", + "notifyChatId": "Notification Target Group", + "notifyChatIdHint": "Select the group chat to receive notifications.", "forwardApprovals": "Forward Approval Requests", "forwardApprovalsHint": "Send approval requests to Feishu as interactive cards", "forwardNotifications": "Forward General Notifications", @@ -649,7 +649,11 @@ "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 enter a Chat ID", + "chatIdRequired": "Please select a group chat", + "selectChat": "— Select a group chat —", + "loadingChats": "Loading groups...", + "refreshChats": "Refresh", + "noChatHint": "No groups found. Please add the bot to a group chat first.", "testMsgSent": "Test message sent", "testMsgFailed": "Failed to send test message", "saved": "Feishu configuration saved", diff --git a/packages/web-ui/src/locales/zh-CN/settings.json b/packages/web-ui/src/locales/zh-CN/settings.json index 25f4c3af..d7eb0cf3 100644 --- a/packages/web-ui/src/locales/zh-CN/settings.json +++ b/packages/web-ui/src/locales/zh-CN/settings.json @@ -612,8 +612,8 @@ "enableIntegration": "启用飞书集成", "enableHint": "启用后,Markus 将通过长连接接入飞书并开始处理事件", "notificationForwarding": "通知转发", - "notifyChatId": "通知目标群聊 ID", - "notifyChatIdHint": "接收通知的飞书群聊 ID,启用通知转发时必填", + "notifyChatId": "通知目标群聊", + "notifyChatIdHint": "选择接收通知的群聊", "forwardApprovals": "转发审批请求", "forwardApprovalsHint": "将审批请求以交互卡片形式发送到飞书", "forwardNotifications": "转发一般通知", @@ -649,7 +649,11 @@ "chatIdPlaceholder": "输入群聊 ID (oc_xxxxxxxx)", "send": "发送", "chatIdHint": "群聊 ID 可在飞书群聊的 URL 中找到,或通过 API 获取", - "chatIdRequired": "请输入群聊 ID", + "chatIdRequired": "请选择一个群聊", + "selectChat": "— 选择一个群聊 —", + "loadingChats": "正在加载群列表…", + "refreshChats": "刷新", + "noChatHint": "暂无可用群聊,请先将机器人添加到群聊中", "testMsgSent": "测试消息已发送", "testMsgFailed": "发送测试消息失败", "saved": "飞书配置已保存", From 94b94e315db19e71aa90751b356b82ea1aee671d Mon Sep 17 00:00:00 2001 From: Jason Carter Date: Fri, 12 Jun 2026 12:38:33 +0800 Subject: [PATCH 11/12] fix(feishu): clarify notification target UI to show private message fallback Users were confused that notifications require a group chat. Updated UI to make it clear that private messages are the default when no group is selected. Co-authored-by: Cursor --- .../src/components/FeishuIntegrationSection.tsx | 15 ++++++++++----- packages/web-ui/src/locales/en/settings.json | 10 ++++++---- packages/web-ui/src/locales/zh-CN/settings.json | 10 ++++++---- 3 files changed, 22 insertions(+), 13 deletions(-) diff --git a/packages/web-ui/src/components/FeishuIntegrationSection.tsx b/packages/web-ui/src/components/FeishuIntegrationSection.tsx index 1d06d6e4..5be139b6 100644 --- a/packages/web-ui/src/components/FeishuIntegrationSection.tsx +++ b/packages/web-ui/src/components/FeishuIntegrationSection.tsx @@ -619,7 +619,7 @@ export function FeishuIntegrationSection() { onChange={e => setTestChatId(e.target.value)} className="w-full px-3 py-2 text-sm bg-surface-primary border border-border-default rounded-lg text-fg-primary focus:outline-none focus:ring-2 focus:ring-brand-500/40 focus:border-brand-500 transition-colors appearance-none pr-8" > - + {botChats.map(chat => ( {botChats.map(chat => (
- {botChats.length === 0 && !loadingChats && ( -

- {t('settings:feishu.noChatHint', { defaultValue: 'No groups found. Please add the bot to a group chat first.' })} + {!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 && ( diff --git a/packages/web-ui/src/locales/en/settings.json b/packages/web-ui/src/locales/en/settings.json index 42d81347..43db4cd2 100644 --- a/packages/web-ui/src/locales/en/settings.json +++ b/packages/web-ui/src/locales/en/settings.json @@ -612,8 +612,8 @@ "enableIntegration": "Enable Feishu Integration", "enableHint": "When enabled, Markus will establish a long connection to Feishu and start processing events", "notificationForwarding": "Notification Forwarding", - "notifyChatId": "Notification Target Group", - "notifyChatIdHint": "Select the group chat to receive notifications.", + "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", @@ -650,10 +650,12 @@ "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": "— Select a group chat —", + "selectChat": "— Private message (default) —", "loadingChats": "Loading groups...", "refreshChats": "Refresh", - "noChatHint": "No groups found. Please add the bot to a group chat first.", + "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", diff --git a/packages/web-ui/src/locales/zh-CN/settings.json b/packages/web-ui/src/locales/zh-CN/settings.json index d7eb0cf3..98c7292b 100644 --- a/packages/web-ui/src/locales/zh-CN/settings.json +++ b/packages/web-ui/src/locales/zh-CN/settings.json @@ -612,8 +612,8 @@ "enableIntegration": "启用飞书集成", "enableHint": "启用后,Markus 将通过长连接接入飞书并开始处理事件", "notificationForwarding": "通知转发", - "notifyChatId": "通知目标群聊", - "notifyChatIdHint": "选择接收通知的群聊", + "notifyChatId": "通知发送到", + "notifyChatIdHint": "选择群聊接收通知,或使用私信(默认)", "forwardApprovals": "转发审批请求", "forwardApprovalsHint": "将审批请求以交互卡片形式发送到飞书", "forwardNotifications": "转发一般通知", @@ -650,10 +650,12 @@ "send": "发送", "chatIdHint": "群聊 ID 可在飞书群聊的 URL 中找到,或通过 API 获取", "chatIdRequired": "请选择一个群聊", - "selectChat": "— 选择一个群聊 —", + "selectChat": "— 私信通知(默认) —", "loadingChats": "正在加载群列表…", "refreshChats": "刷新", - "noChatHint": "暂无可用群聊,请先将机器人添加到群聊中", + "noChatHint": "暂无可用群聊,将机器人添加到群聊后可选择群聊通知", + "noChatFallbackHint": "未选择群聊 — 通知将以私信形式发送给你", + "selectTestChat": "— 选择一个群聊 —", "testMsgSent": "测试消息已发送", "testMsgFailed": "发送测试消息失败", "saved": "飞书配置已保存", From b120b1a4706ab90cf982a2de80fcea5914789962 Mon Sep 17 00:00:00 2001 From: Jason Carter Date: Fri, 12 Jun 2026 12:48:26 +0800 Subject: [PATCH 12/12] minor --- .../org-manager/test/integration-api.test.ts | 40 +++++++++++++++++-- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/packages/org-manager/test/integration-api.test.ts b/packages/org-manager/test/integration-api.test.ts index 89a52f32..145d892a 100644 --- a/packages/org-manager/test/integration-api.test.ts +++ b/packages/org-manager/test/integration-api.test.ts @@ -10,6 +10,9 @@ * - 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'; @@ -176,25 +179,40 @@ describe('Integration Config API (Feishu)', () => { // ── 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: { appId: 'cli_a1111', appSecret: 'xxx' }, + config: { notifyChatId: 'oc_123' }, }); const res = await fetch(`http://localhost:${port}/api/settings/integrations/feishu`, { @@ -206,6 +224,7 @@ describe('Integration Config API (Feishu)', () => { 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 () => { @@ -239,19 +258,26 @@ describe('Integration Config API (Feishu)', () => { 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']).toBe('cli_a2222'); + 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: { appId: 'old_id', appSecret: 'old_secret' }, + config: { connectionMode: 'long_connection' }, }); const res = await fetch(`http://localhost:${port}/api/settings/integrations/feishu`, { @@ -260,9 +286,15 @@ describe('Integration Config API (Feishu)', () => { 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']).toBe('new_id'); + 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'); }); });