diff --git a/docs/users/features/channels/dingtalk.md b/docs/users/features/channels/dingtalk.md index 72ff61435f0..e206a29cd7d 100644 --- a/docs/users/features/channels/dingtalk.md +++ b/docs/users/features/channels/dingtalk.md @@ -185,7 +185,7 @@ You can send photos and documents to the bot, not just text. - **Use DingTalk markdown-aware instructions** — DingTalk supports headings, bold text, links, code blocks, and tables. Keep tables compact because narrow screens may scroll horizontally. - **Restrict access** — In an organization context, `senderPolicy: "open"` may be acceptable. For tighter control, use `"allowlist"` or `"pairing"`. See [DM Pairing](./overview#dm-pairing) for details. -- **Referenced messages** — Quoting (replying to) a user message includes the quoted text as context for the agent. Quoting bot responses is not yet supported. +- **Referenced messages** — Quoting (replying to) a user message includes the quoted text as context for the agent. If the quoted message is a picture, file, audio, or video message, the bot downloads and attaches it the same way as when sent directly. Quoting bot responses is not yet supported. ## Troubleshooting diff --git a/packages/channels/dingtalk/src/DingtalkAdapter.test.ts b/packages/channels/dingtalk/src/DingtalkAdapter.test.ts index 5b5d5cb45e2..fa06420bd5b 100644 --- a/packages/channels/dingtalk/src/DingtalkAdapter.test.ts +++ b/packages/channels/dingtalk/src/DingtalkAdapter.test.ts @@ -1,8 +1,15 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { EventEmitter } from 'node:events'; -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { + existsSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import type { DWClientDownStream } from 'dingtalk-stream-sdk-nodejs'; import type { ChannelOutputSegmentContext, @@ -2373,6 +2380,631 @@ describe('DingtalkChannel parsed-message logging', () => { }); }); +describe('DingtalkChannel quoted media', () => { + const tempDirs = new Set(); + + afterEach(() => { + for (const dir of tempDirs) { + rmSync(dir, { recursive: true, force: true }); + } + tempDirs.clear(); + vi.restoreAllMocks(); + }); + + function mockMediaDownload(mimeType: string, bytes: Uint8Array): string[] { + const downloadCodes: string[] = []; + vi.spyOn(globalThis, 'fetch').mockImplementation( + (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.startsWith('https://oapi.dingtalk.com/gettoken')) { + return Promise.resolve( + new Response( + JSON.stringify({ errcode: 0, access_token: 'app-token' }), + { status: 200 }, + ), + ); + } + if ( + url === 'https://api.dingtalk.com/v1.0/robot/messageFiles/download' + ) { + const request = JSON.parse(String(init?.body)) as { + downloadCode: string; + }; + downloadCodes.push(request.downloadCode); + return Promise.resolve( + new Response( + JSON.stringify({ downloadUrl: 'https://example.com/media' }), + { status: 200 }, + ), + ); + } + return Promise.resolve( + new Response(bytes, { + status: 200, + headers: { 'content-type': mimeType }, + }), + ); + }, + ); + return downloadCodes; + } + + function replyToMedia( + channel: DingtalkChannelInstance, + msgType: string, + content: Record, + ): void { + replyToMediaWithText(channel, msgType, 'inspect this', content); + } + + function replyToMediaWithText( + channel: DingtalkChannelInstance, + msgType: string, + replyText: string, + content: Record, + ): void { + const downstream = { + data: JSON.stringify({ + msgId: `quoted-${msgType}`, + conversationType: '2', + conversationId: 'cid-quoted-media', + sessionWebhook: + 'https://oapi.dingtalk.com/robot/send?access_token=token', + senderNick: 'Alice', + senderStaffId: 'staff-1', + senderId: 'sender-1', + chatbotUserId: 'bot-1', + isInAtList: true, + text: { + content: `@DingTalkTest ${replyText}`, + isReplyMsg: true, + repliedMsg: { + msgId: `media-${msgType}`, + msgType, + senderId: 'sender-1', + content, + }, + }, + }), + headers: { messageId: `quoted-${msgType}` }, + } as unknown as DWClientDownStream; + + ( + channel as unknown as { onMessage(d: DWClientDownStream): void } + ).onMessage(downstream); + } + + function sendDirectMedia( + channel: DingtalkChannelInstance, + msgtype: string, + content: Record, + ): void { + const downstream = { + data: JSON.stringify({ + msgId: `direct-${msgtype}`, + conversationType: '1', + sessionWebhook: + 'https://oapi.dingtalk.com/robot/send?access_token=token', + senderNick: 'Alice', + senderStaffId: 'staff-1', + senderId: 'sender-1', + chatbotUserId: 'bot-1', + msgtype, + content, + }), + headers: { messageId: `direct-${msgtype}` }, + } as unknown as DWClientDownStream; + + ( + channel as unknown as { onMessage(d: DWClientDownStream): void } + ).onMessage(downstream); + } + + it('downloads a replied picture and attaches it to the prompt', async () => { + const downloadCodes = mockMediaDownload( + 'image/png', + new Uint8Array([1, 2, 3]), + ); + const channel = createChannel(); + + replyToMedia(channel, 'picture', { downloadCode: 'quoted-picture-code' }); + + await vi.waitFor(() => { + expect(channel.handleInbound).toHaveBeenCalledOnce(); + }); + expect(downloadCodes).toEqual(['quoted-picture-code']); + expect(channel.handleInbound).toHaveBeenCalledWith( + expect.objectContaining({ + text: 'inspect this', + referencedText: '[image]', + attachments: [ + { + type: 'image', + data: Buffer.from([1, 2, 3]).toString('base64'), + mimeType: 'image/png', + }, + ], + }), + ); + }); + + it('downloads a replied file and attaches its local path to the prompt', async () => { + const downloadCodes = mockMediaDownload( + 'application/json', + new TextEncoder().encode('{"name":"demo"}'), + ); + const channel = createChannel(); + + replyToMedia(channel, 'file', { + downloadCode: 'quoted-file-code', + fileName: 'package.json', + }); + + await vi.waitFor(() => { + expect(channel.handleInbound).toHaveBeenCalledOnce(); + }); + expect(downloadCodes).toEqual(['quoted-file-code']); + const envelope = vi.mocked(channel.handleInbound).mock.calls[0]![0]; + const filePath = envelope.attachments?.[0]?.filePath; + if (filePath) tempDirs.add(dirname(filePath)); + expect(envelope).toMatchObject({ + text: 'inspect this', + referencedText: '[file: package.json]', + attachments: [ + { + type: 'file', + mimeType: 'application/json', + fileName: 'package.json', + }, + ], + }); + expect(filePath).toBeTruthy(); + expect(existsSync(filePath!)).toBe(true); + expect(readFileSync(filePath!, 'utf8')).toBe('{"name":"demo"}'); + }); + + // R5-1: DingTalk audio/video content carries no fileName (the audio wire + // shape is {downloadCode, duration}), so the store name is generated. It + // must carry a mimeType-derived extension: the agent reaches the file via + // `read_file`, whose type detection is extension-first, and an extensionless + // name is refused as binary. + it.each([ + ['audio', 'audio/ogg', { duration: 5 }, /^dingtalk_audio_\d+\.ogg$/], + ['video', 'video/mp4', {}, /^dingtalk_video_\d+\.mp4$/], + ] as const)( + 'downloads replied %s media and attaches its local path to the prompt', + async (msgType, mimeType, extraContent, expectedName) => { + const downloadCodes = mockMediaDownload( + mimeType, + new Uint8Array([4, 5, 6]), + ); + const channel = createChannel(); + + replyToMedia(channel, msgType, { + downloadCode: `quoted-${msgType}-code`, + ...extraContent, + }); + + await vi.waitFor(() => { + expect(channel.handleInbound).toHaveBeenCalledOnce(); + }); + expect(downloadCodes).toEqual([`quoted-${msgType}-code`]); + const envelope = vi.mocked(channel.handleInbound).mock.calls[0]![0]; + const filePath = envelope.attachments?.[0]?.filePath; + if (filePath) tempDirs.add(dirname(filePath)); + expect(envelope).toMatchObject({ + text: 'inspect this', + referencedText: `[${msgType}]`, + attachments: [{ type: msgType, mimeType }], + }); + expect(envelope.attachments?.[0]?.fileName).toMatch(expectedName); + expect(filePath).toBeTruthy(); + expect(existsSync(filePath!)).toBe(true); + }, + ); + + it.each([ + ['picture', {}, '[image]'], + ['file', { fileName: 'missing.pdf' }, '[file: missing.pdf]'], + ])( + 'keeps the quoted %s placeholder without downloading when the code is absent', + async (msgType, content, referencedText) => { + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockRejectedValue(new Error('unexpected download')); + const channel = createChannel(); + + replyToMedia(channel, msgType, content); + + await vi.waitFor(() => { + expect(channel.handleInbound).toHaveBeenCalledOnce(); + }); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(channel.handleInbound).toHaveBeenCalledWith( + expect.objectContaining({ + text: 'inspect this', + referencedText, + }), + ); + expect( + vi.mocked(channel.handleInbound).mock.calls[0]![0], + ).not.toHaveProperty('attachments'); + }, + ); + + it('does not download a quoted message with an unmapped msgType even when it carries a downloadCode', async () => { + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockRejectedValue(new Error('unexpected download')); + const channel = createChannel(); + + replyToMedia(channel, 'richText', { downloadCode: 'quoted-rt-code' }); + + await vi.waitFor(() => { + expect(channel.handleInbound).toHaveBeenCalledOnce(); + }); + expect(fetchSpy).not.toHaveBeenCalled(); + expect( + vi.mocked(channel.handleInbound).mock.calls[0]![0], + ).not.toHaveProperty('attachments'); + }); + + it('attaches both the own media and the quoted media of one message', async () => { + const downloadCodes = mockMediaDownload( + 'image/png', + new Uint8Array([1, 2, 3]), + ); + const channel = createChannel(); + + const downstream = { + data: JSON.stringify({ + msgId: 'quoted-combo', + conversationType: '2', + conversationId: 'cid-quoted-media', + sessionWebhook: + 'https://oapi.dingtalk.com/robot/send?access_token=token', + senderNick: 'Alice', + senderStaffId: 'staff-1', + senderId: 'sender-1', + chatbotUserId: 'bot-1', + isInAtList: true, + msgtype: 'picture', + content: { downloadCode: 'own-picture-code' }, + text: { + content: '@DingTalkTest inspect both', + isReplyMsg: true, + repliedMsg: { + msgId: 'media-file', + msgType: 'file', + senderId: 'sender-1', + content: { + downloadCode: 'quoted-file-code', + fileName: 'report.pdf', + }, + }, + }, + }), + headers: { messageId: 'quoted-combo' }, + } as unknown as DWClientDownStream; + + ( + channel as unknown as { onMessage(d: DWClientDownStream): void } + ).onMessage(downstream); + + await vi.waitFor(() => { + expect(channel.handleInbound).toHaveBeenCalledOnce(); + }); + expect(downloadCodes).toEqual(['own-picture-code', 'quoted-file-code']); + const envelope = vi.mocked(channel.handleInbound).mock.calls[0]![0]; + expect(envelope.attachments).toHaveLength(2); + expect(envelope.attachments?.[0]).toEqual({ + type: 'image', + data: Buffer.from([1, 2, 3]).toString('base64'), + mimeType: 'image/png', + }); + expect(envelope.attachments?.[1]).toMatchObject({ + type: 'file', + fileName: 'report.pdf', + }); + const filePath = envelope.attachments?.[1]?.filePath; + if (filePath) tempDirs.add(dirname(filePath)); + }); + + // R4-1: ChannelBase resolves a single inline image per envelope (the first + // data-only image attachment fills imageBase64) and silently drops every + // later data-only attachment, so the quoted image must be file-backed when + // the message's own image already occupies the slot. + it('file-backs a quoted image when the message already carries its own image', async () => { + const downloadCodes = mockMediaDownload( + 'image/png', + new Uint8Array([1, 2, 3]), + ); + const channel = createChannel(); + + const downstream = { + data: JSON.stringify({ + msgId: 'quoted-two-images', + conversationType: '2', + conversationId: 'cid-quoted-media', + sessionWebhook: + 'https://oapi.dingtalk.com/robot/send?access_token=token', + senderNick: 'Alice', + senderStaffId: 'staff-1', + senderId: 'sender-1', + chatbotUserId: 'bot-1', + isInAtList: true, + msgtype: 'picture', + content: { downloadCode: 'own-picture-code' }, + text: { + content: '@DingTalkTest inspect both', + isReplyMsg: true, + repliedMsg: { + msgId: 'media-picture', + msgType: 'picture', + senderId: 'sender-1', + content: { downloadCode: 'quoted-picture-code' }, + }, + }, + }), + headers: { messageId: 'quoted-two-images' }, + } as unknown as DWClientDownStream; + + ( + channel as unknown as { onMessage(d: DWClientDownStream): void } + ).onMessage(downstream); + + await vi.waitFor(() => { + expect(channel.handleInbound).toHaveBeenCalledOnce(); + }); + expect(downloadCodes).toEqual(['own-picture-code', 'quoted-picture-code']); + const envelope = vi.mocked(channel.handleInbound).mock.calls[0]![0]; + // extractContent yields the '(image)' placeholder for a picture msgtype. + expect(envelope).toMatchObject({ + text: '(image)', + referencedText: '[image]', + }); + expect(envelope.attachments).toHaveLength(2); + // The own image keeps the single inline slot ChannelBase resolves. + expect(envelope.attachments?.[0]).toEqual({ + type: 'image', + data: Buffer.from([1, 2, 3]).toString('base64'), + mimeType: 'image/png', + }); + // The quoted image must not be a second data-only attachment — that shape + // is silently dropped by ChannelBase's single-image resolution. + const quotedAttachment = envelope.attachments?.[1]; + expect(quotedAttachment).toMatchObject({ + type: 'image', + mimeType: 'image/png', + }); + expect(quotedAttachment).not.toHaveProperty('data'); + const filePath = quotedAttachment?.filePath; + if (filePath) tempDirs.add(dirname(filePath)); + expect(filePath).toBeTruthy(); + expect(existsSync(filePath!)).toBe(true); + expect(readFileSync(filePath!)).toEqual(Buffer.from([1, 2, 3])); + expect(quotedAttachment?.fileName).toMatch(/^dingtalk_image_\d+\.png$/); + }); + + it('cleans the generated placeholder for a direct file message', async () => { + mockMediaDownload('application/octet-stream', new Uint8Array([7, 8, 9])); + const channel = createChannel(); + + sendDirectMedia(channel, 'file', { + downloadCode: 'direct-file-code', + fileName: 'notes.txt', + }); + + await vi.waitFor(() => { + expect(channel.handleInbound).toHaveBeenCalledOnce(); + }); + const envelope = vi.mocked(channel.handleInbound).mock.calls[0]![0]; + const filePath = envelope.attachments?.[0]?.filePath; + if (filePath) tempDirs.add(dirname(filePath)); + expect(envelope.text).toBe(''); + expect(envelope.attachments).toMatchObject([ + { type: 'file', fileName: 'notes.txt' }, + ]); + }); + + it('cleans the generated placeholder for a direct audio message', async () => { + mockMediaDownload('audio/ogg', new Uint8Array([7, 8, 9])); + const channel = createChannel(); + + sendDirectMedia(channel, 'audio', { downloadCode: 'direct-audio-code' }); + + await vi.waitFor(() => { + expect(channel.handleInbound).toHaveBeenCalledOnce(); + }); + const envelope = vi.mocked(channel.handleInbound).mock.calls[0]![0]; + const filePath = envelope.attachments?.[0]?.filePath; + if (filePath) tempDirs.add(dirname(filePath)); + expect(envelope.text).toBe(''); + expect(envelope.attachments?.[0]?.fileName).toMatch( + /^dingtalk_audio_\d+\.ogg$/, + ); + }); + + it('cleans the generated placeholder for a direct video message', async () => { + mockMediaDownload('video/mp4', new Uint8Array([7, 8, 9])); + const channel = createChannel(); + + sendDirectMedia(channel, 'video', { downloadCode: 'direct-video-code' }); + + await vi.waitFor(() => { + expect(channel.handleInbound).toHaveBeenCalledOnce(); + }); + const envelope = vi.mocked(channel.handleInbound).mock.calls[0]![0]; + const filePath = envelope.attachments?.[0]?.filePath; + if (filePath) tempDirs.add(dirname(filePath)); + expect(envelope.text).toBe(''); + expect(envelope.attachments).toMatchObject([{ type: 'video' }]); + expect(envelope.attachments?.[0]?.fileName).toMatch( + /^dingtalk_video_\d+\.mp4$/, + ); + }); + + // R1-1: the placeholder cleanup was written for the DIRECT-media path, + // where `extractContent` generates `(audio)` / `(file: name)` itself. On the + // quoted path `envelope.text` is the user's own reply, so a reply reading + // exactly like a placeholder was blanked and the agent got an attachment + // with no prompt. A group `@Bot (audio)` arrives here as exactly `(audio)`. + it.each([ + ['audio', {}, '(audio)'], + ['video', {}, '(video)'], + ['file', { fileName: 'report.pdf' }, '(file: report.pdf)'], + ])( + 'keeps a quoted-%s reply whose text looks like a placeholder', + async (msgType, extra, replyText) => { + mockMediaDownload('application/octet-stream', new Uint8Array([1, 2, 3])); + const channel = createChannel(); + + replyToMediaWithText(channel, msgType, replyText, { + downloadCode: `quoted-${msgType}-code`, + ...extra, + }); + + await vi.waitFor(() => { + expect(channel.handleInbound).toHaveBeenCalledOnce(); + }); + const envelope = vi.mocked(channel.handleInbound).mock.calls[0]![0]; + const filePath = envelope.attachments?.[0]?.filePath; + if (filePath) tempDirs.add(dirname(filePath)); + expect(envelope.text).toBe(replyText); + expect(envelope.attachments).toHaveLength(1); + }, + ); + + // R1-2: these are synchronous throw sites. An escape rejected + // `processMessage`, whose catch sends the generic error reply and never + // calls `handleInbound` — and the msgId is already deduped, so the retry is + // dropped and the prompt is lost for good. + it('still delivers the text when an over-long quoted file name fails the store', async () => { + mockMediaDownload('application/octet-stream', new Uint8Array([1, 2, 3])); + vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + const channel = createChannel(); + const channelFilesRoot = join(tmpdir(), 'channel-files'); + const dirsBefore = new Set( + existsSync(channelFilesRoot) ? readdirSync(channelFilesRoot) : [], + ); + + replyToMedia(channel, 'file', { + downloadCode: 'quoted-file-code', + fileName: 'a'.repeat(300), + }); + + await vi.waitFor(() => { + expect(channel.handleInbound).toHaveBeenCalledOnce(); + }); + const envelope = vi.mocked(channel.handleInbound).mock.calls[0]![0]; + expect(envelope.text).toBe('inspect this'); + expect(envelope).not.toHaveProperty('attachments'); + // The failed store must not leak its store directory into tmpdir. + const leaked = ( + existsSync(channelFilesRoot) ? readdirSync(channelFilesRoot) : [] + ).filter((entry) => !dirsBefore.has(entry)); + expect(leaked).toEqual([]); + }); + + it('still delivers the text when the quoted file name is not a string', async () => { + mockMediaDownload('application/octet-stream', new Uint8Array([1, 2, 3])); + vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + const channel = createChannel(); + + replyToMedia(channel, 'file', { + downloadCode: 'quoted-file-code', + fileName: 12345, + }); + + await vi.waitFor(() => { + expect(channel.handleInbound).toHaveBeenCalledOnce(); + }); + const envelope = vi.mocked(channel.handleInbound).mock.calls[0]![0]; + expect(envelope.text).toBe('inspect this'); + // The attachment is still delivered, under a generated name. + expect(envelope.attachments?.[0]?.fileName).toMatch( + /^dingtalk_file_\d+\.bin$/, + ); + const filePath = envelope.attachments?.[0]?.filePath; + if (filePath) tempDirs.add(dirname(filePath)); + }); + + it('keeps processing the prompt when a quoted-media download fails', async () => { + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockRejectedValue(new Error('offline')); + const stderrSpy = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + const channel = createChannel(); + + replyToMedia(channel, 'file', { + downloadCode: 'unavailable-file-code', + fileName: 'offline.pdf', + }); + + await vi.waitFor(() => { + expect(channel.handleInbound).toHaveBeenCalledOnce(); + }); + expect(fetchSpy).toHaveBeenCalledOnce(); + expect(channel.handleInbound).toHaveBeenCalledWith( + expect.objectContaining({ + text: 'inspect this', + referencedText: '[file: offline.pdf]', + }), + ); + expect( + vi.mocked(channel.handleInbound).mock.calls[0]![0], + ).not.toHaveProperty('attachments'); + expect(stderrSpy).toHaveBeenCalledWith( + '[DingTalk:test-dingtalk] Cannot download media: access token refresh failed.\n', + ); + }); + + it('keeps processing the prompt when the media download API fails', async () => { + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockImplementation((input: RequestInfo | URL) => { + const url = String(input); + if (url.startsWith('https://oapi.dingtalk.com/gettoken')) { + return Promise.resolve( + new Response( + JSON.stringify({ errcode: 0, access_token: 'app-token' }), + { status: 200 }, + ), + ); + } + return Promise.resolve(new Response('unavailable', { status: 503 })); + }); + const stderrSpy = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + const channel = createChannel(); + + replyToMedia(channel, 'file', { + downloadCode: 'unavailable-file-code', + fileName: 'unavailable.pdf', + }); + + await vi.waitFor(() => { + expect(channel.handleInbound).toHaveBeenCalledOnce(); + }); + expect(fetchSpy).toHaveBeenCalledTimes(2); + expect(channel.handleInbound).toHaveBeenCalledWith( + expect.objectContaining({ + text: 'inspect this', + referencedText: '[file: unavailable.pdf]', + }), + ); + expect( + vi.mocked(channel.handleInbound).mock.calls[0]![0], + ).not.toHaveProperty('attachments'); + expect(stderrSpy).toHaveBeenCalledWith( + '[DingTalk] downloadMedia API failed: HTTP 503 unavailable\n', + ); + }); +}); + describe('DingtalkChannel downstream logging', () => { it('replaces raw SDK Buffer logging with a structured downstream summary', () => { createChannel(); diff --git a/packages/channels/dingtalk/src/DingtalkAdapter.ts b/packages/channels/dingtalk/src/DingtalkAdapter.ts index 431fbae386d..c6b6f38a69a 100644 --- a/packages/channels/dingtalk/src/DingtalkAdapter.ts +++ b/packages/channels/dingtalk/src/DingtalkAdapter.ts @@ -1,4 +1,4 @@ -import { mkdirSync, writeFileSync } from 'node:fs'; +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { randomUUID } from 'node:crypto'; import { basename, join } from 'node:path'; import { tmpdir } from 'node:os'; @@ -130,6 +130,19 @@ const DIRECT_MSG_API = const PROACTIVE_MSG_KEY = 'sampleMarkdown'; // DingTalk's built-in {title, text} markdown template key const TOKEN_API = 'https://oapi.dingtalk.com/gettoken'; const PROACTIVE_FETCH_TIMEOUT_MS = 15_000; +// Extensions for generated media store names, keyed by the download's mime +// type. The agent reads stored media via `read_file`, whose type detection is +// extension-first: an extensionless name falls through to the binary content +// sampler and real image/audio/video bytes are refused. +const GENERATED_MEDIA_EXT: Record = { + 'image/jpeg': 'jpg', + 'image/png': 'png', + 'image/gif': 'gif', + 'image/webp': 'webp', + 'audio/ogg': 'ogg', + 'audio/mpeg': 'mp3', + 'video/mp4': 'mp4', +}; const mentionTarget = Symbol('mentionTarget'); const IMAGE_INSTRUCTIONS = [ '', @@ -1376,6 +1389,11 @@ export class DingtalkChannel extends ChannelBase { private extractQuotedContext(data: DingTalkMessageData): { referencedText?: string; isReplyToBot: boolean; + media?: { + downloadCode: string; + mediaType: 'image' | 'file' | 'audio' | 'video'; + fileName?: string; + }; } { // Newer format: text.repliedMsg if (data.text?.isReplyMsg && data.text.repliedMsg) { @@ -1386,7 +1404,21 @@ export class DingtalkChannel extends ChannelBase { // Note: DingTalk doesn't include content for interactiveCard replies // (bot responses sent via webhook). Only user message quotes have text. const text = this.summarizeRepliedContent(replied); - return { referencedText: text || undefined, isReplyToBot }; + const downloadCode = replied.content?.downloadCode; + const mediaType = this.mediaTypeFromMsgType(replied.msgType); + return { + referencedText: text || undefined, + isReplyToBot, + ...(downloadCode && mediaType + ? { + media: { + downloadCode, + mediaType, + fileName: replied.content?.fileName, + }, + } + : {}), + }; } // Legacy format: quoteMessage @@ -1448,6 +1480,21 @@ export class DingtalkChannel extends ChannelBase { return ''; } + /** + * Map a DingTalk message type to the media type used for downloads. Shared + * by the direct-media (`extractContent`) and quoted-media + * (`extractQuotedContext`) paths so the mapping cannot drift between them. + */ + private mediaTypeFromMsgType( + msgType: string | undefined, + ): 'image' | 'file' | 'audio' | 'video' | undefined { + if (msgType === 'picture') return 'image'; + if (msgType === 'file' || msgType === 'audio' || msgType === 'video') { + return msgType; + } + return undefined; + } + /** * Extract text and media download codes from an incoming DingTalk message. * Handles text, richText, picture, file, audio, and video message types. @@ -1457,6 +1504,7 @@ export class DingtalkChannel extends ChannelBase { downloadCodes: string[]; mediaType?: 'image' | 'file' | 'audio' | 'video'; fileName?: string; + placeholder?: string; } { const msgtype = data.msgtype || 'text'; @@ -1487,18 +1535,20 @@ export class DingtalkChannel extends ChannelBase { return { text: '(image)', downloadCodes: code ? [code] : [], - mediaType: 'image', + mediaType: this.mediaTypeFromMsgType(msgtype), }; } if (msgtype === 'file') { const code = data.content?.downloadCode; const fileName = data.content?.fileName || undefined; + const placeholder = `(file: ${fileName || 'file'})`; return { - text: `(file: ${fileName || 'file'})`, + text: placeholder, downloadCodes: code ? [code] : [], - mediaType: 'file', + mediaType: this.mediaTypeFromMsgType(msgtype), fileName, + placeholder, }; } @@ -1508,7 +1558,8 @@ export class DingtalkChannel extends ChannelBase { return { text: recognition || '(audio)', downloadCodes: code ? [code] : [], - mediaType: 'audio', + mediaType: this.mediaTypeFromMsgType(msgtype), + placeholder: recognition ? undefined : '(audio)', }; } @@ -1517,7 +1568,8 @@ export class DingtalkChannel extends ChannelBase { return { text: '(video)', downloadCodes: code ? [code] : [], - mediaType: 'video', + mediaType: this.mediaTypeFromMsgType(msgtype), + placeholder: '(video)', }; } @@ -1528,12 +1580,20 @@ export class DingtalkChannel extends ChannelBase { /** * Download a media file and attach it to the envelope. * Images → base64 in envelope; files → saved to temp dir with path in text. + * + * `cleanPlaceholderText` is the placeholder `extractContent` generated for + * this message's own media — `(audio)`, `(video)`, `(file: name)`. Only the + * direct-media call site has one, and only that call may erase it: on the + * quoted-media path `envelope.text` is the user's own reply, and a reply + * that happens to read exactly like a placeholder must survive (a group + * `@Bot (audio)` reaches here as exactly `(audio)` after mention removal). */ private async attachMedia( envelope: Envelope, downloadCode: string, mediaType: 'image' | 'file' | 'audio' | 'video', fileName?: string, + cleanPlaceholderText?: string, ): Promise { let token: string; try { @@ -1555,7 +1615,16 @@ export class DingtalkChannel extends ChannelBase { const media = await downloadMedia(downloadCode, robotCode, token); if (!media) return; - if (mediaType === 'image') { + // ChannelBase fills a single imageBase64 slot from the FIRST data-only + // image attachment and silently drops every later one, so an image + // arriving after the slot is taken (e.g. a quoted picture alongside the + // message's own picture) falls through to the file-backed path — the + // `saved to:` prompt line is what keeps it reachable for the agent. + const inlineImageSlotFree = !(envelope.attachments || []).some( + (attachment) => attachment.type === 'image' && attachment.data, + ); + + if (mediaType === 'image' && inlineImageSlotFree) { const mimeType = media.mimeType.startsWith('image/') ? media.mimeType : 'image/jpeg'; @@ -1568,19 +1637,52 @@ export class DingtalkChannel extends ChannelBase { }, ]; } else { - // Save non-image files to temp dir so the agent can read them - const dir = join(tmpdir(), 'channel-files', randomUUID()); - mkdirSync(dir, { recursive: true }); - const safeName = - basename(fileName || '') || `dingtalk_${mediaType}_${Date.now()}`; - const filePath = join(dir, safeName); - writeFileSync(filePath, media.buffer); - - // Clean up placeholder text like "(audio)", "(video)", "(file: name)" + // Save the media to temp dir so the agent can read it. + // + // R1-2: these are synchronous throw sites — ENOSPC on a write of up to + // 50 MB, ENAMETOOLONG from a quoted fileName over 255 bytes (`basename` + // does not truncate), a TypeError from a truthy non-string fileName. An + // escape rejects `processMessage`, whose catch sends the generic error + // reply and never calls `handleInbound`; the msgId is already in + // `seenMessages`, so DingTalk's retry is deduped and the user's prompt + // is lost for good. Degrade the way a failed download already does: + // skip the attachment, keep the text. + let dir: string | undefined; + let filePath: string; + let safeName: string; + try { + dir = join(tmpdir(), 'channel-files', randomUUID()); + mkdirSync(dir, { recursive: true }); + safeName = + basename(typeof fileName === 'string' ? fileName : '') || + `dingtalk_${mediaType}_${Date.now()}.${ + GENERATED_MEDIA_EXT[media.mimeType] ?? 'bin' + }`; + filePath = join(dir, safeName); + writeFileSync(filePath, media.buffer); + } catch (error) { + // The store directory (and any partial file) is useless without the + // attachment — remove it so failed stores do not accumulate in tmpdir. + if (dir) { + try { + rmSync(dir, { recursive: true, force: true }); + } catch { + // Best effort; the degraded delivery below is the contract. + } + } + process.stderr.write( + `[DingTalk:${this.name}] Cannot store media, delivering the text without it: ${sanitizeLogText( + error instanceof Error ? error.message : String(error), + 300, + )}\n`, + ); + return; + } + + // Clean up the placeholder this message's own media produced. if ( - envelope.text === `(file: ${fileName || 'file'})` || - envelope.text === '(audio)' || - envelope.text === '(video)' + cleanPlaceholderText !== undefined && + envelope.text === cleanPlaceholderText ) { envelope.text = ''; } @@ -1745,6 +1847,15 @@ export class DingtalkChannel extends ChannelBase { content.downloadCodes[0]!, content.mediaType, content.fileName, + content.placeholder, + ); + } + if (quoted.media) { + await this.attachMedia( + envelope, + quoted.media.downloadCode, + quoted.media.mediaType, + quoted.media.fileName, ); } await this.handleInbound(envelope);