openclaw-lark-multi-agent 1.0.18 → 1.0.20
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/dist/feishu-bot.d.ts +8 -0
- package/dist/feishu-bot.js +160 -4
- package/dist/openclaw-client.js +44 -12
- package/package.json +1 -1
package/dist/feishu-bot.d.ts
CHANGED
|
@@ -176,6 +176,14 @@ export declare class FeishuBot {
|
|
|
176
176
|
* Send a model-drift notification to the affected chat.
|
|
177
177
|
*/
|
|
178
178
|
private hydrateInlineImageKeys;
|
|
179
|
+
private hydrateFeishuDocsInMessage;
|
|
180
|
+
private extractFeishuDocRefs;
|
|
181
|
+
private extractFeishuDocRefsFromText;
|
|
182
|
+
private hydrateFeishuDoc;
|
|
183
|
+
private resolveWikiDocRef;
|
|
184
|
+
private cleanupFeishuMarkdown;
|
|
185
|
+
private fetchFeishuDocMarkdown;
|
|
186
|
+
private safeFileName;
|
|
179
187
|
/**
|
|
180
188
|
* Download a resource (image/file/audio) from a Feishu message.
|
|
181
189
|
* Returns the local file path.
|
package/dist/feishu-bot.js
CHANGED
|
@@ -2,9 +2,9 @@ import * as lark from "@larksuiteoapi/node-sdk";
|
|
|
2
2
|
import { createRequire } from "module";
|
|
3
3
|
import { persistBotModel } from "./config.js";
|
|
4
4
|
import { getI18n, normalizeLocale } from "./i18n.js";
|
|
5
|
-
import { existsSync, readFileSync, statSync } from "fs";
|
|
6
|
-
import { basename, extname, resolve } from "path";
|
|
7
|
-
import { getBridgeAttachmentsDir } from "./paths.js";
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "fs";
|
|
6
|
+
import { basename, extname, join, resolve } from "path";
|
|
7
|
+
import { getBridgeAttachmentsDir, getDataDir } from "./paths.js";
|
|
8
8
|
import { buildFeishuCardElements } from "./markdown.js";
|
|
9
9
|
import { discussionManager } from "./discussion-manager.js";
|
|
10
10
|
const require = createRequire(import.meta.url);
|
|
@@ -13,6 +13,7 @@ const MAX_BOT_STREAK = 10;
|
|
|
13
13
|
const MAX_MERGED_TRIGGER_MESSAGES = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_MAX_MERGED_TRIGGER_MESSAGES || 100);
|
|
14
14
|
const MAX_MERGED_TRIGGER_BYTES = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_MAX_MERGED_TRIGGER_BYTES || 128 * 1024);
|
|
15
15
|
const BRIDGE_ATTACHMENTS_DIR = getBridgeAttachmentsDir();
|
|
16
|
+
const FEISHU_DOCS_DIR = join(getDataDir(), "feishu-docs");
|
|
16
17
|
/**
|
|
17
18
|
* Manages a single Feishu bot instance.
|
|
18
19
|
*
|
|
@@ -322,7 +323,7 @@ export class FeishuBot {
|
|
|
322
323
|
}
|
|
323
324
|
}
|
|
324
325
|
}
|
|
325
|
-
if (messageType !== "text" && messageType !== "image" && messageType !== "file" && messageType !== "audio" && messageType !== "sticker" && messageType !== "post")
|
|
326
|
+
if (messageType !== "text" && messageType !== "image" && messageType !== "file" && messageType !== "audio" && messageType !== "sticker" && messageType !== "post" && messageType !== "share_doc")
|
|
326
327
|
return;
|
|
327
328
|
// --- Dedup: atomically claim this message for this bot before any await.
|
|
328
329
|
// Feishu/WebSocket can deliver the same event more than once; a separate
|
|
@@ -401,9 +402,13 @@ export class FeishuBot {
|
|
|
401
402
|
// Rich text post - extract all text content
|
|
402
403
|
cleanText = await this.hydrateInlineImageKeys(this.extractPostText(content), messageId);
|
|
403
404
|
}
|
|
405
|
+
else if (messageType === "share_doc") {
|
|
406
|
+
cleanText = content.title || content.name || content.text || content.url || "[Feishu document]";
|
|
407
|
+
}
|
|
404
408
|
else if (messageType === "sticker") {
|
|
405
409
|
cleanText = `[Sticker: ${content.file_key || "unknown"}]`;
|
|
406
410
|
}
|
|
411
|
+
cleanText = await this.hydrateFeishuDocsInMessage(cleanText, content, messageId);
|
|
407
412
|
if (!cleanText.trim())
|
|
408
413
|
return;
|
|
409
414
|
const routing = this.getRoutingIntent(chatType, message, messageType === "text" ? (content.text || "") : "");
|
|
@@ -2181,6 +2186,157 @@ export class FeishuBot {
|
|
|
2181
2186
|
out = out.replace(r.from, r.to);
|
|
2182
2187
|
return out;
|
|
2183
2188
|
}
|
|
2189
|
+
async hydrateFeishuDocsInMessage(text, content, messageId) {
|
|
2190
|
+
const refs = this.extractFeishuDocRefs(text, content);
|
|
2191
|
+
if (refs.length === 0)
|
|
2192
|
+
return text;
|
|
2193
|
+
const hydrated = [];
|
|
2194
|
+
for (const ref of refs.slice(0, 5)) {
|
|
2195
|
+
try {
|
|
2196
|
+
const doc = await this.hydrateFeishuDoc(ref, messageId);
|
|
2197
|
+
hydrated.push(`[FeishuDoc: ${doc.title || doc.token} -> ${doc.markdownPath}]`);
|
|
2198
|
+
}
|
|
2199
|
+
catch (err) {
|
|
2200
|
+
hydrated.push(`[FeishuDoc: ${ref.title || ref.token} - hydration failed: ${this.errorSummary(err)}]`);
|
|
2201
|
+
}
|
|
2202
|
+
}
|
|
2203
|
+
return `${text.trim()}\n\n[飞书文档已由 LMA 用机器人权限读取并转换为 Markdown 附件,OpenClaw 可直接读取附件内容。]\n${hydrated.join("\n")}`.trim();
|
|
2204
|
+
}
|
|
2205
|
+
extractFeishuDocRefs(text, content) {
|
|
2206
|
+
const refs = [];
|
|
2207
|
+
const add = (ref) => {
|
|
2208
|
+
if (!ref.token || refs.some((r) => r.type === ref.type && r.token === ref.token))
|
|
2209
|
+
return;
|
|
2210
|
+
refs.push(ref);
|
|
2211
|
+
};
|
|
2212
|
+
const scanValue = (value, title) => {
|
|
2213
|
+
if (!value)
|
|
2214
|
+
return;
|
|
2215
|
+
if (typeof value === "string") {
|
|
2216
|
+
for (const ref of this.extractFeishuDocRefsFromText(value, title))
|
|
2217
|
+
add(ref);
|
|
2218
|
+
return;
|
|
2219
|
+
}
|
|
2220
|
+
if (Array.isArray(value)) {
|
|
2221
|
+
for (const item of value)
|
|
2222
|
+
scanValue(item, title);
|
|
2223
|
+
return;
|
|
2224
|
+
}
|
|
2225
|
+
if (typeof value !== "object")
|
|
2226
|
+
return;
|
|
2227
|
+
const nextTitle = value.title || value.name || value.file_name || title;
|
|
2228
|
+
const explicitType = String(value.obj_type || value.doc_type || value.type || "").toLowerCase();
|
|
2229
|
+
const docxToken = value.docx_token || value.document_id || (explicitType === "docx" ? value.token : undefined);
|
|
2230
|
+
const docToken = value.doc_token || (explicitType === "doc" ? value.token : undefined);
|
|
2231
|
+
const wikiToken = value.wiki_token || value.node_token || (explicitType === "wiki" ? value.token : undefined);
|
|
2232
|
+
if (docxToken)
|
|
2233
|
+
add({ type: "docx", token: String(docxToken), title: nextTitle, url: value.url || value.href });
|
|
2234
|
+
if (docToken)
|
|
2235
|
+
add({ type: "doc", token: String(docToken), title: nextTitle, url: value.url || value.href });
|
|
2236
|
+
if (wikiToken)
|
|
2237
|
+
add({ type: "wiki", token: String(wikiToken), title: nextTitle, url: value.url || value.href });
|
|
2238
|
+
for (const v of Object.values(value))
|
|
2239
|
+
scanValue(v, nextTitle);
|
|
2240
|
+
};
|
|
2241
|
+
scanValue(content);
|
|
2242
|
+
for (const ref of this.extractFeishuDocRefsFromText(text))
|
|
2243
|
+
add(ref);
|
|
2244
|
+
return refs;
|
|
2245
|
+
}
|
|
2246
|
+
extractFeishuDocRefsFromText(text, title) {
|
|
2247
|
+
const refs = [];
|
|
2248
|
+
const pattern = /https?:\/\/[^\s)\]>"']+\/(docx|docs|wiki)\/([A-Za-z0-9_-]+)/g;
|
|
2249
|
+
for (const match of text.matchAll(pattern)) {
|
|
2250
|
+
const kind = match[1] === "docs" ? "doc" : match[1];
|
|
2251
|
+
refs.push({ type: kind, token: match[2], title, url: match[0] });
|
|
2252
|
+
}
|
|
2253
|
+
return refs;
|
|
2254
|
+
}
|
|
2255
|
+
async hydrateFeishuDoc(ref, messageId) {
|
|
2256
|
+
let resolved = ref;
|
|
2257
|
+
if (ref.type === "wiki")
|
|
2258
|
+
resolved = await this.resolveWikiDocRef(ref);
|
|
2259
|
+
const markdown = this.cleanupFeishuMarkdown(await this.fetchFeishuDocMarkdown(resolved));
|
|
2260
|
+
const title = resolved.title || ref.title || `${resolved.type}-${resolved.token}`;
|
|
2261
|
+
mkdirSync(FEISHU_DOCS_DIR, { recursive: true });
|
|
2262
|
+
const fileName = `${messageId}-${this.safeFileName(title)}-${resolved.token.slice(0, 8)}.md`;
|
|
2263
|
+
const markdownPath = join(FEISHU_DOCS_DIR, fileName);
|
|
2264
|
+
const body = [
|
|
2265
|
+
`# ${title}`,
|
|
2266
|
+
``,
|
|
2267
|
+
`- Source type: ${ref.type}${resolved.type !== ref.type ? ` -> ${resolved.type}` : ""}`,
|
|
2268
|
+
`- Token: ${ref.token}`,
|
|
2269
|
+
resolved.url || ref.url ? `- URL: ${resolved.url || ref.url}` : "",
|
|
2270
|
+
`- Hydrated at: ${new Date().toISOString()}`,
|
|
2271
|
+
``,
|
|
2272
|
+
markdown.trim(),
|
|
2273
|
+
``,
|
|
2274
|
+
].filter(Boolean).join("\n");
|
|
2275
|
+
writeFileSync(markdownPath, body, "utf8");
|
|
2276
|
+
return { ...resolved, title, markdownPath };
|
|
2277
|
+
}
|
|
2278
|
+
async resolveWikiDocRef(ref) {
|
|
2279
|
+
const resp = await this.client.wiki.v2.space.getNode({ params: { token: ref.token } });
|
|
2280
|
+
const node = resp?.data?.node;
|
|
2281
|
+
if (!node?.obj_token || !node?.obj_type)
|
|
2282
|
+
throw new Error(`Wiki node not readable: ${resp?.msg || "missing obj_token"}`);
|
|
2283
|
+
const objType = String(node.obj_type).toLowerCase();
|
|
2284
|
+
if (objType !== "docx" && objType !== "doc")
|
|
2285
|
+
throw new Error(`Unsupported wiki object type: ${objType}`);
|
|
2286
|
+
return { type: objType, token: node.obj_token, title: node.title || ref.title, url: ref.url };
|
|
2287
|
+
}
|
|
2288
|
+
cleanupFeishuMarkdown(markdown) {
|
|
2289
|
+
return markdown
|
|
2290
|
+
.replace(/\\&\\#/g, "&#")
|
|
2291
|
+
.replace(/\\&#/g, "&#")
|
|
2292
|
+
.replace(/\\&#/g, "&#")
|
|
2293
|
+
.replace(/&#/g, "&#")
|
|
2294
|
+
.replace(/&#(\d+);/g, (_, code) => {
|
|
2295
|
+
const n = Number(code);
|
|
2296
|
+
return Number.isFinite(n) ? String.fromCodePoint(n) : _;
|
|
2297
|
+
})
|
|
2298
|
+
.replace(/&#x([0-9a-fA-F]+);/g, (_, code) => {
|
|
2299
|
+
const n = Number.parseInt(code, 16);
|
|
2300
|
+
return Number.isFinite(n) ? String.fromCodePoint(n) : _;
|
|
2301
|
+
})
|
|
2302
|
+
.replace(/"/g, '"')
|
|
2303
|
+
.replace(/'/g, "'")
|
|
2304
|
+
.replace(/</g, "<")
|
|
2305
|
+
.replace(/>/g, ">")
|
|
2306
|
+
.replace(/&/g, "&")
|
|
2307
|
+
.replace(/\\([+\-=(){}[\].!,,。;:!?、])/g, "$1")
|
|
2308
|
+
.replace(/\\\*/g, "*")
|
|
2309
|
+
.replace(/\\_/g, "_")
|
|
2310
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
2311
|
+
.trim();
|
|
2312
|
+
}
|
|
2313
|
+
async fetchFeishuDocMarkdown(ref) {
|
|
2314
|
+
let contentErr;
|
|
2315
|
+
const docs = this.client.docs;
|
|
2316
|
+
if ((ref.type === "docx" || ref.type === "doc") && docs?.v1?.content?.get) {
|
|
2317
|
+
try {
|
|
2318
|
+
const resp = await docs.v1.content.get({ params: { doc_token: ref.token, doc_type: ref.type, content_type: "markdown", lang: "zh" } });
|
|
2319
|
+
const content = resp?.data?.content;
|
|
2320
|
+
if (content)
|
|
2321
|
+
return content;
|
|
2322
|
+
contentErr = new Error(`docs content empty: ${resp?.msg || "unknown"}`);
|
|
2323
|
+
}
|
|
2324
|
+
catch (err) {
|
|
2325
|
+
contentErr = err;
|
|
2326
|
+
}
|
|
2327
|
+
}
|
|
2328
|
+
if (ref.type === "docx") {
|
|
2329
|
+
const resp = await this.client.docx.document.rawContent({ path: { document_id: ref.token } });
|
|
2330
|
+
const content = resp?.data?.content;
|
|
2331
|
+
if (content)
|
|
2332
|
+
return content;
|
|
2333
|
+
throw new Error(`docx raw content empty: ${resp?.msg || this.errorSummary(contentErr)}`);
|
|
2334
|
+
}
|
|
2335
|
+
throw new Error(`Legacy doc markdown hydration failed: ${this.errorSummary(contentErr)}`);
|
|
2336
|
+
}
|
|
2337
|
+
safeFileName(name) {
|
|
2338
|
+
return name.replace(/[\\/:*?\"<>|\s]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "feishu-doc";
|
|
2339
|
+
}
|
|
2184
2340
|
/**
|
|
2185
2341
|
* Download a resource (image/file/audio) from a Feishu message.
|
|
2186
2342
|
* Returns the local file path.
|
package/dist/openclaw-client.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import WebSocket from "ws";
|
|
2
2
|
import { randomUUID } from "crypto";
|
|
3
|
-
import { mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
4
|
-
import { basename, extname, join } from "path";
|
|
3
|
+
import { mkdirSync, readFileSync, statSync, writeFileSync } from "fs";
|
|
4
|
+
import { basename, extname, join, resolve, sep } from "path";
|
|
5
5
|
import { getBridgeAttachmentsDir, getDataDir } from "./paths.js";
|
|
6
6
|
export const GATEWAY_PROTOCOL_MIN = 3;
|
|
7
7
|
export const GATEWAY_PROTOCOL_MAX = 4;
|
|
8
8
|
const BRIDGE_ATTACHMENTS_DIR = getBridgeAttachmentsDir();
|
|
9
9
|
const CONTEXT_SYNC_DIR = join(getDataDir(), "context-sync");
|
|
10
|
+
const FEISHU_DOCS_DIR = join(getDataDir(), "feishu-docs");
|
|
11
|
+
const MAX_FEISHU_DOC_ATTACHMENT_BYTES = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_MAX_FEISHU_DOC_ATTACHMENT_BYTES || 1024 * 1024);
|
|
10
12
|
const MAX_INLINE_CONTEXT_MESSAGES = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_MAX_INLINE_CONTEXT_MESSAGES || 20);
|
|
11
13
|
const MAX_INLINE_CONTEXT_BYTES = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_MAX_INLINE_CONTEXT_BYTES || 128 * 1024);
|
|
12
14
|
/**
|
|
@@ -213,11 +215,18 @@ export class OpenClawClient {
|
|
|
213
215
|
// Tool calls in assistant messages — skip, using agent item events instead
|
|
214
216
|
// (session.message toolCall events are batched, not real-time)
|
|
215
217
|
}
|
|
216
|
-
// Agent assistant streams — verbose mode
|
|
217
|
-
//
|
|
218
|
+
// Agent assistant streams — verbose mode buffers intermediate text and
|
|
219
|
+
// flushes it only on a real boundary (for example, before a tool starts).
|
|
220
|
+
// This keeps a single assistant preface together instead of leaking it
|
|
221
|
+
// as multiple debounce-sized Feishu messages.
|
|
218
222
|
if (frame.event === "agent" && (frame.payload?.stream === "assistant" || frame.payload?.stream === "chatDelta")) {
|
|
219
223
|
this.handleVerboseAssistantStream(frame.payload);
|
|
220
224
|
}
|
|
225
|
+
if (frame.event === "agent" && frame.payload?.stream === "lifecycle" && frame.payload?.data?.phase === "end") {
|
|
226
|
+
const rawKey = frame.payload.sessionKey || "";
|
|
227
|
+
this.flushVerboseAssistantState(rawKey);
|
|
228
|
+
this.clearVerboseAssistantState(rawKey);
|
|
229
|
+
}
|
|
221
230
|
// Agent item / command output events — real-time tool tracking for verbose mode.
|
|
222
231
|
if (frame.event === "agent" && frame.payload?.stream === "item") {
|
|
223
232
|
const data = frame.payload.data || {};
|
|
@@ -382,15 +391,9 @@ export class OpenClawClient {
|
|
|
382
391
|
this.verboseAssistantLatest.set(key, text);
|
|
383
392
|
this.verboseAssistantLastTouched.set(key, now);
|
|
384
393
|
this.pruneVerboseCaches(now);
|
|
385
|
-
|
|
386
|
-
if (existing)
|
|
387
|
-
clearTimeout(existing);
|
|
388
|
-
this.verboseAssistantTimers.set(key, setTimeout(() => {
|
|
389
|
-
this.verboseAssistantTimers.delete(key);
|
|
390
|
-
if (!this.isVerboseTranscriptEnabled(rawKey))
|
|
391
|
-
return;
|
|
394
|
+
if (text.length >= 3000) {
|
|
392
395
|
this.flushVerboseAssistantState(rawKey);
|
|
393
|
-
}
|
|
396
|
+
}
|
|
394
397
|
}
|
|
395
398
|
handleProactiveSessionMessage(rawKey, msg) {
|
|
396
399
|
const shortKey = rawKey.replace(/^agent:[^:]+:/, "");
|
|
@@ -1223,6 +1226,7 @@ export class OpenClawClient {
|
|
|
1223
1226
|
const attachments = [];
|
|
1224
1227
|
const seen = new Set();
|
|
1225
1228
|
const imagePattern = /\[Image: ([^\]\n]+)\]/g;
|
|
1229
|
+
const docPattern = /\[FeishuDoc: [^\]\n]*? -> ([^\]\n]+\.md)\]/g;
|
|
1226
1230
|
for (const content of contents) {
|
|
1227
1231
|
for (const match of content.matchAll(imagePattern)) {
|
|
1228
1232
|
const imagePath = match[1]?.trim();
|
|
@@ -1243,6 +1247,34 @@ export class OpenClawClient {
|
|
|
1243
1247
|
console.warn(`[OpenClaw] failed to attach image ${imagePath}:`, err.message);
|
|
1244
1248
|
}
|
|
1245
1249
|
}
|
|
1250
|
+
for (const match of content.matchAll(docPattern)) {
|
|
1251
|
+
const docPath = match[1]?.trim();
|
|
1252
|
+
if (!docPath || seen.has(docPath))
|
|
1253
|
+
continue;
|
|
1254
|
+
seen.add(docPath);
|
|
1255
|
+
try {
|
|
1256
|
+
const resolvedDocPath = resolve(docPath);
|
|
1257
|
+
const allowedRoot = resolve(FEISHU_DOCS_DIR);
|
|
1258
|
+
if (resolvedDocPath !== allowedRoot && !resolvedDocPath.startsWith(`${allowedRoot}${sep}`)) {
|
|
1259
|
+
throw new Error(`Feishu doc markdown path outside hydration dir: ${docPath}`);
|
|
1260
|
+
}
|
|
1261
|
+
const size = statSync(resolvedDocPath).size;
|
|
1262
|
+
let docContent = readFileSync(resolvedDocPath, "utf8");
|
|
1263
|
+
if (size > MAX_FEISHU_DOC_ATTACHMENT_BYTES) {
|
|
1264
|
+
const truncated = Buffer.from(docContent, "utf8").subarray(0, MAX_FEISHU_DOC_ATTACHMENT_BYTES).toString("utf8");
|
|
1265
|
+
docContent = `${truncated}\n\n---\n[文档过大,LMA 已截断到 ${MAX_FEISHU_DOC_ATTACHMENT_BYTES} bytes;如需完整内容,请让 LMA 扩大 OPENCLAW_LARK_MULTI_AGENT_MAX_FEISHU_DOC_ATTACHMENT_BYTES 或改用本地路径读取。]`;
|
|
1266
|
+
}
|
|
1267
|
+
attachments.push({
|
|
1268
|
+
type: "file",
|
|
1269
|
+
mimeType: "text/markdown",
|
|
1270
|
+
fileName: basename(resolvedDocPath),
|
|
1271
|
+
content: Buffer.from(docContent, "utf8").toString("base64"),
|
|
1272
|
+
});
|
|
1273
|
+
}
|
|
1274
|
+
catch (err) {
|
|
1275
|
+
console.warn(`[OpenClaw] failed to attach Feishu doc markdown ${docPath}:`, err.message);
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1246
1278
|
}
|
|
1247
1279
|
return attachments;
|
|
1248
1280
|
}
|