diff --git a/packages/channels/dingtalk/src/DingtalkAdapter.test.ts b/packages/channels/dingtalk/src/DingtalkAdapter.test.ts index 6995810b677..4e4ed4dbb77 100644 --- a/packages/channels/dingtalk/src/DingtalkAdapter.test.ts +++ b/packages/channels/dingtalk/src/DingtalkAdapter.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { EventEmitter } from 'node:events'; -import { mkdtempSync } from 'node:fs'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { DWClientDownStream } from 'dingtalk-stream-sdk-nodejs'; @@ -21,6 +21,17 @@ const dingtalkSdkMock = vi.hoisted(() => ({ rawLog: vi.fn(), })); +const PNG_DATA = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, +]); + +function createTempPng(): { dir: string; path: string } { + const dir = mkdtempSync(join(tmpdir(), 'dingtalk-outbound-image-')); + const path = join(dir, 'image.png'); + writeFileSync(path, PNG_DATA); + return { dir, path }; +} + vi.mock('dingtalk-stream-sdk-nodejs', () => ({ DWClient: class { debug = true; @@ -219,6 +230,16 @@ it('rejects a non-boolean useConnectionManager value', () => { ); }); +it('adds outbound image instructions without replacing custom instructions', () => { + const channel = createChannel({ instructions: 'Keep the answer concise.' }); + const instructions = ( + channel as unknown as { config: { instructions: string } } + ).config.instructions; + + expect(instructions).toContain('Keep the answer concise.'); + expect(instructions).toContain('[IMAGE: /absolute/path/to/file.png]'); +}); + it('keeps callbacks and ACKs bound to the client that received them', async () => { const firstIndex = dingtalkSdkMock.instances.length; const channel = createChannel(); @@ -2457,6 +2478,184 @@ describe('DingtalkChannel mention target lifecycle', () => { }); }); +describe('DingtalkChannel outbound image delivery', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + function stubImageReplyFetch( + mediaHandler: (uploadCall: number) => Response = () => + new Response( + JSON.stringify({ errcode: 0, media_id: '@lAL-test-media-id' }), + { status: 200 }, + ), + ) { + let tokenCall = 0; + let uploadCall = 0; + const spy = vi + .spyOn(globalThis, 'fetch') + .mockImplementation((input: RequestInfo | URL) => { + const url = String(input); + if (url.startsWith('https://oapi.dingtalk.com/gettoken')) { + tokenCall++; + return Promise.resolve( + new Response( + JSON.stringify({ + errcode: 0, + access_token: `proactive-token-${tokenCall}`, + expires_in: 7200, + }), + { status: 200 }, + ), + ); + } + if (url.startsWith('https://oapi.dingtalk.com/media/upload')) { + return Promise.resolve(mediaHandler(uploadCall++)); + } + return Promise.resolve(new Response('{}', { status: 200 })); + }); + const calls = (prefix: string) => + spy.mock.calls.filter((call) => String(call[0]).startsWith(prefix)); + return { + uploadCalls: () => calls('https://oapi.dingtalk.com/media/upload'), + tokenCalls: () => calls('https://oapi.dingtalk.com/gettoken'), + webhookCalls: () => + calls('https://oapi.dingtalk.com/robot/send?access_token=token'), + }; + } + + it('uploads a local image and embeds its MediaID in a reply', async () => { + const image = createTempPng(); + try { + const channel = createChannel({ cwd: image.dir }); + seedWebhook(channel, 'cid123'); + const { uploadCalls, tokenCalls, webhookCalls } = stubImageReplyFetch(); + + await channel.sendMessage( + 'cid123', + `before\n[IMAGE: ${image.path}]\nafter`, + ); + + expect(tokenCalls()).toHaveLength(1); + expect(uploadCalls()).toHaveLength(1); + const calls = webhookCalls(); + expect(calls).toHaveLength(1); + const body = JSON.parse(String((calls[0]![1] as RequestInit).body)) as { + msgtype: string; + markdown: { text: string }; + }; + expect(body.msgtype).toBe('markdown'); + expect(body.markdown.text).toContain('before'); + expect(body.markdown.text).toContain('![image](@lAL-test-media-id)'); + expect(body.markdown.text).toContain('after'); + expect(body.markdown.text).not.toContain('[IMAGE:'); + } finally { + rmSync(image.dir, { recursive: true, force: true }); + } + }); + + it('refreshes the token and retries one expired media upload', async () => { + const image = createTempPng(); + try { + const channel = createChannel({ cwd: image.dir }); + seedWebhook(channel, 'cid123'); + const { uploadCalls, tokenCalls } = stubImageReplyFetch((uploadCall) => + uploadCall === 0 + ? new Response( + JSON.stringify({ errcode: 42001, errmsg: 'token expired' }), + { status: 200 }, + ) + : new Response( + JSON.stringify({ + errcode: 0, + media_id: '@lAL-refreshed-media-id', + }), + { status: 200 }, + ), + ); + + await channel.sendMessage('cid123', `[IMAGE: ${image.path}]`); + + expect(uploadCalls()).toHaveLength(2); + expect(tokenCalls()).toHaveLength(2); + } finally { + rmSync(image.dir, { recursive: true, force: true }); + } + }); + + it('sends a visible fallback without leaking the token when upload fails', async () => { + const image = createTempPng(); + try { + const channel = createChannel({ cwd: image.dir }); + seedWebhook(channel, 'cid123'); + const writeSpy = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + const { webhookCalls } = stubImageReplyFetch( + () => + new Response( + JSON.stringify({ errcode: 40035, errmsg: 'invalid media' }), + { status: 200 }, + ), + ); + + await channel.sendMessage('cid123', `[IMAGE: ${image.path}]`); + + const body = JSON.parse( + String((webhookCalls()[0]![1] as RequestInit).body), + ) as { markdown: { text: string } }; + expect(body.markdown.text).toContain( + '[Image delivery failed: image.png]', + ); + const logged = writeSpy.mock.calls + .map((call) => String(call[0])) + .join(''); + expect(logged).toContain('outbound image upload failed'); + expect(logged).not.toContain('proactive-token-1'); + } finally { + rmSync(image.dir, { recursive: true, force: true }); + } + }); + + it('sends a mentioned image response as one message', async () => { + const image = createTempPng(); + try { + const channel = createChannel({ atSender: true, cwd: image.dir }); + seedWebhook(channel, 'cid123'); + seedMentionTarget(channel, 'm1', 'staff-1'); + const { webhookCalls } = stubImageReplyFetch(); + + getPromptHook(channel, 'onPromptStart')('cid123', 'session-1', 'm1'); + await getResponseHook(channel)( + 'cid123', + `[IMAGE: ${image.path}]`, + 'session-1', + ); + + const calls = webhookCalls(); + expect(calls).toHaveLength(1); + expect( + JSON.parse(String((calls[0]![1] as RequestInit).body)), + ).toMatchObject({ + msgtype: 'markdown', + markdown: { + text: expect.stringContaining('@staff-1\n\n'), + }, + at: { atUserIds: ['staff-1'] }, + }); + expect( + JSON.parse(String((calls[0]![1] as RequestInit).body)), + ).toMatchObject({ + markdown: { + text: expect.stringContaining('![image](@lAL-test-media-id)'), + }, + }); + } finally { + rmSync(image.dir, { recursive: true, force: true }); + } + }); +}); + describe('DingtalkChannel proactive send', () => { afterEach(() => { vi.restoreAllMocks(); @@ -2497,8 +2696,14 @@ describe('DingtalkChannel proactive send', () => { }), { status: 200 }, ), + mediaHandler: (uploadCall: number) => Response = () => + new Response( + JSON.stringify({ errcode: 0, media_id: '@lAL-proactive-media-id' }), + { status: 200 }, + ), ) { let sendCall = 0; + let uploadCall = 0; const spy = vi .spyOn(globalThis, 'fetch') .mockImplementation((input: RequestInfo | URL) => { @@ -2506,6 +2711,9 @@ describe('DingtalkChannel proactive send', () => { if (url.startsWith('https://oapi.dingtalk.com/gettoken')) { return Promise.resolve(tokenHandler()); } + if (url.startsWith('https://oapi.dingtalk.com/media/upload')) { + return Promise.resolve(mediaHandler(uploadCall++)); + } return Promise.resolve(sendHandler(sendCall++)); }); const calls = (prefix: string) => @@ -2516,6 +2724,7 @@ describe('DingtalkChannel proactive send', () => { calls('https://api.dingtalk.com/v1.0/robot/groupMessages/send'), directSendCalls: () => calls('https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend'), + mediaCalls: () => calls('https://oapi.dingtalk.com/media/upload'), tokenCalls: () => calls('https://oapi.dingtalk.com/gettoken'), }; } @@ -2614,6 +2823,40 @@ describe('DingtalkChannel proactive send', () => { expect(msgParamOf(sends[0]!).text).toContain('loop output'); }); + it('uploads and embeds images in proactive group messages', async () => { + const image = createTempPng(); + try { + const channel = proactive(createChannel({ cwd: image.dir })); + const { mediaCalls, sendCalls } = stubProactiveFetch(); + + await channel.pushProactive(groupTarget, `[IMAGE: ${image.path}]`); + + expect(mediaCalls()).toHaveLength(1); + expect(msgParamOf(sendCalls()[0]!).text).toContain( + '![image](@lAL-proactive-media-id)', + ); + } finally { + rmSync(image.dir, { recursive: true, force: true }); + } + }); + + it('uploads and embeds images in proactive direct messages', async () => { + const image = createTempPng(); + try { + const channel = proactive(createChannel({ cwd: image.dir })); + const { directSendCalls, mediaCalls } = stubProactiveFetch(); + + await channel.pushProactive(directTarget, `[IMAGE: ${image.path}]`); + + expect(mediaCalls()).toHaveLength(1); + expect(msgParamOf(directSendCalls()[0]!).text).toContain( + '![image](@lAL-proactive-media-id)', + ); + } finally { + rmSync(image.dir, { recursive: true, force: true }); + } + }); + it('rejects direct messages when DingTalk reports an invalid recipient', async () => { const channel = proactive(createChannel()); vi.spyOn(process.stderr, 'write').mockImplementation(() => true); diff --git a/packages/channels/dingtalk/src/DingtalkAdapter.ts b/packages/channels/dingtalk/src/DingtalkAdapter.ts index 010ad8848aa..107afbaac7a 100644 --- a/packages/channels/dingtalk/src/DingtalkAdapter.ts +++ b/packages/channels/dingtalk/src/DingtalkAdapter.ts @@ -13,6 +13,13 @@ import { } from '@qwen-code/channel-base'; import { normalizeDingTalkMarkdown, extractTitle } from './markdown.js'; import { downloadMedia } from './media.js'; +import { + DingTalkMediaUploadError, + findImageMarkers, + readValidatedImage, + replaceImageMarkers, + uploadDingTalkImage, +} from './outbound-image.js'; import { DingtalkConnectionManager, type DingtalkManagedSocket, @@ -104,6 +111,15 @@ const TOKEN_API = 'https://oapi.dingtalk.com/gettoken'; const PROACTIVE_FETCH_TIMEOUT_MS = 15_000; const TEXT_MESSAGE_LIMIT = 3800; const mentionTarget = Symbol('mentionTarget'); +const IMAGE_INSTRUCTIONS = [ + '', + 'If you created an image file (screenshot, chart, etc.), you can send it to the user by writing:', + '`[IMAGE: /absolute/path/to/file.png]` (without the backticks)', + '', + 'The marker is stripped from text and the image is uploaded automatically.', + '', + 'Only use a real image file inside the workspace or system temporary directory.', +].join('\n'); type MentionTargetEnvelope = Envelope & { [mentionTarget]?: string; @@ -214,6 +230,16 @@ export class DingtalkChannel extends ChannelBase { this.atSender = (config as unknown as Record)['atSender'] === true; + if (!this.config.instructions) { + this.config.instructions = [ + '## DingTalk Channel', + '', + 'You are responding through DingTalk.', + IMAGE_INSTRUCTIONS, + ].join('\n'); + } else if (!this.config.instructions.includes('[IMAGE:')) { + this.config.instructions += IMAGE_INSTRUCTIONS; + } if (!config.clientId || !config.clientSecret) { throw new Error( @@ -423,7 +449,64 @@ export class DingtalkChannel extends ChannelBase { return isGroup && !conversationId; } - private async sendReply(chatId: string, text: string): Promise { + private async prepareOutgoingText(text: string): Promise { + const markers = findImageMarkers(text); + if (markers.length === 0) return text; + + const replacements: string[] = []; + for (const marker of markers) { + const fileName = + basename(marker.path) + .replace(/[\r\n[\]]+/g, '_') + .slice(0, 100) || 'image'; + try { + const image = readValidatedImage(marker.path, { + workspaceDir: this.config.cwd, + }); + let mediaId: string | undefined; + for (let attempt = 0; attempt < 2; attempt++) { + const token = await this.getProactiveToken(); + try { + mediaId = await uploadDingTalkImage(image, token); + break; + } catch (error) { + if ( + error instanceof DingTalkMediaUploadError && + error.authFailure && + attempt === 0 + ) { + this.proactiveToken = undefined; + continue; + } + throw error; + } + } + if (!mediaId) { + throw new Error('DingTalk media upload returned no MediaID'); + } + replacements.push(`![image](${mediaId})`); + } catch (error) { + process.stderr.write( + `[DingTalk:${this.name}] outbound image upload failed (${sanitizeLogText( + fileName, + 100, + )}): ${sanitizeLogText( + error instanceof Error ? error.message : String(error), + 300, + )}\n`, + ); + replacements.push(`[Image delivery failed: ${fileName}]`); + } + } + + return replaceImageMarkers(text, markers, replacements); + } + + private async sendReply( + chatId: string, + text: string, + atUserId?: string, + ): Promise { // chatId is a conversationId — resolve to the latest sessionWebhook const webhook = this.webhooks.get(chatId); if (!webhook) { @@ -433,17 +516,21 @@ export class DingtalkChannel extends ChannelBase { return; } - const chunks = normalizeDingTalkMarkdown(text); - const title = extractTitle(text); + const outgoingText = await this.prepareOutgoingText(text); + const mentionPrefix = atUserId ? `@${atUserId}\n\n` : ''; + const chunks = normalizeDingTalkMarkdown(mentionPrefix + outgoingText); + const title = extractTitle(outgoingText); for (let i = 0; i < chunks.length; i++) { const chunk = chunks[i]!; + const isMention = i === 0 && atUserId !== undefined; const body = { msgtype: 'markdown', markdown: { title: i === 0 ? title : `${title} (cont.)`, text: chunk, }, + ...(isMention ? { at: { atUserIds: [atUserId] } } : {}), }; const resp = await fetch(webhook, { @@ -564,8 +651,9 @@ export class DingtalkChannel extends ChannelBase { ): Promise { if (!text.trim()) return; - const chunks = normalizeDingTalkMarkdown(text); - const title = extractTitle(text); + const outgoingText = await this.prepareOutgoingText(text); + const chunks = normalizeDingTalkMarkdown(outgoingText); + const title = extractTitle(outgoingText); for (let i = 0; i < chunks.length; i++) { await this.sendProactiveChunk( @@ -1022,6 +1110,10 @@ export class DingtalkChannel extends ChannelBase { : undefined; if (atUserId) this.sessionMentionTargets.delete(sessionId); if (this.textReplySessions.has(sessionId)) { + if (findImageMarkers(text).length > 0) { + await this.sendReply(chatId, text, atUserId); + return; + } await this.sendTextReply(chatId, text, atUserId); return; } diff --git a/packages/channels/dingtalk/src/outbound-image.test.ts b/packages/channels/dingtalk/src/outbound-image.test.ts new file mode 100644 index 00000000000..bf7294c6a9d --- /dev/null +++ b/packages/channels/dingtalk/src/outbound-image.test.ts @@ -0,0 +1,258 @@ +import { + mkdtempSync, + mkdirSync, + rmSync, + symlinkSync, + truncateSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + DingTalkMediaUploadError, + findImageMarkers, + readValidatedImage, + replaceImageMarkers, + uploadDingTalkImage, +} from './outbound-image.js'; + +const PNG_DATA = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, +]); + +const testDirs: string[] = []; + +function makeTempDir(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), prefix)); + testDirs.push(dir); + return dir; +} + +afterEach(() => { + vi.restoreAllMocks(); + for (const dir of testDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe('outbound image markers', () => { + it('finds markers outside fenced and inline code', () => { + const text = [ + 'before', + '[IMAGE: /tmp/real.png]', + '```text', + '[IMAGE: /tmp/fenced.png]', + '```', + '`[IMAGE: /tmp/inline.png]`', + '``[IMAGE: /tmp/double-inline.png]``', + 'after', + ].join('\n'); + + const markers = findImageMarkers(text); + + expect(markers).toEqual([ + expect.objectContaining({ path: '/tmp/real.png' }), + ]); + expect(replaceImageMarkers(text, markers, ['![image](media-id)'])).toBe( + [ + 'before', + '![image](media-id)', + '```text', + '[IMAGE: /tmp/fenced.png]', + '```', + '`[IMAGE: /tmp/inline.png]`', + '``[IMAGE: /tmp/double-inline.png]``', + 'after', + ].join('\n'), + ); + }); + + it('replaces repeated markers by source position', () => { + const text = [ + '`[IMAGE: /tmp/same.png]`', + '[IMAGE: /tmp/same.png]', + '[IMAGE: /tmp/same.png]', + ].join('\n'); + const markers = findImageMarkers(text); + + expect(replaceImageMarkers(text, markers, ['first', 'second'])).toBe( + ['`[IMAGE: /tmp/same.png]`', 'first', 'second'].join('\n'), + ); + }); +}); + +describe('readValidatedImage', () => { + it('reads a regular image inside the workspace', () => { + const workspace = makeTempDir('dingtalk-image-workspace-'); + const imagePath = join(workspace, 'image.png'); + writeFileSync(imagePath, PNG_DATA); + + expect( + readValidatedImage(imagePath, { + workspaceDir: workspace, + temporaryDir: workspace, + }), + ).toMatchObject({ + fileName: 'image.png', + mimeType: 'image/png', + data: PNG_DATA, + }); + }); + + it('rejects a symlink that escapes the allowed directories', () => { + const workspace = makeTempDir('dingtalk-image-workspace-'); + const outside = makeTempDir('dingtalk-image-outside-'); + const outsideImage = join(outside, 'outside.png'); + const linkedImage = join(workspace, 'linked.png'); + writeFileSync(outsideImage, PNG_DATA); + symlinkSync(outsideImage, linkedImage); + + expect(() => + readValidatedImage(linkedImage, { + workspaceDir: workspace, + temporaryDir: workspace, + }), + ).toThrow('outside allowed directories'); + }); + + it('rejects extension and content mismatches', () => { + const workspace = makeTempDir('dingtalk-image-workspace-'); + const imagePath = join(workspace, 'image.jpg'); + writeFileSync(imagePath, PNG_DATA); + + expect(() => + readValidatedImage(imagePath, { + workspaceDir: workspace, + temporaryDir: workspace, + }), + ).toThrow('Image type mismatch'); + }); + + it('rejects directories', () => { + const workspace = makeTempDir('dingtalk-image-workspace-'); + const imagePath = join(workspace, 'image.png'); + mkdirSync(imagePath); + + expect(() => + readValidatedImage(imagePath, { + workspaceDir: workspace, + temporaryDir: workspace, + }), + ).toThrow('Not a regular file'); + }); + + it('rejects images larger than the upload limit before reading them', () => { + const workspace = makeTempDir('dingtalk-image-workspace-'); + const imagePath = join(workspace, 'image.png'); + writeFileSync(imagePath, PNG_DATA); + truncateSync(imagePath, 20 * 1024 * 1024 + 1); + + expect(() => + readValidatedImage(imagePath, { + workspaceDir: workspace, + temporaryDir: workspace, + }), + ).toThrow('Image too large'); + }); +}); + +describe('uploadDingTalkImage', () => { + it('uploads a validated image and returns its MediaID', async () => { + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue( + new Response( + JSON.stringify({ errcode: 0, media_id: '@lAL-test-media-id' }), + { status: 200 }, + ), + ); + + await expect( + uploadDingTalkImage( + { + data: PNG_DATA, + fileName: 'image.png', + mimeType: 'image/png', + }, + 'access-token', + ), + ).resolves.toBe('@lAL-test-media-id'); + + const [url, init] = fetchSpy.mock.calls[0]!; + expect(String(url)).toContain('/media/upload?'); + expect(String(url)).toContain('type=image'); + expect(init?.method).toBe('POST'); + expect(init?.body).toBeInstanceOf(FormData); + const media = (init?.body as FormData).get('media'); + expect(media).toBeInstanceOf(Blob); + expect((media as File).name).toBe('image.png'); + }); + + it.each([40014, 42001])( + 'marks DingTalk token error %s as retryable authentication failure', + async (errcode) => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ errcode, errmsg: 'expired' }), { + status: 200, + }), + ); + + const request = uploadDingTalkImage( + { + data: PNG_DATA, + fileName: 'image.png', + mimeType: 'image/png', + }, + 'access-token', + ); + + await expect(request).rejects.toMatchObject({ + name: DingTalkMediaUploadError.name, + authFailure: true, + }); + }, + ); + + it('does not include the access token in upload errors', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ + errcode: 40035, + errmsg: 'invalid secret-access-token', + }), + { status: 200 }, + ), + ); + + await expect( + uploadDingTalkImage( + { + data: PNG_DATA, + fileName: 'image.png', + mimeType: 'image/png', + }, + 'secret-access-token', + ), + ).rejects.not.toThrow(/secret-access-token/); + }); + + it('does not include a credential-bearing request URL in network errors', async () => { + vi.spyOn(globalThis, 'fetch').mockRejectedValue( + new Error( + 'request to https://oapi.dingtalk.com/media/upload?access_token=secret-access-token failed', + ), + ); + + await expect( + uploadDingTalkImage( + { + data: PNG_DATA, + fileName: 'image.png', + mimeType: 'image/png', + }, + 'secret-access-token', + ), + ).rejects.not.toThrow(/secret-access-token/); + }); +}); diff --git a/packages/channels/dingtalk/src/outbound-image.ts b/packages/channels/dingtalk/src/outbound-image.ts new file mode 100644 index 00000000000..de6c011ee33 --- /dev/null +++ b/packages/channels/dingtalk/src/outbound-image.ts @@ -0,0 +1,284 @@ +import { + closeSync, + fstatSync, + openSync, + readFileSync, + readSync, + realpathSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, extname, isAbsolute, relative } from 'node:path'; + +const MEDIA_UPLOAD_API = 'https://oapi.dingtalk.com/media/upload'; +const MEDIA_UPLOAD_TIMEOUT_MS = 30_000; +const MAX_IMAGE_BYTES = 20 * 1024 * 1024; +const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.bmp']); +const AUTH_ERROR_CODES = new Set([40014, 42001]); + +export interface ImageMarker { + start: number; + end: number; + path: string; +} + +export interface ValidatedImage { + data: Buffer; + fileName: string; + mimeType: string; +} + +export class DingTalkMediaUploadError extends Error { + constructor( + message: string, + readonly authFailure: boolean, + ) { + super(message); + this.name = 'DingTalkMediaUploadError'; + } +} + +function maskCode(text: string): string { + const masked = text.split(''); + const blank = (start: number, end: number) => { + for (let i = start; i < end; i++) { + if (masked[i] !== '\n') masked[i] = ' '; + } + }; + + let offset = 0; + while (offset < text.length) { + if (text[offset] === '`') { + let runLength = 1; + while (text[offset + runLength] === '`') runLength++; + const delimiter = '`'.repeat(runLength); + const closing = text.indexOf(delimiter, offset + runLength); + const newline = + runLength >= 3 ? -1 : text.indexOf('\n', offset + runLength); + const closesBeforeNewline = + closing !== -1 && (newline === -1 || closing < newline); + const end = closesBeforeNewline + ? closing + runLength + : newline === -1 + ? text.length + : newline; + blank(offset, end); + offset = end; + continue; + } + offset++; + } + + return masked.join(''); +} + +export function findImageMarkers(text: string): ImageMarker[] { + const visibleText = maskCode(text); + const markerPattern = /\[IMAGE:\s*([^\]\r\n]+)\]/gi; + const markers: ImageMarker[] = []; + + for (const match of visibleText.matchAll(markerPattern)) { + const path = match[1]?.trim(); + if (!path || match.index === undefined) continue; + markers.push({ + start: match.index, + end: match.index + match[0].length, + path, + }); + } + + return markers; +} + +export function replaceImageMarkers( + text: string, + markers: readonly ImageMarker[], + replacements: readonly string[], +): string { + if (markers.length !== replacements.length) { + throw new Error('Image marker replacement count mismatch'); + } + + let result = text; + for (let i = markers.length - 1; i >= 0; i--) { + const marker = markers[i]!; + result = + result.slice(0, marker.start) + + replacements[i]! + + result.slice(marker.end); + } + return result; +} + +function isInside(realPath: string, directory: string): boolean { + const pathFromDirectory = relative(directory, realPath); + return ( + pathFromDirectory === '' || + (!pathFromDirectory.startsWith('..') && !isAbsolute(pathFromDirectory)) + ); +} + +function detectImageMime(data: Buffer): string { + if ( + data[0] === 0x89 && + data[1] === 0x50 && + data[2] === 0x4e && + data[3] === 0x47 + ) { + return 'image/png'; + } + if (data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff) { + return 'image/jpeg'; + } + if (data[0] === 0x47 && data[1] === 0x49 && data[2] === 0x46) { + return 'image/gif'; + } + if (data[0] === 0x42 && data[1] === 0x4d) { + return 'image/bmp'; + } + throw new Error('Unrecognized image format'); +} + +export function readValidatedImage( + imagePath: string, + options: { + workspaceDir: string; + temporaryDir?: string; + }, +): ValidatedImage { + if (!isAbsolute(imagePath)) { + throw new Error(`Image path must be absolute: ${imagePath}`); + } + + const extension = extname(imagePath).toLowerCase(); + if (!IMAGE_EXTENSIONS.has(extension)) { + throw new Error(`Image extension not allowed: ${extension}`); + } + + let realPath: string; + try { + realPath = realpathSync(imagePath); + } catch { + throw new Error(`Image file not found: ${imagePath}`); + } + const allowedDirectories = [ + realpathSync(options.workspaceDir), + realpathSync(options.temporaryDir ?? tmpdir()), + ]; + if (!allowedDirectories.some((directory) => isInside(realPath, directory))) { + throw new Error(`Image path outside allowed directories: ${realPath}`); + } + + const descriptor = openSync(realPath, 'r'); + try { + const stats = fstatSync(descriptor); + if (!stats.isFile()) { + throw new Error(`Not a regular file: ${realPath}`); + } + if (stats.size > MAX_IMAGE_BYTES) { + throw new Error( + `Image too large: ${stats.size} bytes (max ${MAX_IMAGE_BYTES})`, + ); + } + + const header = Buffer.alloc(16); + const bytesRead = readSync(descriptor, header, 0, header.length, 0); + const mimeType = detectImageMime(header.subarray(0, bytesRead)); + const expectedMime: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.bmp': 'image/bmp', + }; + if (mimeType !== expectedMime[extension]) { + throw new Error( + `Image type mismatch: ${extension} expects ${expectedMime[extension]} but got ${mimeType}`, + ); + } + + return { + data: readFileSync(descriptor), + fileName: basename(realPath), + mimeType, + }; + } finally { + closeSync(descriptor); + } +} + +function sanitizeApiMessage(message: unknown, accessToken: string): string { + const value = String(message ?? ''); + return (accessToken ? value.replaceAll(accessToken, '[redacted]') : value) + .replace(/[\r\n\t]+/g, ' ') + .slice(0, 200); +} + +export async function uploadDingTalkImage( + image: ValidatedImage, + accessToken: string, +): Promise { + const form = new FormData(); + form.append( + 'media', + new Blob([image.data], { type: image.mimeType }), + image.fileName, + ); + + let response: Response; + try { + const url = new URL(MEDIA_UPLOAD_API); + url.searchParams.set('access_token', accessToken); + url.searchParams.set('type', 'image'); + response = await fetch(url, { + method: 'POST', + body: form, + signal: AbortSignal.timeout(MEDIA_UPLOAD_TIMEOUT_MS), + }); + } catch { + throw new DingTalkMediaUploadError( + 'DingTalk media upload failed: network request failed', + false, + ); + } + + let payload: Record; + try { + const parsed = (await response.json()) as unknown; + payload = + parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : {}; + } catch { + throw new DingTalkMediaUploadError( + `DingTalk media upload failed: HTTP ${response.status} invalid JSON response`, + response.status === 401, + ); + } + + const errcode = + typeof payload['errcode'] === 'number' ? payload['errcode'] : undefined; + if (!response.ok || (errcode !== undefined && errcode !== 0)) { + const detail = sanitizeApiMessage(payload['errmsg'], accessToken); + throw new DingTalkMediaUploadError( + `DingTalk media upload failed: HTTP ${response.status}${ + errcode === undefined ? '' : ` errcode=${errcode}` + }${detail ? ` ${detail}` : ''}`, + response.status === 401 || + (errcode !== undefined && AUTH_ERROR_CODES.has(errcode)), + ); + } + + const mediaId = + typeof payload['media_id'] === 'string' + ? payload['media_id'] + : typeof payload['mediaId'] === 'string' + ? payload['mediaId'] + : undefined; + if (!mediaId) { + throw new DingTalkMediaUploadError( + 'DingTalk media upload failed: response did not include a MediaID', + false, + ); + } + return mediaId; +}