1+ import { existsSync , readFileSync , writeFileSync } from 'node:fs' ;
2+ import { resolve } from 'node:path' ;
13import type { FastifyPluginAsync , FastifyReply , FastifyRequest } from 'fastify' ;
24import { DEFAULT_THREAD_ID , type IThreadStore } from '../domains/cats/services/stores/ports/ThreadStore.js' ;
35import type { WeixinAdapter } from '../infrastructure/connectors/adapters/WeixinAdapter.js' ;
46import type { IConnectorPermissionStore } from '../infrastructure/connectors/ConnectorPermissionStore.js' ;
7+ import { resolveActiveProjectRoot } from '../utils/active-project-root.js' ;
58import { resolveHeaderUserId } from '../utils/request-identity.js' ;
69
710export interface ConnectorHubRoutesOptions {
@@ -16,6 +19,10 @@ export interface ConnectorHubRoutesOptions {
1619 startWeixinPolling ?: ( ) => void ;
1720 /** F134 Phase D: Permission store for group whitelist + admin management */
1821 permissionStore ?: IConnectorPermissionStore | null ;
22+ /** Optional override for writing connector env updates in tests */
23+ envFilePath ?: string ;
24+ /** Optional fetch override for Feishu registration API in tests */
25+ feishuRegistrationFetch ?: typeof fetch ;
1926}
2027
2128function requireTrustedHubIdentity ( request : FastifyRequest , reply : FastifyReply ) : string | null {
@@ -57,6 +64,11 @@ interface PlatformDef {
5764 steps : PlatformStepDef [ ] ;
5865}
5966
67+ const FEISHU_ACCOUNTS_BASE_URL = 'https://accounts.feishu.cn' ;
68+ const LARK_ACCOUNTS_BASE_URL = 'https://accounts.larksuite.com' ;
69+
70+ type FeishuRegistrationResponse = Record < string , unknown > ;
71+
6072export const CONNECTOR_PLATFORMS : PlatformDef [ ] = [
6173 {
6274 id : 'feishu' ,
@@ -169,6 +181,86 @@ function maskSensitiveValue(_value: string): string {
169181 return '••••••••' ;
170182}
171183
184+ function formatEnvFileValue ( value : string ) : string {
185+ const escapedControlChars = value . replace ( / \r / g, '\\r' ) . replace ( / \n / g, '\\n' ) ;
186+ if ( / ^ [ A - Z a - z 0 - 9 _ . / : @ - ] + $ / . test ( escapedControlChars ) ) return escapedControlChars ;
187+ return `"${ escapedControlChars
188+ . replace ( / \\ / g, '\\\\' )
189+ . replace ( / " / g, '\\"' )
190+ . replace ( / \$ / g, '\\$' )
191+ . replace ( / ` / g, '\\`' ) } "`;
192+ }
193+
194+ function applyEnvUpdatesToFile ( contents : string , updates : Map < string , string | null > ) : string {
195+ const lines = contents === '' ? [ ] : contents . split ( / \r ? \n / ) ;
196+ const seen = new Set < string > ( ) ;
197+ const nextLines : string [ ] = [ ] ;
198+
199+ for ( const line of lines ) {
200+ const match = line . match ( / ^ \s * ( [ A - Z a - z _ ] [ A - Z a - z 0 - 9 _ ] * ) \s * = / ) ;
201+ if ( ! match ) {
202+ nextLines . push ( line ) ;
203+ continue ;
204+ }
205+ const name = match [ 1 ] ! ;
206+ if ( ! updates . has ( name ) ) {
207+ nextLines . push ( line ) ;
208+ continue ;
209+ }
210+ seen . add ( name ) ;
211+ const value = updates . get ( name ) ;
212+ if ( value == null || value === '' ) continue ;
213+ nextLines . push ( `${ name } =${ formatEnvFileValue ( value ) } ` ) ;
214+ }
215+
216+ for ( const [ name , value ] of updates ) {
217+ if ( seen . has ( name ) || value == null || value === '' ) continue ;
218+ nextLines . push ( `${ name } =${ formatEnvFileValue ( value ) } ` ) ;
219+ }
220+
221+ const normalized = nextLines
222+ . join ( '\n' )
223+ . replace ( / \n { 3 , } / g, '\n\n' )
224+ . trimEnd ( ) ;
225+ return normalized . length > 0 ? `${ normalized } \n` : '' ;
226+ }
227+
228+ function persistEnvUpdates ( envFilePath : string , updates : Map < string , string | null > ) : void {
229+ const current = existsSync ( envFilePath ) ? readFileSync ( envFilePath , 'utf8' ) : '' ;
230+ const next = applyEnvUpdatesToFile ( current , updates ) ;
231+ writeFileSync ( envFilePath , next , 'utf8' ) ;
232+ for ( const [ name , value ] of updates ) {
233+ if ( value == null || value === '' ) delete process . env [ name ] ;
234+ else process . env [ name ] = value ;
235+ }
236+ }
237+
238+ async function postFeishuRegistration (
239+ fetchFn : typeof fetch ,
240+ baseUrl : string ,
241+ form : URLSearchParams ,
242+ ) : Promise < FeishuRegistrationResponse > {
243+ const res = await fetchFn ( `${ baseUrl } /oauth/v1/app/registration` , {
244+ method : 'POST' ,
245+ headers : { 'Content-Type' : 'application/x-www-form-urlencoded' } ,
246+ body : form . toString ( ) ,
247+ } ) ;
248+ const data = ( await res . json ( ) . catch ( ( ) => ( { } ) ) ) as FeishuRegistrationResponse ;
249+ if ( ! res . ok && ! ( 'error' in data ) ) {
250+ throw new Error ( `registration api ${ res . status } ` ) ;
251+ }
252+ return data ;
253+ }
254+
255+ function toPositiveNumber ( value : unknown , fallback : number ) : number {
256+ if ( typeof value === 'number' && Number . isFinite ( value ) && value > 0 ) return value ;
257+ if ( typeof value === 'string' ) {
258+ const parsed = Number ( value ) ;
259+ if ( Number . isFinite ( parsed ) && parsed > 0 ) return parsed ;
260+ }
261+ return fallback ;
262+ }
263+
172264export interface PlatformFieldStatus {
173265 envName : string ;
174266 label : string ;
@@ -237,6 +329,8 @@ export function buildConnectorStatus(env: Record<string, string | undefined> = p
237329
238330export const connectorHubRoutes : FastifyPluginAsync < ConnectorHubRoutesOptions > = async ( app , opts ) => {
239331 const { threadStore } = opts ;
332+ const envFilePath = opts . envFilePath ?? resolve ( resolveActiveProjectRoot ( ) , '.env' ) ;
333+ const feishuRegistrationFetch = opts . feishuRegistrationFetch ?? globalThis . fetch ;
240334
241335 app . get ( '/api/connector/hub-threads' , async ( request , reply ) => {
242336 const userId = requireTrustedHubIdentity ( request , reply ) ;
@@ -274,6 +368,127 @@ export const connectorHubRoutes: FastifyPluginAsync<ConnectorHubRoutesOptions> =
274368 return { platforms : status } ;
275369 } ) ;
276370
371+ // ── Feishu QR code create/bind routes ──
372+
373+ app . post ( '/api/connector/feishu/qrcode' , async ( request , reply ) => {
374+ const userId = requireTrustedHubIdentity ( request , reply ) ;
375+ if ( ! userId ) return { error : 'Identity required' } ;
376+
377+ try {
378+ const initData = await postFeishuRegistration (
379+ feishuRegistrationFetch ,
380+ FEISHU_ACCOUNTS_BASE_URL ,
381+ new URLSearchParams ( { action : 'init' } ) ,
382+ ) ;
383+ const supportedMethods = Array . isArray ( initData . supported_auth_methods ) ? initData . supported_auth_methods : [ ] ;
384+ if ( ! supportedMethods . includes ( 'client_secret' ) ) {
385+ reply . status ( 502 ) ;
386+ return { error : 'Feishu registration endpoint does not support client_secret auth method' } ;
387+ }
388+
389+ const beginData = await postFeishuRegistration (
390+ feishuRegistrationFetch ,
391+ FEISHU_ACCOUNTS_BASE_URL ,
392+ new URLSearchParams ( {
393+ action : 'begin' ,
394+ archetype : 'PersonalAgent' ,
395+ auth_method : 'client_secret' ,
396+ request_user_info : 'open_id' ,
397+ } ) ,
398+ ) ;
399+
400+ const verificationUri = beginData . verification_uri_complete ;
401+ const deviceCode = beginData . device_code ;
402+ if ( typeof verificationUri !== 'string' || typeof deviceCode !== 'string' ) {
403+ reply . status ( 502 ) ;
404+ return { error : 'Feishu registration response is missing QR payload' } ;
405+ }
406+
407+ const qrUrl = new URL ( verificationUri ) ;
408+ qrUrl . searchParams . set ( 'from' , 'onboard' ) ;
409+
410+ const QRCode = await import ( 'qrcode' ) ;
411+ const qrDataUri = await QRCode . toDataURL ( qrUrl . toString ( ) , { width : 384 , margin : 2 } ) ;
412+
413+ return {
414+ qrUrl : qrDataUri ,
415+ qrPayload : deviceCode ,
416+ interval : toPositiveNumber ( beginData . interval , 5 ) ,
417+ expiresIn : toPositiveNumber ( beginData . expire_in , 600 ) ,
418+ } ;
419+ } catch ( err ) {
420+ app . log . error ( { err } , '[Feishu QR] Failed to fetch QR code' ) ;
421+ reply . status ( 502 ) ;
422+ return { error : 'Failed to fetch QR code from Feishu registration service' } ;
423+ }
424+ } ) ;
425+
426+ app . get ( '/api/connector/feishu/qrcode-status' , async ( request , reply ) => {
427+ const userId = requireTrustedHubIdentity ( request , reply ) ;
428+ if ( ! userId ) return { error : 'Identity required' } ;
429+
430+ const { qrPayload } = request . query as { qrPayload ?: string } ;
431+ if ( ! qrPayload ) {
432+ reply . status ( 400 ) ;
433+ return { error : 'qrPayload query parameter required' } ;
434+ }
435+
436+ try {
437+ const pollForm = new URLSearchParams ( { action : 'poll' , device_code : qrPayload } ) ;
438+ let pollData = await postFeishuRegistration ( feishuRegistrationFetch , FEISHU_ACCOUNTS_BASE_URL , pollForm ) ;
439+
440+ const tenantBrand = ( ( pollData . user_info as Record < string , unknown > | undefined ) ?. tenant_brand ?? '' ) as string ;
441+ const hasCredentials = typeof pollData . client_id === 'string' && typeof pollData . client_secret === 'string' ;
442+ if ( ! hasCredentials && tenantBrand === 'lark' ) {
443+ try {
444+ pollData = await postFeishuRegistration ( feishuRegistrationFetch , LARK_ACCOUNTS_BASE_URL , pollForm ) ;
445+ } catch ( err ) {
446+ app . log . warn ( { err } , '[Feishu QR] Lark poll fallback failed' ) ;
447+ }
448+ }
449+
450+ const clientId = pollData . client_id ;
451+ const clientSecret = pollData . client_secret ;
452+ if ( typeof clientId === 'string' && typeof clientSecret === 'string' ) {
453+ const updates = new Map < string , string | null > ( [
454+ [ 'FEISHU_APP_ID' , clientId ] ,
455+ [ 'FEISHU_APP_SECRET' , clientSecret ] ,
456+ ] ) ;
457+ const currentMode = process . env . FEISHU_CONNECTION_MODE === 'websocket' ? 'websocket' : 'webhook' ;
458+ const verificationToken = process . env . FEISHU_VERIFICATION_TOKEN ;
459+ if ( currentMode === 'webhook' && ( ! verificationToken || verificationToken . trim ( ) === '' ) ) {
460+ // QR onboarding does not return webhook verification token; default to websocket so setup is immediately valid.
461+ updates . set ( 'FEISHU_CONNECTION_MODE' , 'websocket' ) ;
462+ }
463+ persistEnvUpdates ( envFilePath , updates ) ;
464+ app . log . info ( '[Feishu QR] Bot credentials captured and persisted to env file' ) ;
465+ return { status : 'confirmed' } ;
466+ }
467+
468+ const errorCode = pollData . error ;
469+ if ( errorCode === 'authorization_pending' || errorCode === 'slow_down' ) {
470+ return { status : 'waiting' } ;
471+ }
472+ if ( errorCode === 'access_denied' ) {
473+ return { status : 'denied' } ;
474+ }
475+ if ( errorCode === 'expired_token' ) {
476+ return { status : 'expired' } ;
477+ }
478+ if ( typeof errorCode === 'string' ) {
479+ return {
480+ status : 'error' ,
481+ error : typeof pollData . error_description === 'string' ? pollData . error_description : errorCode ,
482+ } ;
483+ }
484+ return { status : 'waiting' } ;
485+ } catch ( err ) {
486+ app . log . error ( { err } , '[Feishu QR] Failed to poll QR status' ) ;
487+ reply . status ( 502 ) ;
488+ return { error : 'Failed to poll Feishu QR status' } ;
489+ }
490+ } ) ;
491+
277492 // ── F137: WeChat QR code login routes ──
278493
279494 app . post ( '/api/connector/weixin/qrcode' , async ( request , reply ) => {
0 commit comments