fix(feishu): handle image messages from IM channel (#14421)

### What this PR does

Before this PR:

- Feishu bots silently dropped every image message: `handleMessageEvent`
hit `if (messageType !== 'text') return` and never processed
`message_type: 'image'`. Agents could receive text and file attachments
but not images.

After this PR:

- `handleMessageEvent` routes `message_type: 'image'` to a new
`handleImageMessage`.
- `handleImageMessage` parses `image_key` from the event content, calls
the new `downloadFeishuImage`, and emits a `message` event with
`ImageAttachment[]`. A fallback text message is emitted on download
failure.
- `downloadFeishuImage` streams the image via `im.messageResource.get({
params: { type: 'image' }, path: { message_id, file_key: imageKey } })`,
enforces `MAX_FILE_SIZE_BYTES`, and derives `media_type` from the
response `Content-Type` header (the Lark SDK exposes headers on stream
responses via `$return_headers: true`).
- Tests: replaced the old "ignores non-text" case with three focused
cases — positive image handling, malformed `image_key` drop, and a
sticker-type drop. All 17 tests pass.

Fixes #14401

### Why we need it and why it was done in this way

Users reported that image messages sent to a Feishu-connected agent are
silently discarded while text and files work fine. Root cause is a
missing branch in `handleMessageEvent`, not a deeper architectural
issue, so the fix is scoped to the adapter.

The following tradeoffs were made:

- Read `media_type` from the SDK response `Content-Type` header instead
of sniffing magic bytes. The Lark SDK sets `$return_headers: true`
internally for stream responses, so headers are reliably available. This
matches the official `larksuite/openclaw-lark` plugin's approach and
keeps the adapter short.
- Apply the shared 20 MB `MAX_FILE_SIZE_BYTES` limit by checking
mid-stream and calling `stream.destroy()` on overflow. The Feishu API
documents a 100 MB max, but we keep parity with the existing
file-download path.

The following alternatives were considered:

- Magic-byte MIME sniffing (initial draft). Rejected in favor of reading
the `Content-Type` header, which is simpler and consistent with the
official Lark plugin.
- Extending support to `post`-type messages with embedded `tag: 'img'`
elements. Deferred — out of scope for this hotfix (issue only reports
pure image messages).

Links to places where the discussion took place: N/A

### Breaking changes

None.

### Special notes for your reviewer

- This is a minimal, hotfix-scoped change to `FeishuAdapter.ts` and its
test; no refactoring.
- `downloadFeishuImage` deliberately mirrors the existing
`downloadFeishuFile` structure so the two paths stay easy to compare.
- Image-in-post-message handling is intentionally left for a future
change on `v2`.

### Checklist

- [x] PR: The PR description is expressive enough and will help future
contributors
- [x] Code: Write code that humans can understand and Keep it simple
- [x] Refactor: You have left the code cleaner than you found it (Boy
Scout Rule)
- [x] Upgrade: Impact of this change on upgrade flows was considered and
addressed if required
- [x] Documentation: A user-guide update was considered and is not
required (bug fix restores expected behavior already implied by the
"files + images" feature set).
- [x] Self-review: I have reviewed my own code before requesting review
from others

### Release note

```release-note
fix(feishu): restore receipt of image messages from Feishu/Lark bot users (previously silently dropped).
```

Signed-off-by: suyao <sy20010504@gmail.com>
Co-authored-by: 亢奋猫 <kangfenmao@qq.com>
This commit is contained in:
SuYao
2026-04-22 18:14:20 +08:00
committed by GitHub
parent 01caaf06f7
commit fbf73962cb
2 changed files with 175 additions and 2 deletions

View File

@@ -28,12 +28,16 @@ const mockCardCreate = vi.fn().mockResolvedValue({ code: 0, data: { card_id: 'ca
const mockCardSettings = vi.fn().mockResolvedValue({ code: 0 })
const mockCardUpdate = vi.fn().mockResolvedValue({ code: 0 })
const mockElementContent = vi.fn().mockResolvedValue({ code: 0 })
const mockMessageResourceGet = vi.fn()
const mockClient = {
im: {
message: {
create: mockImCreate,
update: mockImUpdate
},
messageResource: {
get: mockMessageResourceGet
}
},
cardkit: {
@@ -80,6 +84,7 @@ describe('FeishuAdapter', () => {
mockCardSettings.mockClear().mockResolvedValue({ code: 0 })
mockCardUpdate.mockClear().mockResolvedValue({ code: 0 })
mockElementContent.mockClear().mockResolvedValue({ code: 0 })
mockMessageResourceGet.mockReset()
mockWsStart.mockClear().mockResolvedValue(undefined)
capturedEventHandlers = {}
})
@@ -355,7 +360,56 @@ describe('FeishuAdapter', () => {
expect(messageSpy).toHaveBeenCalledWith(expect.objectContaining({ text: 'Hello agent' }))
})
it('ignores non-text message types', async () => {
it('handles incoming image messages and emits message event with attachment', async () => {
const adapter = createAdapter({ allowed_chat_ids: [] })
await adapter.connect()
const messageSpy = vi.fn()
adapter.on('message', messageSpy)
const pngBuffer = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x01, 0x02, 0x03])
mockMessageResourceGet.mockResolvedValue({
getReadableStream: () => {
const { Readable } = require('node:stream')
return Readable.from([pngBuffer])
},
headers: { 'content-type': 'image/png' }
})
const handler = capturedEventHandlers['im.message.receive_v1']
await handler({
sender: { sender_id: { open_id: 'ou_user1' } },
message: {
message_id: 'msg-image',
chat_id: 'oc_123',
chat_type: 'p2p',
message_type: 'image',
content: JSON.stringify({ image_key: 'img_abc' })
}
})
// Downloader is fire-and-forget — flush microtasks
await new Promise((resolve) => setImmediate(resolve))
expect(mockMessageResourceGet).toHaveBeenCalledWith({
params: { type: 'image' },
path: { message_id: 'msg-image', file_key: 'img_abc' }
})
expect(messageSpy).toHaveBeenCalledWith({
chatId: 'oc_123',
userId: 'ou_user1',
userName: '',
text: '',
images: [
{
data: pngBuffer.toString('base64'),
media_type: 'image/png'
}
]
})
})
it('emits fallback message when image content has no image_key', async () => {
const adapter = createAdapter({ allowed_chat_ids: [] })
await adapter.connect()
@@ -366,7 +420,7 @@ describe('FeishuAdapter', () => {
await handler({
sender: { sender_id: { open_id: 'ou_user1' } },
message: {
message_id: 'msg-image',
message_id: 'msg-image-bad',
chat_id: 'oc_123',
chat_type: 'p2p',
message_type: 'image',
@@ -374,6 +428,29 @@ describe('FeishuAdapter', () => {
}
})
expect(mockMessageResourceGet).not.toHaveBeenCalled()
expect(messageSpy).not.toHaveBeenCalled()
})
it('ignores unsupported message types (e.g. sticker)', async () => {
const adapter = createAdapter({ allowed_chat_ids: [] })
await adapter.connect()
const messageSpy = vi.fn()
adapter.on('message', messageSpy)
const handler = capturedEventHandlers['im.message.receive_v1']
await handler({
sender: { sender_id: { open_id: 'ou_user1' } },
message: {
message_id: 'msg-sticker',
chat_id: 'oc_123',
chat_type: 'p2p',
message_type: 'sticker',
content: '{}'
}
})
expect(messageSpy).not.toHaveBeenCalled()
})

View File

@@ -10,6 +10,7 @@ import {
ChannelAdapter,
type ChannelAdapterConfig,
type FileAttachment,
type ImageAttachment,
MAX_FILE_SIZE_BYTES,
type SendMessageOptions
} from '../../ChannelAdapter'
@@ -684,6 +685,11 @@ class FeishuAdapter extends ChannelAdapter {
return
}
if (messageType === 'image') {
this.handleImageMessage(event, chatId, userId)
return
}
if (messageType !== 'text') return
let text: string
@@ -720,6 +726,96 @@ class FeishuAdapter extends ChannelAdapter {
})
}
private handleImageMessage(event: FeishuMessageEvent, chatId: string, userId: string): void {
let imageKey: string
try {
const parsed = JSON.parse(event.message.content) as { image_key?: string }
imageKey = parsed.image_key ?? ''
} catch {
return
}
if (!imageKey) return
this.downloadFeishuImage(event.message.message_id, imageKey)
.then((images) => {
if (images.length === 0) {
this.emit('message', {
chatId,
userId,
userName: '',
text: '[Image — download failed]'
})
return
}
this.emit('message', {
chatId,
userId,
userName: '',
text: '',
images
})
})
.catch((error) => {
this.log.warn('Failed to download Feishu image', {
imageKey,
error: error instanceof Error ? error.message : String(error)
})
this.emit('message', {
chatId,
userId,
userName: '',
text: '[Image — download failed]'
})
})
}
private async downloadFeishuImage(messageId: string, imageKey: string): Promise<ImageAttachment[]> {
if (!this.client) return []
this.log.info('Downloading Feishu image', { messageId, imageKey })
let resp: Awaited<ReturnType<typeof this.client.im.messageResource.get>>
try {
resp = await this.client.im.messageResource.get({
params: { type: 'image' },
path: { message_id: messageId, file_key: imageKey }
})
} catch (error) {
this.log.error('Feishu messageResource.get failed', {
messageId,
imageKey,
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined
})
throw error
}
const stream = resp.getReadableStream()
const chunks: Buffer[] = []
let totalSize = 0
for await (const chunk of stream) {
totalSize += chunk.length
if (totalSize > MAX_FILE_SIZE_BYTES) {
this.log.warn('Feishu image too large, aborting download', { imageKey, size: totalSize })
stream.destroy()
return []
}
chunks.push(Buffer.from(chunk))
}
const buffer = Buffer.concat(chunks)
if (buffer.length === 0) return []
const rawContentType =
(resp.headers as Record<string, string | string[] | undefined> | undefined)?.['content-type'] ?? ''
const headerValue = Array.isArray(rawContentType) ? rawContentType[0] : rawContentType
const mediaType = headerValue ? headerValue.split(';')[0].trim() || 'image/png' : 'image/png'
this.log.info('Feishu image downloaded', { imageKey, totalSize: buffer.length, mediaType })
return [{ data: buffer.toString('base64'), media_type: mediaType }]
}
private handleFileMessage(event: FeishuMessageEvent, chatId: string, userId: string): void {
let fileKey: string
let fileName: string