@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/package.json +1 -1
- package/src/accounts.test.ts +74 -3
- package/src/accounts.ts +69 -10
- package/src/bot.checkBotMentioned.test.ts +1 -1
- package/src/bot.test.ts +390 -29
- package/src/bot.ts +131 -61
- package/src/channel.ts +20 -4
- package/src/client.test.ts +14 -0
- package/src/config-schema.test.ts +19 -0
- package/src/config-schema.ts +13 -9
- package/src/dedup.ts +47 -1
- package/src/doc-schema.ts +16 -22
- package/src/docx.account-selection.test.ts +7 -13
- package/src/docx.test.ts +41 -189
- package/src/media.test.ts +104 -1
- package/src/media.ts +21 -1
- package/src/mention.ts +1 -1
- package/src/monitor.account.ts +266 -18
- package/src/monitor.reaction.test.ts +345 -2
- package/src/monitor.startup.test.ts +17 -1
- package/src/monitor.state.defaults.test.ts +46 -0
- package/src/monitor.state.ts +84 -8
- package/src/monitor.test-mocks.ts +12 -0
- package/src/monitor.webhook-security.test.ts +26 -9
- package/src/onboarding.status.test.ts +25 -0
- package/src/onboarding.ts +144 -52
- package/src/probe.test.ts +38 -20
- package/src/probe.ts +57 -37
- package/src/reply-dispatcher.test.ts +41 -0
- package/src/reply-dispatcher.ts +26 -7
- package/src/secret-input.ts +19 -0
- package/src/send-target.test.ts +74 -0
- package/src/send-target.ts +6 -2
- package/src/targets.test.ts +29 -0
- package/src/targets.ts +21 -1
- package/src/types.ts +9 -1
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
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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,
|
package/src/reply-dispatcher.ts
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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
|
+
});
|
package/src/send-target.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
27
|
+
receiveIdType: resolveReceiveIdType(withoutProviderPrefix),
|
|
24
28
|
};
|
|
25
29
|
}
|
package/src/targets.test.ts
CHANGED
|
@@ -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) */
|