Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ 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 mockReactionCreate = vi.fn().mockResolvedValue({ code: 0, data: { reaction_id: 'rx-1' } })
const mockReactionDelete = vi.fn().mockResolvedValue({ code: 0 })

const mockClient = {
im: {
Expand All @@ -38,6 +40,10 @@ const mockClient = {
},
messageResource: {
get: mockMessageResourceGet
},
messageReaction: {
create: mockReactionCreate,
delete: mockReactionDelete
}
},
cardkit: {
Expand Down Expand Up @@ -85,6 +91,8 @@ describe('FeishuAdapter', () => {
mockCardUpdate.mockClear().mockResolvedValue({ code: 0 })
mockElementContent.mockClear().mockResolvedValue({ code: 0 })
mockMessageResourceGet.mockReset()
mockReactionCreate.mockClear().mockResolvedValue({ code: 0, data: { reaction_id: 'rx-1' } })
mockReactionDelete.mockClear().mockResolvedValue({ code: 0 })
mockWsStart.mockClear().mockResolvedValue(undefined)
capturedEventHandlers = {}
})
Expand Down Expand Up @@ -225,10 +233,122 @@ describe('FeishuAdapter', () => {
})
})

it('sendTypingIndicator() is a no-op (Feishu has no native typing API)', async () => {
it('sendTypingIndicator() is a no-op when no user message has been seen', async () => {
const adapter = createAdapter()
await adapter.connect()
await adapter.sendTypingIndicator('oc_123')
expect(mockReactionCreate).not.toHaveBeenCalled()
})

async function deliverIncomingTextMessage(messageId = 'msg-in-1', chatId = 'oc_123') {
const handler = capturedEventHandlers['im.message.receive_v1']
await handler({
sender: { sender_id: { open_id: 'ou_user1' } },
message: {
message_id: messageId,
chat_id: chatId,
chat_type: 'p2p',
message_type: 'text',
content: JSON.stringify({ text: 'Hello agent' })
}
})
}

it('sendTypingIndicator() reacts to the latest user message with INHALE and is idempotent', async () => {
const adapter = createAdapter()
await adapter.connect()

await deliverIncomingTextMessage()

await adapter.sendTypingIndicator('oc_123')
await adapter.sendTypingIndicator('oc_123')

expect(mockReactionCreate).toHaveBeenCalledTimes(1)
expect(mockReactionCreate).toHaveBeenCalledWith({
path: { message_id: 'msg-in-1' },
data: { reaction_type: { emoji_type: 'Typing' } }
})
})

it('sendMessage() promotes the typing reaction from INHALE to OK_HAND', async () => {
const adapter = createAdapter()
await adapter.connect()

await deliverIncomingTextMessage()
mockReactionCreate.mockResolvedValueOnce({ code: 0, data: { reaction_id: 'rx-thinking' } })
await adapter.sendTypingIndicator('oc_123')

mockReactionCreate.mockResolvedValueOnce({ code: 0, data: { reaction_id: 'rx-done' } })
await adapter.sendMessage('oc_123', 'reply')

expect(mockReactionDelete).toHaveBeenCalledWith({
path: { message_id: 'msg-in-1', reaction_id: 'rx-thinking' }
})
expect(mockReactionCreate).toHaveBeenLastCalledWith({
path: { message_id: 'msg-in-1' },
data: { reaction_type: { emoji_type: 'OK' } }
})
})

it('sendMessage() does not add OK_HAND when there was no prior typing reaction', async () => {
const adapter = createAdapter()
await adapter.connect()

// /new style ack — no incoming user message tracked, no typing indicator first
await adapter.sendMessage('oc_123', 'New session created.')

expect(mockReactionCreate).not.toHaveBeenCalled()
expect(mockReactionDelete).not.toHaveBeenCalled()
})

it('onStreamError() swaps the reaction to CRY and posts the error to chat', async () => {
const adapter = createAdapter()
await adapter.connect()

await deliverIncomingTextMessage()
mockReactionCreate.mockResolvedValueOnce({ code: 0, data: { reaction_id: 'rx-thinking' } })
await adapter.sendTypingIndicator('oc_123')

mockReactionCreate.mockResolvedValueOnce({ code: 0, data: { reaction_id: 'rx-error' } })
mockImCreate.mockClear()
await adapter.onStreamError('oc_123', 'boom')

expect(mockReactionDelete).toHaveBeenCalledWith({
path: { message_id: 'msg-in-1', reaction_id: 'rx-thinking' }
})
expect(mockReactionCreate).toHaveBeenLastCalledWith({
path: { message_id: 'msg-in-1' },
data: { reaction_type: { emoji_type: 'CRY' } }
})
// No streaming controller exists, so the error must be sent as a plain message
expect(mockImCreate).toHaveBeenCalledWith({
params: { receive_id_type: 'chat_id' },
data: {
receive_id: 'oc_123',
msg_type: 'post',
content: expect.stringContaining('boom')
}
})
})

it('onStreamError() defers to the streaming card when one exists (no extra message)', async () => {
vi.useFakeTimers()
const adapter = createAdapter()
await adapter.connect()

await deliverIncomingTextMessage()
mockReactionCreate.mockResolvedValueOnce({ code: 0, data: { reaction_id: 'rx-thinking' } })
await adapter.sendTypingIndicator('oc_123')
await adapter.onTextUpdate('oc_123', 'partial...')
await vi.advanceTimersByTimeAsync(500)

mockImCreate.mockClear()
mockReactionCreate.mockResolvedValueOnce({ code: 0, data: { reaction_id: 'rx-error' } })

await adapter.onStreamError('oc_123', 'boom')

// The streaming card displays the error; no plain "Error" message should be sent
expect(mockImCreate).not.toHaveBeenCalled()
})

it('handles incoming text messages and emits message event', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,22 @@ import { registrationBegin, registrationPoll } from './FeishuAppRegistration'

const FEISHU_MAX_LENGTH = 4000

/**
* Lifecycle reactions on the user's last message. Feishu has no native typing
* API, so we use emoji reactions as a visible status indicator: thinking →
* done / error. Each value must be a valid Feishu emoji_type.
* @see https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/reference/im-v1/message-reaction/emojis-introduce
*/
const REACTION_THINKING = 'Typing'
const REACTION_DONE = 'OK'
const REACTION_ERROR = 'CRY'

type ChatReaction = {
messageId: string
reactionId: string
emoji: string
}

type FeishuApiResponse<T = unknown> = {
code?: number
msg?: string
Expand Down Expand Up @@ -454,6 +470,10 @@ class FeishuAdapter extends ChannelAdapter {
private registrationAbort: AbortController | null = null
/** Per-chat streaming controller. One stream at a time per chat. */
private readonly streamingControllers = new Map<string, FeishuStreamingController>()
/** Latest user message id per chat — used as the target for status reactions. */
private readonly latestUserMessageByChat = new Map<string, string>()
/** Active status reaction per chat, so we can swap or remove it. */
private readonly chatReactions = new Map<string, ChatReaction>()

constructor(config: ChannelAdapterConfig) {
super(config)
Expand Down Expand Up @@ -597,6 +617,8 @@ class FeishuAdapter extends ChannelAdapter {
controller.dispose()
}
this.streamingControllers.clear()
this.chatReactions.clear()
this.latestUserMessageByChat.clear()

if (this.wsClient) {
this.wsClient.close()
Expand All @@ -608,10 +630,19 @@ class FeishuAdapter extends ChannelAdapter {
}

async sendMessage(chatId: string, text: string, _opts?: SendMessageOptions): Promise<void> {
void _opts
// Promote the typing reaction to DONE before delivering the reply,
// so the user sees the lifecycle transition. No-op for messages that
// weren't preceded by a typing indicator (e.g. /new acks).
await this.transitionChatReaction(chatId, REACTION_DONE, [REACTION_THINKING])
await this.sendRawMessage(chatId, text)
}

/** Send chunked text via the IM API without touching status reactions. */
private async sendRawMessage(chatId: string, text: string): Promise<void> {
if (!this.client) {
throw new Error('Client is not connected')
}
void _opts

const chunks = splitMessage(text, FEISHU_MAX_LENGTH)

Expand All @@ -634,10 +665,80 @@ class FeishuAdapter extends ChannelAdapter {
}
}

async sendTypingIndicator(_chatId: string): Promise<void> {
void _chatId
// Feishu doesn't have a native typing indicator API.
// The streaming card itself serves as a visual indicator.
async sendTypingIndicator(chatId: string): Promise<void> {
await this.setChatReaction(chatId, REACTION_THINKING)
}

/**
* Set the status reaction for a chat to `emoji`, swapping any existing
* reaction on the same user message. No-op if there is no recent user
* message to react to. Idempotent for the same (messageId, emoji) pair.
*/
private async setChatReaction(chatId: string, emoji: string): Promise<void> {
if (!this.client) return

const messageId = this.latestUserMessageByChat.get(chatId)
if (!messageId) return

const existing = this.chatReactions.get(chatId)
if (existing?.messageId === messageId && existing.emoji === emoji) return

if (existing) {
await this.clearChatReaction(chatId)
}

try {
const res = ensureFeishuSuccess<{ reaction_id?: string }>(
await this.client.im.messageReaction.create({
path: { message_id: messageId },
data: { reaction_type: { emoji_type: emoji } }
}),
'Add status reaction'
)
const reactionId = res.data?.reaction_id
if (reactionId) {
this.chatReactions.set(chatId, { messageId, reactionId, emoji })
}
} catch (error) {
this.log.debug('Failed to add status reaction', {
chatId,
messageId,
emoji,
error: error instanceof Error ? error.message : String(error)
})
}
}

/**
* Swap the active reaction to `emoji`, but only if there is currently a
* transient reaction (e.g. THINKING). Used at completion/error so that
* non-streaming sendMessage calls (e.g. /new) don't get a DONE reaction.
*/
private async transitionChatReaction(chatId: string, emoji: string, from: string[]): Promise<void> {
const existing = this.chatReactions.get(chatId)
if (!existing || !from.includes(existing.emoji)) return
await this.setChatReaction(chatId, emoji)
}

private async clearChatReaction(chatId: string): Promise<void> {
const reaction = this.chatReactions.get(chatId)
if (!reaction) return
this.chatReactions.delete(chatId)
if (!this.client) return

try {
ensureFeishuSuccess(
await this.client.im.messageReaction.delete({
path: { message_id: reaction.messageId, reaction_id: reaction.reactionId }
}),
'Remove status reaction'
)
} catch (error) {
this.log.debug('Failed to remove status reaction', {
chatId,
error: error instanceof Error ? error.message : String(error)
})
}
}

override async onTextUpdate(chatId: string, fullText: string): Promise<void> {
Expand All @@ -653,6 +754,7 @@ class FeishuAdapter extends ChannelAdapter {
}

override async onStreamComplete(chatId: string, finalText: string): Promise<boolean> {
await this.transitionChatReaction(chatId, REACTION_DONE, [REACTION_THINKING])
const controller = this.streamingControllers.get(chatId)
if (!controller) return false

Expand All @@ -661,11 +763,24 @@ class FeishuAdapter extends ChannelAdapter {
}

override async onStreamError(chatId: string, error: string): Promise<void> {
await this.transitionChatReaction(chatId, REACTION_ERROR, [REACTION_THINKING, REACTION_DONE])
const controller = this.streamingControllers.get(chatId)
if (!controller) return
if (controller) {
this.streamingControllers.delete(chatId)
await controller.error(error)
return
}

this.streamingControllers.delete(chatId)
await controller.error(error)
// No streaming card was created (LLM errored before producing any text),
// so the error would otherwise be silent. Send it as a plain message.
try {
await this.sendRawMessage(chatId, `**Error**: ${error}`)
} catch (sendError) {
this.log.warn('Failed to deliver stream error to chat', {
chatId,
error: sendError instanceof Error ? sendError.message : String(sendError)
})
}
}

private handleMessageEvent(event: FeishuMessageEvent): void {
Expand All @@ -677,6 +792,11 @@ class FeishuAdapter extends ChannelAdapter {
return
}

// Remember the latest user message so sendTypingIndicator can react to it.
if (event.message.message_id) {
this.latestUserMessageByChat.set(chatId, event.message.message_id)
}

const messageType = event.message.message_type
const userId = event.sender.sender_id.open_id ?? event.sender.sender_id.user_id ?? ''

Expand Down
Loading