@@ -49,6 +49,11 @@ function log(msg) {
4949// States: 'cold' -> 'warming' -> 'warm' -> (TTL expires) -> 'cold'
5050const tenantState = new Map ( ) ;
5151
52+ // Pending IM pairing confirmations (two-step: /start TOKEN → YES/NO)
53+ // key: `${channel}:${userId}` → { token, empName, expiresAt }
54+ // Stored in-memory only; a proxy restart loses pending pairings (user re-scans QR to retry)
55+ const pendingPairings = new Map ( ) ;
56+
5257function getTenantKey ( channel , userId ) {
5358 return `${ channel } __${ userId } ` ;
5459}
@@ -596,58 +601,114 @@ server.on('stream', (stream, headers) => {
596601 }
597602
598603 // =====================================================================
599- // PATH C: IM Self-Service Pairing — intercept /start TOKEN commands
600- // When an employee scans the Portal QR code, Telegram sends "/start TOKEN".
601- // We validate the token against Admin Console and write the SSM mapping
602- // WITHOUT invoking AgentCore — fast, free, no microVM spin-up.
604+ // PATH C: IM Self-Service Pairing — two-step confirmation flow
605+ //
606+ // Step 1: /start TOKEN → pair-pending (validate) → inject "reply YES to confirm"
607+ // Pending state stored in memory (pendingPairings Map, 10 min TTL)
608+ // Step 2: YES → pair-complete → inject success
609+ // NO → cancel → inject cancel
603610 //
604- // Safety: only intercepts exact pattern /start [A-Z0-9]{10,16}
605- // On any error (invalid token, network, etc.) → falls through to normal routing
611+ // Safety: errors fall through to normal routing (employee gets agent response)
606612 // =====================================================================
607- // Search anywhere in userText — the /start TOKEN appears after OpenClaw's metadata blocks
613+
614+ // Helper: call Admin Console API (internal only, no auth needed)
615+ const callAdminAPI = ( path , payload ) => new Promise ( ( resolve , reject ) => {
616+ const http = require ( 'node:http' ) ;
617+ const body = JSON . stringify ( payload ) ;
618+ const req = http . request ( {
619+ hostname : '127.0.0.1' , port : 8099 , path,
620+ method : 'POST' , headers : { 'Content-Type' : 'application/json' , 'Content-Length' : Buffer . byteLength ( body ) } ,
621+ } , ( res ) => {
622+ let data = '' ;
623+ res . on ( 'data' , c => data += c ) ;
624+ res . on ( 'end' , ( ) => {
625+ try { resolve ( { status : res . statusCode , body : JSON . parse ( data ) } ) ; }
626+ catch { resolve ( { status : res . statusCode , body : { } } ) ; }
627+ } ) ;
628+ } ) ;
629+ req . on ( 'error' , reject ) ;
630+ req . setTimeout ( 5000 , ( ) => { req . destroy ( ) ; reject ( new Error ( 'timeout' ) ) ; } ) ;
631+ req . write ( body ) ;
632+ req . end ( ) ;
633+ } ) ;
634+
635+ // Helper: inject a fake Bedrock response without calling AgentCore
636+ const injectResponse = ( text ) => {
637+ if ( isStream ) {
638+ stream . respond ( { ':status' : 200 , 'content-type' : 'application/vnd.amazon.eventstream' } ) ;
639+ for ( const e of buildEventStream ( text ) ) stream . write ( e ) ;
640+ stream . end ( ) ;
641+ } else {
642+ stream . respond ( { ':status' : 200 , 'content-type' : 'application/json' } ) ;
643+ stream . end ( JSON . stringify ( buildConverseResponse ( text ) ) ) ;
644+ }
645+ } ;
646+
647+ const pendingKey = `${ channel } :${ userId } ` ;
648+ const msgTrim = userText . trim ( ) ;
649+
650+ // ── Step 2a: YES confirmation ──────────────────────────────────────
651+ if ( / ^ ( y e s | Y E S | Y e s | Y | y | 确 认 | 绑 定 ) $ / . test ( msgTrim ) && pendingPairings . has ( pendingKey ) ) {
652+ const pending = pendingPairings . get ( pendingKey ) ;
653+ if ( Date . now ( ) > pending . expiresAt ) {
654+ pendingPairings . delete ( pendingKey ) ;
655+ injectResponse ( '⏱ 绑定超时,请回到 Portal 重新生成二维码。' ) ;
656+ return ;
657+ }
658+ try {
659+ const result = await callAdminAPI ( '/api/v1/bindings/pair-complete' , {
660+ token : pending . token , channel, channelUserId : userId ,
661+ } ) ;
662+ pendingPairings . delete ( pendingKey ) ;
663+ if ( result . status === 200 && result . body . success ) {
664+ log ( `PATH C: Pairing confirmed ${ channel } ${ userId } → ${ result . body . employeeId } ` ) ;
665+ injectResponse ( `✅ 绑定成功!你现在可以在这里直接与 AI Agent 对话了。` ) ;
666+ } else {
667+ injectResponse ( `绑定失败:${ result . body . detail || '请重试' } 。` ) ;
668+ }
669+ } catch ( e ) {
670+ log ( `PATH C: pair-complete error: ${ e . message } ` ) ;
671+ injectResponse ( '绑定时出错,请稍后重试。' ) ;
672+ }
673+ return ;
674+ }
675+
676+ // ── Step 2b: NO / cancel ───────────────────────────────────────────
677+ if ( / ^ ( n o | N O | N o | N | n | 取 消 | c a n c e l | C A N C E L ) $ / . test ( msgTrim ) && pendingPairings . has ( pendingKey ) ) {
678+ pendingPairings . delete ( pendingKey ) ;
679+ injectResponse ( '已取消。如需重新绑定请回到 Portal 生成二维码。' ) ;
680+ return ;
681+ }
682+
683+ // ── Step 1: /start TOKEN ───────────────────────────────────────────
608684 const pairMatch = userText . match ( / \/ s t a r t \s + ( [ A - Z a - z 0 - 9 ] { 10 , 16 } ) / ) ;
609685 if ( pairMatch && userId !== 'unknown' && channel !== 'unknown' ) {
610686 const token = pairMatch [ 1 ] . toUpperCase ( ) ;
611687 try {
612- const http = require ( 'node:http' ) ;
613- const pairResult = await new Promise ( ( resolve , reject ) => {
614- const payload = JSON . stringify ( { channel, channelUserId : userId , token } ) ;
615- const req = http . request ( {
616- hostname : '127.0.0.1' , port : 8099 , path : '/api/v1/bindings/pair-complete' ,
617- method : 'POST' , headers : { 'Content-Type' : 'application/json' , 'Content-Length' : Buffer . byteLength ( payload ) } ,
618- } , ( res ) => {
619- let data = '' ;
620- res . on ( 'data' , c => data += c ) ;
621- res . on ( 'end' , ( ) => {
622- try { resolve ( { status : res . statusCode , body : JSON . parse ( data ) } ) ; }
623- catch { resolve ( { status : res . statusCode , body : { } } ) ; }
624- } ) ;
625- } ) ;
626- req . on ( 'error' , reject ) ;
627- req . setTimeout ( 5000 , ( ) => { req . destroy ( ) ; reject ( new Error ( 'pair-complete timeout' ) ) ; } ) ;
628- req . write ( payload ) ;
629- req . end ( ) ;
688+ const pending = await callAdminAPI ( '/api/v1/bindings/pair-pending' , {
689+ token, channel, channelUserId : userId ,
630690 } ) ;
631-
632- if ( pairResult . status === 200 && pairResult . body . success ) {
633- const { employeeName, positionName } = pairResult . body ;
634- const confirmMsg = `✅ Connected! Hi ${ employeeName } — your ${ positionName || 'AI' } Agent is ready. Just send me a message to get started!` ;
635- log ( `PATH C: Pairing complete ${ channel } ${ userId } → ${ pairResult . body . employeeId } ` ) ;
636- if ( isStream ) {
637- stream . respond ( { ':status' : 200 , 'content-type' : 'application/vnd.amazon.eventstream' } ) ;
638- for ( const e of buildEventStream ( confirmMsg ) ) stream . write ( e ) ;
639- stream . end ( ) ;
640- } else {
641- stream . respond ( { ':status' : 200 , 'content-type' : 'application/json' } ) ;
642- stream . end ( JSON . stringify ( buildConverseResponse ( confirmMsg ) ) ) ;
643- }
644- return ; // ← pairing handled, do NOT route to AgentCore
691+ if ( pending . status === 200 && pending . body . valid ) {
692+ const { employeeName, positionName, isRebind } = pending . body ;
693+ pendingPairings . set ( pendingKey , {
694+ token,
695+ empName : employeeName ,
696+ expiresAt : Date . now ( ) + 10 * 60 * 1000 ,
697+ } ) ;
698+ const action = isRebind ? '重新绑定' : '绑定' ;
699+ const msg = `你正在将此账号${ action } 到 [${ employeeName } ${ positionName ? ' · ' + positionName : '' } ]。\n\n回复 YES 确认,回复 NO 取消(10 分钟内有效)。` ;
700+ log ( `PATH C: Pending pairing ${ channel } ${ userId } → ${ employeeName } ` ) ;
701+ injectResponse ( msg ) ;
702+ return ;
703+ }
704+ if ( pending . status === 200 && pending . body . reason === 'already_bound_other' ) {
705+ injectResponse ( `此账号已绑定到 ${ pending . body . boundTo } ,请联系 IT 管理员解绑后再试。` ) ;
706+ return ;
645707 }
646- // Token invalid/expired — fall through to normal routing with a hint
647- log ( `PATH C: Pairing token invalid/expired for ${ userId } , routing normally` ) ;
708+ // Token invalid/expired — fall through to normal routing
709+ log ( `PATH C: pair-pending invalid ( ${ pending . body ?. reason } ) , routing normally` ) ;
648710 } catch ( pairErr ) {
649711 log ( `PATH C: Pairing error (falling through): ${ pairErr . message } ` ) ;
650- // Fall through to normal routing — employee gets regular agent response
651712 }
652713 }
653714
0 commit comments