11#!/usr/bin/env bun
22// Telegram → AI Bridge(多后端:Claude Agent SDK / Codex SDK)
33
4- import { Bot , InlineKeyboard , GrammyError } from "grammy" ;
4+ import { Bot , InlineKeyboard , InputFile , GrammyError } from "grammy" ;
55import { HttpsProxyAgent } from "https-proxy-agent" ;
6- import { mkdirSync , writeFileSync , readdirSync , statSync , unlinkSync } from "fs" ;
6+ import { mkdirSync , writeFileSync , readdirSync , statSync , unlinkSync , existsSync } from "fs" ;
77import { basename , join } from "path" ;
88import {
99 getSession ,
@@ -563,6 +563,19 @@ async function sendLong(ctx, text) {
563563 }
564564}
565565
566+ function estimateCodeRatio ( text ) {
567+ const codeBlocks = text . match ( / ` ` ` [ \s \S ] * ?` ` ` / g) || [ ] ;
568+ const codeLen = codeBlocks . reduce ( ( sum , b ) => sum + b . length , 0 ) ;
569+ return text . length > 0 ? codeLen / text . length : 0 ;
570+ }
571+
572+ function detectCodeLang ( text ) {
573+ const m = text . match ( / ` ` ` ( \w + ) / ) ;
574+ const lang = m ?. [ 1 ] ?. toLowerCase ( ) ;
575+ const map = { javascript : "js" , typescript : "ts" , python : "py" , bash : "sh" , shell : "sh" , ruby : "rb" } ;
576+ return map [ lang ] || lang || "txt" ;
577+ }
578+
566579function getSessionProjectLabel ( sessionMeta , fallbackCwd = "" ) {
567580 const cwd = sessionMeta ?. cwd || fallbackCwd || "" ;
568581 if ( ! cwd ) return "" ;
@@ -921,6 +934,8 @@ async function processPrompt(ctx, prompt) {
921934 let capturedSessionId = sessionId || null ;
922935 let resultText = "" ;
923936 let resultSuccess = true ;
937+ const capturedImages = [ ] ; // { data, mediaType, toolUseId }
938+ const capturedFiles = [ ] ; // { filePath, source }
924939
925940 // 软看门狗:只打日志,不 abort(TG 发出去的消息无法撤回)
926941 const startTime = Date . now ( ) ;
@@ -969,6 +984,17 @@ async function processPrompt(ctx, prompt) {
969984 } ) ;
970985 }
971986
987+ // 收集图片/文件事件
988+ if ( event . type === "image" && capturedImages . length < 10 ) {
989+ capturedImages . push ( event ) ;
990+ }
991+ if ( event . type === "file_persisted" ) {
992+ capturedFiles . push ( { filePath : event . filename , source : "persisted" } ) ;
993+ }
994+ if ( event . type === "file_written" ) {
995+ capturedFiles . push ( { filePath : event . filePath , source : event . tool } ) ;
996+ }
997+
972998 // 实时进度(progress + text 事件)
973999 idleMonitor . heartbeat ( chatId ) ;
9741000 progress . processEvent ( event ) ;
@@ -1004,6 +1030,43 @@ async function processPrompt(ctx, prompt) {
10041030 durationMs : Date . now ( ) - startTime ,
10051031 } ) ;
10061032
1033+ // 发送捕获的图片(在文字结果之前)
1034+ if ( resultSuccess && capturedImages . length > 0 ) {
1035+ for ( const img of capturedImages ) {
1036+ try {
1037+ const buf = Buffer . from ( img . data , "base64" ) ;
1038+ if ( buf . length > 10 * 1024 * 1024 ) continue ; // TG sendPhoto 限 10MB
1039+ const ext = ( img . mediaType || "image/png" ) . split ( "/" ) [ 1 ] || "png" ;
1040+ await ctx . replyWithPhoto ( new InputFile ( buf , `output.${ ext } ` ) ) ;
1041+ } catch ( e ) {
1042+ console . error ( `[Bridge] sendPhoto failed: ${ e . message } ` ) ;
1043+ }
1044+ }
1045+ }
1046+
1047+ // 发送捕获的文件(图片/文档类)
1048+ if ( resultSuccess && capturedFiles . length > 0 ) {
1049+ const IMAGE_EXTS = new Set ( [ ".png" , ".jpg" , ".jpeg" , ".gif" , ".webp" ] ) ;
1050+ const DOC_EXTS = new Set ( [ ".pdf" , ".docx" , ".xlsx" , ".csv" , ".html" ] ) ;
1051+ const sentPaths = new Set ( ) ;
1052+ for ( const f of capturedFiles ) {
1053+ if ( ! f . filePath || sentPaths . has ( f . filePath ) ) continue ;
1054+ const ext = f . filePath . slice ( f . filePath . lastIndexOf ( "." ) ) . toLowerCase ( ) ;
1055+ if ( ! IMAGE_EXTS . has ( ext ) && ! DOC_EXTS . has ( ext ) ) continue ;
1056+ if ( ! existsSync ( f . filePath ) ) continue ;
1057+ sentPaths . add ( f . filePath ) ;
1058+ try {
1059+ if ( IMAGE_EXTS . has ( ext ) ) {
1060+ await ctx . replyWithPhoto ( new InputFile ( f . filePath ) ) ;
1061+ } else {
1062+ await ctx . replyWithDocument ( new InputFile ( f . filePath ) ) ;
1063+ }
1064+ } catch ( e ) {
1065+ console . error ( `[Bridge] sendFile failed (${ basename ( f . filePath ) } ): ${ e . message } ` ) ;
1066+ }
1067+ }
1068+ }
1069+
10071070 // 发最终结果
10081071 if ( ! resultSuccess ) {
10091072 finalizeFailure ( summarizeText ( resultText , 240 ) , "RESULT_ERROR" ) ;
@@ -1014,11 +1077,16 @@ async function processPrompt(ctx, prompt) {
10141077 if ( replies && resultText . length <= 4000 ) {
10151078 const kb = new InlineKeyboard ( ) ;
10161079 for ( const r of replies ) {
1017- // TG callback_data 限 64 字节,"reply:" 占 6 字节,剩 58 给内容
10181080 const cbData = `reply:${ r . slice ( 0 , 58 ) } ` ;
10191081 kb . text ( r , cbData ) ;
10201082 }
10211083 await ctx . reply ( resultText , { reply_markup : kb } ) ;
1084+ } else if ( resultText . length > 4000 && estimateCodeRatio ( resultText ) > 0.6 ) {
1085+ // 长代码输出 → 文件附件 + 摘要
1086+ const ext = detectCodeLang ( resultText ) || "txt" ;
1087+ await ctx . replyWithDocument ( new InputFile ( Buffer . from ( resultText , "utf-8" ) , `output.${ ext } ` ) ) ;
1088+ const preview = resultText . slice ( 0 , 300 ) . replace ( / ` ` ` \w * \n ? / , "" ) ;
1089+ await ctx . reply ( `${ preview } \n\n📎 完整输出 (${ resultText . length } 字符) 见附件` ) ;
10221090 } else {
10231091 await sendLong ( ctx , resultText ) ;
10241092 }
0 commit comments