Files
openclaw/extensions/feishu/src/send.ts
Madoka 63ce7c74bd 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 c129a691fcf552a1cebe1e8a22ea8611ffc3b377)

* feishu extension: tighten reply target fallback semantics

(cherry picked from commit f85ec610f267020b66713c09e648ec004b2e26f1)

* 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>
2026-03-04 20:32:28 -06:00

478 lines
14 KiB
TypeScript

import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
import { resolveFeishuAccount } from "./accounts.js";
import { createFeishuClient } from "./client.js";
import type { MentionTarget } from "./mention.js";
import { buildMentionedMessage, buildMentionedCardContent } from "./mention.js";
import { parsePostContent } from "./post.js";
import { getFeishuRuntime } from "./runtime.js";
import { assertFeishuMessageApiSuccess, toFeishuSendResult } from "./send-result.js";
import { resolveFeishuSendTarget } from "./send-target.js";
import type { FeishuSendResult } from "./types.js";
const WITHDRAWN_REPLY_ERROR_CODES = new Set([230011, 231003]);
function shouldFallbackFromReplyTarget(response: { code?: number; msg?: string }): boolean {
if (response.code !== undefined && WITHDRAWN_REPLY_ERROR_CODES.has(response.code)) {
return true;
}
const msg = response.msg?.toLowerCase() ?? "";
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;
senderId?: string;
senderOpenId?: string;
senderType?: string;
content: string;
contentType: string;
createTime?: number;
};
function parseInteractiveCardContent(parsed: unknown): string {
if (!parsed || typeof parsed !== "object") {
return "[Interactive Card]";
}
const candidate = parsed as { elements?: unknown };
if (!Array.isArray(candidate.elements)) {
return "[Interactive Card]";
}
const texts: string[] = [];
for (const element of candidate.elements) {
if (!element || typeof element !== "object") {
continue;
}
const item = element as {
tag?: string;
content?: string;
text?: { content?: string };
};
if (item.tag === "div" && typeof item.text?.content === "string") {
texts.push(item.text.content);
continue;
}
if (item.tag === "markdown" && typeof item.content === "string") {
texts.push(item.content);
}
}
return texts.join("\n").trim() || "[Interactive Card]";
}
function parseQuotedMessageContent(rawContent: string, msgType: string): string {
if (!rawContent) {
return "";
}
let parsed: unknown;
try {
parsed = JSON.parse(rawContent);
} catch {
return rawContent;
}
if (msgType === "text") {
const text = (parsed as { text?: unknown })?.text;
return typeof text === "string" ? text : "[Text message]";
}
if (msgType === "post") {
return parsePostContent(rawContent).textContent;
}
if (msgType === "interactive") {
return parseInteractiveCardContent(parsed);
}
if (typeof parsed === "string") {
return parsed;
}
const genericText = (parsed as { text?: unknown; title?: unknown } | null)?.text;
if (typeof genericText === "string" && genericText.trim()) {
return genericText;
}
const genericTitle = (parsed as { title?: unknown } | null)?.title;
if (typeof genericTitle === "string" && genericTitle.trim()) {
return genericTitle;
}
return `[${msgType || "unknown"} message]`;
}
/**
* Get a message by its ID.
* Useful for fetching quoted/replied message content.
*/
export async function getMessageFeishu(params: {
cfg: ClawdbotConfig;
messageId: string;
accountId?: string;
}): Promise<FeishuMessageInfo | null> {
const { cfg, messageId, accountId } = params;
const account = resolveFeishuAccount({ cfg, accountId });
if (!account.configured) {
throw new Error(`Feishu account "${account.accountId}" not configured`);
}
const client = createFeishuClient(account);
try {
const response = (await client.im.message.get({
path: { message_id: messageId },
})) as {
code?: number;
msg?: string;
data?: {
items?: Array<{
message_id?: string;
chat_id?: string;
msg_type?: string;
body?: { content?: string };
sender?: {
id?: string;
id_type?: string;
sender_type?: string;
};
create_time?: string;
}>;
message_id?: string;
chat_id?: string;
msg_type?: string;
body?: { content?: string };
sender?: {
id?: string;
id_type?: string;
sender_type?: string;
};
create_time?: string;
};
};
if (response.code !== 0) {
return null;
}
// Support both list shape (data.items[0]) and single-object shape (data as message)
const rawItem = response.data?.items?.[0] ?? response.data;
const item =
rawItem &&
(rawItem.body !== undefined || (rawItem as { message_id?: string }).message_id !== undefined)
? rawItem
: null;
if (!item) {
return null;
}
const msgType = item.msg_type ?? "text";
const rawContent = item.body?.content ?? "";
const content = parseQuotedMessageContent(rawContent, msgType);
return {
messageId: item.message_id ?? messageId,
chatId: item.chat_id ?? "",
senderId: item.sender?.id,
senderOpenId: item.sender?.id_type === "open_id" ? item.sender?.id : undefined,
senderType: item.sender?.sender_type,
content,
contentType: msgType,
createTime: item.create_time ? parseInt(String(item.create_time), 10) : undefined,
};
} catch {
return null;
}
}
export type SendFeishuMessageParams = {
cfg: ClawdbotConfig;
to: string;
text: string;
replyToMessageId?: string;
/** When true, reply creates a Feishu topic thread instead of an inline reply */
replyInThread?: boolean;
/** Mention target users */
mentions?: MentionTarget[];
/** Account ID (optional, uses default if not specified) */
accountId?: string;
};
function buildFeishuPostMessagePayload(params: { messageText: string }): {
content: string;
msgType: string;
} {
const { messageText } = params;
return {
content: JSON.stringify({
zh_cn: {
content: [
[
{
tag: "md",
text: messageText,
},
],
],
},
}),
msgType: "post",
};
}
export async function sendMessageFeishu(
params: SendFeishuMessageParams,
): Promise<FeishuSendResult> {
const { cfg, to, text, replyToMessageId, replyInThread, mentions, accountId } = params;
const { client, receiveId, receiveIdType } = resolveFeishuSendTarget({ cfg, to, accountId });
const tableMode = getFeishuRuntime().channel.text.resolveMarkdownTableMode({
cfg,
channel: "feishu",
});
// Build message content (with @mention support)
let rawText = text ?? "";
if (mentions && mentions.length > 0) {
rawText = buildMentionedMessage(mentions, rawText);
}
const messageText = getFeishuRuntime().channel.text.convertMarkdownTables(rawText, tableMode);
const { content, msgType } = buildFeishuPostMessagePayload({ messageText });
const directParams = { receiveId, receiveIdType, content, msgType };
if (replyToMessageId) {
let response: { code?: number; msg?: string; data?: { message_id?: string } };
try {
response = await client.im.message.reply({
path: { message_id: replyToMessageId },
data: {
content,
msg_type: msgType,
...(replyInThread ? { reply_in_thread: true } : {}),
},
});
} 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);
}
return sendFallbackDirect(client, directParams, "Feishu send failed");
}
export type SendFeishuCardParams = {
cfg: ClawdbotConfig;
to: string;
card: Record<string, unknown>;
replyToMessageId?: string;
/** When true, reply creates a Feishu topic thread instead of an inline reply */
replyInThread?: boolean;
accountId?: string;
};
export async function sendCardFeishu(params: SendFeishuCardParams): Promise<FeishuSendResult> {
const { cfg, to, card, replyToMessageId, replyInThread, accountId } = params;
const { client, receiveId, receiveIdType } = resolveFeishuSendTarget({ cfg, to, accountId });
const content = JSON.stringify(card);
const directParams = { receiveId, receiveIdType, content, msgType: "interactive" };
if (replyToMessageId) {
let response: { code?: number; msg?: string; data?: { message_id?: string } };
try {
response = await client.im.message.reply({
path: { message_id: replyToMessageId },
data: {
content,
msg_type: "interactive",
...(replyInThread ? { reply_in_thread: true } : {}),
},
});
} 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);
}
return sendFallbackDirect(client, directParams, "Feishu card send failed");
}
export async function updateCardFeishu(params: {
cfg: ClawdbotConfig;
messageId: string;
card: Record<string, unknown>;
accountId?: string;
}): Promise<void> {
const { cfg, messageId, card, accountId } = params;
const account = resolveFeishuAccount({ cfg, accountId });
if (!account.configured) {
throw new Error(`Feishu account "${account.accountId}" not configured`);
}
const client = createFeishuClient(account);
const content = JSON.stringify(card);
const response = await client.im.message.patch({
path: { message_id: messageId },
data: { content },
});
if (response.code !== 0) {
throw new Error(`Feishu card update failed: ${response.msg || `code ${response.code}`}`);
}
}
/**
* Build a Feishu interactive card with markdown content.
* Cards render markdown properly (code blocks, tables, links, etc.)
* Uses schema 2.0 format for proper markdown rendering.
*/
export function buildMarkdownCard(text: string): Record<string, unknown> {
return {
schema: "2.0",
config: {
wide_screen_mode: true,
},
body: {
elements: [
{
tag: "markdown",
content: text,
},
],
},
};
}
/**
* Send a message as a markdown card (interactive message).
* This renders markdown properly in Feishu (code blocks, tables, bold/italic, etc.)
*/
export async function sendMarkdownCardFeishu(params: {
cfg: ClawdbotConfig;
to: string;
text: string;
replyToMessageId?: string;
/** When true, reply creates a Feishu topic thread instead of an inline reply */
replyInThread?: boolean;
/** Mention target users */
mentions?: MentionTarget[];
accountId?: string;
}): Promise<FeishuSendResult> {
const { cfg, to, text, replyToMessageId, replyInThread, mentions, accountId } = params;
let cardText = text;
if (mentions && mentions.length > 0) {
cardText = buildMentionedCardContent(mentions, text);
}
const card = buildMarkdownCard(cardText);
return sendCardFeishu({ cfg, to, card, replyToMessageId, replyInThread, accountId });
}
/**
* Edit an existing text message.
* Note: Feishu only allows editing messages within 24 hours.
*/
export async function editMessageFeishu(params: {
cfg: ClawdbotConfig;
messageId: string;
text: string;
accountId?: string;
}): Promise<void> {
const { cfg, messageId, text, accountId } = params;
const account = resolveFeishuAccount({ cfg, accountId });
if (!account.configured) {
throw new Error(`Feishu account "${account.accountId}" not configured`);
}
const client = createFeishuClient(account);
const tableMode = getFeishuRuntime().channel.text.resolveMarkdownTableMode({
cfg,
channel: "feishu",
});
const messageText = getFeishuRuntime().channel.text.convertMarkdownTables(text ?? "", tableMode);
const { content, msgType } = buildFeishuPostMessagePayload({ messageText });
const response = await client.im.message.update({
path: { message_id: messageId },
data: {
msg_type: msgType,
content,
},
});
if (response.code !== 0) {
throw new Error(`Feishu message edit failed: ${response.msg || `code ${response.code}`}`);
}
}