fix(feishu): comprehensive reply mechanism — outbound replyToId forwarding + topic-aware reply targeting (#33789)

* fix(feishu): comprehensive reply mechanism fix — outbound replyToId forwarding + topic-aware reply targeting

- Forward replyToId from ChannelOutboundContext through sendText/sendMedia
  to sendMessageFeishu/sendMarkdownCardFeishu/sendMediaFeishu, enabling
  reply-to-message via the message tool.

- Fix group reply targeting: use ctx.messageId (triggering message) in
  normal groups to prevent silent topic thread creation (#32980). Preserve
  ctx.rootId targeting for topic-mode groups (group_topic/group_topic_sender)
  and groups with explicit replyInThread config.

- Add regression tests for both fixes.

Fixes #32980
Fixes #32958
Related #19784

* fix: normalize Feishu delivery.to before comparing with messaging tool targets

- Add normalizeDeliveryTarget helper to strip user:/chat: prefixes for Feishu
- Apply normalization in matchesMessagingToolDeliveryTarget before comparison
- This ensures cron duplicate suppression works when session uses prefixed targets
  (user:ou_xxx) but messaging tool extract uses normalized bare IDs (ou_xxx)

Fixes review comment on PR #32755

(cherry picked from commit fc20106f16)

* fix(feishu): catch thrown SDK errors for withdrawn reply targets

The Feishu Lark SDK can throw exceptions (SDK errors with .code or
AxiosErrors with .response.data.code) for withdrawn/deleted reply
targets, in addition to returning error codes in the response object.

Wrap reply calls in sendMessageFeishu and sendCardFeishu with
try-catch to handle thrown withdrawn/not-found errors (230011,
231003) and fall back to client.im.message.create, matching the
existing response-level fallback behavior.

Also extract sendFallbackDirect helper to deduplicate the
direct-send fallback block across both functions.

Closes #33496

(cherry picked from commit ad0901aec1)

* feishu: forward outbound reply target context

(cherry picked from commit c129a691fc)

* feishu extension: tighten reply target fallback semantics

(cherry picked from commit f85ec610f2)

* fix(feishu): align synthesized fallback typing and changelog attribution

* test(feishu): cover group_topic_sender reply targeting

---------

Co-authored-by: Xu Zimo <xuzimojimmy@163.com>
Co-authored-by: Munem Hashmi <munem.hashmi@gmail.com>
Co-authored-by: bmendonca3 <bmendonca3@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
Madoka
2026-03-05 10:32:28 +08:00
committed by GitHub
parent 432e0222dd
commit 63ce7c74bd
8 changed files with 529 additions and 59 deletions

View File

@@ -19,6 +19,61 @@ function shouldFallbackFromReplyTarget(response: { code?: number; msg?: string }
return msg.includes("withdrawn") || msg.includes("not found");
}
/** Check whether a thrown error indicates a withdrawn/not-found reply target. */
function isWithdrawnReplyError(err: unknown): boolean {
if (typeof err !== "object" || err === null) {
return false;
}
// SDK error shape: err.code
const code = (err as { code?: number }).code;
if (typeof code === "number" && WITHDRAWN_REPLY_ERROR_CODES.has(code)) {
return true;
}
// AxiosError shape: err.response.data.code
const response = (err as { response?: { data?: { code?: number; msg?: string } } }).response;
if (
typeof response?.data?.code === "number" &&
WITHDRAWN_REPLY_ERROR_CODES.has(response.data.code)
) {
return true;
}
return false;
}
type FeishuCreateMessageClient = {
im: {
message: {
create: (opts: {
params: { receive_id_type: "chat_id" | "email" | "open_id" | "union_id" | "user_id" };
data: { receive_id: string; content: string; msg_type: string };
}) => Promise<{ code?: number; msg?: string; data?: { message_id?: string } }>;
};
};
};
/** Send a direct message as a fallback when a reply target is unavailable. */
async function sendFallbackDirect(
client: FeishuCreateMessageClient,
params: {
receiveId: string;
receiveIdType: "chat_id" | "email" | "open_id" | "union_id" | "user_id";
content: string;
msgType: string;
},
errorPrefix: string,
): Promise<FeishuSendResult> {
const response = await client.im.message.create({
params: { receive_id_type: params.receiveIdType },
data: {
receive_id: params.receiveId,
content: params.content,
msg_type: params.msgType,
},
});
assertFeishuMessageApiSuccess(response, errorPrefix);
return toFeishuSendResult(response, params.receiveId);
}
export type FeishuMessageInfo = {
messageId: string;
chatId: string;
@@ -239,41 +294,33 @@ export async function sendMessageFeishu(
const { content, msgType } = buildFeishuPostMessagePayload({ messageText });
const directParams = { receiveId, receiveIdType, content, msgType };
if (replyToMessageId) {
const response = await client.im.message.reply({
path: { message_id: replyToMessageId },
data: {
content,
msg_type: msgType,
...(replyInThread ? { reply_in_thread: true } : {}),
},
});
if (shouldFallbackFromReplyTarget(response)) {
const fallback = await client.im.message.create({
params: { receive_id_type: receiveIdType },
let response: { code?: number; msg?: string; data?: { message_id?: string } };
try {
response = await client.im.message.reply({
path: { message_id: replyToMessageId },
data: {
receive_id: receiveId,
content,
msg_type: msgType,
...(replyInThread ? { reply_in_thread: true } : {}),
},
});
assertFeishuMessageApiSuccess(fallback, "Feishu send failed");
return toFeishuSendResult(fallback, receiveId);
} catch (err) {
if (!isWithdrawnReplyError(err)) {
throw err;
}
return sendFallbackDirect(client, directParams, "Feishu send failed");
}
if (shouldFallbackFromReplyTarget(response)) {
return sendFallbackDirect(client, directParams, "Feishu send failed");
}
assertFeishuMessageApiSuccess(response, "Feishu reply failed");
return toFeishuSendResult(response, receiveId);
}
const response = await client.im.message.create({
params: { receive_id_type: receiveIdType },
data: {
receive_id: receiveId,
content,
msg_type: msgType,
},
});
assertFeishuMessageApiSuccess(response, "Feishu send failed");
return toFeishuSendResult(response, receiveId);
return sendFallbackDirect(client, directParams, "Feishu send failed");
}
export type SendFeishuCardParams = {
@@ -291,41 +338,33 @@ export async function sendCardFeishu(params: SendFeishuCardParams): Promise<Feis
const { client, receiveId, receiveIdType } = resolveFeishuSendTarget({ cfg, to, accountId });
const content = JSON.stringify(card);
const directParams = { receiveId, receiveIdType, content, msgType: "interactive" };
if (replyToMessageId) {
const response = await client.im.message.reply({
path: { message_id: replyToMessageId },
data: {
content,
msg_type: "interactive",
...(replyInThread ? { reply_in_thread: true } : {}),
},
});
if (shouldFallbackFromReplyTarget(response)) {
const fallback = await client.im.message.create({
params: { receive_id_type: receiveIdType },
let response: { code?: number; msg?: string; data?: { message_id?: string } };
try {
response = await client.im.message.reply({
path: { message_id: replyToMessageId },
data: {
receive_id: receiveId,
content,
msg_type: "interactive",
...(replyInThread ? { reply_in_thread: true } : {}),
},
});
assertFeishuMessageApiSuccess(fallback, "Feishu card send failed");
return toFeishuSendResult(fallback, receiveId);
} catch (err) {
if (!isWithdrawnReplyError(err)) {
throw err;
}
return sendFallbackDirect(client, directParams, "Feishu card send failed");
}
if (shouldFallbackFromReplyTarget(response)) {
return sendFallbackDirect(client, directParams, "Feishu card send failed");
}
assertFeishuMessageApiSuccess(response, "Feishu card reply failed");
return toFeishuSendResult(response, receiveId);
}
const response = await client.im.message.create({
params: { receive_id_type: receiveIdType },
data: {
receive_id: receiveId,
content,
msg_type: "interactive",
},
});
assertFeishuMessageApiSuccess(response, "Feishu card send failed");
return toFeishuSendResult(response, receiveId);
return sendFallbackDirect(client, directParams, "Feishu card send failed");
}
export async function updateCardFeishu(params: {