@openclaw/feishu 2026.3.1 → 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.
package/src/probe.ts CHANGED
@@ -2,15 +2,16 @@ import { raceWithTimeoutAndAbort } from "./async.js";
2
2
  import { createFeishuClient, type FeishuClientCredentials } from "./client.js";
3
3
  import type { FeishuProbeResult } from "./types.js";
4
4
 
5
- /** Cache successful probe results to reduce API calls (bot info is static).
5
+ /** Cache probe results to reduce repeated health-check calls.
6
6
  * Gateway health checks call probeFeishu() every minute; without caching this
7
7
  * burns ~43,200 calls/month, easily exceeding Feishu's free-tier quota.
8
- * A 10-min TTL cuts that to ~4,320 calls/month. (#26684) */
8
+ * Successful bot info is effectively static, while failures are cached briefly
9
+ * to avoid hammering the API during transient outages. */
9
10
  const probeCache = new Map<string, { result: FeishuProbeResult; expiresAt: number }>();
10
- const PROBE_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
11
+ const PROBE_SUCCESS_TTL_MS = 10 * 60 * 1000; // 10 minutes
12
+ const PROBE_ERROR_TTL_MS = 60 * 1000; // 1 minute
11
13
  const MAX_PROBE_CACHE_SIZE = 64;
12
14
  export const FEISHU_PROBE_REQUEST_TIMEOUT_MS = 10_000;
13
-
14
15
  export type ProbeFeishuOptions = {
15
16
  timeoutMs?: number;
16
17
  abortSignal?: AbortSignal;
@@ -23,6 +24,21 @@ type FeishuBotInfoResponse = {
23
24
  data?: { bot?: { bot_name?: string; open_id?: string } };
24
25
  };
25
26
 
27
+ function setCachedProbeResult(
28
+ cacheKey: string,
29
+ result: FeishuProbeResult,
30
+ ttlMs: number,
31
+ ): FeishuProbeResult {
32
+ probeCache.set(cacheKey, { result, expiresAt: Date.now() + ttlMs });
33
+ if (probeCache.size > MAX_PROBE_CACHE_SIZE) {
34
+ const oldest = probeCache.keys().next().value;
35
+ if (oldest !== undefined) {
36
+ probeCache.delete(oldest);
37
+ }
38
+ }
39
+ return result;
40
+ }
41
+
26
42
  export async function probeFeishu(
27
43
  creds?: FeishuClientCredentials,
28
44
  options: ProbeFeishuOptions = {},
@@ -78,11 +94,15 @@ export async function probeFeishu(
78
94
  };
79
95
  }
80
96
  if (responseResult.status === "timeout") {
81
- return {
82
- ok: false,
83
- appId: creds.appId,
84
- error: `probe timed out after ${timeoutMs}ms`,
85
- };
97
+ return setCachedProbeResult(
98
+ cacheKey,
99
+ {
100
+ ok: false,
101
+ appId: creds.appId,
102
+ error: `probe timed out after ${timeoutMs}ms`,
103
+ },
104
+ PROBE_ERROR_TTL_MS,
105
+ );
86
106
  }
87
107
 
88
108
  const response = responseResult.value;
@@ -95,38 +115,38 @@ export async function probeFeishu(
95
115
  }
96
116
 
97
117
  if (response.code !== 0) {
98
- return {
99
- ok: false,
100
- appId: creds.appId,
101
- error: `API error: ${response.msg || `code ${response.code}`}`,
102
- };
118
+ return setCachedProbeResult(
119
+ cacheKey,
120
+ {
121
+ ok: false,
122
+ appId: creds.appId,
123
+ error: `API error: ${response.msg || `code ${response.code}`}`,
124
+ },
125
+ PROBE_ERROR_TTL_MS,
126
+ );
103
127
  }
104
128
 
105
129
  const bot = response.bot || response.data?.bot;
106
- const result: FeishuProbeResult = {
107
- ok: true,
108
- appId: creds.appId,
109
- botName: bot?.bot_name,
110
- botOpenId: bot?.open_id,
111
- };
112
-
113
- // Cache successful results only
114
- probeCache.set(cacheKey, { result, expiresAt: Date.now() + PROBE_CACHE_TTL_MS });
115
- // Evict oldest entry if cache exceeds max size
116
- if (probeCache.size > MAX_PROBE_CACHE_SIZE) {
117
- const oldest = probeCache.keys().next().value;
118
- if (oldest !== undefined) {
119
- probeCache.delete(oldest);
120
- }
121
- }
122
-
123
- return result;
130
+ return setCachedProbeResult(
131
+ cacheKey,
132
+ {
133
+ ok: true,
134
+ appId: creds.appId,
135
+ botName: bot?.bot_name,
136
+ botOpenId: bot?.open_id,
137
+ },
138
+ PROBE_SUCCESS_TTL_MS,
139
+ );
124
140
  } catch (err) {
125
- return {
126
- ok: false,
127
- appId: creds.appId,
128
- error: err instanceof Error ? err.message : String(err),
129
- };
141
+ return setCachedProbeResult(
142
+ cacheKey,
143
+ {
144
+ ok: false,
145
+ appId: creds.appId,
146
+ error: err instanceof Error ? err.message : String(err),
147
+ },
148
+ PROBE_ERROR_TTL_MS,
149
+ );
130
150
  }
131
151
  }
132
152
 
@@ -185,6 +185,23 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
185
185
  expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
186
186
  });
187
187
 
188
+ it("suppresses internal block payload delivery", async () => {
189
+ createFeishuReplyDispatcher({
190
+ cfg: {} as never,
191
+ agentId: "agent",
192
+ runtime: {} as never,
193
+ chatId: "oc_chat",
194
+ });
195
+
196
+ const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
197
+ await options.deliver({ text: "internal reasoning chunk" }, { kind: "block" });
198
+
199
+ expect(streamingInstances).toHaveLength(0);
200
+ expect(sendMessageFeishuMock).not.toHaveBeenCalled();
201
+ expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
202
+ expect(sendMediaFeishuMock).not.toHaveBeenCalled();
203
+ });
204
+
188
205
  it("uses streaming session for auto mode markdown payloads", async () => {
189
206
  createFeishuReplyDispatcher({
190
207
  cfg: {} as never,
@@ -352,6 +369,30 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
352
369
  });
353
370
  });
354
371
 
372
+ it("disables streaming for thread replies and keeps reply metadata", async () => {
373
+ createFeishuReplyDispatcher({
374
+ cfg: {} as never,
375
+ agentId: "agent",
376
+ runtime: { log: vi.fn(), error: vi.fn() } as never,
377
+ chatId: "oc_chat",
378
+ replyToMessageId: "om_msg",
379
+ replyInThread: false,
380
+ threadReply: true,
381
+ rootId: "om_root_topic",
382
+ });
383
+
384
+ const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
385
+ await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" });
386
+
387
+ expect(streamingInstances).toHaveLength(0);
388
+ expect(sendMarkdownCardFeishuMock).toHaveBeenCalledWith(
389
+ expect.objectContaining({
390
+ replyToMessageId: "om_msg",
391
+ replyInThread: true,
392
+ }),
393
+ );
394
+ });
395
+
355
396
  it("passes replyInThread to media attachments", async () => {
356
397
  createFeishuReplyDispatcher({
357
398
  cfg: {} as never,
@@ -45,6 +45,8 @@ export type CreateFeishuReplyDispatcherParams = {
45
45
  /** When true, preserve typing indicator on reply target but send messages without reply metadata */
46
46
  skipReplyToInMessages?: boolean;
47
47
  replyInThread?: boolean;
48
+ /** True when inbound message is already inside a thread/topic context */
49
+ threadReply?: boolean;
48
50
  rootId?: string;
49
51
  mentionTargets?: MentionTarget[];
50
52
  accountId?: string;
@@ -62,11 +64,14 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
62
64
  replyToMessageId,
63
65
  skipReplyToInMessages,
64
66
  replyInThread,
67
+ threadReply,
65
68
  rootId,
66
69
  mentionTargets,
67
70
  accountId,
68
71
  } = params;
69
72
  const sendReplyToMessageId = skipReplyToInMessages ? undefined : replyToMessageId;
73
+ const threadReplyMode = threadReply === true;
74
+ const effectiveReplyInThread = threadReplyMode ? true : replyInThread;
70
75
  const account = resolveFeishuAccount({ cfg, accountId });
71
76
  const prefixContext = createReplyPrefixContext({ cfg, agentId });
72
77
 
@@ -89,6 +94,12 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
89
94
  ) {
90
95
  return;
91
96
  }
97
+ // Feishu reactions persist until explicitly removed, so skip keepalive
98
+ // re-adds when a reaction already exists. Re-adding the same emoji
99
+ // triggers a new push notification for every call (#28660).
100
+ if (typingState?.reactionId) {
101
+ return;
102
+ }
92
103
  typingState = await addTypingIndicator({
93
104
  cfg,
94
105
  messageId: replyToMessageId,
@@ -125,7 +136,9 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
125
136
  const chunkMode = core.channel.text.resolveChunkMode(cfg, "feishu");
126
137
  const tableMode = core.channel.text.resolveMarkdownTableMode({ cfg, channel: "feishu" });
127
138
  const renderMode = account.config?.renderMode ?? "auto";
128
- const streamingEnabled = account.config?.streaming !== false && renderMode !== "raw";
139
+ // Card streaming may miss thread affinity in topic contexts; use direct replies there.
140
+ const streamingEnabled =
141
+ !threadReplyMode && account.config?.streaming !== false && renderMode !== "raw";
129
142
 
130
143
  let streaming: FeishuStreamingSession | null = null;
131
144
  let streamText = "";
@@ -152,7 +165,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
152
165
  try {
153
166
  await streaming.start(chatId, resolveReceiveIdType(chatId), {
154
167
  replyToMessageId,
155
- replyInThread,
168
+ replyInThread: effectiveReplyInThread,
156
169
  rootId,
157
170
  });
158
171
  } catch (error) {
@@ -192,6 +205,12 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
192
205
  void typingCallbacks.onReplyStart?.();
193
206
  },
194
207
  deliver: async (payload: ReplyPayload, info) => {
208
+ // FIX: Filter out internal 'block' reasoning chunks immediately to prevent
209
+ // data leak and race conditions with streaming state initialization.
210
+ if (info?.kind === "block") {
211
+ return;
212
+ }
213
+
195
214
  const text = payload.text ?? "";
196
215
  const mediaList =
197
216
  payload.mediaUrls && payload.mediaUrls.length > 0
@@ -209,7 +228,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
209
228
  if (hasText) {
210
229
  const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text));
211
230
 
212
- if ((info?.kind === "block" || info?.kind === "final") && streamingEnabled && useCard) {
231
+ if (info?.kind === "final" && streamingEnabled && useCard) {
213
232
  startStreaming();
214
233
  if (streamingStartPromise) {
215
234
  await streamingStartPromise;
@@ -229,7 +248,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
229
248
  to: chatId,
230
249
  mediaUrl,
231
250
  replyToMessageId: sendReplyToMessageId,
232
- replyInThread,
251
+ replyInThread: effectiveReplyInThread,
233
252
  accountId,
234
253
  });
235
254
  }
@@ -249,7 +268,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
249
268
  to: chatId,
250
269
  text: chunk,
251
270
  replyToMessageId: sendReplyToMessageId,
252
- replyInThread,
271
+ replyInThread: effectiveReplyInThread,
253
272
  mentions: first ? mentionTargets : undefined,
254
273
  accountId,
255
274
  });
@@ -267,7 +286,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
267
286
  to: chatId,
268
287
  text: chunk,
269
288
  replyToMessageId: sendReplyToMessageId,
270
- replyInThread,
289
+ replyInThread: effectiveReplyInThread,
271
290
  mentions: first ? mentionTargets : undefined,
272
291
  accountId,
273
292
  });
@@ -283,7 +302,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
283
302
  to: chatId,
284
303
  mediaUrl,
285
304
  replyToMessageId: sendReplyToMessageId,
286
- replyInThread,
305
+ replyInThread: effectiveReplyInThread,
287
306
  accountId,
288
307
  });
289
308
  }
@@ -0,0 +1,19 @@
1
+ import {
2
+ hasConfiguredSecretInput,
3
+ normalizeResolvedSecretInputString,
4
+ normalizeSecretInputString,
5
+ } from "openclaw/plugin-sdk";
6
+ import { z } from "zod";
7
+
8
+ export { hasConfiguredSecretInput, normalizeResolvedSecretInputString, normalizeSecretInputString };
9
+
10
+ export function buildSecretInputSchema() {
11
+ return z.union([
12
+ z.string(),
13
+ z.object({
14
+ source: z.enum(["env", "file", "exec"]),
15
+ provider: z.string().min(1),
16
+ id: z.string().min(1),
17
+ }),
18
+ ]);
19
+ }
@@ -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
  }
@@ -13,6 +13,18 @@ describe("resolveReceiveIdType", () => {
13
13
  it("defaults unprefixed IDs to user_id", () => {
14
14
  expect(resolveReceiveIdType("u_123")).toBe("user_id");
15
15
  });
16
+
17
+ it("treats explicit group targets as chat_id", () => {
18
+ expect(resolveReceiveIdType("group:oc_123")).toBe("chat_id");
19
+ });
20
+
21
+ it("treats explicit channel targets as chat_id", () => {
22
+ expect(resolveReceiveIdType("channel:oc_123")).toBe("chat_id");
23
+ });
24
+
25
+ it("treats dm-prefixed open IDs as open_id", () => {
26
+ expect(resolveReceiveIdType("dm:ou_123")).toBe("open_id");
27
+ });
16
28
  });
17
29
 
18
30
  describe("normalizeFeishuTarget", () => {
@@ -25,9 +37,20 @@ describe("normalizeFeishuTarget", () => {
25
37
  expect(normalizeFeishuTarget("feishu:chat:oc_123")).toBe("oc_123");
26
38
  });
27
39
 
40
+ it("normalizes group/channel prefixes to chat ids", () => {
41
+ expect(normalizeFeishuTarget("group:oc_123")).toBe("oc_123");
42
+ expect(normalizeFeishuTarget("feishu:group:oc_123")).toBe("oc_123");
43
+ expect(normalizeFeishuTarget("channel:oc_456")).toBe("oc_456");
44
+ expect(normalizeFeishuTarget("lark:channel:oc_456")).toBe("oc_456");
45
+ });
46
+
28
47
  it("accepts provider-prefixed raw ids", () => {
29
48
  expect(normalizeFeishuTarget("feishu:ou_123")).toBe("ou_123");
30
49
  });
50
+
51
+ it("strips provider and dm prefixes", () => {
52
+ expect(normalizeFeishuTarget("lark:dm:ou_123")).toBe("ou_123");
53
+ });
31
54
  });
32
55
 
33
56
  describe("looksLikeFeishuId", () => {
@@ -38,4 +61,10 @@ describe("looksLikeFeishuId", () => {
38
61
  it("accepts provider-prefixed chat targets", () => {
39
62
  expect(looksLikeFeishuId("lark:chat:oc_123")).toBe(true);
40
63
  });
64
+
65
+ it("accepts group/channel targets", () => {
66
+ expect(looksLikeFeishuId("feishu:group:oc_123")).toBe(true);
67
+ expect(looksLikeFeishuId("group:oc_123")).toBe(true);
68
+ expect(looksLikeFeishuId("channel:oc_456")).toBe(true);
69
+ });
41
70
  });
package/src/targets.ts CHANGED
@@ -33,9 +33,18 @@ export function normalizeFeishuTarget(raw: string): string | null {
33
33
  if (lowered.startsWith("chat:")) {
34
34
  return withoutProvider.slice("chat:".length).trim() || null;
35
35
  }
36
+ if (lowered.startsWith("group:")) {
37
+ return withoutProvider.slice("group:".length).trim() || null;
38
+ }
39
+ if (lowered.startsWith("channel:")) {
40
+ return withoutProvider.slice("channel:".length).trim() || null;
41
+ }
36
42
  if (lowered.startsWith("user:")) {
37
43
  return withoutProvider.slice("user:".length).trim() || null;
38
44
  }
45
+ if (lowered.startsWith("dm:")) {
46
+ return withoutProvider.slice("dm:".length).trim() || null;
47
+ }
39
48
  if (lowered.startsWith("open_id:")) {
40
49
  return withoutProvider.slice("open_id:".length).trim() || null;
41
50
  }
@@ -56,6 +65,17 @@ export function formatFeishuTarget(id: string, type?: FeishuIdType): string {
56
65
 
57
66
  export function resolveReceiveIdType(id: string): "chat_id" | "open_id" | "user_id" {
58
67
  const trimmed = id.trim();
68
+ const lowered = trimmed.toLowerCase();
69
+ if (lowered.startsWith("chat:") || lowered.startsWith("group:")) {
70
+ return "chat_id";
71
+ }
72
+ if (lowered.startsWith("open_id:")) {
73
+ return "open_id";
74
+ }
75
+ if (lowered.startsWith("user:") || lowered.startsWith("dm:")) {
76
+ const normalized = trimmed.replace(/^(user|dm):/i, "").trim();
77
+ return normalized.startsWith(OPEN_ID_PREFIX) ? "open_id" : "user_id";
78
+ }
59
79
  if (trimmed.startsWith(CHAT_ID_PREFIX)) {
60
80
  return "chat_id";
61
81
  }
@@ -70,7 +90,7 @@ export function looksLikeFeishuId(raw: string): boolean {
70
90
  if (!trimmed) {
71
91
  return false;
72
92
  }
73
- if (/^(chat|user|open_id):/i.test(trimmed)) {
93
+ if (/^(chat|group|channel|user|dm|open_id):/i.test(trimmed)) {
74
94
  return true;
75
95
  }
76
96
  if (trimmed.startsWith(CHAT_ID_PREFIX)) {
package/src/types.ts CHANGED
@@ -14,8 +14,15 @@ export type FeishuAccountConfig = z.infer<typeof FeishuAccountConfigSchema>;
14
14
  export type FeishuDomain = "feishu" | "lark" | (string & {});
15
15
  export type FeishuConnectionMode = "websocket" | "webhook";
16
16
 
17
+ export type FeishuDefaultAccountSelectionSource =
18
+ | "explicit-default"
19
+ | "mapped-default"
20
+ | "fallback";
21
+ export type FeishuAccountSelectionSource = "explicit" | FeishuDefaultAccountSelectionSource;
22
+
17
23
  export type ResolvedFeishuAccount = {
18
24
  accountId: string;
25
+ selectionSource: FeishuAccountSelectionSource;
19
26
  enabled: boolean;
20
27
  configured: boolean;
21
28
  name?: string;
@@ -36,10 +43,11 @@ export type FeishuMessageContext = {
36
43
  senderId: string;
37
44
  senderOpenId: string;
38
45
  senderName?: string;
39
- chatType: "p2p" | "group";
46
+ chatType: "p2p" | "group" | "private";
40
47
  mentionedBot: boolean;
41
48
  rootId?: string;
42
49
  parentId?: string;
50
+ threadId?: string;
43
51
  content: string;
44
52
  contentType: string;
45
53
  /** Mention forward targets (excluding the bot itself) */