@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.
- package/index.ts +2 -0
- package/package.json +2 -1
- package/skills/feishu-doc/SKILL.md +109 -3
- package/src/accounts.test.ts +161 -0
- package/src/accounts.ts +76 -8
- package/src/async.ts +62 -0
- package/src/bitable.ts +189 -215
- package/src/bot.card-action.test.ts +63 -0
- package/src/bot.checkBotMentioned.test.ts +56 -1
- package/src/bot.test.ts +1271 -56
- package/src/bot.ts +499 -215
- package/src/card-action.ts +79 -0
- package/src/channel.ts +26 -4
- package/src/chat-schema.ts +24 -0
- package/src/chat.test.ts +89 -0
- package/src/chat.ts +130 -0
- package/src/client.test.ts +121 -0
- package/src/client.ts +13 -0
- package/src/config-schema.test.ts +101 -1
- package/src/config-schema.ts +66 -11
- package/src/dedup.ts +47 -1
- package/src/doc-schema.ts +135 -0
- package/src/docx-batch-insert.ts +190 -0
- package/src/docx-color-text.ts +149 -0
- package/src/docx-table-ops.ts +298 -0
- package/src/docx.account-selection.test.ts +70 -0
- package/src/docx.test.ts +331 -9
- package/src/docx.ts +996 -72
- package/src/drive.ts +38 -33
- package/src/media.test.ts +227 -7
- package/src/media.ts +52 -11
- package/src/mention.ts +1 -1
- package/src/monitor.account.ts +534 -0
- package/src/monitor.reaction.test.ts +578 -0
- package/src/monitor.startup.test.ts +203 -0
- package/src/monitor.startup.ts +51 -0
- package/src/monitor.state.defaults.test.ts +46 -0
- package/src/monitor.state.ts +152 -0
- package/src/monitor.test-mocks.ts +12 -0
- package/src/monitor.transport.ts +163 -0
- package/src/monitor.ts +44 -346
- package/src/monitor.webhook-security.test.ts +53 -10
- package/src/onboarding.status.test.ts +25 -0
- package/src/onboarding.ts +144 -52
- package/src/outbound.test.ts +181 -0
- package/src/outbound.ts +94 -7
- package/src/perm.ts +37 -30
- package/src/policy.test.ts +56 -1
- package/src/policy.ts +5 -1
- package/src/post.test.ts +105 -0
- package/src/post.ts +274 -0
- package/src/probe.test.ts +271 -0
- package/src/probe.ts +131 -19
- package/src/reply-dispatcher.test.ts +300 -0
- package/src/reply-dispatcher.ts +159 -46
- package/src/secret-input.ts +19 -0
- package/src/send-target.test.ts +74 -0
- package/src/send-target.ts +6 -2
- package/src/send.reply-fallback.test.ts +105 -0
- package/src/send.test.ts +168 -0
- package/src/send.ts +143 -18
- package/src/streaming-card.ts +131 -43
- package/src/targets.test.ts +55 -1
- package/src/targets.ts +32 -7
- package/src/tool-account-routing.test.ts +129 -0
- package/src/tool-account.ts +70 -0
- package/src/tool-factory-test-harness.ts +76 -0
- package/src/tools-config.test.ts +21 -0
- package/src/tools-config.ts +2 -1
- package/src/types.ts +10 -1
- package/src/typing.test.ts +144 -0
- package/src/typing.ts +140 -10
- package/src/wiki.ts +55 -50
package/src/streaming-card.ts
CHANGED
|
@@ -3,11 +3,19 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import type { Client } from "@larksuiteoapi/node-sdk";
|
|
6
|
+
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk";
|
|
6
7
|
import type { FeishuDomain } from "./types.js";
|
|
7
8
|
|
|
8
9
|
type Credentials = { appId: string; appSecret: string; domain?: FeishuDomain };
|
|
9
10
|
type CardState = { cardId: string; messageId: string; sequence: number; currentText: string };
|
|
10
11
|
|
|
12
|
+
/** Optional header for streaming cards (title bar with color template) */
|
|
13
|
+
export type StreamingCardHeader = {
|
|
14
|
+
title: string;
|
|
15
|
+
/** Color template: blue, green, red, orange, purple, indigo, wathet, turquoise, yellow, grey, carmine, violet, lime */
|
|
16
|
+
template?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
11
19
|
// Token cache (keyed by domain + appId)
|
|
12
20
|
const tokenCache = new Map<string, { token: string; expiresAt: number }>();
|
|
13
21
|
|
|
@@ -21,6 +29,20 @@ function resolveApiBase(domain?: FeishuDomain): string {
|
|
|
21
29
|
return "https://open.feishu.cn/open-apis";
|
|
22
30
|
}
|
|
23
31
|
|
|
32
|
+
function resolveAllowedHostnames(domain?: FeishuDomain): string[] {
|
|
33
|
+
if (domain === "lark") {
|
|
34
|
+
return ["open.larksuite.com"];
|
|
35
|
+
}
|
|
36
|
+
if (domain && domain !== "feishu" && domain.startsWith("http")) {
|
|
37
|
+
try {
|
|
38
|
+
return [new URL(domain).hostname];
|
|
39
|
+
} catch {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return ["open.feishu.cn"];
|
|
44
|
+
}
|
|
45
|
+
|
|
24
46
|
async function getToken(creds: Credentials): Promise<string> {
|
|
25
47
|
const key = `${creds.domain ?? "feishu"}|${creds.appId}`;
|
|
26
48
|
const cached = tokenCache.get(key);
|
|
@@ -28,17 +50,23 @@ async function getToken(creds: Credentials): Promise<string> {
|
|
|
28
50
|
return cached.token;
|
|
29
51
|
}
|
|
30
52
|
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
53
|
+
const { response, release } = await fetchWithSsrFGuard({
|
|
54
|
+
url: `${resolveApiBase(creds.domain)}/auth/v3/tenant_access_token/internal`,
|
|
55
|
+
init: {
|
|
56
|
+
method: "POST",
|
|
57
|
+
headers: { "Content-Type": "application/json" },
|
|
58
|
+
body: JSON.stringify({ app_id: creds.appId, app_secret: creds.appSecret }),
|
|
59
|
+
},
|
|
60
|
+
policy: { allowedHostnames: resolveAllowedHostnames(creds.domain) },
|
|
61
|
+
auditContext: "feishu.streaming-card.token",
|
|
35
62
|
});
|
|
36
|
-
const data = (await
|
|
63
|
+
const data = (await response.json()) as {
|
|
37
64
|
code: number;
|
|
38
65
|
msg: string;
|
|
39
66
|
tenant_access_token?: string;
|
|
40
67
|
expire?: number;
|
|
41
68
|
};
|
|
69
|
+
await release();
|
|
42
70
|
if (data.code !== 0 || !data.tenant_access_token) {
|
|
43
71
|
throw new Error(`Token error: ${data.msg}`);
|
|
44
72
|
}
|
|
@@ -78,13 +106,19 @@ export class FeishuStreamingSession {
|
|
|
78
106
|
async start(
|
|
79
107
|
receiveId: string,
|
|
80
108
|
receiveIdType: "open_id" | "user_id" | "union_id" | "email" | "chat_id" = "chat_id",
|
|
109
|
+
options?: {
|
|
110
|
+
replyToMessageId?: string;
|
|
111
|
+
replyInThread?: boolean;
|
|
112
|
+
rootId?: string;
|
|
113
|
+
header?: StreamingCardHeader;
|
|
114
|
+
},
|
|
81
115
|
): Promise<void> {
|
|
82
116
|
if (this.state) {
|
|
83
117
|
return;
|
|
84
118
|
}
|
|
85
119
|
|
|
86
120
|
const apiBase = resolveApiBase(this.creds.domain);
|
|
87
|
-
const cardJson = {
|
|
121
|
+
const cardJson: Record<string, unknown> = {
|
|
88
122
|
schema: "2.0",
|
|
89
123
|
config: {
|
|
90
124
|
streaming_mode: true,
|
|
@@ -95,35 +129,71 @@ export class FeishuStreamingSession {
|
|
|
95
129
|
elements: [{ tag: "markdown", content: "⏳ Thinking...", element_id: "content" }],
|
|
96
130
|
},
|
|
97
131
|
};
|
|
132
|
+
if (options?.header) {
|
|
133
|
+
cardJson.header = {
|
|
134
|
+
title: { tag: "plain_text", content: options.header.title },
|
|
135
|
+
template: options.header.template ?? "blue",
|
|
136
|
+
};
|
|
137
|
+
}
|
|
98
138
|
|
|
99
139
|
// Create card entity
|
|
100
|
-
const createRes = await
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
140
|
+
const { response: createRes, release: releaseCreate } = await fetchWithSsrFGuard({
|
|
141
|
+
url: `${apiBase}/cardkit/v1/cards`,
|
|
142
|
+
init: {
|
|
143
|
+
method: "POST",
|
|
144
|
+
headers: {
|
|
145
|
+
Authorization: `Bearer ${await getToken(this.creds)}`,
|
|
146
|
+
"Content-Type": "application/json",
|
|
147
|
+
},
|
|
148
|
+
body: JSON.stringify({ type: "card_json", data: JSON.stringify(cardJson) }),
|
|
105
149
|
},
|
|
106
|
-
|
|
150
|
+
policy: { allowedHostnames: resolveAllowedHostnames(this.creds.domain) },
|
|
151
|
+
auditContext: "feishu.streaming-card.create",
|
|
107
152
|
});
|
|
108
153
|
const createData = (await createRes.json()) as {
|
|
109
154
|
code: number;
|
|
110
155
|
msg: string;
|
|
111
156
|
data?: { card_id: string };
|
|
112
157
|
};
|
|
158
|
+
await releaseCreate();
|
|
113
159
|
if (createData.code !== 0 || !createData.data?.card_id) {
|
|
114
160
|
throw new Error(`Create card failed: ${createData.msg}`);
|
|
115
161
|
}
|
|
116
162
|
const cardId = createData.data.card_id;
|
|
163
|
+
const cardContent = JSON.stringify({ type: "card", data: { card_id: cardId } });
|
|
117
164
|
|
|
118
|
-
//
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
165
|
+
// Topic-group replies require root_id routing. Prefer create+root_id when available.
|
|
166
|
+
let sendRes;
|
|
167
|
+
if (options?.rootId) {
|
|
168
|
+
const createData = {
|
|
122
169
|
receive_id: receiveId,
|
|
123
170
|
msg_type: "interactive",
|
|
124
|
-
content:
|
|
125
|
-
|
|
126
|
-
|
|
171
|
+
content: cardContent,
|
|
172
|
+
root_id: options.rootId,
|
|
173
|
+
};
|
|
174
|
+
sendRes = await this.client.im.message.create({
|
|
175
|
+
params: { receive_id_type: receiveIdType },
|
|
176
|
+
data: createData,
|
|
177
|
+
});
|
|
178
|
+
} else if (options?.replyToMessageId) {
|
|
179
|
+
sendRes = await this.client.im.message.reply({
|
|
180
|
+
path: { message_id: options.replyToMessageId },
|
|
181
|
+
data: {
|
|
182
|
+
msg_type: "interactive",
|
|
183
|
+
content: cardContent,
|
|
184
|
+
...(options.replyInThread ? { reply_in_thread: true } : {}),
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
} else {
|
|
188
|
+
sendRes = await this.client.im.message.create({
|
|
189
|
+
params: { receive_id_type: receiveIdType },
|
|
190
|
+
data: {
|
|
191
|
+
receive_id: receiveId,
|
|
192
|
+
msg_type: "interactive",
|
|
193
|
+
content: cardContent,
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
}
|
|
127
197
|
if (sendRes.code !== 0 || !sendRes.data?.message_id) {
|
|
128
198
|
throw new Error(`Send card failed: ${sendRes.msg}`);
|
|
129
199
|
}
|
|
@@ -138,18 +208,27 @@ export class FeishuStreamingSession {
|
|
|
138
208
|
}
|
|
139
209
|
const apiBase = resolveApiBase(this.creds.domain);
|
|
140
210
|
this.state.sequence += 1;
|
|
141
|
-
await
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
211
|
+
await fetchWithSsrFGuard({
|
|
212
|
+
url: `${apiBase}/cardkit/v1/cards/${this.state.cardId}/elements/content/content`,
|
|
213
|
+
init: {
|
|
214
|
+
method: "PUT",
|
|
215
|
+
headers: {
|
|
216
|
+
Authorization: `Bearer ${await getToken(this.creds)}`,
|
|
217
|
+
"Content-Type": "application/json",
|
|
218
|
+
},
|
|
219
|
+
body: JSON.stringify({
|
|
220
|
+
content: text,
|
|
221
|
+
sequence: this.state.sequence,
|
|
222
|
+
uuid: `s_${this.state.cardId}_${this.state.sequence}`,
|
|
223
|
+
}),
|
|
146
224
|
},
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
225
|
+
policy: { allowedHostnames: resolveAllowedHostnames(this.creds.domain) },
|
|
226
|
+
auditContext: "feishu.streaming-card.update",
|
|
227
|
+
})
|
|
228
|
+
.then(async ({ release }) => {
|
|
229
|
+
await release();
|
|
230
|
+
})
|
|
231
|
+
.catch((error) => onError?.(error));
|
|
153
232
|
}
|
|
154
233
|
|
|
155
234
|
async update(text: string): Promise<void> {
|
|
@@ -194,20 +273,29 @@ export class FeishuStreamingSession {
|
|
|
194
273
|
|
|
195
274
|
// Close streaming mode
|
|
196
275
|
this.state.sequence += 1;
|
|
197
|
-
await
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
276
|
+
await fetchWithSsrFGuard({
|
|
277
|
+
url: `${apiBase}/cardkit/v1/cards/${this.state.cardId}/settings`,
|
|
278
|
+
init: {
|
|
279
|
+
method: "PATCH",
|
|
280
|
+
headers: {
|
|
281
|
+
Authorization: `Bearer ${await getToken(this.creds)}`,
|
|
282
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
283
|
+
},
|
|
284
|
+
body: JSON.stringify({
|
|
285
|
+
settings: JSON.stringify({
|
|
286
|
+
config: { streaming_mode: false, summary: { content: truncateSummary(text) } },
|
|
287
|
+
}),
|
|
288
|
+
sequence: this.state.sequence,
|
|
289
|
+
uuid: `c_${this.state.cardId}_${this.state.sequence}`,
|
|
206
290
|
}),
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
})
|
|
291
|
+
},
|
|
292
|
+
policy: { allowedHostnames: resolveAllowedHostnames(this.creds.domain) },
|
|
293
|
+
auditContext: "feishu.streaming-card.close",
|
|
294
|
+
})
|
|
295
|
+
.then(async ({ release }) => {
|
|
296
|
+
await release();
|
|
297
|
+
})
|
|
298
|
+
.catch((e) => this.log?.(`Close failed: ${String(e)}`));
|
|
211
299
|
|
|
212
300
|
this.log?.(`Closed streaming: cardId=${this.state.cardId}`);
|
|
213
301
|
}
|
package/src/targets.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { resolveReceiveIdType } from "./targets.js";
|
|
2
|
+
import { looksLikeFeishuId, normalizeFeishuTarget, resolveReceiveIdType } from "./targets.js";
|
|
3
3
|
|
|
4
4
|
describe("resolveReceiveIdType", () => {
|
|
5
5
|
it("resolves chat IDs by oc_ prefix", () => {
|
|
@@ -13,4 +13,58 @@ 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
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("normalizeFeishuTarget", () => {
|
|
31
|
+
it("strips provider and user prefixes", () => {
|
|
32
|
+
expect(normalizeFeishuTarget("feishu:user:ou_123")).toBe("ou_123");
|
|
33
|
+
expect(normalizeFeishuTarget("lark:user:ou_123")).toBe("ou_123");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("strips provider and chat prefixes", () => {
|
|
37
|
+
expect(normalizeFeishuTarget("feishu:chat:oc_123")).toBe("oc_123");
|
|
38
|
+
});
|
|
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
|
+
|
|
47
|
+
it("accepts provider-prefixed raw ids", () => {
|
|
48
|
+
expect(normalizeFeishuTarget("feishu:ou_123")).toBe("ou_123");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("strips provider and dm prefixes", () => {
|
|
52
|
+
expect(normalizeFeishuTarget("lark:dm:ou_123")).toBe("ou_123");
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("looksLikeFeishuId", () => {
|
|
57
|
+
it("accepts provider-prefixed user targets", () => {
|
|
58
|
+
expect(looksLikeFeishuId("feishu:user:ou_123")).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("accepts provider-prefixed chat targets", () => {
|
|
62
|
+
expect(looksLikeFeishuId("lark:chat:oc_123")).toBe(true);
|
|
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
|
+
});
|
|
16
70
|
});
|
package/src/targets.ts
CHANGED
|
@@ -4,6 +4,10 @@ const CHAT_ID_PREFIX = "oc_";
|
|
|
4
4
|
const OPEN_ID_PREFIX = "ou_";
|
|
5
5
|
const USER_ID_REGEX = /^[a-zA-Z0-9_-]+$/;
|
|
6
6
|
|
|
7
|
+
function stripProviderPrefix(raw: string): string {
|
|
8
|
+
return raw.replace(/^(feishu|lark):/i, "").trim();
|
|
9
|
+
}
|
|
10
|
+
|
|
7
11
|
export function detectIdType(id: string): FeishuIdType | null {
|
|
8
12
|
const trimmed = id.trim();
|
|
9
13
|
if (trimmed.startsWith(CHAT_ID_PREFIX)) {
|
|
@@ -24,18 +28,28 @@ export function normalizeFeishuTarget(raw: string): string | null {
|
|
|
24
28
|
return null;
|
|
25
29
|
}
|
|
26
30
|
|
|
27
|
-
const
|
|
31
|
+
const withoutProvider = stripProviderPrefix(trimmed);
|
|
32
|
+
const lowered = withoutProvider.toLowerCase();
|
|
28
33
|
if (lowered.startsWith("chat:")) {
|
|
29
|
-
return
|
|
34
|
+
return withoutProvider.slice("chat:".length).trim() || null;
|
|
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;
|
|
30
41
|
}
|
|
31
42
|
if (lowered.startsWith("user:")) {
|
|
32
|
-
return
|
|
43
|
+
return withoutProvider.slice("user:".length).trim() || null;
|
|
44
|
+
}
|
|
45
|
+
if (lowered.startsWith("dm:")) {
|
|
46
|
+
return withoutProvider.slice("dm:".length).trim() || null;
|
|
33
47
|
}
|
|
34
48
|
if (lowered.startsWith("open_id:")) {
|
|
35
|
-
return
|
|
49
|
+
return withoutProvider.slice("open_id:".length).trim() || null;
|
|
36
50
|
}
|
|
37
51
|
|
|
38
|
-
return
|
|
52
|
+
return withoutProvider;
|
|
39
53
|
}
|
|
40
54
|
|
|
41
55
|
export function formatFeishuTarget(id: string, type?: FeishuIdType): string {
|
|
@@ -51,6 +65,17 @@ export function formatFeishuTarget(id: string, type?: FeishuIdType): string {
|
|
|
51
65
|
|
|
52
66
|
export function resolveReceiveIdType(id: string): "chat_id" | "open_id" | "user_id" {
|
|
53
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
|
+
}
|
|
54
79
|
if (trimmed.startsWith(CHAT_ID_PREFIX)) {
|
|
55
80
|
return "chat_id";
|
|
56
81
|
}
|
|
@@ -61,11 +86,11 @@ export function resolveReceiveIdType(id: string): "chat_id" | "open_id" | "user_
|
|
|
61
86
|
}
|
|
62
87
|
|
|
63
88
|
export function looksLikeFeishuId(raw: string): boolean {
|
|
64
|
-
const trimmed = raw.trim();
|
|
89
|
+
const trimmed = stripProviderPrefix(raw.trim());
|
|
65
90
|
if (!trimmed) {
|
|
66
91
|
return false;
|
|
67
92
|
}
|
|
68
|
-
if (/^(chat|user|open_id):/i.test(trimmed)) {
|
|
93
|
+
if (/^(chat|group|channel|user|dm|open_id):/i.test(trimmed)) {
|
|
69
94
|
return true;
|
|
70
95
|
}
|
|
71
96
|
if (trimmed.startsWith(CHAT_ID_PREFIX)) {
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
|
+
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
3
|
+
import { registerFeishuBitableTools } from "./bitable.js";
|
|
4
|
+
import { registerFeishuDriveTools } from "./drive.js";
|
|
5
|
+
import { registerFeishuPermTools } from "./perm.js";
|
|
6
|
+
import { createToolFactoryHarness } from "./tool-factory-test-harness.js";
|
|
7
|
+
import { registerFeishuWikiTools } from "./wiki.js";
|
|
8
|
+
|
|
9
|
+
const createFeishuClientMock = vi.fn((account: { appId?: string } | undefined) => ({
|
|
10
|
+
__appId: account?.appId,
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
vi.mock("./client.js", () => ({
|
|
14
|
+
createFeishuClient: (account: { appId?: string } | undefined) => createFeishuClientMock(account),
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
function createConfig(params: {
|
|
18
|
+
toolsA?: {
|
|
19
|
+
wiki?: boolean;
|
|
20
|
+
drive?: boolean;
|
|
21
|
+
perm?: boolean;
|
|
22
|
+
};
|
|
23
|
+
toolsB?: {
|
|
24
|
+
wiki?: boolean;
|
|
25
|
+
drive?: boolean;
|
|
26
|
+
perm?: boolean;
|
|
27
|
+
};
|
|
28
|
+
defaultAccount?: string;
|
|
29
|
+
}): OpenClawPluginApi["config"] {
|
|
30
|
+
return {
|
|
31
|
+
channels: {
|
|
32
|
+
feishu: {
|
|
33
|
+
enabled: true,
|
|
34
|
+
defaultAccount: params.defaultAccount,
|
|
35
|
+
accounts: {
|
|
36
|
+
a: {
|
|
37
|
+
appId: "app-a",
|
|
38
|
+
appSecret: "sec-a",
|
|
39
|
+
tools: params.toolsA,
|
|
40
|
+
},
|
|
41
|
+
b: {
|
|
42
|
+
appId: "app-b",
|
|
43
|
+
appSecret: "sec-b",
|
|
44
|
+
tools: params.toolsB,
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
} as OpenClawPluginApi["config"];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe("feishu tool account routing", () => {
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
vi.clearAllMocks();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("wiki tool registers when first account disables it and routes to agentAccountId", async () => {
|
|
58
|
+
const { api, resolveTool } = createToolFactoryHarness(
|
|
59
|
+
createConfig({
|
|
60
|
+
toolsA: { wiki: false },
|
|
61
|
+
toolsB: { wiki: true },
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
64
|
+
registerFeishuWikiTools(api);
|
|
65
|
+
|
|
66
|
+
const tool = resolveTool("feishu_wiki", { agentAccountId: "b" });
|
|
67
|
+
await tool.execute("call", { action: "search" });
|
|
68
|
+
|
|
69
|
+
expect(createFeishuClientMock.mock.calls.at(-1)?.[0]?.appId).toBe("app-b");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("wiki tool prefers configured defaultAccount over inherited default account context", async () => {
|
|
73
|
+
const { api, resolveTool } = createToolFactoryHarness(
|
|
74
|
+
createConfig({
|
|
75
|
+
defaultAccount: "b",
|
|
76
|
+
toolsA: { wiki: true },
|
|
77
|
+
toolsB: { wiki: true },
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
registerFeishuWikiTools(api);
|
|
81
|
+
|
|
82
|
+
const tool = resolveTool("feishu_wiki", { agentAccountId: "a" });
|
|
83
|
+
await tool.execute("call", { action: "search" });
|
|
84
|
+
|
|
85
|
+
expect(createFeishuClientMock.mock.calls.at(-1)?.[0]?.appId).toBe("app-b");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("drive tool registers when first account disables it and routes to agentAccountId", async () => {
|
|
89
|
+
const { api, resolveTool } = createToolFactoryHarness(
|
|
90
|
+
createConfig({
|
|
91
|
+
toolsA: { drive: false },
|
|
92
|
+
toolsB: { drive: true },
|
|
93
|
+
}),
|
|
94
|
+
);
|
|
95
|
+
registerFeishuDriveTools(api);
|
|
96
|
+
|
|
97
|
+
const tool = resolveTool("feishu_drive", { agentAccountId: "b" });
|
|
98
|
+
await tool.execute("call", { action: "unknown_action" });
|
|
99
|
+
|
|
100
|
+
expect(createFeishuClientMock.mock.calls.at(-1)?.[0]?.appId).toBe("app-b");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("perm tool registers when only second account enables it and routes to agentAccountId", async () => {
|
|
104
|
+
const { api, resolveTool } = createToolFactoryHarness(
|
|
105
|
+
createConfig({
|
|
106
|
+
toolsA: { perm: false },
|
|
107
|
+
toolsB: { perm: true },
|
|
108
|
+
}),
|
|
109
|
+
);
|
|
110
|
+
registerFeishuPermTools(api);
|
|
111
|
+
|
|
112
|
+
const tool = resolveTool("feishu_perm", { agentAccountId: "b" });
|
|
113
|
+
await tool.execute("call", { action: "unknown_action" });
|
|
114
|
+
|
|
115
|
+
expect(createFeishuClientMock.mock.calls.at(-1)?.[0]?.appId).toBe("app-b");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("bitable tool routes to agentAccountId and allows explicit accountId override", async () => {
|
|
119
|
+
const { api, resolveTool } = createToolFactoryHarness(createConfig({}));
|
|
120
|
+
registerFeishuBitableTools(api);
|
|
121
|
+
|
|
122
|
+
const tool = resolveTool("feishu_bitable_get_meta", { agentAccountId: "b" });
|
|
123
|
+
await tool.execute("call-ctx", { url: "invalid-url" });
|
|
124
|
+
await tool.execute("call-override", { url: "invalid-url", accountId: "a" });
|
|
125
|
+
|
|
126
|
+
expect(createFeishuClientMock.mock.calls[0]?.[0]?.appId).toBe("app-b");
|
|
127
|
+
expect(createFeishuClientMock.mock.calls[1]?.[0]?.appId).toBe("app-a");
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type * as Lark from "@larksuiteoapi/node-sdk";
|
|
2
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
3
|
+
import { resolveFeishuAccount } from "./accounts.js";
|
|
4
|
+
import { createFeishuClient } from "./client.js";
|
|
5
|
+
import { resolveToolsConfig } from "./tools-config.js";
|
|
6
|
+
import type { FeishuToolsConfig, ResolvedFeishuAccount } from "./types.js";
|
|
7
|
+
|
|
8
|
+
type AccountAwareParams = { accountId?: string };
|
|
9
|
+
|
|
10
|
+
function normalizeOptionalAccountId(value: string | undefined): string | undefined {
|
|
11
|
+
const trimmed = value?.trim();
|
|
12
|
+
return trimmed ? trimmed : undefined;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function readConfiguredDefaultAccountId(config: OpenClawPluginApi["config"]): string | undefined {
|
|
16
|
+
const value = (config?.channels?.feishu as { defaultAccount?: unknown } | undefined)
|
|
17
|
+
?.defaultAccount;
|
|
18
|
+
if (typeof value !== "string") {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
return normalizeOptionalAccountId(value);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function resolveFeishuToolAccount(params: {
|
|
25
|
+
api: Pick<OpenClawPluginApi, "config">;
|
|
26
|
+
executeParams?: AccountAwareParams;
|
|
27
|
+
defaultAccountId?: string;
|
|
28
|
+
}): ResolvedFeishuAccount {
|
|
29
|
+
if (!params.api.config) {
|
|
30
|
+
throw new Error("Feishu config unavailable");
|
|
31
|
+
}
|
|
32
|
+
return resolveFeishuAccount({
|
|
33
|
+
cfg: params.api.config,
|
|
34
|
+
accountId:
|
|
35
|
+
normalizeOptionalAccountId(params.executeParams?.accountId) ??
|
|
36
|
+
readConfiguredDefaultAccountId(params.api.config) ??
|
|
37
|
+
normalizeOptionalAccountId(params.defaultAccountId),
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function createFeishuToolClient(params: {
|
|
42
|
+
api: Pick<OpenClawPluginApi, "config">;
|
|
43
|
+
executeParams?: AccountAwareParams;
|
|
44
|
+
defaultAccountId?: string;
|
|
45
|
+
}): Lark.Client {
|
|
46
|
+
return createFeishuClient(resolveFeishuToolAccount(params));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function resolveAnyEnabledFeishuToolsConfig(
|
|
50
|
+
accounts: ResolvedFeishuAccount[],
|
|
51
|
+
): Required<FeishuToolsConfig> {
|
|
52
|
+
const merged: Required<FeishuToolsConfig> = {
|
|
53
|
+
doc: false,
|
|
54
|
+
chat: false,
|
|
55
|
+
wiki: false,
|
|
56
|
+
drive: false,
|
|
57
|
+
perm: false,
|
|
58
|
+
scopes: false,
|
|
59
|
+
};
|
|
60
|
+
for (const account of accounts) {
|
|
61
|
+
const cfg = resolveToolsConfig(account.config.tools);
|
|
62
|
+
merged.doc = merged.doc || cfg.doc;
|
|
63
|
+
merged.chat = merged.chat || cfg.chat;
|
|
64
|
+
merged.wiki = merged.wiki || cfg.wiki;
|
|
65
|
+
merged.drive = merged.drive || cfg.drive;
|
|
66
|
+
merged.perm = merged.perm || cfg.perm;
|
|
67
|
+
merged.scopes = merged.scopes || cfg.scopes;
|
|
68
|
+
}
|
|
69
|
+
return merged;
|
|
70
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { AnyAgentTool, OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
|
+
|
|
3
|
+
type ToolContextLike = {
|
|
4
|
+
agentAccountId?: string;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
type ToolFactoryLike = (ctx: ToolContextLike) => AnyAgentTool | AnyAgentTool[] | null | undefined;
|
|
8
|
+
|
|
9
|
+
export type ToolLike = {
|
|
10
|
+
name: string;
|
|
11
|
+
execute: (toolCallId: string, params: unknown) => Promise<unknown> | unknown;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
type RegisteredTool = {
|
|
15
|
+
tool: AnyAgentTool | ToolFactoryLike;
|
|
16
|
+
opts?: { name?: string };
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function toToolList(value: AnyAgentTool | AnyAgentTool[] | null | undefined): AnyAgentTool[] {
|
|
20
|
+
if (!value) return [];
|
|
21
|
+
return Array.isArray(value) ? value : [value];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function asToolLike(tool: AnyAgentTool, fallbackName?: string): ToolLike {
|
|
25
|
+
const candidate = tool as Partial<ToolLike>;
|
|
26
|
+
const name = candidate.name ?? fallbackName;
|
|
27
|
+
const execute = candidate.execute;
|
|
28
|
+
if (!name || typeof execute !== "function") {
|
|
29
|
+
throw new Error(`Resolved tool is missing required fields (name=${String(name)})`);
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
name,
|
|
33
|
+
execute: (toolCallId, params) => execute(toolCallId, params),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function createToolFactoryHarness(cfg: OpenClawPluginApi["config"]) {
|
|
38
|
+
const registered: RegisteredTool[] = [];
|
|
39
|
+
|
|
40
|
+
const api: Pick<OpenClawPluginApi, "config" | "logger" | "registerTool"> = {
|
|
41
|
+
config: cfg,
|
|
42
|
+
logger: {
|
|
43
|
+
info: () => {},
|
|
44
|
+
warn: () => {},
|
|
45
|
+
error: () => {},
|
|
46
|
+
debug: () => {},
|
|
47
|
+
},
|
|
48
|
+
registerTool: (tool, opts) => {
|
|
49
|
+
registered.push({ tool, opts });
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const resolveTool = (name: string, ctx: ToolContextLike = {}): ToolLike => {
|
|
54
|
+
for (const entry of registered) {
|
|
55
|
+
if (entry.opts?.name === name && typeof entry.tool !== "function") {
|
|
56
|
+
return asToolLike(entry.tool, name);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (typeof entry.tool === "function") {
|
|
60
|
+
const builtTools = toToolList(entry.tool(ctx));
|
|
61
|
+
const hit = builtTools.find((tool) => (tool as { name?: string }).name === name);
|
|
62
|
+
if (hit) {
|
|
63
|
+
return asToolLike(hit, name);
|
|
64
|
+
}
|
|
65
|
+
} else if ((entry.tool as { name?: string }).name === name) {
|
|
66
|
+
return asToolLike(entry.tool, name);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
throw new Error(`Tool not registered: ${name}`);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
api: api as OpenClawPluginApi,
|
|
74
|
+
resolveTool,
|
|
75
|
+
};
|
|
76
|
+
}
|