Skip to content

Commit 47f26f0

Browse files
AliceLJYclaude
andcommitted
feat: P0 output relay — images, files, and long code back to Telegram
- adapters/claude.js: capture SDKUserMessage (tool result images), SDKFilesPersistedEvent, and Write/Edit file paths as new event types - bridge.js: collect image/file events during streaming, send via replyWithPhoto/replyWithDocument before text result; long code blocks (>60% code, >4000 chars) sent as file attachment with preview - progress.js: add screenshot tool icons Closes the biggest UX gap: input was bidirectional but output was text-only. Now CC screenshots, generated images, and created files flow back to Telegram automatically. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 3d2a85e commit 47f26f0

3 files changed

Lines changed: 117 additions & 3 deletions

File tree

adapters/claude.js

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,10 @@ export function createAdapter(config = {}) {
230230
};
231231
}
232232
}
233+
// 从 Write/Edit 输入提取文件路径
234+
if ((block.name === "Write" || block.name === "Edit") && block.input?.file_path) {
235+
yield { type: "file_written", filePath: block.input.file_path, tool: block.name };
236+
}
233237
yield {
234238
type: "progress",
235239
toolName: block.name,
@@ -241,6 +245,43 @@ export function createAdapter(config = {}) {
241245
}
242246
}
243247

248+
// 捕获工具结果中的图片(SDKUserMessage)
249+
if (msg.type === "user" && msg.parent_tool_use_id) {
250+
const content = msg.message?.content;
251+
if (Array.isArray(content)) {
252+
for (const block of content) {
253+
// tool_result 嵌套内容
254+
if (block.type === "tool_result" && Array.isArray(block.content)) {
255+
for (const part of block.content) {
256+
if (part.type === "image" && part.source?.data) {
257+
yield {
258+
type: "image",
259+
data: part.source.data,
260+
mediaType: part.source.media_type || "image/png",
261+
toolUseId: block.tool_use_id,
262+
};
263+
}
264+
}
265+
}
266+
// 顶层 image block
267+
if (block.type === "image" && block.source?.data) {
268+
yield {
269+
type: "image",
270+
data: block.source.data,
271+
mediaType: block.source.media_type || "image/png",
272+
};
273+
}
274+
}
275+
}
276+
}
277+
278+
// 捕获文件持久化事件
279+
if (msg.type === "system" && msg.subtype === "files_persisted") {
280+
for (const f of msg.files || []) {
281+
yield { type: "file_persisted", filename: f.filename, fileId: f.file_id };
282+
}
283+
}
284+
244285
if (msg.type === "result") {
245286
const resultText = msg.subtype === "success" ? (msg.result || "") : (msg.errors || []).join("\n");
246287
console.log(`[Claude SDK] result: subtype=${msg.subtype} cost=${msg.total_cost_usd} text=${resultText.slice(0, 200)}`);

bridge.js

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
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";
55
import { HttpsProxyAgent } from "https-proxy-agent";
6-
import { mkdirSync, writeFileSync, readdirSync, statSync, unlinkSync } from "fs";
6+
import { mkdirSync, writeFileSync, readdirSync, statSync, unlinkSync, existsSync } from "fs";
77
import { basename, join } from "path";
88
import {
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+
566579
function 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
}

progress.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ const TOOL_ICONS = {
1919
TaskList: "📋",
2020
TaskGet: "📋",
2121
AskUserQuestion: "❓",
22+
take_screenshot: "📸",
23+
"mcp__computer-use__screenshot": "📸",
24+
"mcp__peekaboo__see": "👁️",
25+
"mcp__chrome-devtools__take_screenshot": "📸",
26+
"mcp__chrome-devtools__take_snapshot": "📸",
2227
};
2328

2429
const SILENT_TOOLS = new Set([

0 commit comments

Comments
 (0)