@@ -3,8 +3,8 @@ import { getSettings, loadSettings } from "../config";
33import { resetSession } from "../sessions" ;
44import { transcribeAudioToText } from "../whisper" ;
55import { resolveSkillPrompt , listSkills } from "../skills" ;
6- import { mkdir } from "node:fs/promises" ;
7- import { extname , join } from "node:path" ;
6+ import { mkdir , stat } from "node:fs/promises" ;
7+ import { extname , basename , join } from "node:path" ;
88
99// --- Markdown → Telegram HTML conversion (ported from nanobot) ---
1010
@@ -445,6 +445,95 @@ async function sendReaction(token: string, chatId: number, messageId: number, em
445445 } ) ;
446446}
447447
448+ const IMAGE_EXTENSIONS = new Set ( [ ".png" , ".jpg" , ".jpeg" , ".gif" , ".webp" ] ) ;
449+
450+ function isImageExtension ( filePath : string ) : boolean {
451+ return IMAGE_EXTENSIONS . has ( extname ( filePath ) . toLowerCase ( ) ) ;
452+ }
453+
454+ async function sendFileDocument (
455+ token : string ,
456+ chatId : number ,
457+ filePath : string ,
458+ caption ?: string ,
459+ threadId ?: number
460+ ) : Promise < void > {
461+ const fileData = await Bun . file ( filePath ) . arrayBuffer ( ) ;
462+ const fileName = basename ( filePath ) ;
463+ const blob = new Blob ( [ fileData ] ) ;
464+ const form = new FormData ( ) ;
465+ form . append ( "chat_id" , String ( chatId ) ) ;
466+ form . append ( "document" , blob , fileName ) ;
467+ if ( caption ) form . append ( "caption" , caption ) ;
468+ if ( threadId ) form . append ( "message_thread_id" , String ( threadId ) ) ;
469+
470+ const res = await fetch ( `${ API_BASE } ${ token } /sendDocument` , {
471+ method : "POST" ,
472+ body : form ,
473+ } ) ;
474+ if ( ! res . ok ) {
475+ const body = await res . text ( ) . catch ( ( ) => "" ) ;
476+ throw new Error ( `Telegram sendDocument: ${ res . status } ${ res . statusText } ${ body } ` ) ;
477+ }
478+ }
479+
480+ async function sendPhotoFile (
481+ token : string ,
482+ chatId : number ,
483+ filePath : string ,
484+ caption ?: string ,
485+ threadId ?: number
486+ ) : Promise < void > {
487+ const fileData = await Bun . file ( filePath ) . arrayBuffer ( ) ;
488+ const fileName = basename ( filePath ) ;
489+ const blob = new Blob ( [ fileData ] ) ;
490+ const form = new FormData ( ) ;
491+ form . append ( "chat_id" , String ( chatId ) ) ;
492+ form . append ( "photo" , blob , fileName ) ;
493+ if ( caption ) form . append ( "caption" , caption ) ;
494+ if ( threadId ) form . append ( "message_thread_id" , String ( threadId ) ) ;
495+
496+ const res = await fetch ( `${ API_BASE } ${ token } /sendPhoto` , {
497+ method : "POST" ,
498+ body : form ,
499+ } ) ;
500+ if ( ! res . ok ) {
501+ const body = await res . text ( ) . catch ( ( ) => "" ) ;
502+ throw new Error ( `Telegram sendPhoto: ${ res . status } ${ res . statusText } ${ body } ` ) ;
503+ }
504+ }
505+
506+ async function sendLocalFile (
507+ token : string ,
508+ chatId : number ,
509+ filePath : string ,
510+ caption ?: string ,
511+ threadId ?: number
512+ ) : Promise < void > {
513+ if ( isImageExtension ( filePath ) ) {
514+ await sendPhotoFile ( token , chatId , filePath , caption , threadId ) ;
515+ } else {
516+ await sendFileDocument ( token , chatId , filePath , caption , threadId ) ;
517+ }
518+ }
519+
520+ interface FileDirective {
521+ path : string ;
522+ }
523+
524+ function extractFileDirectives ( text : string ) : { cleanedText : string ; files : FileDirective [ ] } {
525+ const files : FileDirective [ ] = [ ] ;
526+ const cleanedText = text
527+ . replace ( / \[ f i l e : ( \/ [ ^ \] \r \n ] + ) \] / g, ( _match , filePath ) => {
528+ files . push ( { path : filePath . trim ( ) } ) ;
529+ return "" ;
530+ } )
531+ . replace ( / [ \t ] + \n / g, "\n" )
532+ . replace ( / \n { 3 , } / g, "\n\n" )
533+ . trim ( ) ;
534+ return { cleanedText, files } ;
535+ }
536+
448537let botUsername : string | null = null ;
449538let botId : number | null = null ;
450539
@@ -808,19 +897,30 @@ async function handleMessage(message: TelegramMessage): Promise<void> {
808897 await sendMessage ( config . token , chatId , errText , threadId ) ;
809898 }
810899 } else {
811- const { cleanedText, reactionEmoji } = extractReactionDirective ( result . stdout || "" ) ;
900+ const { cleanedText : afterReaction , reactionEmoji } = extractReactionDirective ( result . stdout || "" ) ;
812901 if ( reactionEmoji ) {
813902 await sendReaction ( config . token , chatId , message . message_id , reactionEmoji ) . catch ( ( err ) => {
814903 console . error ( `[Telegram] Failed to send reaction for ${ label } : ${ err instanceof Error ? err . message : err } ` ) ;
815904 } ) ;
816905 }
906+
907+ // Extract [file:/path/to/file] directives from response
908+ const { cleanedText, files } = extractFileDirectives ( afterReaction ) ;
909+
910+ // Send files
911+ for ( const file of files ) {
912+ try {
913+ await stat ( file . path ) ;
914+ await sendLocalFile ( config . token , chatId , file . path , undefined , threadId ) ;
915+ } catch ( err ) {
916+ const errMsg = err instanceof Error ? err . message : String ( err ) ;
917+ console . error ( `[Telegram] Failed to send file ${ file . path } for ${ label } : ${ errMsg } ` ) ;
918+ await sendMessage ( config . token , chatId , `Could not send file ${ file . path } : ${ errMsg } ` , threadId ) ;
919+ }
920+ }
921+
817922 const finalText = cleanedText || "(empty response)" ;
818923 if ( streamMsgId ) {
819- // Edit the streaming message with final formatted HTML.
820- // editStream() already set the message to the correct plain text content,
821- // so if all edits fail (e.g. "message is not modified"), do NOT send a new
822- // message — the user already sees the correct content and a sendMessage
823- // would create a duplicate.
824924 const html = markdownToTelegramHtml ( normalizeTelegramText ( finalText ) ) ;
825925 await callApi ( config . token , "editMessageText" , {
826926 chat_id : chatId , message_id : streamMsgId ,
@@ -829,10 +929,6 @@ async function handleMessage(message: TelegramMessage): Promise<void> {
829929 chat_id : chatId , message_id : streamMsgId ,
830930 text : finalText . slice ( 0 , 4096 ) ,
831931 } ) . catch ( ( ) => {
832- // If all edits fail and the stream message has tool output (verbose),
833- // send the final response as a new message. But if there were no tool
834- // lines, the stream message already shows the correct text — "not
835- // modified" just means it's already right, so don't send a duplicate.
836932 if ( verbose && hadToolLines ) {
837933 return sendMessage ( config . token , chatId , finalText , threadId ) ;
838934 }
@@ -1070,7 +1166,7 @@ async function poll(): Promise<void> {
10701166// --- Exports ---
10711167
10721168/** Send a message to a specific chat (used by heartbeat forwarding) */
1073- export { sendMessage } ;
1169+ export { sendMessage , sendLocalFile , sendPhotoFile , sendFileDocument } ;
10741170
10751171process . on ( "SIGTERM" , ( ) => { running = false ; } ) ;
10761172process . on ( "SIGINT" , ( ) => { running = false ; } ) ;
0 commit comments