openclaw-lark-multi-agent 0.1.0
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/LICENSE +21 -0
- package/README.md +301 -0
- package/README.zh-CN.md +301 -0
- package/SECURITY.md +29 -0
- package/config.example.json +26 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +208 -0
- package/dist/config.d.ts +17 -0
- package/dist/config.js +28 -0
- package/dist/feishu-bot.d.ts +123 -0
- package/dist/feishu-bot.js +985 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +52 -0
- package/dist/message-store.d.ts +80 -0
- package/dist/message-store.js +333 -0
- package/dist/openclaw-client.d.ts +111 -0
- package/dist/openclaw-client.js +555 -0
- package/package.json +65 -0
- package/scripts/install-linux-systemd.sh +147 -0
- package/scripts/install-windows-service.bat +47 -0
- package/scripts/openclaw-lark-multi-agent.plist +29 -0
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
import WebSocket from "ws";
|
|
2
|
+
import { randomUUID } from "crypto";
|
|
3
|
+
import { readFileSync } from "fs";
|
|
4
|
+
import { basename, extname } from "path";
|
|
5
|
+
/**
|
|
6
|
+
* OpenClaw Gateway WebSocket client.
|
|
7
|
+
* Full agent pipeline — tools, memory, skills, context management by OpenClaw.
|
|
8
|
+
*/
|
|
9
|
+
export class OpenClawClient {
|
|
10
|
+
config;
|
|
11
|
+
ws = null;
|
|
12
|
+
pending = new Map();
|
|
13
|
+
connected = false;
|
|
14
|
+
connectPromise = null;
|
|
15
|
+
agentEvents = new Map();
|
|
16
|
+
reconnectDelay = 1000;
|
|
17
|
+
maxReconnectDelay = 30000;
|
|
18
|
+
shouldReconnect = true;
|
|
19
|
+
/** Callbacks for tool events (verbose mode) */
|
|
20
|
+
toolEventCallbacks = new Map();
|
|
21
|
+
sessionMessageCallbacks = new Map();
|
|
22
|
+
/** Session keys that should be re-subscribed on reconnect */
|
|
23
|
+
subscribedKeys = new Set();
|
|
24
|
+
/** Session keys with active chatSend — suppress proactive message delivery */
|
|
25
|
+
suppressedSessions = new Set();
|
|
26
|
+
constructor(config) {
|
|
27
|
+
this.config = config;
|
|
28
|
+
}
|
|
29
|
+
async connect() {
|
|
30
|
+
if (this.connected)
|
|
31
|
+
return;
|
|
32
|
+
if (this.connectPromise)
|
|
33
|
+
return this.connectPromise;
|
|
34
|
+
try {
|
|
35
|
+
this.connectPromise = this._doConnect();
|
|
36
|
+
await this.connectPromise;
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
this.connectPromise = null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
_doConnect() {
|
|
43
|
+
return new Promise((resolve, reject) => {
|
|
44
|
+
const wsUrl = this.config.baseUrl.replace(/^http/, "ws");
|
|
45
|
+
this.ws = new WebSocket(wsUrl);
|
|
46
|
+
let handshakeDone = false;
|
|
47
|
+
this.ws.on("open", () => { });
|
|
48
|
+
this.ws.on("message", (raw) => {
|
|
49
|
+
const frame = JSON.parse(raw.toString());
|
|
50
|
+
if (!handshakeDone) {
|
|
51
|
+
if (frame.type === "event" && frame.event === "connect.challenge") {
|
|
52
|
+
this.ws.send(JSON.stringify({
|
|
53
|
+
type: "req",
|
|
54
|
+
id: "connect-1",
|
|
55
|
+
method: "connect",
|
|
56
|
+
params: {
|
|
57
|
+
minProtocol: 3,
|
|
58
|
+
maxProtocol: 3,
|
|
59
|
+
client: {
|
|
60
|
+
id: "gateway-client",
|
|
61
|
+
version: "1.0.0",
|
|
62
|
+
platform: "linux",
|
|
63
|
+
mode: "backend",
|
|
64
|
+
},
|
|
65
|
+
role: "operator",
|
|
66
|
+
scopes: ["operator.read", "operator.write", "operator.admin"],
|
|
67
|
+
auth: { token: this.config.token },
|
|
68
|
+
userAgent: "openclaw-lark-multi-agent/1.0.0",
|
|
69
|
+
},
|
|
70
|
+
}));
|
|
71
|
+
}
|
|
72
|
+
else if (frame.type === "res" && frame.ok && frame.payload?.type === "hello-ok") {
|
|
73
|
+
handshakeDone = true;
|
|
74
|
+
this.connected = true;
|
|
75
|
+
console.log("[OpenClaw] Connected to Gateway WS");
|
|
76
|
+
// Re-subscribe all previously subscribed sessions
|
|
77
|
+
for (const key of this.subscribedKeys) {
|
|
78
|
+
this.rpc("sessions.messages.subscribe", { key }).catch(() => { });
|
|
79
|
+
this.rpc("sessions.messages.subscribe", { key: `agent:main:${key}` }).catch(() => { });
|
|
80
|
+
}
|
|
81
|
+
resolve();
|
|
82
|
+
}
|
|
83
|
+
else if (frame.type === "res" && !frame.ok) {
|
|
84
|
+
reject(new Error(`Handshake failed: ${JSON.stringify(frame.error)}`));
|
|
85
|
+
}
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
// Responses
|
|
89
|
+
if (frame.type === "res" && frame.id) {
|
|
90
|
+
const p = this.pending.get(frame.id);
|
|
91
|
+
if (p) {
|
|
92
|
+
this.pending.delete(frame.id);
|
|
93
|
+
clearTimeout(p.timer);
|
|
94
|
+
if (frame.ok)
|
|
95
|
+
p.resolve(frame.payload);
|
|
96
|
+
else
|
|
97
|
+
p.reject(new Error(`RPC error: ${JSON.stringify(frame.error)}`));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// Agent events — store per session key
|
|
101
|
+
if (frame.event === "agent" || frame.event === "chat") {
|
|
102
|
+
const sk = frame.payload?.sessionKey || "__default__";
|
|
103
|
+
if (!this.agentEvents.has(sk))
|
|
104
|
+
this.agentEvents.set(sk, []);
|
|
105
|
+
// Normalize chat events to look like agent events for collectReply
|
|
106
|
+
if (frame.event === "chat") {
|
|
107
|
+
const state = frame.payload?.state;
|
|
108
|
+
const msg = frame.payload?.message;
|
|
109
|
+
if (state === "final") {
|
|
110
|
+
// Store chat final text as fallback (only used if agent stream had no text)
|
|
111
|
+
const textParts = [];
|
|
112
|
+
if (msg?.content) {
|
|
113
|
+
for (const part of (Array.isArray(msg.content) ? msg.content : [])) {
|
|
114
|
+
if (part.type === "text" && part.text)
|
|
115
|
+
textParts.push(part.text);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
// Store as a special chatFinal event (not assistant delta to avoid double-counting)
|
|
119
|
+
this.agentEvents.get(sk).push({
|
|
120
|
+
...frame.payload,
|
|
121
|
+
stream: "chatFinal",
|
|
122
|
+
data: { text: textParts.join("\n") },
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
this.agentEvents.get(sk).push(frame.payload);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
// Log all events for debugging
|
|
131
|
+
if (frame.type === "event" && frame.event !== "tick") {
|
|
132
|
+
console.log(`[OpenClaw] Event: ${frame.event}`, JSON.stringify(frame.payload || {}).substring(0, 200));
|
|
133
|
+
}
|
|
134
|
+
// Session message events (agent-initiated / proactive + tool calls for verbose)
|
|
135
|
+
if (frame.event === "session.message" && frame.payload) {
|
|
136
|
+
const rawKey = frame.payload.sessionKey || "";
|
|
137
|
+
// Try both raw key and without agent:main: prefix
|
|
138
|
+
const shortKey = rawKey.replace(/^agent:[^:]+:/, "");
|
|
139
|
+
const msg = frame.payload.message || frame.payload;
|
|
140
|
+
const role = msg.role;
|
|
141
|
+
const content = msg.content;
|
|
142
|
+
// Proactive assistant text messages (suppress during active chatSend)
|
|
143
|
+
if (role === "assistant" && typeof content === "string") {
|
|
144
|
+
if (this.suppressedSessions.has(rawKey) || this.suppressedSessions.has(shortKey)) {
|
|
145
|
+
console.log(`[OpenClaw] Suppressing proactive msg for ${shortKey} (active chatSend)`);
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
const cb = this.sessionMessageCallbacks.get(rawKey) || this.sessionMessageCallbacks.get(shortKey);
|
|
149
|
+
if (cb)
|
|
150
|
+
cb(content);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
// Tool calls in assistant messages — skip, using agent item events instead
|
|
154
|
+
// (session.message toolCall events are batched, not real-time)
|
|
155
|
+
}
|
|
156
|
+
// Agent item events — real-time tool call tracking for verbose mode
|
|
157
|
+
if (frame.event === "agent" && frame.payload?.stream === "item") {
|
|
158
|
+
const data = frame.payload.data || {};
|
|
159
|
+
const rawKey = frame.payload.sessionKey || "";
|
|
160
|
+
const shortKey = rawKey.replace(/^agent:[^:]+:/, "");
|
|
161
|
+
const toolCb = this.toolEventCallbacks.get(rawKey) || this.toolEventCallbacks.get(shortKey);
|
|
162
|
+
if (toolCb && data.kind === "tool" && data.phase === "start" && data.name) {
|
|
163
|
+
const meta = data.meta || "";
|
|
164
|
+
toolCb(data.name, meta, "");
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
this.ws.on("error", (err) => {
|
|
169
|
+
if (!handshakeDone)
|
|
170
|
+
reject(err);
|
|
171
|
+
else
|
|
172
|
+
console.error("[OpenClaw] WS error:", err.message);
|
|
173
|
+
});
|
|
174
|
+
this.ws.on("close", () => {
|
|
175
|
+
this.connected = false;
|
|
176
|
+
this.connectPromise = null;
|
|
177
|
+
console.log("[OpenClaw] WS disconnected");
|
|
178
|
+
if (this.shouldReconnect) {
|
|
179
|
+
this.scheduleReconnect();
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
scheduleReconnect() {
|
|
185
|
+
const delay = this.reconnectDelay;
|
|
186
|
+
this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay);
|
|
187
|
+
console.log(`[OpenClaw] Reconnecting in ${delay}ms...`);
|
|
188
|
+
setTimeout(async () => {
|
|
189
|
+
try {
|
|
190
|
+
await this.connect();
|
|
191
|
+
this.reconnectDelay = 1000; // reset on success
|
|
192
|
+
console.log("[OpenClaw] Reconnected successfully");
|
|
193
|
+
}
|
|
194
|
+
catch (err) {
|
|
195
|
+
console.error("[OpenClaw] Reconnect failed:", err.message);
|
|
196
|
+
if (this.shouldReconnect)
|
|
197
|
+
this.scheduleReconnect();
|
|
198
|
+
}
|
|
199
|
+
}, delay);
|
|
200
|
+
}
|
|
201
|
+
rpc(method, params, timeoutMs = 120000) {
|
|
202
|
+
return new Promise(async (resolve, reject) => {
|
|
203
|
+
if (!this.ws || !this.connected) {
|
|
204
|
+
try {
|
|
205
|
+
await this.connect();
|
|
206
|
+
}
|
|
207
|
+
catch (err) {
|
|
208
|
+
reject(new Error("Not connected and reconnect failed"));
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const id = randomUUID();
|
|
213
|
+
const timer = setTimeout(() => {
|
|
214
|
+
this.pending.delete(id);
|
|
215
|
+
reject(new Error(`RPC timeout: ${method}`));
|
|
216
|
+
}, timeoutMs);
|
|
217
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
218
|
+
this.ws.send(JSON.stringify({ type: "req", id, method, params }));
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Collect agent reply by polling accumulated events.
|
|
223
|
+
* Matches by initial runId OR sessionKey to handle multi-turn tool calling
|
|
224
|
+
* where OpenClaw creates new runIds for each tool-call round.
|
|
225
|
+
* No aggressive timeout — waits for lifecycle end as the source of truth.
|
|
226
|
+
* 30-minute safety net only for catastrophic WS disconnection.
|
|
227
|
+
*/
|
|
228
|
+
collectReply(runId, timeoutMs = 1800000, targetSessionKey) {
|
|
229
|
+
return new Promise((resolve, reject) => {
|
|
230
|
+
let text = "";
|
|
231
|
+
let chatFinalText = "";
|
|
232
|
+
let sessionKey = targetSessionKey ? `agent:main:${targetSessionKey}` : "";
|
|
233
|
+
let chatFinalTimer = null;
|
|
234
|
+
let lifecycleEndTimer = null;
|
|
235
|
+
const collectStartedAt = Date.now();
|
|
236
|
+
let lifecycleStartedLogged = false;
|
|
237
|
+
const timer = setTimeout(() => {
|
|
238
|
+
clearInterval(poller);
|
|
239
|
+
if (chatFinalTimer)
|
|
240
|
+
clearTimeout(chatFinalTimer);
|
|
241
|
+
if (lifecycleEndTimer)
|
|
242
|
+
clearTimeout(lifecycleEndTimer);
|
|
243
|
+
console.warn(`[OpenClaw] collectReply timeout for runId=${runId} sessionKey=${sessionKey}`);
|
|
244
|
+
this.abortChat(targetSessionKey || sessionKey, runId).catch((err) => {
|
|
245
|
+
console.warn(`[OpenClaw] abort after collectReply timeout failed:`, err.message);
|
|
246
|
+
});
|
|
247
|
+
resolve(text || chatFinalText || "(timeout: no reply received)");
|
|
248
|
+
}, timeoutMs);
|
|
249
|
+
const finish = (finalText) => {
|
|
250
|
+
clearTimeout(timer);
|
|
251
|
+
clearInterval(poller);
|
|
252
|
+
if (chatFinalTimer)
|
|
253
|
+
clearTimeout(chatFinalTimer);
|
|
254
|
+
if (lifecycleEndTimer)
|
|
255
|
+
clearTimeout(lifecycleEndTimer);
|
|
256
|
+
resolve(finalText);
|
|
257
|
+
};
|
|
258
|
+
const poller = setInterval(() => {
|
|
259
|
+
const bucketsToScan = sessionKey
|
|
260
|
+
? [sessionKey]
|
|
261
|
+
: Array.from(this.agentEvents.keys());
|
|
262
|
+
for (const bucketKey of bucketsToScan) {
|
|
263
|
+
const bucket = this.agentEvents.get(bucketKey);
|
|
264
|
+
if (!bucket)
|
|
265
|
+
continue;
|
|
266
|
+
let i = 0;
|
|
267
|
+
while (i < bucket.length) {
|
|
268
|
+
const ev = bucket[i];
|
|
269
|
+
const evRunId = typeof ev.runId === "string" ? ev.runId : "";
|
|
270
|
+
const matchesRun = evRunId ? evRunId === runId : false;
|
|
271
|
+
// OpenClaw chat.send may emit the user-facing chat runId while the actual
|
|
272
|
+
// agent lifecycle uses an internal runId. We clear this session's event buffer
|
|
273
|
+
// immediately before chat.send, so matching by sessionKey here is safe and
|
|
274
|
+
// necessary to collect the real agent output.
|
|
275
|
+
const matchesSession = sessionKey && (ev.sessionKey === sessionKey || ev.sessionKey === targetSessionKey);
|
|
276
|
+
if (matchesRun || matchesSession) {
|
|
277
|
+
if (!sessionKey && ev.sessionKey)
|
|
278
|
+
sessionKey = ev.sessionKey;
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
i++;
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
bucket.splice(i, 1);
|
|
285
|
+
if (ev.stream === "lifecycle" && ev.data?.phase === "start" && !lifecycleStartedLogged) {
|
|
286
|
+
lifecycleStartedLogged = true;
|
|
287
|
+
console.log(`[OpenClaw] lifecycle start for runId=${runId} after ${Date.now() - collectStartedAt}ms`);
|
|
288
|
+
}
|
|
289
|
+
if (ev.stream === "assistant" && ev.data?.delta) {
|
|
290
|
+
text += ev.data.delta;
|
|
291
|
+
}
|
|
292
|
+
if (ev.stream === "chatFinal") {
|
|
293
|
+
chatFinalText = ev.data?.text || "";
|
|
294
|
+
// Fallback: if lifecycle end doesn't arrive within 5s, resolve
|
|
295
|
+
if (!chatFinalTimer) {
|
|
296
|
+
chatFinalTimer = setTimeout(() => {
|
|
297
|
+
console.warn(`[OpenClaw] collectReply: lifecycle end missing, using chatFinal fallback`);
|
|
298
|
+
this.abortChat(targetSessionKey || sessionKey, runId).catch((err) => {
|
|
299
|
+
console.warn(`[OpenClaw] abort after chatFinal fallback failed:`, err.message);
|
|
300
|
+
});
|
|
301
|
+
// Prefer final chat message over accumulated deltas: some providers may
|
|
302
|
+
// emit only partial deltas (e.g. "N") while final contains "NO_REPLY".
|
|
303
|
+
finish(chatFinalText || text);
|
|
304
|
+
}, 5000);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (ev.stream === "lifecycle" && ev.data?.phase === "end") {
|
|
308
|
+
// Prefer final chat message over accumulated deltas: some providers may
|
|
309
|
+
// emit only partial deltas (e.g. "N") while final contains "NO_REPLY".
|
|
310
|
+
const finalText = chatFinalText || text;
|
|
311
|
+
const finishFromLifecycle = () => {
|
|
312
|
+
const latestFinalText = chatFinalText || text;
|
|
313
|
+
if (!latestFinalText && ev.data?.livenessState !== "working") {
|
|
314
|
+
const state = ev.data?.livenessState || "unknown";
|
|
315
|
+
const reason = ev.data?.stopReason || "";
|
|
316
|
+
const replayInvalid = ev.data?.replayInvalid ? ", replayInvalid" : "";
|
|
317
|
+
finish(`⚠️ Agent 未正常完成\n状态: ${state}${replayInvalid}${reason ? "\n原因: " + reason : ""}\n请重试,或用 /reset 重置会话`);
|
|
318
|
+
}
|
|
319
|
+
else {
|
|
320
|
+
finish(latestFinalText);
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
// If lifecycle end beats chat final, a short delta like "N" can be a truncated
|
|
324
|
+
// final reply. Wait briefly for chatFinal before resolving.
|
|
325
|
+
if (!chatFinalText && text.length <= 1) {
|
|
326
|
+
lifecycleEndTimer = setTimeout(finishFromLifecycle, 800);
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
finishFromLifecycle();
|
|
330
|
+
}
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
if (ev.stream === "lifecycle" && ev.data?.phase === "error") {
|
|
334
|
+
clearTimeout(timer);
|
|
335
|
+
clearInterval(poller);
|
|
336
|
+
if (chatFinalTimer)
|
|
337
|
+
clearTimeout(chatFinalTimer);
|
|
338
|
+
if (lifecycleEndTimer)
|
|
339
|
+
clearTimeout(lifecycleEndTimer);
|
|
340
|
+
reject(new Error(`Agent error: ${ev.data?.error || "unknown"}`));
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}, 50);
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
// --- Session management ---
|
|
349
|
+
async createSession(params) {
|
|
350
|
+
return this.rpc("sessions.create", params);
|
|
351
|
+
}
|
|
352
|
+
async patchSession(params) {
|
|
353
|
+
return this.rpc("sessions.patch", params, 10000);
|
|
354
|
+
}
|
|
355
|
+
async getSessionStatus(key) {
|
|
356
|
+
return this.rpc("sessions.describe", { key });
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Get session info (model, tokens, etc.) for status display.
|
|
360
|
+
*/
|
|
361
|
+
async getSessionInfo(sessionKey) {
|
|
362
|
+
return this.rpc("sessions.describe", { key: sessionKey });
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Ensure session is using the expected model. If not, patch it back.
|
|
366
|
+
* Returns true if a correction was made.
|
|
367
|
+
*/
|
|
368
|
+
async ensureModel(sessionKey, expectedModel) {
|
|
369
|
+
try {
|
|
370
|
+
// Always patch to ensure model is correct — describe may return internal model names
|
|
371
|
+
// Use short timeout as sessions.patch may not return a response
|
|
372
|
+
await this.patchSession({ key: sessionKey, model: expectedModel }).catch(() => { });
|
|
373
|
+
// Also try with full key prefix
|
|
374
|
+
await this.patchSession({ key: `agent:main:${sessionKey}`, model: expectedModel }).catch(() => { });
|
|
375
|
+
console.log(`[OpenClaw] Model ensured: ${sessionKey} → ${expectedModel}`);
|
|
376
|
+
}
|
|
377
|
+
catch (err) {
|
|
378
|
+
console.warn(`[OpenClaw] ensureModel patch failed:`, err.message);
|
|
379
|
+
}
|
|
380
|
+
return false;
|
|
381
|
+
}
|
|
382
|
+
async deleteSession(key, deleteTranscript = true) {
|
|
383
|
+
return this.rpc("sessions.delete", { key, deleteTranscript });
|
|
384
|
+
}
|
|
385
|
+
async resetSession(key) {
|
|
386
|
+
// sessions.reset may not return a response; use short timeout
|
|
387
|
+
return this.rpc("sessions.reset", { key }, 5000).catch(() => { });
|
|
388
|
+
}
|
|
389
|
+
async compactSession(key) {
|
|
390
|
+
return this.rpc("sessions.compact", { key });
|
|
391
|
+
}
|
|
392
|
+
// --- Chat ---
|
|
393
|
+
/**
|
|
394
|
+
* Send a message to a session and get the agent reply.
|
|
395
|
+
* deliver=false prevents OpenClaw from auto-posting to channels.
|
|
396
|
+
*/
|
|
397
|
+
async abortChat(sessionKey, runId) {
|
|
398
|
+
const key = sessionKey.startsWith("agent:main:") ? sessionKey.slice("agent:main:".length) : sessionKey;
|
|
399
|
+
return this.rpc("chat.abort", { sessionKey: key, runId }, 5000).catch(() => { });
|
|
400
|
+
}
|
|
401
|
+
async chatSend(params) {
|
|
402
|
+
const sk = params.sessionKey;
|
|
403
|
+
const fullSessionKey = `agent:main:${sk}`;
|
|
404
|
+
this.suppressedSessions.add(sk);
|
|
405
|
+
this.suppressedSessions.add(fullSessionKey);
|
|
406
|
+
try {
|
|
407
|
+
// Drop stale buffered events for this session before starting a new run.
|
|
408
|
+
// This prevents an old final text (e.g. previous "ok") from being consumed by
|
|
409
|
+
// the next message while still allowing sessionKey matching for internal runIds.
|
|
410
|
+
this.agentEvents.set(fullSessionKey, []);
|
|
411
|
+
this.agentEvents.set(sk, []);
|
|
412
|
+
const sendStartedAt = Date.now();
|
|
413
|
+
const result = await this.rpc("chat.send", {
|
|
414
|
+
sessionKey: sk,
|
|
415
|
+
message: params.message,
|
|
416
|
+
attachments: params.attachments,
|
|
417
|
+
deliver: params.deliver ?? false,
|
|
418
|
+
idempotencyKey: randomUUID(),
|
|
419
|
+
});
|
|
420
|
+
console.log(`[OpenClaw] chat.send runId: ${result.runId} (rpc=${Date.now() - sendStartedAt}ms, attachments=${params.attachments?.length || 0})`);
|
|
421
|
+
return await this.collectReply(result.runId, params.timeoutMs || 1800000, sk);
|
|
422
|
+
}
|
|
423
|
+
finally {
|
|
424
|
+
this.suppressedSessions.delete(sk);
|
|
425
|
+
this.suppressedSessions.delete(fullSessionKey);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Build and send a context catch-up message followed by the actual message.
|
|
430
|
+
*
|
|
431
|
+
* Batches unsynced messages into a single context block to minimize agent runs.
|
|
432
|
+
* Format:
|
|
433
|
+
* [Context: messages you missed]
|
|
434
|
+
* [Alice 00:30]: blah
|
|
435
|
+
* [GPT 00:31]: blah
|
|
436
|
+
* ---
|
|
437
|
+
* Now respond to: <actual message>
|
|
438
|
+
*
|
|
439
|
+
* If there are no unsynced messages, just sends the actual message directly.
|
|
440
|
+
*/
|
|
441
|
+
async chatSendWithContext(params) {
|
|
442
|
+
const attachments = this.extractImageAttachments([
|
|
443
|
+
...params.unsyncedMessages.map((m) => m.content),
|
|
444
|
+
params.currentMessage,
|
|
445
|
+
]);
|
|
446
|
+
const mediaInstruction = attachments.length > 0
|
|
447
|
+
? "\n\n[Media note: Image attachments are included with this message. If your model can inspect images directly, use the attached image input. If it cannot, use the image tool on the provided media/attachment path; do not try unrelated network or model-provider workarounds.]"
|
|
448
|
+
: "";
|
|
449
|
+
if (params.unsyncedMessages.length === 0) {
|
|
450
|
+
// No context to catch up, send directly
|
|
451
|
+
return this.chatSend({
|
|
452
|
+
sessionKey: params.sessionKey,
|
|
453
|
+
message: params.currentMessage + mediaInstruction,
|
|
454
|
+
attachments,
|
|
455
|
+
deliver: params.deliver,
|
|
456
|
+
timeoutMs: params.timeoutMs,
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
// Build context block + actual message in one chat.send
|
|
460
|
+
const contextLines = params.unsyncedMessages.map((m) => {
|
|
461
|
+
const time = new Date(m.timestamp).toLocaleTimeString("zh-CN", {
|
|
462
|
+
hour: "2-digit",
|
|
463
|
+
minute: "2-digit",
|
|
464
|
+
hour12: false,
|
|
465
|
+
});
|
|
466
|
+
const tag = m.senderType === "bot" ? `${m.senderName} (AI)` : m.senderName;
|
|
467
|
+
return `[${tag} ${time}]: ${m.content}`;
|
|
468
|
+
});
|
|
469
|
+
const combined = `[以下是你不在线期间群里的对话,请了解上下文]\n` +
|
|
470
|
+
contextLines.join("\n") +
|
|
471
|
+
`\n---\n` +
|
|
472
|
+
`[${params.currentSenderName}]: ${params.currentMessage}`;
|
|
473
|
+
return this.chatSend({
|
|
474
|
+
sessionKey: params.sessionKey,
|
|
475
|
+
message: combined + mediaInstruction,
|
|
476
|
+
attachments,
|
|
477
|
+
deliver: params.deliver,
|
|
478
|
+
timeoutMs: params.timeoutMs,
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
extractImageAttachments(contents) {
|
|
482
|
+
const attachments = [];
|
|
483
|
+
const seen = new Set();
|
|
484
|
+
const imagePattern = /\[Image: ([^\]\n]+)\]/g;
|
|
485
|
+
for (const content of contents) {
|
|
486
|
+
for (const match of content.matchAll(imagePattern)) {
|
|
487
|
+
const imagePath = match[1]?.trim();
|
|
488
|
+
if (!imagePath || imagePath.startsWith("download failed") || seen.has(imagePath))
|
|
489
|
+
continue;
|
|
490
|
+
seen.add(imagePath);
|
|
491
|
+
try {
|
|
492
|
+
const ext = extname(imagePath).toLowerCase();
|
|
493
|
+
const mimeType = ext === ".png" ? "image/png" : ext === ".webp" ? "image/webp" : ext === ".gif" ? "image/gif" : "image/jpeg";
|
|
494
|
+
attachments.push({
|
|
495
|
+
type: "image",
|
|
496
|
+
mimeType,
|
|
497
|
+
fileName: basename(imagePath),
|
|
498
|
+
content: readFileSync(imagePath).toString("base64"),
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
catch (err) {
|
|
502
|
+
console.warn(`[OpenClaw] failed to attach image ${imagePath}:`, err.message);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
return attachments;
|
|
507
|
+
}
|
|
508
|
+
async disconnect() {
|
|
509
|
+
this.shouldReconnect = false;
|
|
510
|
+
if (this.ws) {
|
|
511
|
+
this.ws.close();
|
|
512
|
+
this.ws = null;
|
|
513
|
+
this.connected = false;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
// --- Session event subscription ---
|
|
517
|
+
/**
|
|
518
|
+
* Subscribe to message events for a session.
|
|
519
|
+
* When the agent proactively produces a message, the callback fires.
|
|
520
|
+
*/
|
|
521
|
+
async subscribeSession(sessionKey, onMessage) {
|
|
522
|
+
// Register under both the short key and the full key with agent:main: prefix
|
|
523
|
+
this.sessionMessageCallbacks.set(sessionKey, onMessage);
|
|
524
|
+
this.sessionMessageCallbacks.set(`agent:main:${sessionKey}`, onMessage);
|
|
525
|
+
this.subscribedKeys.add(sessionKey);
|
|
526
|
+
try {
|
|
527
|
+
// Try subscribing with short key first, then full key
|
|
528
|
+
await this.rpc("sessions.messages.subscribe", { key: sessionKey }).catch(() => { });
|
|
529
|
+
await this.rpc("sessions.messages.subscribe", { key: `agent:main:${sessionKey}` }).catch(() => { });
|
|
530
|
+
}
|
|
531
|
+
catch (err) {
|
|
532
|
+
console.warn(`[OpenClaw] Failed to subscribe ${sessionKey}:`, err.message);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
async unsubscribeSession(sessionKey) {
|
|
536
|
+
this.sessionMessageCallbacks.delete(sessionKey);
|
|
537
|
+
this.sessionMessageCallbacks.delete(`agent:main:${sessionKey}`);
|
|
538
|
+
this.toolEventCallbacks.delete(sessionKey);
|
|
539
|
+
this.toolEventCallbacks.delete(`agent:main:${sessionKey}`);
|
|
540
|
+
this.subscribedKeys.delete(sessionKey);
|
|
541
|
+
try {
|
|
542
|
+
await this.rpc("sessions.messages.unsubscribe", { key: sessionKey });
|
|
543
|
+
}
|
|
544
|
+
catch {
|
|
545
|
+
// ignore
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* Register a callback for tool call events on a session.
|
|
550
|
+
*/
|
|
551
|
+
onToolEvent(sessionKey, callback) {
|
|
552
|
+
this.toolEventCallbacks.set(sessionKey, callback);
|
|
553
|
+
this.toolEventCallbacks.set(`agent:main:${sessionKey}`, callback);
|
|
554
|
+
}
|
|
555
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "openclaw-lark-multi-agent",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Multi-bot Lark/Feishu bridge for OpenClaw, with per-bot model routing and isolated sessions",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"build": "tsc",
|
|
8
|
+
"start": "node dist/index.js",
|
|
9
|
+
"dev": "tsx src/index.ts",
|
|
10
|
+
"test": "vitest run",
|
|
11
|
+
"test:coverage": "vitest run --coverage",
|
|
12
|
+
"prepack": "npm run build",
|
|
13
|
+
"prepublishOnly": "npm test && npm pack --dry-run"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@larksuiteoapi/node-sdk": "^1.62.1",
|
|
17
|
+
"@types/ws": "^8.18.1",
|
|
18
|
+
"better-sqlite3": "^12.9.0",
|
|
19
|
+
"ws": "^8.20.0"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@types/better-sqlite3": "^7.6.13",
|
|
23
|
+
"@types/node": "^22.0.0",
|
|
24
|
+
"@vitest/coverage-v8": "^4.1.5",
|
|
25
|
+
"tsx": "^4.19.0",
|
|
26
|
+
"typescript": "^5.7.0",
|
|
27
|
+
"vitest": "^4.1.5"
|
|
28
|
+
},
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "git+ssh://git@github.com/hackerphysics/openclaw-lark-multi-agent.git"
|
|
33
|
+
},
|
|
34
|
+
"bugs": {
|
|
35
|
+
"url": "https://github.com/hackerphysics/openclaw-lark-multi-agent/issues"
|
|
36
|
+
},
|
|
37
|
+
"homepage": "https://github.com/hackerphysics/openclaw-lark-multi-agent#readme",
|
|
38
|
+
"keywords": [
|
|
39
|
+
"openclaw",
|
|
40
|
+
"lark",
|
|
41
|
+
"feishu",
|
|
42
|
+
"multi-agent",
|
|
43
|
+
"chatbot",
|
|
44
|
+
"agent",
|
|
45
|
+
"gateway"
|
|
46
|
+
],
|
|
47
|
+
"bin": {
|
|
48
|
+
"openclaw-lark-multi-agent": "dist/cli.js"
|
|
49
|
+
},
|
|
50
|
+
"files": [
|
|
51
|
+
"dist",
|
|
52
|
+
"scripts",
|
|
53
|
+
"config.example.json",
|
|
54
|
+
"README.md",
|
|
55
|
+
"README.zh-CN.md",
|
|
56
|
+
"LICENSE",
|
|
57
|
+
"SECURITY.md"
|
|
58
|
+
],
|
|
59
|
+
"engines": {
|
|
60
|
+
"node": ">=22"
|
|
61
|
+
},
|
|
62
|
+
"publishConfig": {
|
|
63
|
+
"access": "public"
|
|
64
|
+
}
|
|
65
|
+
}
|