diff --git a/README.md b/README.md index 0bdde3d..3754738 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,6 @@ Key design goals: ## Screenshot - ### Install ```bash @@ -281,6 +280,9 @@ GitHub skill: Telegram adaptation notes: - Plain text messages behave like a normal Codex conversation turn +- Photo, document, video, audio, voice, animation, sticker, and video-note messages are also routed to Codex as structured attachment prompts +- Media captions are treated as the user request; attachment metadata and Telegram file links are included when available +- On SDK runs, files created or updated in the current Codex turn can be sent back to the Telegram user as document attachments - `/exec` runs a one-off Codex task and does not overwrite the saved project conversation slot - `/auto` runs a one-off Codex task with `approvalPolicy=never` on the SDK backend, or `codex exec --full-auto` on the CLI backend - `/new` is implemented by the bot and resets the current chat session @@ -304,6 +306,7 @@ Telegram adaptation notes: Codex output is streamed with throttled `editMessageText` updates. - Throttle: controlled by `STREAM_THROTTLE_MS` (default `1200`) +- While Codex is still working, the bot keeps Telegram's `typing` indicator alive so the chat shows that a reply is in progress - Long output: auto-chunked to Telegram-safe message sizes - MarkdownV2: escaped to avoid parse failures - Reasoning tags: `...` extracted and rendered as: diff --git a/scripts/telegramSmoke.ts b/scripts/telegramSmoke.ts index b37e85d..e17c9ad 100644 --- a/scripts/telegramSmoke.ts +++ b/scripts/telegramSmoke.ts @@ -29,20 +29,23 @@ const expectedUsername = String(process.env.TELEGRAM_EXPECTED_USERNAME || "") .replace(/^@/, ""); const smokeChatId = String(process.env.TELEGRAM_SMOKE_CHAT_ID || "").trim(); -if (!token) { - console.error("Missing BOT_TOKEN."); +function fail(message: string): never { + process.stderr.write(`${message}\n`); process.exit(1); } +if (!token) { + fail("Missing BOT_TOKEN."); +} + const getMeResponse = await fetch(`https://api.telegram.org/bot${token}/getMe`); const getMePayload = (await getMeResponse.json()) as TelegramApiResponse; if (!getMeResponse.ok || !getMePayload?.ok) { - console.error( + fail( `Telegram getMe failed: ${getMePayload?.description || getMeResponse.status}` ); - process.exit(1); } const botUser = getMePayload.result; @@ -50,8 +53,7 @@ console.log(`Bot username: @${botUser.username}`); console.log(`Bot id: ${botUser.id}`); if (expectedUsername && botUser.username !== expectedUsername) { - console.error(`Expected @${expectedUsername}, got @${botUser.username}`); - process.exit(1); + fail(`Expected @${expectedUsername}, got @${botUser.username}`); } if (smokeChatId) { @@ -73,10 +75,9 @@ if (smokeChatId) { (await sendResponse.json()) as TelegramApiResponse; if (!sendResponse.ok || !sendPayload?.ok) { - console.error( + fail( `Telegram sendMessage failed: ${sendPayload?.description || sendResponse.status}` ); - process.exit(1); } console.log(`Smoke message sent to chat ${smokeChatId}.`); diff --git a/src/bot/handlers.ts b/src/bot/handlers.ts index 9b569f4..8cb465e 100644 --- a/src/bot/handlers.ts +++ b/src/bot/handlers.ts @@ -1,3 +1,6 @@ +import { createHash } from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; import { Markup } from "telegraf"; import { buildPlanPrompt, @@ -39,6 +42,33 @@ interface RegisterHandlersOptions { }; } +type SupportedMediaType = + | "photo" + | "document" + | "video" + | "audio" + | "voice" + | "animation" + | "sticker" + | "video_note"; + +interface TelegramMediaDescriptor { + type: SupportedMediaType; + fileId: string; + lines: string[]; + fileName?: string; + mimeType?: string; + fileSize?: number; +} + +const ATTACHMENT_CACHE_DIR = path.join( + "/tmp", + "codexclaw-telegram-attachments" +); +const MAX_ATTACHMENT_DOWNLOAD_BYTES = 20 * 1024 * 1024; +const MAX_INLINE_TEXT_BYTES = 64 * 1024; +const MAX_INLINE_TEXT_CHARS = 12000; + async function sendChunkedMarkdown( ctx: any, text: string, @@ -145,6 +175,329 @@ function suggestProjectName( return suggestClosestWord(input, candidates, threshold); } +function formatByteSize(size?: number): string { + if (!Number.isFinite(size) || !size || size < 0) { + return "unknown"; + } + + if (size < 1024) { + return `${size} B`; + } + if (size < 1024 * 1024) { + return `${(size / 1024).toFixed(1)} KB`; + } + + return `${(size / (1024 * 1024)).toFixed(1)} MB`; +} + +function sanitizeFileName(name: string): string { + const cleaned = String(name || "") + .trim() + .replace(/[^a-zA-Z0-9._-]+/g, "-") + .replace(/^-+|-+$/g, ""); + return cleaned || "attachment"; +} + +function extensionFromMimeType(mimeType = ""): string { + const normalized = String(mimeType || "").toLowerCase(); + switch (normalized) { + case "image/jpeg": + return ".jpg"; + case "image/png": + return ".png"; + case "image/webp": + return ".webp"; + case "image/gif": + return ".gif"; + case "text/plain": + return ".txt"; + case "application/json": + return ".json"; + case "text/markdown": + return ".md"; + case "text/csv": + return ".csv"; + default: + return ""; + } +} + +function inferAttachmentFileName(media: TelegramMediaDescriptor): string { + if (media.fileName) { + return sanitizeFileName(media.fileName); + } + + const suffix = extensionFromMimeType(media.mimeType); + return sanitizeFileName(`${media.type}${suffix}`); +} + +function isInlineTextMimeType(mimeType = "", fileName = ""): boolean { + const normalizedMime = String(mimeType || "").toLowerCase(); + const normalizedName = String(fileName || "").toLowerCase(); + + if ( + normalizedMime.startsWith("text/") || + normalizedMime === "application/json" || + normalizedMime === "application/xml" || + normalizedMime === "application/javascript" + ) { + return true; + } + + return /\.(txt|md|markdown|json|yaml|yml|xml|csv|log|ts|tsx|js|jsx|mjs|cjs|py|rb|go|rs|java|kt|swift|sh|sql|html|css)$/i.test( + normalizedName + ); +} + +async function cacheTelegramAttachment( + media: TelegramMediaDescriptor, + fileUrl: string +): Promise<{ + cachedPath: string; + inlineText?: string; +}> { + if (!fileUrl) { + throw new Error("Telegram file URL is unavailable."); + } + + const response = await fetch(fileUrl); + if (!response.ok) { + throw new Error(`Attachment download failed with HTTP ${response.status}.`); + } + + const contentLengthHeader = response.headers.get("content-length"); + const contentLength = contentLengthHeader ? Number(contentLengthHeader) : NaN; + if ( + Number.isFinite(contentLength) && + contentLength > MAX_ATTACHMENT_DOWNLOAD_BYTES + ) { + throw new Error( + `Attachment is too large to cache (${formatByteSize(contentLength)}).` + ); + } + + const bytes = Buffer.from(await response.arrayBuffer()); + if (bytes.byteLength > MAX_ATTACHMENT_DOWNLOAD_BYTES) { + throw new Error( + `Attachment is too large to cache (${formatByteSize(bytes.byteLength)}).` + ); + } + + await fs.mkdir(ATTACHMENT_CACHE_DIR, { recursive: true }); + const hash = createHash("sha1") + .update(media.fileId) + .digest("hex") + .slice(0, 12); + const fileName = inferAttachmentFileName(media); + const targetPath = path.join(ATTACHMENT_CACHE_DIR, `${hash}-${fileName}`); + await fs.writeFile(targetPath, bytes); + + let inlineText = ""; + if ( + isInlineTextMimeType(media.mimeType, media.fileName) && + bytes.byteLength <= MAX_INLINE_TEXT_BYTES + ) { + inlineText = bytes.toString("utf8").slice(0, MAX_INLINE_TEXT_CHARS).trim(); + } + + return { + cachedPath: targetPath, + inlineText: inlineText || undefined + }; +} + +function detectTelegramMedia(message: any): TelegramMediaDescriptor | null { + if (Array.isArray(message?.photo) && message.photo.length) { + const photo = message.photo[message.photo.length - 1]; + return { + type: "photo", + fileId: photo.file_id, + fileSize: photo.file_size, + lines: [ + `attachment type: photo`, + `dimensions: ${photo.width || "unknown"}x${photo.height || "unknown"}`, + `size: ${formatByteSize(photo.file_size)}` + ] + }; + } + + if (message?.document?.file_id) { + const document = message.document; + return { + type: "document", + fileId: document.file_id, + fileName: document.file_name, + mimeType: document.mime_type, + fileSize: document.file_size, + lines: [ + `attachment type: document`, + `file name: ${document.file_name || "unknown"}`, + `mime type: ${document.mime_type || "unknown"}`, + `size: ${formatByteSize(document.file_size)}` + ] + }; + } + + if (message?.video?.file_id) { + const video = message.video; + return { + type: "video", + fileId: video.file_id, + fileName: video.file_name, + mimeType: video.mime_type, + fileSize: video.file_size, + lines: [ + `attachment type: video`, + `file name: ${video.file_name || "unknown"}`, + `mime type: ${video.mime_type || "unknown"}`, + `duration: ${video.duration ?? "unknown"} s`, + `dimensions: ${video.width || "unknown"}x${video.height || "unknown"}`, + `size: ${formatByteSize(video.file_size)}` + ] + }; + } + + if (message?.audio?.file_id) { + const audio = message.audio; + return { + type: "audio", + fileId: audio.file_id, + fileName: audio.file_name, + mimeType: audio.mime_type, + fileSize: audio.file_size, + lines: [ + `attachment type: audio`, + `file name: ${audio.file_name || "unknown"}`, + `mime type: ${audio.mime_type || "unknown"}`, + `duration: ${audio.duration ?? "unknown"} s`, + `performer: ${audio.performer || "unknown"}`, + `title: ${audio.title || "unknown"}`, + `size: ${formatByteSize(audio.file_size)}` + ] + }; + } + + if (message?.voice?.file_id) { + const voice = message.voice; + return { + type: "voice", + fileId: voice.file_id, + mimeType: voice.mime_type, + fileSize: voice.file_size, + lines: [ + `attachment type: voice`, + `mime type: ${voice.mime_type || "unknown"}`, + `duration: ${voice.duration ?? "unknown"} s`, + `size: ${formatByteSize(voice.file_size)}` + ] + }; + } + + if (message?.animation?.file_id) { + const animation = message.animation; + return { + type: "animation", + fileId: animation.file_id, + fileName: animation.file_name, + mimeType: animation.mime_type, + fileSize: animation.file_size, + lines: [ + `attachment type: animation`, + `file name: ${animation.file_name || "unknown"}`, + `mime type: ${animation.mime_type || "unknown"}`, + `duration: ${animation.duration ?? "unknown"} s`, + `dimensions: ${animation.width || "unknown"}x${animation.height || "unknown"}`, + `size: ${formatByteSize(animation.file_size)}` + ] + }; + } + + if (message?.sticker?.file_id) { + const sticker = message.sticker; + return { + type: "sticker", + fileId: sticker.file_id, + lines: [ + `attachment type: sticker`, + `emoji: ${sticker.emoji || "none"}`, + `set name: ${sticker.set_name || "unknown"}`, + `dimensions: ${sticker.width || "unknown"}x${sticker.height || "unknown"}`, + `animated: ${sticker.is_animated ? "yes" : "no"}`, + `video sticker: ${sticker.is_video ? "yes" : "no"}` + ] + }; + } + + if (message?.video_note?.file_id) { + const note = message.video_note; + return { + type: "video_note", + fileId: note.file_id, + fileSize: note.file_size, + lines: [ + `attachment type: video note`, + `duration: ${note.duration ?? "unknown"} s`, + `length: ${note.length || "unknown"}`, + `size: ${formatByteSize(note.file_size)}` + ] + }; + } + + return null; +} + +async function buildMediaPrompt(ctx: any): Promise { + const media = detectTelegramMedia(ctx.message); + if (!media) { + return null; + } + + let fileUrl = ""; + try { + const link = await ctx.telegram?.getFileLink?.(media.fileId); + fileUrl = link ? String(link) : ""; + } catch { + fileUrl = ""; + } + + let cachedPath = ""; + let inlineText = ""; + let downloadError = ""; + try { + const cached = await cacheTelegramAttachment(media, fileUrl); + cachedPath = cached.cachedPath; + inlineText = cached.inlineText || ""; + } catch (error) { + downloadError = toErrorMessage(error); + } + + const caption = String(ctx.message?.caption || "").trim(); + const lines = [ + "The user sent a Telegram attachment instead of plain text.", + "Treat the attachment metadata below as input context for this turn.", + "If a local cached path is present, inspect that file directly.", + ...media.lines, + `telegram file id: ${media.fileId}`, + `telegram file url: ${fileUrl || "unavailable"}`, + `cached local path: ${cachedPath || "unavailable"}`, + `download status: ${downloadError ? `not cached (${downloadError})` : "cached"}`, + `caption: ${caption || "(none)"}` + ]; + + if (inlineText) { + lines.push("", "inline attachment text:", inlineText); + } + + lines.push( + "", + caption + ? `User request: ${caption}` + : "User request: Please inspect this attachment and help based on the available metadata." + ); + + return lines.join("\n"); +} + export function registerHandlers({ bot, router, @@ -892,4 +1245,36 @@ export function registerHandlers({ ); } }); + + const handleMediaMessage = async (ctx: any) => { + const locale = localeOf(ctx.chat.id); + + try { + const prompt = await buildMediaPrompt(ctx); + if (!prompt) { + return; + } + + const result = await ptyManager.sendPrompt(ctx, prompt); + await handlePromptResult(ctx, locale, result); + } catch (error) { + await sendChunkedMarkdown( + ctx, + t(locale, "processingFailed", { error: toErrorMessage(error) }) + ); + } + }; + + for (const event of [ + "photo", + "document", + "video", + "audio", + "voice", + "animation", + "sticker", + "video_note" + ]) { + bot.on(event, handleMediaMessage); + } } diff --git a/src/bot/i18n.ts b/src/bot/i18n.ts index bd7eed8..c6ad655 100644 --- a/src/bot/i18n.ts +++ b/src/bot/i18n.ts @@ -345,6 +345,15 @@ const MESSAGES: Record = { sessionRestored: ({ project, mode }) => `Resumed Codex for ${project} (${mode}).`, sessionStarted: ({ mode }) => `Started Codex (${mode}).`, + artifactBatchNotice: ({ sentCount, totalCount, fileLines, omittedCount }) => + joinLines([ + `Generated ${totalCount} file(s) in this turn.`, + "Sending these Telegram attachments:", + ...fileLines, + omittedCount > 0 + ? `Not sent automatically: ${omittedCount} more file(s).` + : "All eligible files are being sent." + ]), mcpServerNotConfigured: "No MCP servers are configured. Add server definitions to MCP_SERVERS in .env first.", mcpExplicitOnly: @@ -802,6 +811,15 @@ const MESSAGES: Record = { sessionRestored: ({ project, mode }) => `已恢复 ${project} 的 Codex 会话 (${mode})。`, sessionStarted: ({ mode }) => `Codex 会话已启动 (${mode})。`, + artifactBatchNotice: ({ sentCount, totalCount, fileLines, omittedCount }) => + joinLines([ + `本轮共生成 ${totalCount} 个文件。`, + "即将通过 Telegram 发送这些附件:", + ...fileLines, + omittedCount > 0 + ? `还有 ${omittedCount} 个文件未自动发送。` + : "所有符合条件的文件都会发送。" + ]), mcpServerNotConfigured: "MCP server 未配置。请先在 .env 的 MCP_SERVERS 中添加服务定义。", mcpExplicitOnly: diff --git a/src/runner/ptyManager.ts b/src/runner/ptyManager.ts index c0be07e..6c91dd7 100644 --- a/src/runner/ptyManager.ts +++ b/src/runner/ptyManager.ts @@ -1,5 +1,9 @@ import fs from "node:fs"; -import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { + execFileSync, + spawn, + type ChildProcessWithoutNullStreams +} from "node:child_process"; import path from "node:path"; import process from "node:process"; import { @@ -42,6 +46,27 @@ interface TelegramApiLike { text: string, options?: Record ): Promise; + sendChatAction?(chatId: string | number, action: string): Promise; + sendDocument?( + chatId: string | number, + document: unknown, + options?: Record + ): Promise; + sendPhoto?( + chatId: string | number, + photo: unknown, + options?: Record + ): Promise; + sendVideo?( + chatId: string | number, + video: unknown, + options?: Record + ): Promise; + sendAudio?( + chatId: string | number, + audio: unknown, + options?: Record + ): Promise; editMessageText( chatId: string | number, messageId: number, @@ -109,6 +134,9 @@ interface RunnerSession { lastRendered: string; flushQueue: Promise; throttledFlush: ReturnType; + chatActionInterval: NodeJS.Timeout | null; + changedFiles: Set; + baselineChangedFiles: Set; write: ((input: string) => void) | null; interrupt: (() => void) | null; close: (() => void) | null; @@ -385,6 +413,55 @@ const WORKFLOW_PHASE_MARKERS: ReadonlyArray<{ } ]; +const MAX_TELEGRAM_ARTIFACTS = 3; +const MAX_TELEGRAM_ARTIFACT_BYTES = 10 * 1024 * 1024; + +function getArtifactTransport(filePath: string): { + action: "upload_document" | "upload_photo" | "upload_video" | "upload_voice"; + method: "sendDocument" | "sendPhoto" | "sendVideo" | "sendAudio"; +} { + const extension = path.extname(filePath).toLowerCase(); + + if ([".png", ".jpg", ".jpeg", ".webp"].includes(extension)) { + return { + action: "upload_photo", + method: "sendPhoto" + }; + } + + if ([".mp4", ".mov", ".webm", ".mkv"].includes(extension)) { + return { + action: "upload_video", + method: "sendVideo" + }; + } + + if ([".mp3", ".wav", ".m4a", ".ogg"].includes(extension)) { + return { + action: "upload_voice", + method: "sendAudio" + }; + } + + return { + action: "upload_document", + method: "sendDocument" + }; +} + +function normalizeGitStatusPath(rawPath: string, workdir: string): string { + const normalized = String(rawPath || "").trim(); + if (!normalized) { + return ""; + } + + const nextPath = normalized.includes(" -> ") + ? normalized.split(" -> ").at(-1) || normalized + : normalized; + + return path.resolve(workdir, nextPath); +} + function detectWorkflowPhase(rawText: string): WorkflowPhase | null { const normalized = String(rawText || "").toLowerCase(); if (!normalized) { @@ -909,6 +986,7 @@ export class PtyManager { options.workdir || state.currentWorkdir || this.config.runner.cwd ); const projectState = this.ensureProjectState(key, workdir); + const baselineChangedFiles = this.listChangedFilesFromGit(workdir); const session: RunnerSession = { chatId: key, mode, @@ -930,6 +1008,9 @@ export class PtyManager { this.config.runner.throttleMs, { leading: true, trailing: true } ), + chatActionInterval: null, + changedFiles: new Set(), + baselineChangedFiles, write: null, interrupt: null, close: null, @@ -937,9 +1018,58 @@ export class PtyManager { }; this.sessions.set(key, session); + this.startChatActionHeartbeat(session); return session; } + startChatActionHeartbeat(session: RunnerSession): void { + const sendChatAction = this.bot.telegram.sendChatAction; + if (!sendChatAction) { + return; + } + + const send = () => + sendChatAction + .call(this.bot.telegram, session.chatId, "typing") + .catch(() => {}); + + send(); + session.chatActionInterval = setInterval(send, 4000); + } + + stopChatActionHeartbeat(session: RunnerSession): void { + if (!session.chatActionInterval) { + return; + } + + clearInterval(session.chatActionInterval); + session.chatActionInterval = null; + } + + listChangedFilesFromGit(workdir: string): Set { + try { + const output = execFileSync( + "git", + ["status", "--short", "--untracked-files=all"], + { + cwd: workdir, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"] + } + ); + + return new Set( + output + .split("\n") + .map((line) => line.slice(3)) + .map((entry) => normalizeGitStatusPath(entry, workdir)) + .filter(Boolean) + ); + } catch { + return new Set(); + } + } + captureSessionMetadata(session: RunnerSession): void { if (!session.trackConversation) return; @@ -956,6 +1086,7 @@ export class PtyManager { updateSdkRenderableItem(session: RunnerSession, item: ThreadItem): void { const text = summarizeSdkItem(item, this.isVerbose(session.chatId)); + this.captureChangedFiles(session, item); const hasEntry = session.renderableItems.has(item.id); if (!text) { @@ -987,11 +1118,144 @@ export class PtyManager { .trim(); } + captureChangedFiles(session: RunnerSession, item: ThreadItem): void { + if (item.type !== "file_change") { + return; + } + + for (const change of item.changes || []) { + if (!change?.path) { + continue; + } + + session.changedFiles.add(path.resolve(session.workdir, change.path)); + } + } + + async sendChangedFilesToTelegram(session: RunnerSession): Promise { + if (!session.changedFiles.size) { + return; + } + + const eligibleFiles = [...session.changedFiles] + .filter((filePath) => { + if (!fs.existsSync(filePath)) { + console.info( + `[artifacts] skipping missing file for chat ${session.chatId}: ${filePath}` + ); + return false; + } + + try { + const stat = fs.statSync(filePath); + if (!stat.isFile()) { + console.info( + `[artifacts] skipping non-file path for chat ${session.chatId}: ${filePath}` + ); + return false; + } + if (stat.size > MAX_TELEGRAM_ARTIFACT_BYTES) { + console.info( + `[artifacts] skipping oversized file for chat ${session.chatId}: ${filePath} (${stat.size} bytes)` + ); + return false; + } + + return true; + } catch { + return false; + } + }) + .sort(); + const candidates = eligibleFiles.slice(0, MAX_TELEGRAM_ARTIFACTS); + + if (!candidates.length) { + console.info( + `[artifacts] no eligible files to send for chat ${session.chatId}` + ); + } + + if (candidates.length > 1) { + const fileLines = candidates.map((filePath) => { + const relativePath = + path.relative(session.workdir, filePath) || path.basename(filePath); + return `- ${relativePath}`; + }); + const omittedCount = Math.max( + 0, + eligibleFiles.length - candidates.length + ); + + await this.bot.telegram + .sendMessage( + session.chatId, + t(this.getLanguage(session.chatId), "artifactBatchNotice", { + sentCount: candidates.length, + totalCount: eligibleFiles.length, + fileLines, + omittedCount + }) + ) + .catch(() => {}); + } + + for (const filePath of candidates) { + const relativePath = + path.relative(session.workdir, filePath) || path.basename(filePath); + const transport = getArtifactTransport(filePath); + const sender = this.bot.telegram[transport.method]; + + if (!sender) { + console.warn( + `[artifacts] telegram method ${transport.method} is unavailable for ${relativePath}` + ); + continue; + } + + await this.bot.telegram + .sendChatAction?.(session.chatId, transport.action) + .catch(() => {}); + + await sender + .call( + this.bot.telegram, + session.chatId, + { + source: filePath, + filename: path.basename(filePath) + }, + { + caption: `Generated file: ${relativePath}` + } + ) + .then(() => { + console.info( + `[artifacts] sent ${relativePath} to chat ${session.chatId} via ${transport.method}` + ); + }) + .catch((error: unknown) => { + console.warn( + `[artifacts] failed to send ${relativePath} to chat ${session.chatId}: ${toErrorMessage(error)}` + ); + }); + } + } + async finalizeSession( session: RunnerSession, exitCode: number | null, signal: ExitSignal ): Promise { + this.stopChatActionHeartbeat(session); + const changedNow = this.listChangedFilesFromGit(session.workdir); + for (const filePath of changedNow) { + if (!session.baselineChangedFiles.has(filePath)) { + session.changedFiles.add(filePath); + } + } + await session.flushQueue.catch(() => {}); + await this.flushToTelegram(session.chatId).catch(() => {}); + await this.sendChangedFilesToTelegram(session); this.captureSessionMetadata(session); const projectState = this.ensureProjectState( session.chatId, @@ -1132,6 +1396,7 @@ export class PtyManager { ); proc.on("error", async (error) => { + this.stopChatActionHeartbeat(session); await this.bot.telegram .sendMessage( session.chatId, diff --git a/tests/handlers.test.ts b/tests/handlers.test.ts index 98363f5..583fb51 100644 --- a/tests/handlers.test.ts +++ b/tests/handlers.test.ts @@ -1,3 +1,4 @@ +import fs from "node:fs/promises"; import test from "node:test"; import assert from "node:assert/strict"; import { registerHandlers } from "../src/bot/handlers.js"; @@ -16,8 +17,12 @@ interface TestContext { from: { id: number; }; - message: { - text: string; + telegram?: { + getFileLink?: (fileId: string) => Promise; + }; + message: Record & { + text?: string; + caption?: string; }; callbackQuery?: { data?: string; @@ -54,6 +59,7 @@ function createContext(text: string, chatId = 1): TestContext { from: { id: chatId }, + telegram: {}, message: { text }, @@ -68,10 +74,19 @@ function createContext(text: string, chatId = 1): TestContext { }; } +function createMediaContext( + message: TestContext["message"], + chatId = 1 +): TestContext { + const ctx = createContext("", chatId); + ctx.message = message; + return ctx; +} + function createDependencies( overrides: { - sendPrompt?: () => Promise; - continuePendingPrompt?: () => Promise; + sendPrompt?: (...args: any[]) => Promise; + continuePendingPrompt?: (...args: any[]) => Promise; routeMessage?: (text: string) => Promise; githubExecute?: () => Promise; getStatus?: () => Record; @@ -425,3 +440,222 @@ test("text handler shows guidance when plain-text github write actions are block assert.match(ctx.replies[0].text, /explicit/i); assert.match(ctx.replies[0].text, /\/gh create repo/i); }); + +test("photo messages are converted into Codex prompts with caption and file link", async () => { + const prompts: string[] = []; + const originalFetch = globalThis.fetch; + const { bot } = createDependencies({ + sendPrompt: async (_ctx: unknown, prompt: string) => { + prompts.push(prompt); + return { + started: true, + mode: "sdk" + }; + } + }); + const ctx = createMediaContext({ + caption: "帮我看看这张图里有什么问题", + photo: [ + { + file_id: "photo-small", + width: 90, + height: 90, + file_size: 1000 + }, + { + file_id: "photo-large", + width: 1280, + height: 720, + file_size: 245760 + } + ] + }); + ctx.telegram = { + getFileLink: async (fileId: string) => + `https://example.test/files/${fileId}.jpg` + }; + const photoHandler = bot.events.get("photo"); + + if (!photoHandler) { + throw new Error("Expected photo handler to be registered"); + } + + globalThis.fetch = async () => + new Response(Buffer.from("fake image bytes"), { + status: 200, + headers: { + "content-length": "16", + "content-type": "image/jpeg" + } + }); + + try { + await photoHandler(ctx); + } finally { + globalThis.fetch = originalFetch; + } + + assert.equal(prompts.length, 1); + assert.match(prompts[0], /attachment type: photo/i); + assert.match(prompts[0], /1280x720/); + assert.match(prompts[0], /telegram file id: photo-large/); + assert.match(prompts[0], /https:\/\/example\.test\/files\/photo-large\.jpg/); + assert.match( + prompts[0], + /cached local path: \/tmp\/codexclaw-telegram-attachments\// + ); + assert.match(prompts[0], /download status: cached/); + assert.match(prompts[0], /帮我看看这张图里有什么问题/); +}); + +test("document messages include inline text when the attachment is readable text", async () => { + const prompts: string[] = []; + const originalFetch = globalThis.fetch; + const { bot } = createDependencies({ + sendPrompt: async (_ctx: unknown, prompt: string) => { + prompts.push(prompt); + return { + started: true, + mode: "sdk" + }; + } + }); + const ctx = createMediaContext({ + document: { + file_id: "doc-1", + file_name: "error.log", + mime_type: "text/plain", + file_size: 4096 + } + }); + ctx.telegram = { + getFileLink: async (fileId: string) => + `https://example.test/files/${fileId}.txt` + }; + const documentHandler = bot.events.get("document"); + + if (!documentHandler) { + throw new Error("Expected document handler to be registered"); + } + + globalThis.fetch = async () => + new Response(Buffer.from("line one\nline two\nline three\n"), { + status: 200, + headers: { + "content-length": "28", + "content-type": "text/plain" + } + }); + + try { + await documentHandler(ctx); + } finally { + globalThis.fetch = originalFetch; + } + + assert.equal(prompts.length, 1); + assert.match(prompts[0], /attachment type: document/i); + assert.match(prompts[0], /file name: error\.log/i); + assert.match(prompts[0], /mime type: text\/plain/i); + assert.match(prompts[0], /inline attachment text:/i); + assert.match(prompts[0], /line one/); + assert.match(prompts[0], /caption: \(none\)/i); +}); + +test("document messages fall back cleanly when attachment download fails", async () => { + const prompts: string[] = []; + const originalFetch = globalThis.fetch; + const { bot } = createDependencies({ + sendPrompt: async (_ctx: unknown, prompt: string) => { + prompts.push(prompt); + return { + started: true, + mode: "sdk" + }; + } + }); + const ctx = createMediaContext({ + document: { + file_id: "doc-2", + file_name: "broken.log", + mime_type: "text/plain", + file_size: 42 + } + }); + ctx.telegram = { + getFileLink: async (fileId: string) => + `https://example.test/files/${fileId}.txt` + }; + const documentHandler = bot.events.get("document"); + + if (!documentHandler) { + throw new Error("Expected document handler to be registered"); + } + + globalThis.fetch = async () => + new Response("denied", { + status: 403 + }); + + try { + await documentHandler(ctx); + } finally { + globalThis.fetch = originalFetch; + } + + assert.equal(prompts.length, 1); + assert.match(prompts[0], /cached local path: unavailable/i); + assert.match(prompts[0], /download status: not cached/i); +}); + +test("cached text attachments are actually written to disk", async () => { + const prompts: string[] = []; + const originalFetch = globalThis.fetch; + const { bot } = createDependencies({ + sendPrompt: async (_ctx: unknown, prompt: string) => { + prompts.push(prompt); + return { + started: true, + mode: "sdk" + }; + } + }); + const ctx = createMediaContext({ + document: { + file_id: "doc-cache", + file_name: "sample.txt", + mime_type: "text/plain", + file_size: 12 + } + }); + ctx.telegram = { + getFileLink: async () => "https://example.test/files/sample.txt" + }; + const documentHandler = bot.events.get("document"); + + if (!documentHandler) { + throw new Error("Expected document handler to be registered"); + } + + globalThis.fetch = async () => + new Response(Buffer.from("cached hello"), { + status: 200, + headers: { + "content-length": "12", + "content-type": "text/plain" + } + }); + + try { + await documentHandler(ctx); + } finally { + globalThis.fetch = originalFetch; + } + + const cachedPathMatch = prompts[0]?.match( + /cached local path: (\/tmp\/codexclaw-telegram-attachments\/[^\n]+)/ + ); + assert.ok(cachedPathMatch); + const cachedContent = await fs.readFile(cachedPathMatch[1], "utf8"); + assert.equal(cachedContent, "cached hello"); +}); diff --git a/tests/ptyManager.test.ts b/tests/ptyManager.test.ts index 07d2b5d..23b609e 100644 --- a/tests/ptyManager.test.ts +++ b/tests/ptyManager.test.ts @@ -30,9 +30,14 @@ type FakeCall = interface SentMessageRecord { chatId: string | number; - text: string; + text?: string; messageId?: number; edited?: boolean; + document?: unknown; + photo?: unknown; + video?: unknown; + audio?: unknown; + options?: Record; } function createManager(overrides: ManagerOverrides = {}) { @@ -40,6 +45,10 @@ function createManager(overrides: ManagerOverrides = {}) { const workspaceRoot = overrides.workspaceRoot || runnerCwd; const telegram: TelegramStub = overrides.telegram || { sendMessage: async () => ({ message_id: 1 }), + sendDocument: async () => ({ message_id: 1 }), + sendPhoto: async () => ({ message_id: 1 }), + sendVideo: async () => ({ message_id: 1 }), + sendAudio: async () => ({ message_id: 1 }), editMessageText: async () => ({}), deleteMessage: async () => ({}) }; @@ -486,6 +495,9 @@ test("pty manager stores SDK thread ids per project and resumes them", async () if (!firstMessage) { throw new Error("Expected at least one Telegram message"); } + if (!firstMessage.text) { + throw new Error("Expected the Telegram record to include text"); + } assert.match(firstMessage.text, /Project A ready/); await manager.sendPrompt({ chat: { id: 9 } }, "continue project a"); @@ -497,6 +509,9 @@ test("pty manager stores SDK thread ids per project and resumes them", async () if (!resumedMessage) { throw new Error("Expected a resumed Telegram message"); } + if (!resumedMessage.text) { + throw new Error("Expected the resumed Telegram record to include text"); + } assert.match(resumedMessage.text, /Project A resumed/); }); @@ -689,5 +704,389 @@ test("pty manager shows exec fallback notices when verbose output is on", async await manager.sendPrompt({ chat: { id: 77 } }, "who are u"); assert.equal(sentMessages.length, 1); + if (!sentMessages[0].text) { + throw new Error("Expected the fallback notice to include text"); + } assert.match(sentMessages[0].text, /Interactive terminal is unavailable/); }); + +test("pty manager sends Telegram typing actions while a session is active", async () => { + const chatActions: Array<{ chatId: string | number; action: string }> = []; + let releaseTurn = () => {}; + const turnReleased = new Promise((resolve) => { + releaseTurn = () => resolve(); + }); + const manager = createManager({ + backend: "sdk", + telegram: { + sendMessage: async () => ({ message_id: 1 }), + sendChatAction: async (chatId: string | number, action: string) => { + chatActions.push({ chatId, action }); + return {}; + }, + editMessageText: async () => ({}), + deleteMessage: async () => ({}) + }, + codexClientFactory: createFakeCodexClient([ + { + events: async function* () { + await turnReleased; + yield { + type: "item.completed", + item: { + id: "item-1", + type: "agent_message", + text: "done" + } + }; + yield { + type: "turn.completed", + usage: { + input_tokens: 1, + cached_input_tokens: 0, + output_tokens: 1 + } + }; + } + } + ]) + }); + + await manager.sendPrompt({ chat: { id: 88 } }, "wait a bit"); + await waitFor(() => chatActions.length > 0); + + assert.deepEqual(chatActions[0], { chatId: "88", action: "typing" }); + releaseTurn(); + await waitFor(() => !manager.getStatus(88).active); +}); + +test("pty manager sends changed files back to Telegram after an SDK run", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "claws-artifacts-")); + fs.writeFileSync(path.join(root, "report.txt"), "artifact body"); + const sentMessages: SentMessageRecord[] = []; + + const manager = createManager({ + backend: "sdk", + runnerCwd: root, + workspaceRoot: root, + telegram: { + sendMessage: async (chatId: string | number, text: string) => { + sentMessages.push({ chatId, text }); + return { message_id: sentMessages.length }; + }, + sendChatAction: async () => ({}), + sendDocument: async ( + chatId: string | number, + document: unknown, + options?: Record + ) => { + sentMessages.push({ chatId, document, options }); + return { message_id: sentMessages.length }; + }, + editMessageText: async () => ({}), + deleteMessage: async () => ({}) + }, + codexClientFactory: createFakeCodexClient([ + { + events: async function* () { + yield { + type: "item.completed", + item: { + id: "item-file", + type: "file_change", + changes: [{ kind: "write", path: "report.txt" }] + } + }; + yield { + type: "item.completed", + item: { + id: "item-msg", + type: "agent_message", + text: "generated report" + } + }; + yield { + type: "turn.completed", + usage: { + input_tokens: 1, + cached_input_tokens: 0, + output_tokens: 1 + } + }; + } + } + ]) + }); + + await manager.sendPrompt({ chat: { id: 33 } }, "make a report"); + await waitFor(() => !manager.getStatus(33).active); + + const artifact = sentMessages.find((entry) => Boolean(entry.document)); + assert.ok(artifact); + assert.equal(artifact.chatId, "33"); + assert.deepEqual(artifact.options, { + caption: "Generated file: report.txt" + }); +}); + +test("pty manager announces the file list when multiple artifacts are being sent", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "claws-batch-artifacts-")); + fs.writeFileSync(path.join(root, "a.txt"), "A"); + fs.writeFileSync(path.join(root, "b.txt"), "B"); + const sentMessages: SentMessageRecord[] = []; + + const manager = createManager({ + backend: "sdk", + runnerCwd: root, + workspaceRoot: root, + telegram: { + sendMessage: async (chatId: string | number, text: string) => { + sentMessages.push({ chatId, text }); + return { message_id: sentMessages.length }; + }, + sendChatAction: async () => ({}), + sendDocument: async ( + chatId: string | number, + document: unknown, + options?: Record + ) => { + sentMessages.push({ chatId, document, options }); + return { message_id: sentMessages.length }; + }, + editMessageText: async () => ({}), + deleteMessage: async () => ({}) + }, + codexClientFactory: createFakeCodexClient([ + { + events: async function* () { + yield { + type: "item.completed", + item: { + id: "item-file-1", + type: "file_change", + changes: [{ kind: "write", path: "a.txt" }] + } + }; + yield { + type: "item.completed", + item: { + id: "item-file-2", + type: "file_change", + changes: [{ kind: "write", path: "b.txt" }] + } + }; + yield { + type: "turn.completed", + usage: { + input_tokens: 1, + cached_input_tokens: 0, + output_tokens: 1 + } + }; + } + } + ]) + }); + + await manager.sendPrompt({ chat: { id: 34 } }, "make two files"); + await waitFor(() => !manager.getStatus(34).active); + + const notice = sentMessages.find( + (entry) => + entry.text && /Sending these Telegram attachments/i.test(entry.text) + ); + assert.ok(notice); + assert.match(notice.text || "", /a\.txt/); + assert.match(notice.text || "", /b\.txt/); + + const artifacts = sentMessages.filter((entry) => Boolean(entry.document)); + assert.equal(artifacts.length, 2); +}); + +test("pty manager falls back to git diff artifacts for sdk sessions", async () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), "claws-sdk-git-artifacts-") + ); + fs.mkdirSync(path.join(root, ".git")); + fs.writeFileSync(path.join(root, "weather.png"), "png-bytes"); + const sentMessages: SentMessageRecord[] = []; + + const manager = createManager({ + backend: "sdk", + runnerCwd: root, + workspaceRoot: root, + telegram: { + sendMessage: async () => ({ message_id: 1 }), + sendChatAction: async () => ({}), + sendDocument: async () => ({ message_id: 1 }), + sendPhoto: async ( + chatId: string | number, + photo: unknown, + options?: Record + ) => { + sentMessages.push({ chatId, photo, options }); + return { message_id: sentMessages.length }; + }, + editMessageText: async () => ({}), + deleteMessage: async () => ({}) + }, + codexClientFactory: createFakeCodexClient([ + { + events: async function* () { + yield { + type: "item.completed", + item: { + id: "item-msg", + type: "agent_message", + text: "weather chart ready" + } + }; + yield { + type: "turn.completed", + usage: { + input_tokens: 1, + cached_input_tokens: 0, + output_tokens: 1 + } + }; + } + } + ]) + }); + + const originalListChangedFilesFromGit = + manager.listChangedFilesFromGit.bind(manager); + let callCount = 0; + manager.listChangedFilesFromGit = () => { + callCount += 1; + if (callCount === 1) { + return new Set(); + } + + return new Set([path.join(root, "weather.png")]); + }; + + await manager.sendPrompt({ chat: { id: 66 } }, "make a weather png"); + await waitFor(() => !manager.getStatus(66).active); + manager.listChangedFilesFromGit = originalListChangedFilesFromGit; + + const artifact = sentMessages.find((entry) => Boolean(entry.photo)); + assert.ok(artifact); + assert.equal(artifact.chatId, "66"); + assert.deepEqual(artifact.options, { + caption: "Generated file: weather.png" + }); +}); + +test("pty manager sends generated images back as Telegram photos", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "claws-photo-artifacts-")); + fs.writeFileSync(path.join(root, "preview.png"), "png-bytes"); + const sentMessages: SentMessageRecord[] = []; + + const manager = createManager({ + backend: "sdk", + runnerCwd: root, + workspaceRoot: root, + telegram: { + sendMessage: async () => ({ message_id: 1 }), + sendChatAction: async () => ({}), + sendDocument: async () => ({ message_id: 1 }), + sendPhoto: async ( + chatId: string | number, + photo: unknown, + options?: Record + ) => { + sentMessages.push({ chatId, photo, options }); + return { message_id: sentMessages.length }; + }, + editMessageText: async () => ({}), + deleteMessage: async () => ({}) + }, + codexClientFactory: createFakeCodexClient([ + { + events: async function* () { + yield { + type: "item.completed", + item: { + id: "item-photo", + type: "file_change", + changes: [{ kind: "write", path: "preview.png" }] + } + }; + yield { + type: "turn.completed", + usage: { + input_tokens: 1, + cached_input_tokens: 0, + output_tokens: 1 + } + }; + } + } + ]) + }); + + await manager.sendPrompt({ chat: { id: 44 } }, "make a preview image"); + await waitFor(() => !manager.getStatus(44).active); + + const photoArtifact = sentMessages.find((entry) => Boolean(entry.photo)); + assert.ok(photoArtifact); + assert.equal(photoArtifact.chatId, "44"); + assert.deepEqual(photoArtifact.options, { + caption: "Generated file: preview.png" + }); +}); + +test("pty manager sends newly changed git files back for cli sessions", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "claws-cli-artifacts-")); + fs.mkdirSync(path.join(root, ".git")); + fs.writeFileSync(path.join(root, "before.txt"), "old"); + + const sentMessages: SentMessageRecord[] = []; + const manager = createManager({ + backend: "cli", + runnerCwd: root, + workspaceRoot: root, + telegram: { + sendMessage: async () => ({ message_id: 1 }), + sendChatAction: async () => ({}), + sendDocument: async ( + chatId: string | number, + document: unknown, + options?: Record + ) => { + sentMessages.push({ chatId, document, options }); + return { message_id: sentMessages.length }; + }, + editMessageText: async () => ({}), + deleteMessage: async () => ({}) + } + }); + + const originalListChangedFilesFromGit = + manager.listChangedFilesFromGit.bind(manager); + let callCount = 0; + manager.listChangedFilesFromGit = () => { + callCount += 1; + if (callCount === 1) { + return new Set([path.join(root, "before.txt")]); + } + + return new Set([ + path.join(root, "before.txt"), + path.join(root, "after.txt") + ]); + }; + + fs.writeFileSync(path.join(root, "after.txt"), "new"); + const session = manager.createBaseSession("55", "exec", { workdir: root }); + await manager.finalizeSession(session, 0, null); + manager.listChangedFilesFromGit = originalListChangedFilesFromGit; + + const artifact = sentMessages.find((entry) => Boolean(entry.document)); + assert.ok(artifact); + assert.equal(artifact.chatId, "55"); + assert.deepEqual(artifact.options, { + caption: "Generated file: after.txt" + }); +});