11import {
22 type ChatSurfaceAdapter ,
3+ type ChatSurfaceEvent ,
34 type ChatSurfaceEventSink ,
45 type ChatSurfaceIncomingMessage ,
56 type ChatSurfaceRequestContext ,
@@ -24,6 +25,16 @@ export {
2425type TelegramUpdate = {
2526 message ?: {
2627 text ?: string ;
28+ caption ?: string ;
29+ voice ?: {
30+ file_id ?: string ;
31+ mime_type ?: string ;
32+ } ;
33+ audio ?: {
34+ file_id ?: string ;
35+ file_name ?: string ;
36+ mime_type ?: string ;
37+ } ;
2738 chat ?: {
2839 id ?: number | string ;
2940 } ;
@@ -37,12 +48,29 @@ type TelegramUpdate = {
3748 } ;
3849} ;
3950
51+ type TelegramGetFileResponse = {
52+ ok : boolean ;
53+ result ?: {
54+ file_path ?: string ;
55+ } ;
56+ description ?: string ;
57+ } ;
58+
4059type ChatSurfaceConnectAction = {
4160 type : "url" ;
4261 label : string ;
4362 url : string ;
4463} ;
4564
65+ type TelegramChatSurfaceEvent =
66+ | ChatSurfaceEvent
67+ | {
68+ type : "audio" ;
69+ audio : Buffer ;
70+ filename : string ;
71+ mimeType : string ;
72+ } ;
73+
4674const TELEGRAM_API_BASE_URL = "https://api.telegram.org" ;
4775const TELEGRAM_SECRET_HEADER = "x-telegram-bot-api-secret-token" ;
4876const TELEGRAM_MESSAGE_MAX_LENGTH = 4096 ;
@@ -93,14 +121,16 @@ function splitTelegramMessage(text: string) {
93121 return chunks ;
94122}
95123
96- function parseTelegramStartPayload ( text : string ) {
124+ function parseTelegramStartCommand ( text : string ) {
97125 const [ command , ...payloadParts ] = text . trim ( ) . split ( TELEGRAM_COMMAND_PARTS_RE ) ;
126+ const isStartCommand =
127+ command === TELEGRAM_START_COMMAND_PREFIX ||
128+ command . startsWith ( `${ TELEGRAM_START_COMMAND_PREFIX } @` ) ;
98129
99- if ( command !== TELEGRAM_START_COMMAND_PREFIX && ! command . startsWith ( `${ TELEGRAM_START_COMMAND_PREFIX } @` ) ) {
100- return null ;
101- }
102-
103- return payloadParts . join ( " " ) || null ;
130+ return {
131+ isStartCommand,
132+ payload : isStartCommand ? payloadParts . join ( " " ) || null : null ,
133+ } ;
104134}
105135
106136export class TelegramChatSurfaceAdapter implements ChatSurfaceAdapter {
@@ -132,24 +162,59 @@ export class TelegramChatSurfaceAdapter implements ChatSurfaceAdapter {
132162 }
133163
134164 const update = ctx . body as TelegramUpdate ;
135- const text = update . message ?. text ;
136- const chatId = update . message ?. chat ?. id ;
137- const userId = update . message ?. from ?. id ;
165+ const message = update . message ;
166+ const text = message ?. text ;
167+ const chatId = message ?. chat ?. id ;
168+ const userId = message ?. from ?. id ;
138169
139- if ( ! text || chatId === undefined || userId === undefined ) {
170+ if ( chatId === undefined || userId === undefined ) {
140171 return null ;
141172 }
142173
143- const startPayload = parseTelegramStartPayload ( text ) ;
174+ const startCommand = text
175+ ? parseTelegramStartCommand ( text )
176+ : { isStartCommand : false , payload : null } ;
177+
178+ if ( text ) {
179+ return {
180+ surface : this . name ,
181+ prompt : text ,
182+ externalConversationId : String ( chatId ) ,
183+ externalUserId : String ( userId ) ,
184+ userTimeZone : "UTC" ,
185+ metadata : {
186+ isStartCommand : startCommand . isStartCommand ,
187+ startPayload : startCommand . payload ,
188+ telegramUpdate : update ,
189+ } ,
190+ } ;
191+ }
192+
193+ const voiceFileId = message ?. voice ?. file_id ;
194+ const audioFileId = message ?. audio ?. file_id ;
195+ const fileId = voiceFileId ?? audioFileId ;
196+
197+ if ( ! fileId ) {
198+ return null ;
199+ }
200+
201+ const audio = await this . downloadTelegramFile ( {
202+ fileId,
203+ filename : message ?. audio ?. file_name ?? ( voiceFileId ? "telegram-voice.ogg" : "telegram-audio" ) ,
204+ mimeType : message ?. voice ?. mime_type ?? message ?. audio ?. mime_type ?? "application/octet-stream" ,
205+ abortSignal : ctx . abortSignal ,
206+ } ) ;
144207
145208 return {
146209 surface : this . name ,
147- prompt : text ,
210+ prompt : message ?. caption ?? "" ,
211+ audio,
148212 externalConversationId : String ( chatId ) ,
149213 externalUserId : String ( userId ) ,
150214 userTimeZone : "UTC" ,
151215 metadata : {
152- startPayload,
216+ isStartCommand : startCommand . isStartCommand ,
217+ startPayload : startCommand . payload ,
153218 telegramUpdate : update ,
154219 } ,
155220 } ;
@@ -245,7 +310,7 @@ export class TelegramChatSurfaceAdapter implements ChatSurfaceAdapter {
245310 startTyping ( ) ;
246311
247312 return {
248- emit : async ( event ) => {
313+ emit : async ( event : TelegramChatSurfaceEvent ) => {
249314 if ( closed ) {
250315 return ;
251316 }
@@ -265,9 +330,16 @@ export class TelegramChatSurfaceAdapter implements ChatSurfaceAdapter {
265330 stopTyping ( ) ;
266331 clearDraftTimer ( ) ;
267332
268- await this . sendFinalMessage (
333+ await this . sendFinalMessage ( chatId , text || event . text ) ;
334+ return ;
335+ }
336+
337+ if ( event . type === "audio" ) {
338+ await this . sendAudioFile (
269339 chatId ,
270- text || event . text ,
340+ event . audio ,
341+ event . filename ,
342+ event . mimeType ,
271343 ) ;
272344 return ;
273345 }
@@ -298,21 +370,11 @@ export class TelegramChatSurfaceAdapter implements ChatSurfaceAdapter {
298370 }
299371
300372 for ( const chunk of splitTelegramMessage ( text ) ) {
301- const response = await fetch ( `${ TELEGRAM_API_BASE_URL } /bot${ this . options . botToken } /sendMessage` , {
302- method : "POST" ,
303- headers : {
304- "Content-Type" : "application/json" ,
305- } ,
306- body : JSON . stringify ( {
307- chat_id : chatId ,
308- text : chunk ,
309- parse_mode : "Markdown" ,
310- } ) ,
373+ await this . telegramJson ( "sendMessage" , {
374+ chat_id : chatId ,
375+ text : chunk ,
376+ parse_mode : "Markdown" ,
311377 } ) ;
312-
313- if ( ! response . ok ) {
314- throw new Error ( `Telegram sendMessage failed: ${ response . status } ${ await response . text ( ) } ` ) ;
315- }
316378 }
317379 }
318380
@@ -334,30 +396,95 @@ export class TelegramChatSurfaceAdapter implements ChatSurfaceAdapter {
334396 type : "image/png" ,
335397 } ) , filename ) ;
336398
337- const response = await fetch ( `${ TELEGRAM_API_BASE_URL } /bot${ this . options . botToken } /sendPhoto` , {
399+ await this . telegramMultipart ( "sendPhoto" , formData ) ;
400+ }
401+
402+ private async sendChatAction ( chatId : string , action : "typing" | "upload_voice" | "upload_audio" ) {
403+ await this . telegramJson ( "sendChatAction" , {
404+ chat_id : chatId ,
405+ action,
406+ } ) ;
407+ }
408+
409+ private async downloadTelegramFile ( input : {
410+ fileId : string ;
411+ filename : string ;
412+ mimeType : string ;
413+ abortSignal : AbortSignal ;
414+ } ) {
415+ const fileResponse = await fetch (
416+ `${ TELEGRAM_API_BASE_URL } /bot${ this . options . botToken } /getFile?file_id=${ encodeURIComponent ( input . fileId ) } ` ,
417+ { signal : input . abortSignal } ,
418+ ) ;
419+
420+ if ( ! fileResponse . ok ) {
421+ throw new Error ( `Telegram getFile failed: ${ fileResponse . status } ${ await fileResponse . text ( ) } ` ) ;
422+ }
423+
424+ const fileData = await fileResponse . json ( ) as TelegramGetFileResponse ;
425+ const filePath = fileData . result ?. file_path ;
426+
427+ if ( ! fileData . ok || ! filePath ) {
428+ throw new Error ( `Telegram getFile failed: ${ fileData . description ?? "file_path is missing" } ` ) ;
429+ }
430+
431+ const downloadResponse = await fetch (
432+ `${ TELEGRAM_API_BASE_URL } /file/bot${ this . options . botToken } /${ filePath } ` ,
433+ { signal : input . abortSignal } ,
434+ ) ;
435+
436+ if ( ! downloadResponse . ok ) {
437+ throw new Error ( `Telegram file download failed: ${ downloadResponse . status } ${ await downloadResponse . text ( ) } ` ) ;
438+ }
439+
440+ return {
441+ buffer : Buffer . from ( await downloadResponse . arrayBuffer ( ) ) ,
442+ filename : input . filename ,
443+ mimeType : input . mimeType ,
444+ } ;
445+ }
446+
447+ private async sendAudioFile (
448+ chatId : string ,
449+ audio : Buffer ,
450+ filename : string ,
451+ mimeType : string ,
452+ ) {
453+ const sendAsVoice = [ "audio/ogg" , "audio/opus" ] . includes ( mimeType ) || filename . endsWith ( ".ogg" ) || filename . endsWith ( ".opus" ) ;
454+ const method = sendAsVoice ? "sendVoice" : "sendAudio" ;
455+ const fieldName = sendAsVoice ? "voice" : "audio" ;
456+ const audioBytes = new Uint8Array ( audio ) ;
457+ const formData = new FormData ( ) ;
458+ formData . append ( "chat_id" , chatId ) ;
459+ formData . append ( fieldName , new Blob ( [ audioBytes ] , {
460+ type : mimeType ,
461+ } ) , filename ) ;
462+
463+ await this . telegramMultipart ( method , formData ) ;
464+ }
465+
466+ private async telegramMultipart ( method : string , formData : FormData ) {
467+ const response = await fetch ( `${ TELEGRAM_API_BASE_URL } /bot${ this . options . botToken } /${ method } ` , {
338468 method : "POST" ,
339469 body : formData ,
340470 } ) ;
341471
342472 if ( ! response . ok ) {
343- throw new Error ( `Telegram sendPhoto failed: ${ response . status } ${ await response . text ( ) } ` ) ;
473+ throw new Error ( `Telegram ${ method } failed: ${ response . status } ${ await response . text ( ) } ` ) ;
344474 }
345475 }
346476
347- private async sendChatAction ( chatId : string , action : "typing" ) {
348- const response = await fetch ( `${ TELEGRAM_API_BASE_URL } /bot${ this . options . botToken } /sendChatAction ` , {
477+ private async telegramJson ( method : string , body : unknown ) {
478+ const response = await fetch ( `${ TELEGRAM_API_BASE_URL } /bot${ this . options . botToken } /${ method } ` , {
349479 method : "POST" ,
350480 headers : {
351481 "Content-Type" : "application/json" ,
352482 } ,
353- body : JSON . stringify ( {
354- chat_id : chatId ,
355- action,
356- } ) ,
483+ body : JSON . stringify ( body ) ,
357484 } ) ;
358485
359486 if ( ! response . ok ) {
360- throw new Error ( `Telegram sendChatAction failed: ${ response . status } ${ await response . text ( ) } ` ) ;
487+ throw new Error ( `Telegram ${ method } failed: ${ response . status } ${ await response . text ( ) } ` ) ;
361488 }
362489 }
363490
@@ -367,22 +494,12 @@ export class TelegramChatSurfaceAdapter implements ChatSurfaceAdapter {
367494 text : string ;
368495 parseMode ?: "Markdown" | "MarkdownV2" | "HTML" ;
369496 } ) {
370- const response = await fetch ( `${ TELEGRAM_API_BASE_URL } /bot${ this . options . botToken } /sendMessageDraft` , {
371- method : "POST" ,
372- headers : {
373- "Content-Type" : "application/json" ,
374- } ,
375- body : JSON . stringify ( {
376- chat_id : Number ( input . chatId ) ,
377- draft_id : input . draftId ,
378- text : input . text ,
379- parse_mode : input . parseMode ,
380- } ) ,
497+ await this . telegramJson ( "sendMessageDraft" , {
498+ chat_id : Number ( input . chatId ) ,
499+ draft_id : input . draftId ,
500+ text : input . text ,
501+ parse_mode : input . parseMode ,
381502 } ) ;
382-
383- if ( ! response . ok ) {
384- throw new Error ( `Telegram sendMessageDraft failed: ${ response . status } ${ await response . text ( ) } ` ) ;
385- }
386503 }
387504}
388505
0 commit comments