2222 */
2323
2424import * as Lark from '@larksuiteoapi/node-sdk' ;
25+ import axios from 'axios' ;
2526import fs from 'node:fs' ;
2627import os from 'node:os' ;
2728import path from 'node:path' ;
@@ -42,6 +43,78 @@ import { spawn, execSync } from 'node:child_process';
4243 }
4344}
4445
46+ // ─── Hardened HTTP instance for the Lark SDK ───────────────────
47+ // The SDK ships a bare `axios.create()` with no keep-alive, no timeout, and no
48+ // retry. Every streaming-card update therefore opens a fresh TLS connection,
49+ // which on a flaky network fails repeatedly with
50+ // `Client network socket disconnected before secure TLS connection was
51+ // established` (ECONNRESET). When that final-card update fails, the user's
52+ // conclusion never lands on the card (issue: 最终回复不更新卡片).
53+ //
54+ // This shared instance hardens ALL outbound SDK calls (streaming-card updates,
55+ // message sends, media uploads) by:
56+ // 1. Reusing TLS connections via a keep-alive agent (fewer handshakes).
57+ // 2. Enforcing a request timeout (the SDK default is 0 = forever, which let
58+ // hung sockets accumulate and bloated the process to 4.8G).
59+ // 3. Retrying transient network errors with exponential backoff.
60+ const LARK_HTTP_TIMEOUT_MS = Number ( process . env . FEISHU_BRIDGE_HTTP_TIMEOUT_MS ) || 15000 ;
61+ const LARK_HTTP_MAX_RETRY = Number ( process . env . FEISHU_BRIDGE_HTTP_MAX_RETRY ) || 3 ;
62+
63+ const _larkHttpsAgent = new https . Agent ( {
64+ keepAlive : true ,
65+ keepAliveMsecs : 1000 ,
66+ maxSockets : 32 ,
67+ maxFreeSockets : 8 ,
68+ timeout : LARK_HTTP_TIMEOUT_MS ,
69+ } ) ;
70+
71+ function _isRetryableNetworkError ( err ) {
72+ if ( ! err ) return false ;
73+ const code = err . code ;
74+ // Low-level socket / TLS failures before a response is received.
75+ if ( [ 'ECONNRESET' , 'ECONNREFUSED' , 'ETIMEDOUT' , 'EPIPE' , 'EAI_AGAIN' , 'ENETUNREACH' , 'EHOSTUNREACH' ] . includes ( code ) ) {
76+ return true ;
77+ }
78+ // No response received at all (request never completed).
79+ if ( err . response === undefined && err . request !== undefined ) {
80+ return true ;
81+ }
82+ // Transient server-side failures.
83+ const status = err . response ?. status ;
84+ if ( status && ( status === 408 || status === 425 || status === 429 || ( status >= 500 && status <= 599 ) ) ) {
85+ return true ;
86+ }
87+ return false ;
88+ }
89+
90+ function buildLarkHttpInstance ( ) {
91+ const instance = axios . create ( {
92+ timeout : LARK_HTTP_TIMEOUT_MS ,
93+ httpAgent : new http . Agent ( { keepAlive : true } ) ,
94+ httpsAgent : _larkHttpsAgent ,
95+ } ) ;
96+ let retryHit = 0 ;
97+ instance . interceptors . response . use ( undefined , async ( error ) => {
98+ const config = error ?. config || { } ;
99+ if ( ! config || ! _isRetryableNetworkError ( error ) ) {
100+ return Promise . reject ( error ) ;
101+ }
102+ config . __retryCount = config . __retryCount || 0 ;
103+ if ( config . __retryCount >= LARK_HTTP_MAX_RETRY ) {
104+ return Promise . reject ( error ) ;
105+ }
106+ config . __retryCount += 1 ;
107+ retryHit += 1 ;
108+ const delayMs = Math . min ( 2000 , 300 * 2 ** ( config . __retryCount - 1 ) ) + Math . floor ( Math . random ( ) * 150 ) ;
109+ console . warn ( `[net] retry #${ config . __retryCount } /${ LARK_HTTP_MAX_RETRY } in ${ delayMs } ms (code=${ error . code || error . response ?. status } , url=${ config . url ?. split ( '?' ) [ 0 ] } )` ) ;
110+ await new Promise ( ( r ) => setTimeout ( r , delayMs ) ) ;
111+ return instance . request ( config ) ;
112+ } ) ;
113+ return instance ;
114+ }
115+
116+ const larkHttpInstance = buildLarkHttpInstance ( ) ;
117+
45118// Load .env automatically (so users don't need to export env vars manually).
46119// - Does NOT override existing process.env values.
47120// - Keeps this bridge dependency-free (no dotenv package).
@@ -2281,11 +2354,19 @@ async function processAndReply(claude, text, channel, chatId, replyCtx) {
22812354 const streamOpts = incomingMessageId ? { replyTo : incomingMessageId } : { } ;
22822355 let cardCreated = false ;
22832356 let finalResult = null ;
2357+ // controller ref stashed so we can inspect `streamingFailed` after the
2358+ // stream completes (the SDK swallows update errors internally and only flips
2359+ // this flag — see MarkdownStreamControllerImpl.pushContent).
2360+ let streamController = null ;
2361+ // Lifted into the outer scope so the catch block can fall back to a plain
2362+ // message if the streaming card never received the final content.
2363+ let finalDisplayText = null ;
22842364
22852365 try {
22862366 await channel . stream ( chatId , {
22872367 markdown : async ( controller ) => {
22882368 cardCreated = true ;
2369+ streamController = controller ;
22892370
22902371 // Show a "thinking" placeholder immediately so the user sees
22912372 // the card is being worked on (no blank-slate silence).
@@ -2345,7 +2426,6 @@ async function processAndReply(claude, text, channel, chatId, replyCtx) {
23452426 }
23462427
23472428 // Final update with clean result text
2348- let finalDisplayText ;
23492429 if ( finalResult ?. interrupted ) {
23502430 finalDisplayText = '⚡ 当前处理已被打断' ;
23512431 } else {
@@ -2360,9 +2440,31 @@ async function processAndReply(claude, text, channel, chatId, replyCtx) {
23602440 finalDisplayText = `✅ 已执行(无输出)${ costNote } ` ;
23612441 }
23622442 }
2363- controller . setContent ( finalDisplayText ) ;
2443+ // Best-effort final push. The SDK schedules this through its throttle
2444+ // queue; if the underlying PUT fails (e.g. ECONNRESET) the SDK swallows
2445+ // the error and flips `streamingFailed`. We detect that below and
2446+ // re-deliver via a plain message so the conclusion is never lost.
2447+ try {
2448+ await controller . setContent ( finalDisplayText ) ;
2449+ } catch ( e ) {
2450+ console . warn ( '[stream] final setContent threw:' , e ?. message || String ( e ) ) ;
2451+ }
23642452 } ,
23652453 } , streamOpts ) ;
2454+
2455+ // Stream completed (SDK ran completeTerminal). If the final card update
2456+ // failed transitively, streamingFailed is now true — re-deliver the
2457+ // conclusion as a plain message so the user is not left staring at a
2458+ // stuck "✍️ 正在生成回复…" progress marker.
2459+ if ( streamController ?. streamingFailed ) {
2460+ console . warn ( '[stream] final card update failed (streamingFailed), falling back to plain message' ) ;
2461+ try {
2462+ await sendReplyToFeishu ( channel , chatId , finalDisplayText ?? '' , { incomingMessageId } ) ;
2463+ } catch ( e ) {
2464+ console . error ( '[stream] fallback plain message also failed:' , e ?. message || String ( e ) ) ;
2465+ }
2466+ }
2467+ return finalResult ;
23662468 } catch ( e ) {
23672469 if ( ! cardCreated ) {
23682470 // Stream failed to start — fall back to non-streaming send.
@@ -2412,8 +2514,17 @@ async function processAndReply(claude, text, channel, chatId, replyCtx) {
24122514 return null ;
24132515 }
24142516 }
2415- // Stream started but producer failed — SDK already showed error in card
2517+ // Stream started but producer failed — SDK already showed error in card.
2518+ // Don't swallow silently: log it, and if we already computed the final
2519+ // text, re-deliver it as a plain message so the conclusion survives.
24162520 console . error ( '[WARN] stream failed after card creation:' , e ?. message || String ( e ) ) ;
2521+ if ( finalDisplayText ) {
2522+ try {
2523+ await sendReplyToFeishu ( channel , chatId , finalDisplayText , { incomingMessageId } ) ;
2524+ } catch ( e2 ) {
2525+ console . error ( '[stream] post-failure fallback also failed:' , e2 ?. message || String ( e2 ) ) ;
2526+ }
2527+ }
24172528 }
24182529
24192530 // Send media files from the final result (after stream completes)
@@ -2994,6 +3105,7 @@ for (const [alias, proj] of Object.entries(bridgeConfig.projects)) {
29943105 appType : Lark . AppType . SelfBuild ,
29953106 source : 'feishu-codes-bridge' ,
29963107 loggerLevel : Lark . LoggerLevel . info ,
3108+ httpInstance : larkHttpInstance ,
29973109 policy : {
29983110 dmMode : 'open' ,
29993111 requireMention : false ,
0 commit comments