Skip to content

Commit 94b0dc3

Browse files
Fenrurclaude
andcommitted
feat: file/photo sending to Telegram (PR moazbuilds#23)
- Add [file:/path] directive extraction from Claude responses - Send images via sendPhoto, other files via sendDocument - Export sendLocalFile, sendPhotoFile, sendFileDocument Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 6be9f7e commit 94b0dc3

File tree

1 file changed

+109
-13
lines changed

1 file changed

+109
-13
lines changed

src/commands/telegram.ts

Lines changed: 109 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import { getSettings, loadSettings } from "../config";
33
import { resetSession } from "../sessions";
44
import { transcribeAudioToText } from "../whisper";
55
import { 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(/\[file:(\/[^\]\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+
448537
let botUsername: string | null = null;
449538
let 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

10751171
process.on("SIGTERM", () => { running = false; });
10761172
process.on("SIGINT", () => { running = false; });

0 commit comments

Comments
 (0)