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"
+ });
+});