@openclaw/feishu 2026.2.25 → 2026.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/index.ts +2 -0
  2. package/package.json +2 -1
  3. package/skills/feishu-doc/SKILL.md +109 -3
  4. package/src/accounts.test.ts +161 -0
  5. package/src/accounts.ts +76 -8
  6. package/src/async.ts +62 -0
  7. package/src/bitable.ts +189 -215
  8. package/src/bot.card-action.test.ts +63 -0
  9. package/src/bot.checkBotMentioned.test.ts +56 -1
  10. package/src/bot.test.ts +1271 -56
  11. package/src/bot.ts +499 -215
  12. package/src/card-action.ts +79 -0
  13. package/src/channel.ts +26 -4
  14. package/src/chat-schema.ts +24 -0
  15. package/src/chat.test.ts +89 -0
  16. package/src/chat.ts +130 -0
  17. package/src/client.test.ts +121 -0
  18. package/src/client.ts +13 -0
  19. package/src/config-schema.test.ts +101 -1
  20. package/src/config-schema.ts +66 -11
  21. package/src/dedup.ts +47 -1
  22. package/src/doc-schema.ts +135 -0
  23. package/src/docx-batch-insert.ts +190 -0
  24. package/src/docx-color-text.ts +149 -0
  25. package/src/docx-table-ops.ts +298 -0
  26. package/src/docx.account-selection.test.ts +70 -0
  27. package/src/docx.test.ts +331 -9
  28. package/src/docx.ts +996 -72
  29. package/src/drive.ts +38 -33
  30. package/src/media.test.ts +227 -7
  31. package/src/media.ts +52 -11
  32. package/src/mention.ts +1 -1
  33. package/src/monitor.account.ts +534 -0
  34. package/src/monitor.reaction.test.ts +578 -0
  35. package/src/monitor.startup.test.ts +203 -0
  36. package/src/monitor.startup.ts +51 -0
  37. package/src/monitor.state.defaults.test.ts +46 -0
  38. package/src/monitor.state.ts +152 -0
  39. package/src/monitor.test-mocks.ts +12 -0
  40. package/src/monitor.transport.ts +163 -0
  41. package/src/monitor.ts +44 -346
  42. package/src/monitor.webhook-security.test.ts +53 -10
  43. package/src/onboarding.status.test.ts +25 -0
  44. package/src/onboarding.ts +144 -52
  45. package/src/outbound.test.ts +181 -0
  46. package/src/outbound.ts +94 -7
  47. package/src/perm.ts +37 -30
  48. package/src/policy.test.ts +56 -1
  49. package/src/policy.ts +5 -1
  50. package/src/post.test.ts +105 -0
  51. package/src/post.ts +274 -0
  52. package/src/probe.test.ts +271 -0
  53. package/src/probe.ts +131 -19
  54. package/src/reply-dispatcher.test.ts +300 -0
  55. package/src/reply-dispatcher.ts +159 -46
  56. package/src/secret-input.ts +19 -0
  57. package/src/send-target.test.ts +74 -0
  58. package/src/send-target.ts +6 -2
  59. package/src/send.reply-fallback.test.ts +105 -0
  60. package/src/send.test.ts +168 -0
  61. package/src/send.ts +143 -18
  62. package/src/streaming-card.ts +131 -43
  63. package/src/targets.test.ts +55 -1
  64. package/src/targets.ts +32 -7
  65. package/src/tool-account-routing.test.ts +129 -0
  66. package/src/tool-account.ts +70 -0
  67. package/src/tool-factory-test-harness.ts +76 -0
  68. package/src/tools-config.test.ts +21 -0
  69. package/src/tools-config.ts +2 -1
  70. package/src/types.ts +10 -1
  71. package/src/typing.test.ts +144 -0
  72. package/src/typing.ts +140 -10
  73. package/src/wiki.ts +55 -50
@@ -0,0 +1,74 @@
1
+ import type { ClawdbotConfig } from "openclaw/plugin-sdk";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { resolveFeishuSendTarget } from "./send-target.js";
4
+
5
+ const resolveFeishuAccountMock = vi.hoisted(() => vi.fn());
6
+ const createFeishuClientMock = vi.hoisted(() => vi.fn());
7
+
8
+ vi.mock("./accounts.js", () => ({
9
+ resolveFeishuAccount: resolveFeishuAccountMock,
10
+ }));
11
+
12
+ vi.mock("./client.js", () => ({
13
+ createFeishuClient: createFeishuClientMock,
14
+ }));
15
+
16
+ describe("resolveFeishuSendTarget", () => {
17
+ const cfg = {} as ClawdbotConfig;
18
+ const client = { id: "client" };
19
+
20
+ beforeEach(() => {
21
+ resolveFeishuAccountMock.mockReset().mockReturnValue({
22
+ accountId: "default",
23
+ enabled: true,
24
+ configured: true,
25
+ });
26
+ createFeishuClientMock.mockReset().mockReturnValue(client);
27
+ });
28
+
29
+ it("keeps explicit group targets as chat_id even when ID shape is ambiguous", () => {
30
+ const result = resolveFeishuSendTarget({
31
+ cfg,
32
+ to: "feishu:group:group_room_alpha",
33
+ });
34
+
35
+ expect(result.receiveId).toBe("group_room_alpha");
36
+ expect(result.receiveIdType).toBe("chat_id");
37
+ expect(result.client).toBe(client);
38
+ });
39
+
40
+ it("maps dm-prefixed open IDs to open_id", () => {
41
+ const result = resolveFeishuSendTarget({
42
+ cfg,
43
+ to: "lark:dm:ou_123",
44
+ });
45
+
46
+ expect(result.receiveId).toBe("ou_123");
47
+ expect(result.receiveIdType).toBe("open_id");
48
+ });
49
+
50
+ it("maps dm-prefixed non-open IDs to user_id", () => {
51
+ const result = resolveFeishuSendTarget({
52
+ cfg,
53
+ to: " feishu:dm:user_123 ",
54
+ });
55
+
56
+ expect(result.receiveId).toBe("user_123");
57
+ expect(result.receiveIdType).toBe("user_id");
58
+ });
59
+
60
+ it("throws when target account is not configured", () => {
61
+ resolveFeishuAccountMock.mockReturnValue({
62
+ accountId: "default",
63
+ enabled: true,
64
+ configured: false,
65
+ });
66
+
67
+ expect(() =>
68
+ resolveFeishuSendTarget({
69
+ cfg,
70
+ to: "feishu:group:oc_123",
71
+ }),
72
+ ).toThrow('Feishu account "default" not configured');
73
+ });
74
+ });
@@ -8,18 +8,22 @@ export function resolveFeishuSendTarget(params: {
8
8
  to: string;
9
9
  accountId?: string;
10
10
  }) {
11
+ const target = params.to.trim();
11
12
  const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId });
12
13
  if (!account.configured) {
13
14
  throw new Error(`Feishu account "${account.accountId}" not configured`);
14
15
  }
15
16
  const client = createFeishuClient(account);
16
- const receiveId = normalizeFeishuTarget(params.to);
17
+ const receiveId = normalizeFeishuTarget(target);
17
18
  if (!receiveId) {
18
19
  throw new Error(`Invalid Feishu target: ${params.to}`);
19
20
  }
21
+ // Preserve explicit routing prefixes (chat/group/user/dm/open_id) when present.
22
+ // normalizeFeishuTarget strips these prefixes, so infer type from the raw target first.
23
+ const withoutProviderPrefix = target.replace(/^(feishu|lark):/i, "");
20
24
  return {
21
25
  client,
22
26
  receiveId,
23
- receiveIdType: resolveReceiveIdType(receiveId),
27
+ receiveIdType: resolveReceiveIdType(withoutProviderPrefix),
24
28
  };
25
29
  }
@@ -0,0 +1,105 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const resolveFeishuSendTargetMock = vi.hoisted(() => vi.fn());
4
+ const resolveMarkdownTableModeMock = vi.hoisted(() => vi.fn(() => "preserve"));
5
+ const convertMarkdownTablesMock = vi.hoisted(() => vi.fn((text: string) => text));
6
+
7
+ vi.mock("./send-target.js", () => ({
8
+ resolveFeishuSendTarget: resolveFeishuSendTargetMock,
9
+ }));
10
+
11
+ vi.mock("./runtime.js", () => ({
12
+ getFeishuRuntime: () => ({
13
+ channel: {
14
+ text: {
15
+ resolveMarkdownTableMode: resolveMarkdownTableModeMock,
16
+ convertMarkdownTables: convertMarkdownTablesMock,
17
+ },
18
+ },
19
+ }),
20
+ }));
21
+
22
+ import { sendCardFeishu, sendMessageFeishu } from "./send.js";
23
+
24
+ describe("Feishu reply fallback for withdrawn/deleted targets", () => {
25
+ const replyMock = vi.fn();
26
+ const createMock = vi.fn();
27
+
28
+ beforeEach(() => {
29
+ vi.clearAllMocks();
30
+ resolveFeishuSendTargetMock.mockReturnValue({
31
+ client: {
32
+ im: {
33
+ message: {
34
+ reply: replyMock,
35
+ create: createMock,
36
+ },
37
+ },
38
+ },
39
+ receiveId: "ou_target",
40
+ receiveIdType: "open_id",
41
+ });
42
+ });
43
+
44
+ it("falls back to create for withdrawn post replies", async () => {
45
+ replyMock.mockResolvedValue({
46
+ code: 230011,
47
+ msg: "The message was withdrawn.",
48
+ });
49
+ createMock.mockResolvedValue({
50
+ code: 0,
51
+ data: { message_id: "om_new" },
52
+ });
53
+
54
+ const result = await sendMessageFeishu({
55
+ cfg: {} as never,
56
+ to: "user:ou_target",
57
+ text: "hello",
58
+ replyToMessageId: "om_parent",
59
+ });
60
+
61
+ expect(replyMock).toHaveBeenCalledTimes(1);
62
+ expect(createMock).toHaveBeenCalledTimes(1);
63
+ expect(result.messageId).toBe("om_new");
64
+ });
65
+
66
+ it("falls back to create for withdrawn card replies", async () => {
67
+ replyMock.mockResolvedValue({
68
+ code: 231003,
69
+ msg: "The message is not found",
70
+ });
71
+ createMock.mockResolvedValue({
72
+ code: 0,
73
+ data: { message_id: "om_card_new" },
74
+ });
75
+
76
+ const result = await sendCardFeishu({
77
+ cfg: {} as never,
78
+ to: "user:ou_target",
79
+ card: { schema: "2.0" },
80
+ replyToMessageId: "om_parent",
81
+ });
82
+
83
+ expect(replyMock).toHaveBeenCalledTimes(1);
84
+ expect(createMock).toHaveBeenCalledTimes(1);
85
+ expect(result.messageId).toBe("om_card_new");
86
+ });
87
+
88
+ it("still throws for non-withdrawn reply failures", async () => {
89
+ replyMock.mockResolvedValue({
90
+ code: 999999,
91
+ msg: "unknown failure",
92
+ });
93
+
94
+ await expect(
95
+ sendMessageFeishu({
96
+ cfg: {} as never,
97
+ to: "user:ou_target",
98
+ text: "hello",
99
+ replyToMessageId: "om_parent",
100
+ }),
101
+ ).rejects.toThrow("Feishu reply failed");
102
+
103
+ expect(createMock).not.toHaveBeenCalled();
104
+ });
105
+ });
@@ -0,0 +1,168 @@
1
+ import type { ClawdbotConfig } from "openclaw/plugin-sdk";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { getMessageFeishu } from "./send.js";
4
+
5
+ const { mockClientGet, mockCreateFeishuClient, mockResolveFeishuAccount } = vi.hoisted(() => ({
6
+ mockClientGet: vi.fn(),
7
+ mockCreateFeishuClient: vi.fn(),
8
+ mockResolveFeishuAccount: vi.fn(),
9
+ }));
10
+
11
+ vi.mock("./client.js", () => ({
12
+ createFeishuClient: mockCreateFeishuClient,
13
+ }));
14
+
15
+ vi.mock("./accounts.js", () => ({
16
+ resolveFeishuAccount: mockResolveFeishuAccount,
17
+ }));
18
+
19
+ describe("getMessageFeishu", () => {
20
+ beforeEach(() => {
21
+ vi.clearAllMocks();
22
+ mockResolveFeishuAccount.mockReturnValue({
23
+ accountId: "default",
24
+ configured: true,
25
+ });
26
+ mockCreateFeishuClient.mockReturnValue({
27
+ im: {
28
+ message: {
29
+ get: mockClientGet,
30
+ },
31
+ },
32
+ });
33
+ });
34
+
35
+ it("extracts text content from interactive card elements", async () => {
36
+ mockClientGet.mockResolvedValueOnce({
37
+ code: 0,
38
+ data: {
39
+ items: [
40
+ {
41
+ message_id: "om_1",
42
+ chat_id: "oc_1",
43
+ msg_type: "interactive",
44
+ body: {
45
+ content: JSON.stringify({
46
+ elements: [
47
+ { tag: "markdown", content: "hello markdown" },
48
+ { tag: "div", text: { content: "hello div" } },
49
+ ],
50
+ }),
51
+ },
52
+ },
53
+ ],
54
+ },
55
+ });
56
+
57
+ const result = await getMessageFeishu({
58
+ cfg: {} as ClawdbotConfig,
59
+ messageId: "om_1",
60
+ });
61
+
62
+ expect(result).toEqual(
63
+ expect.objectContaining({
64
+ messageId: "om_1",
65
+ chatId: "oc_1",
66
+ contentType: "interactive",
67
+ content: "hello markdown\nhello div",
68
+ }),
69
+ );
70
+ });
71
+
72
+ it("extracts text content from post messages", async () => {
73
+ mockClientGet.mockResolvedValueOnce({
74
+ code: 0,
75
+ data: {
76
+ items: [
77
+ {
78
+ message_id: "om_post",
79
+ chat_id: "oc_post",
80
+ msg_type: "post",
81
+ body: {
82
+ content: JSON.stringify({
83
+ zh_cn: {
84
+ title: "Summary",
85
+ content: [[{ tag: "text", text: "post body" }]],
86
+ },
87
+ }),
88
+ },
89
+ },
90
+ ],
91
+ },
92
+ });
93
+
94
+ const result = await getMessageFeishu({
95
+ cfg: {} as ClawdbotConfig,
96
+ messageId: "om_post",
97
+ });
98
+
99
+ expect(result).toEqual(
100
+ expect.objectContaining({
101
+ messageId: "om_post",
102
+ chatId: "oc_post",
103
+ contentType: "post",
104
+ content: "Summary\n\npost body",
105
+ }),
106
+ );
107
+ });
108
+
109
+ it("returns text placeholder instead of raw JSON for unsupported message types", async () => {
110
+ mockClientGet.mockResolvedValueOnce({
111
+ code: 0,
112
+ data: {
113
+ items: [
114
+ {
115
+ message_id: "om_file",
116
+ chat_id: "oc_file",
117
+ msg_type: "file",
118
+ body: {
119
+ content: JSON.stringify({ file_key: "file_v3_123" }),
120
+ },
121
+ },
122
+ ],
123
+ },
124
+ });
125
+
126
+ const result = await getMessageFeishu({
127
+ cfg: {} as ClawdbotConfig,
128
+ messageId: "om_file",
129
+ });
130
+
131
+ expect(result).toEqual(
132
+ expect.objectContaining({
133
+ messageId: "om_file",
134
+ chatId: "oc_file",
135
+ contentType: "file",
136
+ content: "[file message]",
137
+ }),
138
+ );
139
+ });
140
+
141
+ it("supports single-object response shape from Feishu API", async () => {
142
+ mockClientGet.mockResolvedValueOnce({
143
+ code: 0,
144
+ data: {
145
+ message_id: "om_single",
146
+ chat_id: "oc_single",
147
+ msg_type: "text",
148
+ body: {
149
+ content: JSON.stringify({ text: "single payload" }),
150
+ },
151
+ },
152
+ });
153
+
154
+ const result = await getMessageFeishu({
155
+ cfg: {} as ClawdbotConfig,
156
+ messageId: "om_single",
157
+ });
158
+
159
+ expect(result).toEqual(
160
+ expect.objectContaining({
161
+ messageId: "om_single",
162
+ chatId: "oc_single",
163
+ contentType: "text",
164
+ content: "single payload",
165
+ }),
166
+ );
167
+ });
168
+ });
package/src/send.ts CHANGED
@@ -3,21 +3,105 @@ import { resolveFeishuAccount } from "./accounts.js";
3
3
  import { createFeishuClient } from "./client.js";
4
4
  import type { MentionTarget } from "./mention.js";
5
5
  import { buildMentionedMessage, buildMentionedCardContent } from "./mention.js";
6
+ import { parsePostContent } from "./post.js";
6
7
  import { getFeishuRuntime } from "./runtime.js";
7
8
  import { assertFeishuMessageApiSuccess, toFeishuSendResult } from "./send-result.js";
8
9
  import { resolveFeishuSendTarget } from "./send-target.js";
9
10
  import type { FeishuSendResult } from "./types.js";
10
11
 
12
+ const WITHDRAWN_REPLY_ERROR_CODES = new Set([230011, 231003]);
13
+
14
+ function shouldFallbackFromReplyTarget(response: { code?: number; msg?: string }): boolean {
15
+ if (response.code !== undefined && WITHDRAWN_REPLY_ERROR_CODES.has(response.code)) {
16
+ return true;
17
+ }
18
+ const msg = response.msg?.toLowerCase() ?? "";
19
+ return msg.includes("withdrawn") || msg.includes("not found");
20
+ }
21
+
11
22
  export type FeishuMessageInfo = {
12
23
  messageId: string;
13
24
  chatId: string;
14
25
  senderId?: string;
15
26
  senderOpenId?: string;
27
+ senderType?: string;
16
28
  content: string;
17
29
  contentType: string;
18
30
  createTime?: number;
19
31
  };
20
32
 
33
+ function parseInteractiveCardContent(parsed: unknown): string {
34
+ if (!parsed || typeof parsed !== "object") {
35
+ return "[Interactive Card]";
36
+ }
37
+
38
+ const candidate = parsed as { elements?: unknown };
39
+ if (!Array.isArray(candidate.elements)) {
40
+ return "[Interactive Card]";
41
+ }
42
+
43
+ const texts: string[] = [];
44
+ for (const element of candidate.elements) {
45
+ if (!element || typeof element !== "object") {
46
+ continue;
47
+ }
48
+ const item = element as {
49
+ tag?: string;
50
+ content?: string;
51
+ text?: { content?: string };
52
+ };
53
+ if (item.tag === "div" && typeof item.text?.content === "string") {
54
+ texts.push(item.text.content);
55
+ continue;
56
+ }
57
+ if (item.tag === "markdown" && typeof item.content === "string") {
58
+ texts.push(item.content);
59
+ }
60
+ }
61
+ return texts.join("\n").trim() || "[Interactive Card]";
62
+ }
63
+
64
+ function parseQuotedMessageContent(rawContent: string, msgType: string): string {
65
+ if (!rawContent) {
66
+ return "";
67
+ }
68
+
69
+ let parsed: unknown;
70
+ try {
71
+ parsed = JSON.parse(rawContent);
72
+ } catch {
73
+ return rawContent;
74
+ }
75
+
76
+ if (msgType === "text") {
77
+ const text = (parsed as { text?: unknown })?.text;
78
+ return typeof text === "string" ? text : "[Text message]";
79
+ }
80
+
81
+ if (msgType === "post") {
82
+ return parsePostContent(rawContent).textContent;
83
+ }
84
+
85
+ if (msgType === "interactive") {
86
+ return parseInteractiveCardContent(parsed);
87
+ }
88
+
89
+ if (typeof parsed === "string") {
90
+ return parsed;
91
+ }
92
+
93
+ const genericText = (parsed as { text?: unknown; title?: unknown } | null)?.text;
94
+ if (typeof genericText === "string" && genericText.trim()) {
95
+ return genericText;
96
+ }
97
+ const genericTitle = (parsed as { title?: unknown } | null)?.title;
98
+ if (typeof genericTitle === "string" && genericTitle.trim()) {
99
+ return genericTitle;
100
+ }
101
+
102
+ return `[${msgType || "unknown"} message]`;
103
+ }
104
+
21
105
  /**
22
106
  * Get a message by its ID.
23
107
  * Useful for fetching quoted/replied message content.
@@ -54,6 +138,16 @@ export async function getMessageFeishu(params: {
54
138
  };
55
139
  create_time?: string;
56
140
  }>;
141
+ message_id?: string;
142
+ chat_id?: string;
143
+ msg_type?: string;
144
+ body?: { content?: string };
145
+ sender?: {
146
+ id?: string;
147
+ id_type?: string;
148
+ sender_type?: string;
149
+ };
150
+ create_time?: string;
57
151
  };
58
152
  };
59
153
 
@@ -61,30 +155,30 @@ export async function getMessageFeishu(params: {
61
155
  return null;
62
156
  }
63
157
 
64
- const item = response.data?.items?.[0];
158
+ // Support both list shape (data.items[0]) and single-object shape (data as message)
159
+ const rawItem = response.data?.items?.[0] ?? response.data;
160
+ const item =
161
+ rawItem &&
162
+ (rawItem.body !== undefined || (rawItem as { message_id?: string }).message_id !== undefined)
163
+ ? rawItem
164
+ : null;
65
165
  if (!item) {
66
166
  return null;
67
167
  }
68
168
 
69
- // Parse content based on message type
70
- let content = item.body?.content ?? "";
71
- try {
72
- const parsed = JSON.parse(content);
73
- if (item.msg_type === "text" && parsed.text) {
74
- content = parsed.text;
75
- }
76
- } catch {
77
- // Keep raw content if parsing fails
78
- }
169
+ const msgType = item.msg_type ?? "text";
170
+ const rawContent = item.body?.content ?? "";
171
+ const content = parseQuotedMessageContent(rawContent, msgType);
79
172
 
80
173
  return {
81
174
  messageId: item.message_id ?? messageId,
82
175
  chatId: item.chat_id ?? "",
83
176
  senderId: item.sender?.id,
84
177
  senderOpenId: item.sender?.id_type === "open_id" ? item.sender?.id : undefined,
178
+ senderType: item.sender?.sender_type,
85
179
  content,
86
- contentType: item.msg_type ?? "text",
87
- createTime: item.create_time ? parseInt(item.create_time, 10) : undefined,
180
+ contentType: msgType,
181
+ createTime: item.create_time ? parseInt(String(item.create_time), 10) : undefined,
88
182
  };
89
183
  } catch {
90
184
  return null;
@@ -96,6 +190,8 @@ export type SendFeishuMessageParams = {
96
190
  to: string;
97
191
  text: string;
98
192
  replyToMessageId?: string;
193
+ /** When true, reply creates a Feishu topic thread instead of an inline reply */
194
+ replyInThread?: boolean;
99
195
  /** Mention target users */
100
196
  mentions?: MentionTarget[];
101
197
  /** Account ID (optional, uses default if not specified) */
@@ -127,7 +223,7 @@ function buildFeishuPostMessagePayload(params: { messageText: string }): {
127
223
  export async function sendMessageFeishu(
128
224
  params: SendFeishuMessageParams,
129
225
  ): Promise<FeishuSendResult> {
130
- const { cfg, to, text, replyToMessageId, mentions, accountId } = params;
226
+ const { cfg, to, text, replyToMessageId, replyInThread, mentions, accountId } = params;
131
227
  const { client, receiveId, receiveIdType } = resolveFeishuSendTarget({ cfg, to, accountId });
132
228
  const tableMode = getFeishuRuntime().channel.text.resolveMarkdownTableMode({
133
229
  cfg,
@@ -149,8 +245,21 @@ export async function sendMessageFeishu(
149
245
  data: {
150
246
  content,
151
247
  msg_type: msgType,
248
+ ...(replyInThread ? { reply_in_thread: true } : {}),
152
249
  },
153
250
  });
251
+ if (shouldFallbackFromReplyTarget(response)) {
252
+ const fallback = await client.im.message.create({
253
+ params: { receive_id_type: receiveIdType },
254
+ data: {
255
+ receive_id: receiveId,
256
+ content,
257
+ msg_type: msgType,
258
+ },
259
+ });
260
+ assertFeishuMessageApiSuccess(fallback, "Feishu send failed");
261
+ return toFeishuSendResult(fallback, receiveId);
262
+ }
154
263
  assertFeishuMessageApiSuccess(response, "Feishu reply failed");
155
264
  return toFeishuSendResult(response, receiveId);
156
265
  }
@@ -172,11 +281,13 @@ export type SendFeishuCardParams = {
172
281
  to: string;
173
282
  card: Record<string, unknown>;
174
283
  replyToMessageId?: string;
284
+ /** When true, reply creates a Feishu topic thread instead of an inline reply */
285
+ replyInThread?: boolean;
175
286
  accountId?: string;
176
287
  };
177
288
 
178
289
  export async function sendCardFeishu(params: SendFeishuCardParams): Promise<FeishuSendResult> {
179
- const { cfg, to, card, replyToMessageId, accountId } = params;
290
+ const { cfg, to, card, replyToMessageId, replyInThread, accountId } = params;
180
291
  const { client, receiveId, receiveIdType } = resolveFeishuSendTarget({ cfg, to, accountId });
181
292
  const content = JSON.stringify(card);
182
293
 
@@ -186,8 +297,21 @@ export async function sendCardFeishu(params: SendFeishuCardParams): Promise<Feis
186
297
  data: {
187
298
  content,
188
299
  msg_type: "interactive",
300
+ ...(replyInThread ? { reply_in_thread: true } : {}),
189
301
  },
190
302
  });
303
+ if (shouldFallbackFromReplyTarget(response)) {
304
+ const fallback = await client.im.message.create({
305
+ params: { receive_id_type: receiveIdType },
306
+ data: {
307
+ receive_id: receiveId,
308
+ content,
309
+ msg_type: "interactive",
310
+ },
311
+ });
312
+ assertFeishuMessageApiSuccess(fallback, "Feishu card send failed");
313
+ return toFeishuSendResult(fallback, receiveId);
314
+ }
191
315
  assertFeishuMessageApiSuccess(response, "Feishu card reply failed");
192
316
  return toFeishuSendResult(response, receiveId);
193
317
  }
@@ -260,18 +384,19 @@ export async function sendMarkdownCardFeishu(params: {
260
384
  to: string;
261
385
  text: string;
262
386
  replyToMessageId?: string;
387
+ /** When true, reply creates a Feishu topic thread instead of an inline reply */
388
+ replyInThread?: boolean;
263
389
  /** Mention target users */
264
390
  mentions?: MentionTarget[];
265
391
  accountId?: string;
266
392
  }): Promise<FeishuSendResult> {
267
- const { cfg, to, text, replyToMessageId, mentions, accountId } = params;
268
- // Build message content (with @mention support)
393
+ const { cfg, to, text, replyToMessageId, replyInThread, mentions, accountId } = params;
269
394
  let cardText = text;
270
395
  if (mentions && mentions.length > 0) {
271
396
  cardText = buildMentionedCardContent(mentions, text);
272
397
  }
273
398
  const card = buildMarkdownCard(cardText);
274
- return sendCardFeishu({ cfg, to, card, replyToMessageId, accountId });
399
+ return sendCardFeishu({ cfg, to, card, replyToMessageId, replyInThread, accountId });
275
400
  }
276
401
 
277
402
  /**