fix: reply to Feishu trigger messages

This commit is contained in:
2026-07-09 20:04:13 +08:00
parent fc0df7b823
commit fc5a5365d8
8 changed files with 229 additions and 96 deletions
+106 -56
View File
@@ -28,6 +28,10 @@ export interface OutboundPayload {
readonly content: string;
}
export interface SendMessageOptions {
readonly replyToMessageId?: string | undefined;
}
export async function withRetry<T>(
fn: () => Promise<T>,
options: {
@@ -193,29 +197,24 @@ export function parsePostMessage(content: string): { text: string; imageKeys: st
}
/** Send a text message using the best Feishu message type for the content. */
export async function sendTextMessage(rt: FeishuRuntime, chatId: string, text: string): Promise<string | null> {
export async function sendTextMessage(
rt: FeishuRuntime,
chatId: string,
text: string,
options?: SendMessageOptions,
): Promise<string | null> {
const payload = buildOutboundPayload(text);
const client = rt.client as unknown as {
im: { v1: { message: { create: (p: unknown) => Promise<MessageCreateResponse> } } };
};
try {
const res = await client.im.v1.message.create({
params: { receive_id_type: "chat_id" },
data: { receive_id: chatId, msg_type: payload.msgType, content: payload.content },
});
return messageIdFromResponse(res);
return await sendMessagePayload(rt, chatId, payload, options);
} catch (e) {
if (payload.msgType === "post" && isPostContentFormatError(e)) {
try {
const res = await client.im.v1.message.create({
params: { receive_id_type: "chat_id" },
data: {
receive_id: chatId,
msg_type: "text",
content: JSON.stringify({ text: _stripMarkdownToPlainText(text) }),
},
});
return messageIdFromResponse(res);
return await sendMessagePayload(
rt,
chatId,
{ msgType: "text", content: JSON.stringify({ text: _stripMarkdownToPlainText(text) }) },
options,
);
} catch {
return null;
}
@@ -225,10 +224,15 @@ export async function sendTextMessage(rt: FeishuRuntime, chatId: string, text: s
}
/** Send long text as multiple Feishu messages. Returns the last successful message_id. */
export async function sendLongText(rt: FeishuRuntime, chatId: string, text: string): Promise<string | null> {
export async function sendLongText(
rt: FeishuRuntime,
chatId: string,
text: string,
options?: SendMessageOptions,
): Promise<string | null> {
let lastMessageId: string | null = null;
for (const chunk of splitAtBoundary(text, DEFAULT_MAX_MESSAGE_LENGTH)) {
const messageId = await sendTextMessage(rt, chatId, chunk);
const messageId = await sendTextMessage(rt, chatId, chunk, options);
if (messageId !== null) {
lastMessageId = messageId;
}
@@ -237,23 +241,31 @@ export async function sendLongText(rt: FeishuRuntime, chatId: string, text: stri
}
/** Send a plain text message (fire-and-forget). */
export async function sendText(rt: FeishuRuntime, chatId: string, text: string): Promise<void> {
await sendTextMessage(rt, chatId, text);
export async function sendText(
rt: FeishuRuntime,
chatId: string,
text: string,
options?: SendMessageOptions,
): Promise<void> {
await sendTextMessage(rt, chatId, text, options);
}
/** Send an interactive card message (always patchable). Used for streaming. */
export async function sendInteractiveCardMessage(rt: FeishuRuntime, chatId: string, text: string): Promise<string | null> {
export async function sendInteractiveCardMessage(
rt: FeishuRuntime,
chatId: string,
text: string,
options?: SendMessageOptions,
): Promise<string | null> {
const payload = buildOutboundPayload(text);
const card = buildInteractiveCardFromOutboundPayload(payload);
const client = rt.client as unknown as {
im: { v1: { message: { create: (p: unknown) => Promise<MessageCreateResponse> } } };
};
try {
const res = await client.im.v1.message.create({
params: { receive_id_type: "chat_id" },
data: { receive_id: chatId, msg_type: "interactive", content: JSON.stringify(card) },
});
return messageIdFromResponse(res);
return await sendMessagePayload(
rt,
chatId,
{ msgType: "interactive", content: JSON.stringify(card) },
options,
);
} catch {
return null;
}
@@ -278,16 +290,19 @@ export async function patchTextMessage(rt: FeishuRuntime, messageId: string, tex
}
/** Send a raw interactive card (arbitrary JSON). Returns message_id on success. */
export async function sendCard(rt: FeishuRuntime, chatId: string, card: Record<string, unknown>): Promise<string | null> {
const client = rt.client as unknown as {
im: { v1: { message: { create: (p: unknown) => Promise<MessageCreateResponse> } } };
};
export async function sendCard(
rt: FeishuRuntime,
chatId: string,
card: Record<string, unknown>,
options?: SendMessageOptions,
): Promise<string | null> {
try {
const res = await client.im.v1.message.create({
params: { receive_id_type: "chat_id" },
data: { receive_id: chatId, msg_type: "interactive", content: JSON.stringify(card) },
});
return messageIdFromResponse(res);
return await sendMessagePayload(
rt,
chatId,
{ msgType: "interactive", content: JSON.stringify(card) },
options,
);
} catch {
return null;
}
@@ -318,6 +333,7 @@ export async function sendApprovalCard(
title: string,
body: string,
buttons: ReadonlyArray<{ label: string; value: string; style?: "primary" | "default" | "danger" }>,
options?: SendMessageOptions,
): Promise<string | null> {
const card = {
config: { wide_screen_mode: true },
@@ -335,15 +351,13 @@ export async function sendApprovalCard(
},
],
};
const client = rt.client as unknown as {
im: { v1: { message: { create: (p: unknown) => Promise<MessageCreateResponse> } } };
};
try {
const res = await client.im.v1.message.create({
params: { receive_id_type: "chat_id" },
data: { receive_id: chatId, msg_type: "interactive", content: JSON.stringify(card) },
});
return messageIdFromResponse(res);
return await sendMessagePayload(
rt,
chatId,
{ msgType: "interactive", content: JSON.stringify(card) },
options,
);
} catch (e) {
rt.logger.warn({ chatId, err: e instanceof Error ? e.message : String(e) }, "sendApprovalCard failed");
return null;
@@ -491,11 +505,16 @@ export async function downloadMessageFile(
}
/** Upload and send a file to a chat. */
export async function sendFile(rt: FeishuRuntime, chatId: string, filePath: string, fileName: string): Promise<string | null> {
export async function sendFile(
rt: FeishuRuntime,
chatId: string,
filePath: string,
fileName: string,
options?: SendMessageOptions,
): Promise<string | null> {
const client = rt.client as unknown as {
im: { v1: {
file: { create: (p: unknown) => Promise<FileCreateResponse> }
message: { create: (p: unknown) => Promise<MessageCreateResponse> }
} };
};
try {
@@ -510,13 +529,9 @@ export async function sendFile(rt: FeishuRuntime, chatId: string, filePath: stri
rt.logger.warn({ chatId, filePath }, "sendFile failed: upload response missing file_key");
return null;
}
const msgRes = await withRetry(async () =>
client.im.v1.message.create({
params: { receive_id_type: "chat_id" },
data: { receive_id: chatId, msg_type: "file", content: JSON.stringify({ file_key: fileKey }) },
}),
const messageId = await withRetry(async () =>
sendMessagePayload(rt, chatId, { msgType: "file", content: JSON.stringify({ file_key: fileKey }) }, options),
);
const messageId = messageIdFromResponse(msgRes);
if (messageId === null) {
rt.logger.warn({ chatId, filePath }, "sendFile failed: message response missing message_id");
}
@@ -527,6 +542,41 @@ export async function sendFile(rt: FeishuRuntime, chatId: string, filePath: stri
}
}
async function sendMessagePayload(
rt: FeishuRuntime,
chatId: string,
payload: OutboundPayload,
options?: SendMessageOptions,
): Promise<string | null> {
const client = rt.client as unknown as {
im: { v1: { message: {
create: (p: unknown) => Promise<MessageCreateResponse>;
reply: (p: unknown) => Promise<MessageCreateResponse>;
} } };
};
const replyToMessageId = normalizeReplyMessageId(options?.replyToMessageId);
if (replyToMessageId !== null) {
const res = await client.im.v1.message.reply({
path: { message_id: replyToMessageId },
data: {
msg_type: payload.msgType,
content: payload.content,
},
});
return messageIdFromResponse(res);
}
const res = await client.im.v1.message.create({
params: { receive_id_type: "chat_id" },
data: { receive_id: chatId, msg_type: payload.msgType, content: payload.content },
});
return messageIdFromResponse(res);
}
function normalizeReplyMessageId(messageId: string | undefined): string | null {
return messageId === undefined || messageId === "" ? null : messageId;
}
type MessageCreateResponse = {
readonly message_id?: string | undefined;
readonly data?: { readonly message_id?: string | undefined } | undefined;