mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-07-05 21:50:46 +08:00
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:
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user