diff --git a/adapters/common/config.ts b/adapters/common/config.ts index 095504d51..3fe5d0184 100644 --- a/adapters/common/config.ts +++ b/adapters/common/config.ts @@ -30,6 +30,7 @@ export type TelegramConfig = { export type FeishuConfig = { appId: string appSecret: string + domain: string encryptKey: string verificationToken: string allowedUsers: string[] @@ -116,6 +117,7 @@ export function loadConfig(): AdapterConfig { feishu: { appId: process.env.FEISHU_APP_ID || fs_.appId || '', appSecret: process.env.FEISHU_APP_SECRET || fs_.appSecret || '', + domain: process.env.FEISHU_DOMAIN || fs_.domain || 'feishu', encryptKey: process.env.FEISHU_ENCRYPT_KEY || fs_.encryptKey || '', verificationToken: process.env.FEISHU_VERIFICATION_TOKEN || fs_.verificationToken || '', allowedUsers: fs_.allowedUsers ?? [], diff --git a/adapters/feishu/index.ts b/adapters/feishu/index.ts index 9fa535e9b..10e196468 100644 --- a/adapters/feishu/index.ts +++ b/adapters/feishu/index.ts @@ -46,11 +46,13 @@ if (!config.feishu.appId || !config.feishu.appSecret) { process.exit(1) } +const larkDomain = config.feishu.domain === 'lark' ? Lark.Domain.Lark : Lark.Domain.Feishu + const larkClient = new Lark.Client({ appId: config.feishu.appId, appSecret: config.feishu.appSecret, appType: Lark.AppType.SelfBuild, - domain: Lark.Domain.Feishu, + domain: larkDomain, }) const bridge = new WsBridge(config.serverUrl, 'feishu') @@ -1314,7 +1316,7 @@ async function start(): Promise { wsClient = new Lark.WSClient({ appId: config.feishu.appId, appSecret: config.feishu.appSecret, - domain: Lark.Domain.Feishu, + domain: larkDomain, loggerLevel: Lark.LoggerLevel.info, }) diff --git a/desktop/src/api/adapters.ts b/desktop/src/api/adapters.ts index 8bac289bb..08e55c1d0 100644 --- a/desktop/src/api/adapters.ts +++ b/desktop/src/api/adapters.ts @@ -17,6 +17,24 @@ export type DingtalkRegistrationPoll = { config?: AdapterFileConfig } +export type FeishuSetupBegin = { + deviceCode: string + userCode?: string + verificationUri?: string + verificationUriComplete: string + expiresInSeconds: number + intervalSeconds: number + qrDataUrl?: string +} + +export type FeishuSetupPoll = { + status: 'WAITING' | 'SUCCESS' | 'FAIL' | 'EXPIRED' | 'UNKNOWN' + domain?: string + openId?: string + failReason?: string + config?: AdapterFileConfig +} + export const adaptersApi = { getConfig() { return api.get('/api/adapters') @@ -52,4 +70,16 @@ export const adaptersApi = { pollDingtalkRegistration(deviceCode: string) { return api.post('/api/adapters/dingtalk/registration/poll', { deviceCode }) }, + + beginFeishuSetup(domain = 'feishu') { + return api.post('/api/adapters/feishu/setup/begin', { domain }) + }, + + pollFeishuSetup(deviceCode: string, domain = 'feishu') { + return api.post('/api/adapters/feishu/setup/poll', { deviceCode, domain }) + }, + + unbindFeishu() { + return api.post('/api/adapters/feishu/unbind', {}) + }, } diff --git a/desktop/src/pages/AdapterSettings.tsx b/desktop/src/pages/AdapterSettings.tsx index 0738f83b2..bc113c6cc 100644 --- a/desktop/src/pages/AdapterSettings.tsx +++ b/desktop/src/pages/AdapterSettings.tsx @@ -25,6 +25,9 @@ export function AdapterSettings() { pollDingtalkRegistration, unbindWechatAccount, unbindDingtalkBot, + beginFeishuSetup, + pollFeishuSetup, + unbindFeishu, } = useAdapterStore() // Active IM tab —— Feishu 默认展示,在前 @@ -45,6 +48,18 @@ export function AdapterSettings() { const [fsVerificationToken, setFsVerificationToken] = useState('') const [fsAllowedUsers, setFsAllowedUsers] = useState('') const [fsStreamingCard, setFsStreamingCard] = useState(false) + // Feishu QR scan-to-create + const [fsRegistration, setFsRegistration] = useState<{ + deviceCode: string + verificationUriComplete: string + qrDataUrl?: string + intervalSeconds: number + expiresAt: number + } | null>(null) + const [fsAuthStatus, setFsAuthStatus] = useState<'idle' | 'waiting' | 'bound' | 'error'>('idle') + const [fsAuthError, setFsAuthError] = useState('') + const [isStartingFsAuth, setIsStartingFsAuth] = useState(false) + const [isUnbindingFs, setIsUnbindingFs] = useState(false) // WeChat const [wcAllowedUsers, setWcAllowedUsers] = useState('') @@ -189,6 +204,48 @@ export function AdapterSettings() { } }, [dtRegistration, dtAuthStatus, pollDingtalkRegistration, fetchConfig, t]) + // Feishu QR poll + useEffect(() => { + if (!fsRegistration || fsAuthStatus !== 'waiting') return + + let cancelled = false + const poll = async () => { + if (Date.now() > fsRegistration.expiresAt) { + setFsAuthStatus('error') + setFsAuthError(t('settings.adapters.dingtalkAuthExpired')) + setFsRegistration(null) + return + } + + try { + const result = await pollFeishuSetup(fsRegistration.deviceCode) + if (cancelled) return + if (result.status === 'SUCCESS') { + setFsAuthStatus('bound') + setFsRegistration(null) + setFsAuthError('') + await fetchConfig() + } else if (result.status === 'FAIL' || result.status === 'EXPIRED') { + setFsAuthStatus('error') + setFsAuthError(result.failReason || t('settings.adapters.dingtalkAuthFailed')) + setFsRegistration(null) + } + } catch (err) { + if (!cancelled) { + setFsAuthStatus('error') + setFsAuthError(err instanceof Error ? err.message : t('settings.adapters.dingtalkAuthFailed')) + } + } + } + + const timer = window.setInterval(poll, Math.max(1, fsRegistration.intervalSeconds) * 1000) + void poll() + return () => { + cancelled = true + window.clearInterval(timer) + } + }, [fsRegistration, fsAuthStatus, pollFeishuSetup, fetchConfig, t]) + async function handleSave() { setIsSaving(true) setSaveStatus('idle') @@ -347,6 +404,44 @@ export function AdapterSettings() { } }, [unbindDingtalkBot, fetchConfig, t]) + const handleStartFeishuAuth = useCallback(async () => { + setIsStartingFsAuth(true) + setFsAuthStatus('idle') + setFsAuthError('') + try { + const begin = await beginFeishuSetup() + setFsRegistration({ + deviceCode: begin.deviceCode, + verificationUriComplete: begin.verificationUriComplete, + qrDataUrl: begin.qrDataUrl, + intervalSeconds: begin.intervalSeconds, + expiresAt: Date.now() + begin.expiresInSeconds * 1000, + }) + setFsAuthStatus('waiting') + } catch (err) { + setFsAuthStatus('error') + setFsAuthError(err instanceof Error ? err.message : t('settings.adapters.dingtalkAuthFailed')) + } finally { + setIsStartingFsAuth(false) + } + }, [beginFeishuSetup, t]) + + const handleUnbindFeishuBot = useCallback(async () => { + setIsUnbindingFs(true) + setFsAuthError('') + try { + await unbindFeishu() + setFsAuthStatus('idle') + setFsRegistration(null) + await fetchConfig() + } catch (err) { + setFsAuthStatus('error') + setFsAuthError(err instanceof Error ? err.message : 'Failed to unbind Feishu') + } finally { + setIsUnbindingFs(false) + } + }, [unbindFeishu, fetchConfig]) + const handleUnbind = useCallback(async (platform: ImPlatform, userId: string | number) => { setPendingUnbind({ platform, userId }) }, []) @@ -505,6 +600,57 @@ export function AdapterSettings() { {activeIm === 'feishu' && (
+
+
+
+

+ {config.feishu?.appId ? t('settings.adapters.dingtalkBound') : t('settings.adapters.dingtalkQrTitle')} +

+

{t('settings.adapters.dingtalkQrDesc')}

+
+
+ + {(config.feishu?.appId || fsAppId) && ( + + )} +
+
+ + {fsRegistration && ( +
+ {fsRegistration.qrDataUrl ? ( + {t('settings.adapters.dingtalkQrAlt')} + ) : null} +
+

{t('settings.adapters.dingtalkWaiting')}

+ + {fsRegistration.verificationUriComplete} + +
+
+ )} + + {fsAuthStatus === 'bound' && ( +

{t('settings.adapters.dingtalkBound')}

+ )} + {fsAuthStatus === 'error' && ( +

{fsAuthError}

+ )} +
+
Promise unbindWechatAccount: () => Promise unbindDingtalkBot: () => Promise + beginFeishuSetup: () => Promise + pollFeishuSetup: (deviceCode: string) => Promise + unbindFeishu: () => Promise } export const useAdapterStore = create((set, get) => ({ @@ -137,6 +140,23 @@ export const useAdapterStore = create((set, get) => ({ void notifyTauriRestartAdapters() }, + beginFeishuSetup: () => adaptersApi.beginFeishuSetup(), + + pollFeishuSetup: async (deviceCode) => { + const result = await adaptersApi.pollFeishuSetup(deviceCode) + if (result.config) { + set({ config: result.config }) + void notifyTauriRestartAdapters() + } + return result + }, + + unbindFeishu: async () => { + const config = await adaptersApi.unbindFeishu() + set({ config }) + void notifyTauriRestartAdapters() + }, + removePairedUser: async (platform, userId) => { const { config } = get() const platformConfig = config[platform] diff --git a/desktop/src/types/adapter.ts b/desktop/src/types/adapter.ts index 732105842..4dfe557fb 100644 --- a/desktop/src/types/adapter.ts +++ b/desktop/src/types/adapter.ts @@ -23,6 +23,7 @@ export type AdapterFileConfig = { feishu?: { appId?: string appSecret?: string + domain?: string encryptKey?: string verificationToken?: string allowedUsers?: string[] diff --git a/src/server/__tests__/adapters.test.ts b/src/server/__tests__/adapters.test.ts index fd3c7d116..5f9d0b3db 100644 --- a/src/server/__tests__/adapters.test.ts +++ b/src/server/__tests__/adapters.test.ts @@ -148,4 +148,77 @@ describe('Adapters API', () => { expect(json.dingtalk.permissionCardTemplateId).toBeUndefined() expect(json.dingtalk.pairedUsers).toEqual([]) }) + + it('clears Feishu credentials on unbind', async () => { + const put = makeRequest('PUT', '/api/adapters', { + feishu: { + appId: 'cli_test', + appSecret: 'secret_test', + domain: 'feishu', + encryptKey: 'enc_test', + verificationToken: 'tok_test', + allowedUsers: ['ou_allowed'], + streamingCard: true, + pairedUsers: [{ userId: 'ou_user', displayName: 'Feishu User', pairedAt: 1 }], + }, + }) + await handleAdaptersApi(put.req, put.url, put.segments) + + const unbind = makeRequest('POST', '/api/adapters/feishu/unbind') + const res = await handleAdaptersApi(unbind.req, unbind.url, unbind.segments) + expect(res.status).toBe(200) + const json = await res.json() as any + expect(json.feishu.appId).toBeUndefined() + expect(json.feishu.appSecret).toBeUndefined() + expect(json.feishu.domain).toBeUndefined() + expect(json.feishu.encryptKey).toBeUndefined() + expect(json.feishu.verificationToken).toBeUndefined() + expect(json.feishu.allowedUsers).toEqual([]) + expect(json.feishu.pairedUsers).toEqual([]) + expect(json.feishu.streamingCard).toBe(false) + }) + + it('begins Feishu QR registration and returns QR payload', async () => { + // Mock the Feishu accounts endpoint + const mockInit = { + nonce: 'test_nonce', + supported_auth_methods: ['client_secret'], + } + const mockBegin = { + device_code: 'dc_test_feishu', + verification_uri_complete: 'https://accounts.feishu.cn/scan/dc_test', + user_code: 'XYZ-123', + interval: 3, + expire_in: 600, + } + let callCount = 0 + const originalFetch = globalThis.fetch + globalThis.fetch = async (input: any, init?: any) => { + callCount++ + const url = typeof input === 'string' ? input : input?.url || '' + if (url.includes('accounts.feishu.cn')) { + const body = init?.body as string || '' + if (body.includes('action=init')) { + return new Response(JSON.stringify(mockInit), { status: 200 }) + } + if (body.includes('action=begin')) { + return new Response(JSON.stringify(mockBegin), { status: 200 }) + } + } + return originalFetch(input, init) + } + + try { + const begin = makeRequest('POST', '/api/adapters/feishu/setup/begin', { domain: 'feishu' }) + const res = await handleAdaptersApi(begin.req, begin.url, begin.segments) + expect(res.status).toBe(200) + const json = await res.json() as any + expect(json.deviceCode).toBe('dc_test_feishu') + expect(json.verificationUriComplete).toContain('accounts.feishu.cn') + expect(json.expiresInSeconds).toBe(600) + expect(callCount).toBe(2) // init + begin + } finally { + globalThis.fetch = originalFetch + } + }) }) diff --git a/src/server/api/adapters.ts b/src/server/api/adapters.ts index c4e67cae0..ddbf27396 100644 --- a/src/server/api/adapters.ts +++ b/src/server/api/adapters.ts @@ -133,6 +133,144 @@ async function pollDingtalkRegistration(deviceCode: string): Promise { }) } +/** + * Feishu QR scan-to-create registration + */ +const FEISHU_ACCOUNTS_BASE_URL: Record = { + feishu: 'https://accounts.feishu.cn', + lark: 'https://accounts.larksuite.com', +} +const FEISHU_REGISTRATION_PATH = '/oauth/v1/app/registration' +const FEISHU_ONBOARD_REQUEST_TIMEOUT_MS = 15_000 + +async function postFeishuRegistration( + baseUrl: string, + body: Record, +): Promise> { + const url = `${baseUrl}${FEISHU_REGISTRATION_PATH}` + const formBody = new URLSearchParams(body).toString() + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: formBody, + signal: AbortSignal.timeout(FEISHU_ONBOARD_REQUEST_TIMEOUT_MS), + }) + // Feishu returns JSON even on 4xx (e.g. poll returns authorization_pending as 400) + const text = await res.text() + try { + return JSON.parse(text) as Record + } catch { + if (!res.ok) throw ApiError.internal(`[Feishu] HTTP ${res.status}: ${text.slice(0, 200)}`) + throw ApiError.internal('[Feishu] invalid JSON response') + } +} + +async function beginFeishuRegistration( + domain: string, +): Promise { + const baseUrl = FEISHU_ACCOUNTS_BASE_URL[domain] || FEISHU_ACCOUNTS_BASE_URL.feishu + + // Init — verify client_secret is supported + const initRes = await postFeishuRegistration(baseUrl, { action: 'init' }) + const methods = (initRes.supported_auth_methods as string[]) || [] + if (!methods.includes('client_secret')) { + throw ApiError.internal( + `Feishu/Lark registration does not support client_secret auth. Supported: ${methods.join(', ')}`, + ) + } + + // Begin — start device code flow + const beginRes = await postFeishuRegistration(baseUrl, { + action: 'begin', + archetype: 'PersonalAgent', + auth_method: 'client_secret', + request_user_info: 'open_id', + }) + + const deviceCode = String(beginRes.device_code ?? '').trim() + if (!deviceCode) throw ApiError.internal('[Feishu begin] missing device_code') + + let qrUrl = String(beginRes.verification_uri_complete ?? '').trim() + if (!qrUrl) throw ApiError.internal('[Feishu begin] missing verification_uri_complete') + + const expiresInSeconds = Math.min( + Number(beginRes.expire_in ?? 600), + 600, // cap at 10 minutes + ) + + return { + deviceCode, + userCode: String(beginRes.user_code ?? '').trim() || undefined, + verificationUri: String(beginRes.verification_uri ?? '').trim() || undefined, + verificationUriComplete: qrUrl, + expiresInSeconds: Number.isFinite(expiresInSeconds) && expiresInSeconds > 0 ? expiresInSeconds : 600, + intervalSeconds: Math.max(1, Number(beginRes.interval ?? 5) || 5), + qrDataUrl: await createQrDataUrl(qrUrl), + } +} + +async function pollFeishuRegistration( + deviceCode: string, + domain: string, +): Promise { + if (!deviceCode) throw ApiError.badRequest('deviceCode is required') + + const baseUrl = FEISHU_ACCOUNTS_BASE_URL[domain] || FEISHU_ACCOUNTS_BASE_URL.feishu + + let pollRes: Record + try { + pollRes = await postFeishuRegistration(baseUrl, { + action: 'poll', + device_code: deviceCode, + tp: 'ob_app', + }) + } catch { + // Network error during poll — treat as still waiting + return Response.json({ status: 'WAITING' }) + } + + // Domain auto-detection: if user scanned via Lark app, switch domain + const userInfo = (pollRes.user_info as Record) || {} + const tenantBrand = String(userInfo.tenant_brand ?? '').trim() + let effectiveDomain = domain + if (tenantBrand === 'lark' && domain !== 'lark') { + effectiveDomain = 'lark' + } + + // Success + if (pollRes.client_id && pollRes.client_secret) { + const appId = String(pollRes.client_id).trim() + const appSecret = String(pollRes.client_secret).trim() + const openId = String(userInfo.open_id ?? '').trim() || undefined + + await adapterService.updateConfig({ + feishu: { + appId, + appSecret, + domain: effectiveDomain, + // Don't persist bot identity — resolved at runtime + }, + }) + return Response.json({ + status: 'SUCCESS', + domain: effectiveDomain, + openId, + config: await adapterService.getConfig(), + }) + } + + // Terminal errors + const error = String(pollRes.error ?? '').trim() + if (error === 'access_denied') { + return Response.json({ status: 'FAIL', failReason: 'User denied the authorization' }) + } + if (error === 'expired_token') { + return Response.json({ status: 'EXPIRED', failReason: 'Device code expired' }) + } + + // Still waiting (authorization_pending or unknown) + return Response.json({ status: 'WAITING' }) +} export async function handleAdaptersApi( req: Request, _url: URL, @@ -165,6 +303,36 @@ export async function handleAdaptersApi( } } + // -- Feishu -- + if (tail[0] === 'feishu' && req.method === 'POST' && tail[1] === 'unbind') { + await adapterService.updateConfig({ + feishu: { + appId: undefined, + appSecret: undefined, + domain: undefined, + encryptKey: undefined, + verificationToken: undefined, + allowedUsers: [], + pairedUsers: [], + streamingCard: false, + }, + }) + return Response.json(await adapterService.getConfig()) + } + if (tail[0] === 'feishu' && tail[1] === 'setup') { + if (req.method === 'POST' && tail[2] === 'begin') { + const body = await req.json().catch(() => ({})) as { domain?: string } + const domain = String(body.domain ?? 'feishu').trim() || 'feishu' + return Response.json(await beginFeishuRegistration(domain)) + } + if (req.method === 'POST' && tail[2] === 'poll') { + const body = await req.json().catch(() => ({})) as { deviceCode?: string; domain?: string } + return pollFeishuRegistration( + String(body.deviceCode ?? '').trim(), + String(body.domain ?? 'feishu').trim() || 'feishu', + ) + } + } if (req.method === 'GET') { const config = await adapterService.getConfig() return Response.json(config) diff --git a/src/server/services/adapterService.ts b/src/server/services/adapterService.ts index 6d727fd14..05e9011d6 100644 --- a/src/server/services/adapterService.ts +++ b/src/server/services/adapterService.ts @@ -35,6 +35,7 @@ export type AdapterFileConfig = { feishu?: { appId?: string appSecret?: string + domain?: string encryptKey?: string verificationToken?: string allowedUsers?: string[]