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,985 @@
|
|
|
1
|
+
import * as lark from "@larksuiteoapi/node-sdk";
|
|
2
|
+
const MAX_BOT_STREAK = 10;
|
|
3
|
+
/**
|
|
4
|
+
* Manages a single Feishu bot instance.
|
|
5
|
+
*
|
|
6
|
+
* Each bot owns an OpenClaw session (full agent pipeline).
|
|
7
|
+
* All messages are recorded locally in SQLite.
|
|
8
|
+
* When this bot needs to respond, unsynced messages are batched into
|
|
9
|
+
* a single context catch-up + the actual message → one agent run.
|
|
10
|
+
*/
|
|
11
|
+
export class FeishuBot {
|
|
12
|
+
config;
|
|
13
|
+
client;
|
|
14
|
+
wsClient;
|
|
15
|
+
eventDispatcher;
|
|
16
|
+
openclawClient;
|
|
17
|
+
store;
|
|
18
|
+
botOpenId = null;
|
|
19
|
+
/** Tracks which chatId sessions have been initialized */
|
|
20
|
+
initializedSessions = new Set();
|
|
21
|
+
/** Per-chat busy lock: timestamp when became busy (0 = not busy) */
|
|
22
|
+
busyChats = new Map();
|
|
23
|
+
/** Per-chat pending reply message IDs (to ack with DONE when reply arrives) */
|
|
24
|
+
pendingAckMessages = new Map();
|
|
25
|
+
/** Per-chat pending tool message sends (to await before final reply) */
|
|
26
|
+
pendingToolSends = new Map();
|
|
27
|
+
/** Per-chat processQueue lock to avoid duplicate concurrent chat.send runs */
|
|
28
|
+
queueRuns = new Map();
|
|
29
|
+
/** Per-chat serial send queue to guarantee message order */
|
|
30
|
+
sendQueue = new Map();
|
|
31
|
+
adminOpenId;
|
|
32
|
+
static allBots = new Map();
|
|
33
|
+
constructor(config, openclawClient, store, adminOpenId) {
|
|
34
|
+
this.config = config;
|
|
35
|
+
this.openclawClient = openclawClient;
|
|
36
|
+
this.store = store;
|
|
37
|
+
this.adminOpenId = adminOpenId || null;
|
|
38
|
+
// Session keys are now per-chat: lma-<botname>-<chatId>
|
|
39
|
+
this.client = new lark.Client({
|
|
40
|
+
appId: config.appId,
|
|
41
|
+
appSecret: config.appSecret,
|
|
42
|
+
appType: lark.AppType.SelfBuild,
|
|
43
|
+
});
|
|
44
|
+
this.wsClient = new lark.WSClient({
|
|
45
|
+
appId: config.appId,
|
|
46
|
+
appSecret: config.appSecret,
|
|
47
|
+
loggerLevel: lark.LoggerLevel.info,
|
|
48
|
+
});
|
|
49
|
+
this.eventDispatcher = new lark.EventDispatcher({}).register({
|
|
50
|
+
"im.message.receive_v1": this.handleMessage.bind(this),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
register() {
|
|
54
|
+
FeishuBot.allBots.set(this.config.appId, this);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Get the session key for a specific chat.
|
|
58
|
+
* Format: lma-<botname>-<chatId>
|
|
59
|
+
*/
|
|
60
|
+
getSessionKey(chatId) {
|
|
61
|
+
return `lma-${this.config.name.toLowerCase()}-${chatId}`;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Ensure the session for a given chatId exists with the correct model.
|
|
65
|
+
* Lazy: only creates on first message in that chat.
|
|
66
|
+
*/
|
|
67
|
+
async ensureSession(chatId) {
|
|
68
|
+
const sessionKey = this.getSessionKey(chatId);
|
|
69
|
+
if (this.initializedSessions.has(sessionKey)) {
|
|
70
|
+
// Already initialized this process lifetime, just ensure model
|
|
71
|
+
const corrected = await this.openclawClient.ensureModel(sessionKey, this.config.model);
|
|
72
|
+
if (corrected) {
|
|
73
|
+
console.log(`[${this.config.name}] Model auto-corrected to ${this.config.model}`);
|
|
74
|
+
await this.notifyModelDrift(chatId, sessionKey);
|
|
75
|
+
}
|
|
76
|
+
return sessionKey;
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
// Check if session already exists in OpenClaw
|
|
80
|
+
const existing = await this.openclawClient.getSessionInfo(sessionKey).catch(() => null);
|
|
81
|
+
if (existing?.session) {
|
|
82
|
+
// Session exists — preserve it, only ensure model is correct
|
|
83
|
+
console.log(`[${this.config.name}] Session exists: ${sessionKey} (tokens: ${existing.session.totalTokens || 0})`);
|
|
84
|
+
const corrected = await this.openclawClient.ensureModel(sessionKey, this.config.model);
|
|
85
|
+
if (corrected) {
|
|
86
|
+
console.log(`[${this.config.name}] Model auto-corrected to ${this.config.model}`);
|
|
87
|
+
await this.notifyModelDrift(chatId, sessionKey);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
// Session doesn't exist — create new
|
|
92
|
+
await this.openclawClient.createSession({
|
|
93
|
+
key: sessionKey,
|
|
94
|
+
model: this.config.model,
|
|
95
|
+
label: `LMA: ${this.config.name} [${chatId.slice(-8)}]`,
|
|
96
|
+
});
|
|
97
|
+
// Always patch model after create to ensure it takes effect
|
|
98
|
+
await this.openclawClient.patchSession({ key: sessionKey, model: this.config.model });
|
|
99
|
+
console.log(`[${this.config.name}] Session created: ${sessionKey} (model: ${this.config.model})`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
console.warn(`[${this.config.name}] ensureSession error:`, err.message);
|
|
104
|
+
}
|
|
105
|
+
this.initializedSessions.add(sessionKey);
|
|
106
|
+
// Subscribe to session events (proactive messages + tool calls)
|
|
107
|
+
await this.openclawClient.subscribeSession(sessionKey, async (text) => {
|
|
108
|
+
try {
|
|
109
|
+
console.log(`[${this.config.name}] Proactive message for ${chatId.slice(-8)}`);
|
|
110
|
+
await this.sendMessage(chatId, text);
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
console.error(`[${this.config.name}] Failed to deliver proactive msg:`, err.message);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
// Subscribe to tool events for verbose mode
|
|
117
|
+
this.openclawClient.onToolEvent(sessionKey, async (toolName, toolInput, toolOutput) => {
|
|
118
|
+
if (!this.store.getBotVerbose(this.config.name, chatId))
|
|
119
|
+
return;
|
|
120
|
+
console.log(`[${this.config.name}] [${new Date().toISOString()}] Tool event received: ${toolName}`);
|
|
121
|
+
const sendPromise = this.sendOrdered(chatId, async () => {
|
|
122
|
+
try {
|
|
123
|
+
const inputPreview = toolInput.length > 200 ? toolInput.substring(0, 200) + "..." : toolInput;
|
|
124
|
+
const outputPreview = toolOutput.length > 300 ? toolOutput.substring(0, 300) + "..." : toolOutput;
|
|
125
|
+
const msg = `🔧 Tool: ${toolName}\n📥 ${inputPreview}${toolOutput ? `\n📤 ${outputPreview}` : ""}`;
|
|
126
|
+
console.log(`[${this.config.name}] [${new Date().toISOString()}] Sending tool msg to Feishu...`);
|
|
127
|
+
await this.sendMessage(chatId, msg);
|
|
128
|
+
console.log(`[${this.config.name}] [${new Date().toISOString()}] Tool msg sent OK`);
|
|
129
|
+
}
|
|
130
|
+
catch (err) {
|
|
131
|
+
console.warn(`[${this.config.name}] Failed to send tool event:`, err.message);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
// Track the send promise so processQueue can await it before final reply
|
|
135
|
+
const pending = this.pendingToolSends.get(chatId) || [];
|
|
136
|
+
pending.push(sendPromise);
|
|
137
|
+
this.pendingToolSends.set(chatId, pending);
|
|
138
|
+
});
|
|
139
|
+
return sessionKey;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Start the Feishu WS connection. Sessions are created lazily per chat.
|
|
143
|
+
*/
|
|
144
|
+
async start() {
|
|
145
|
+
await this.probeBotIdentity();
|
|
146
|
+
await this.wsClient.start({ eventDispatcher: this.eventDispatcher });
|
|
147
|
+
console.log(`[${this.config.name}] Bot started (model: ${this.config.model}, open_id: ${this.botOpenId || "unknown"})`);
|
|
148
|
+
// Drain any unsynced messages left from previous run
|
|
149
|
+
this.drainOnStartup();
|
|
150
|
+
}
|
|
151
|
+
/** Resolve this bot's open_id at startup so direct @bot mentions work even
|
|
152
|
+
* when Feishu mention payloads omit app_id and only contain open_id/name. */
|
|
153
|
+
async probeBotIdentity() {
|
|
154
|
+
try {
|
|
155
|
+
const res = await this.client.request({
|
|
156
|
+
method: "POST",
|
|
157
|
+
url: "/open-apis/bot/v1/openclaw_bot/ping",
|
|
158
|
+
data: { needBotInfo: true },
|
|
159
|
+
});
|
|
160
|
+
const botInfo = res?.data?.pingBotInfo;
|
|
161
|
+
if (botInfo?.botID) {
|
|
162
|
+
this.botOpenId = botInfo.botID;
|
|
163
|
+
console.log(`[${this.config.name}] Bot identity resolved: ${botInfo.botName || this.config.name} (${this.botOpenId})`);
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
console.warn(`[${this.config.name}] Bot identity probe returned no botID`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
catch (err) {
|
|
170
|
+
console.warn(`[${this.config.name}] Bot identity probe failed:`, err.message);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* On startup, check all known chats for unsynced messages and process them.
|
|
175
|
+
* Also re-subscribe to known sessions for tool events.
|
|
176
|
+
*/
|
|
177
|
+
async drainOnStartup() {
|
|
178
|
+
try {
|
|
179
|
+
const chats = this.store.getAllChatInfo();
|
|
180
|
+
for (const chat of chats) {
|
|
181
|
+
// Skip p2p chats that belong to other bots
|
|
182
|
+
if (chat.chatType === "p2p" && chat.ownerBot && chat.ownerBot !== this.config.name) {
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
// Re-subscribe to existing sessions
|
|
186
|
+
const sessionKey = this.getSessionKey(chat.chatId);
|
|
187
|
+
await this.ensureSession(chat.chatId);
|
|
188
|
+
// Drain only messages that were explicitly marked as reply triggers.
|
|
189
|
+
// Context-only messages should not start an OpenClaw run after restart.
|
|
190
|
+
const pendingTriggerIds = this.store.getPendingTriggerIds(this.config.name, chat.chatId);
|
|
191
|
+
if (pendingTriggerIds.size > 0) {
|
|
192
|
+
console.log(`[${this.config.name}] Startup drain: ${pendingTriggerIds.size} pending trigger(s) in ${chat.chatName || chat.chatId.slice(-8)}`);
|
|
193
|
+
await this.processQueue(chat.chatId);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
catch (err) {
|
|
198
|
+
console.warn(`[${this.config.name}] Startup drain failed:`, err.message);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
static getAllBots() {
|
|
202
|
+
return FeishuBot.allBots;
|
|
203
|
+
}
|
|
204
|
+
static findByOpenId(openId) {
|
|
205
|
+
for (const bot of FeishuBot.allBots.values()) {
|
|
206
|
+
if (bot.botOpenId === openId)
|
|
207
|
+
return bot;
|
|
208
|
+
}
|
|
209
|
+
return undefined;
|
|
210
|
+
}
|
|
211
|
+
async handleMessage(data) {
|
|
212
|
+
try {
|
|
213
|
+
const event = data;
|
|
214
|
+
const message = event.message;
|
|
215
|
+
const sender = event.sender;
|
|
216
|
+
const chatId = message.chat_id;
|
|
217
|
+
const chatType = message.chat_type;
|
|
218
|
+
const messageType = message.message_type;
|
|
219
|
+
const messageId = message.message_id;
|
|
220
|
+
const isBot = sender?.sender_type === "app";
|
|
221
|
+
// Extract bot open_id from mentions
|
|
222
|
+
if (message.mentions) {
|
|
223
|
+
for (const m of message.mentions) {
|
|
224
|
+
const bot = FeishuBot.allBots.get(m.id?.app_id || "");
|
|
225
|
+
if (bot && m.id?.open_id) {
|
|
226
|
+
bot.botOpenId = m.id.open_id;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (messageType !== "text" && messageType !== "image" && messageType !== "file" && messageType !== "audio" && messageType !== "sticker" && messageType !== "post")
|
|
231
|
+
return;
|
|
232
|
+
// --- Dedup: skip if this bot already processed this message ---
|
|
233
|
+
if (this.store.hasBotProcessed(this.config.name, messageId))
|
|
234
|
+
return;
|
|
235
|
+
// --- Cache chat info (lazy, at most once per hour) ---
|
|
236
|
+
await this.fetchAndCacheChatInfo(chatId, chatType);
|
|
237
|
+
// --- P2P isolation: only the owning bot processes p2p messages ---
|
|
238
|
+
if (chatType === "p2p") {
|
|
239
|
+
const chatInfo = this.store.getChatInfo(chatId);
|
|
240
|
+
if (chatInfo?.ownerBot && chatInfo.ownerBot !== this.config.name) {
|
|
241
|
+
return; // This p2p chat belongs to another bot
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
let content;
|
|
245
|
+
try {
|
|
246
|
+
content = JSON.parse(message.content);
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
console.warn(`[${this.config.name}] Failed to parse message content, skipping`);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
// --- Extract text content based on message type ---
|
|
253
|
+
let cleanText = "";
|
|
254
|
+
if (messageType === "text") {
|
|
255
|
+
const rawText = content.text || "";
|
|
256
|
+
cleanText = this.cleanMentions(rawText);
|
|
257
|
+
}
|
|
258
|
+
else if (messageType === "image") {
|
|
259
|
+
// Download image and pass local path
|
|
260
|
+
const imageKey = content.image_key;
|
|
261
|
+
if (imageKey) {
|
|
262
|
+
try {
|
|
263
|
+
const imgPath = await this.downloadResource(messageId, imageKey, "image");
|
|
264
|
+
cleanText = `[Image: ${imgPath}]`;
|
|
265
|
+
}
|
|
266
|
+
catch (err) {
|
|
267
|
+
cleanText = `[Image: download failed - ${err.message}]`;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
else if (messageType === "file") {
|
|
272
|
+
const fileKey = content.file_key;
|
|
273
|
+
const fileName = content.file_name || "unknown";
|
|
274
|
+
if (fileKey) {
|
|
275
|
+
try {
|
|
276
|
+
const filePath = await this.downloadResource(messageId, fileKey, "file");
|
|
277
|
+
cleanText = `[File: ${fileName} -> ${filePath}]`;
|
|
278
|
+
}
|
|
279
|
+
catch (err) {
|
|
280
|
+
cleanText = `[File: ${fileName} - download failed]`;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
else if (messageType === "audio") {
|
|
285
|
+
const fileKey = content.file_key;
|
|
286
|
+
if (fileKey) {
|
|
287
|
+
try {
|
|
288
|
+
const audioPath = await this.downloadResource(messageId, fileKey, "file");
|
|
289
|
+
cleanText = `[Audio: ${audioPath}]`;
|
|
290
|
+
}
|
|
291
|
+
catch (err) {
|
|
292
|
+
cleanText = `[Audio: download failed]`;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
else if (messageType === "post") {
|
|
297
|
+
// Rich text post - extract all text content
|
|
298
|
+
cleanText = this.extractPostText(content);
|
|
299
|
+
}
|
|
300
|
+
else if (messageType === "sticker") {
|
|
301
|
+
cleanText = `[Sticker: ${content.file_key || "unknown"}]`;
|
|
302
|
+
}
|
|
303
|
+
if (!cleanText.trim())
|
|
304
|
+
return;
|
|
305
|
+
// Escape hatch: //command means send /command through to OpenClaw,
|
|
306
|
+
// while /command remains a bridge-level openclaw-lark-multi-agent command.
|
|
307
|
+
const trimmedCleanText = cleanText.trim();
|
|
308
|
+
if (trimmedCleanText.startsWith("//")) {
|
|
309
|
+
cleanText = "/" + trimmedCleanText.slice(2);
|
|
310
|
+
}
|
|
311
|
+
// --- Record to local store (ALL messages, before command/response checks) ---
|
|
312
|
+
const senderName = isBot
|
|
313
|
+
? this.resolveBotName(sender) || "Bot"
|
|
314
|
+
: this.resolveHumanName(sender) || "User";
|
|
315
|
+
let insertedId = this.store.insert({
|
|
316
|
+
chatId,
|
|
317
|
+
messageId,
|
|
318
|
+
senderType: isBot ? "bot" : "human",
|
|
319
|
+
senderName,
|
|
320
|
+
content: cleanText,
|
|
321
|
+
timestamp: Date.now(),
|
|
322
|
+
});
|
|
323
|
+
if (insertedId < 0)
|
|
324
|
+
insertedId = this.store.getMessageId(messageId) || -1;
|
|
325
|
+
// Mark as processed only after successful parse + insert
|
|
326
|
+
this.store.markBotProcessed(this.config.name, messageId);
|
|
327
|
+
// --- Commands: in p2p always respond; in group, check shouldRespond first ---
|
|
328
|
+
// Single slash commands are handled by the bridge. Double slash commands were
|
|
329
|
+
// already unescaped above and should pass through to OpenClaw instead.
|
|
330
|
+
const isBridgeCommand = !trimmedCleanText.startsWith("//");
|
|
331
|
+
const isCommand = isBridgeCommand && /^\/(help|status|compact|reset|verbose|free)/.test(cleanText.trim());
|
|
332
|
+
if (isCommand) {
|
|
333
|
+
// In group chats, commands also require mention/shouldRespond
|
|
334
|
+
if (chatType !== "p2p" && !this.shouldRespond(chatType, message, isBot, chatId, message.content))
|
|
335
|
+
return;
|
|
336
|
+
const markCommandSynced = () => {
|
|
337
|
+
if (insertedId > 0) {
|
|
338
|
+
this.store.markSynced(this.config.name, chatId, insertedId);
|
|
339
|
+
this.store.clearPendingTriggers(this.config.name, chatId, insertedId);
|
|
340
|
+
}
|
|
341
|
+
};
|
|
342
|
+
if (cleanText.trim().startsWith("/help")) {
|
|
343
|
+
const helpText = [
|
|
344
|
+
`📚 ${this.config.name} Bot 命令列表`,
|
|
345
|
+
`━━━━━━━━━━━━━━━━━━`,
|
|
346
|
+
`📊 /status — 查看当前模型、Token 用量、Session 状态`,
|
|
347
|
+
`🧹 /compact — 压缩上下文(保留摘要,释放 token)`,
|
|
348
|
+
`🔄 /reset — 重置会话(清空历史,从头开始)`,
|
|
349
|
+
`🔊 /verbose — 开关 Tool Call 显示(查看 AI 调用了哪些工具)`,
|
|
350
|
+
`🔓 /free — 开关 Free Discussion(群聊中无需 @ 即可回复)`,
|
|
351
|
+
`❓ /help — 显示此帮助信息`,
|
|
352
|
+
].join("\n");
|
|
353
|
+
await this.replyMessage(messageId, helpText);
|
|
354
|
+
markCommandSynced();
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
if (cleanText.trim().startsWith("/status")) {
|
|
358
|
+
await this.ensureSession(chatId);
|
|
359
|
+
await this.handleStatusCommand(chatId, chatType, messageId);
|
|
360
|
+
markCommandSynced();
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
if (cleanText.trim().startsWith("/compact")) {
|
|
364
|
+
await this.ensureSession(chatId);
|
|
365
|
+
await this.handleCompactCommand(chatId, messageId);
|
|
366
|
+
markCommandSynced();
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
if (cleanText.trim().startsWith("/reset")) {
|
|
370
|
+
await this.ensureSession(chatId);
|
|
371
|
+
await this.handleResetCommand(chatId, messageId);
|
|
372
|
+
markCommandSynced();
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
if (cleanText.trim().startsWith("/verbose")) {
|
|
376
|
+
const isOn = this.store.getBotVerbose(this.config.name, chatId);
|
|
377
|
+
this.store.setBotVerbose(this.config.name, chatId, !isOn);
|
|
378
|
+
if (isOn) {
|
|
379
|
+
await this.replyMessage(messageId, `🔇 ${this.config.name} Verbose 已关闭\n只影响当前 Bot 在当前会话的 Tool call 显示`);
|
|
380
|
+
}
|
|
381
|
+
else {
|
|
382
|
+
await this.replyMessage(messageId, `🔊 ${this.config.name} Verbose 已开启\n只影响当前 Bot 在当前会话的 Tool call 显示`);
|
|
383
|
+
}
|
|
384
|
+
markCommandSynced();
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
if (cleanText.trim().startsWith("/free")) {
|
|
388
|
+
if (chatType === "p2p") {
|
|
389
|
+
await this.replyMessage(messageId, "❌ Free Discussion 只在群聊中可用");
|
|
390
|
+
markCommandSynced();
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
const chatInfo = this.store.getChatInfo(chatId);
|
|
394
|
+
const isOn = chatInfo?.freeDiscussion || false;
|
|
395
|
+
this.store.setFreeDiscussion(chatId, !isOn);
|
|
396
|
+
if (isOn) {
|
|
397
|
+
await this.replyMessage(messageId, "🔒 Free Discussion 已关闭\n群聊中需要 @ 指定 Bot 才会回复");
|
|
398
|
+
}
|
|
399
|
+
else {
|
|
400
|
+
await this.replyMessage(messageId, "🔓 Free Discussion 已开启\n所有 Bot 可以自由参与讨论(连续 Bot 回复超过 " + MAX_BOT_STREAK + " 轮将暂停,等待人类发言)");
|
|
401
|
+
}
|
|
402
|
+
markCommandSynced();
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
// --- Should this bot respond? ---
|
|
407
|
+
if (!this.shouldRespond(chatType, message, isBot, chatId, message.content))
|
|
408
|
+
return;
|
|
409
|
+
if (!isBot && insertedId > 0) {
|
|
410
|
+
this.store.markPendingTrigger(this.config.name, chatId, insertedId);
|
|
411
|
+
}
|
|
412
|
+
// Track this message for reaction status updates
|
|
413
|
+
const pending = this.pendingAckMessages.get(chatId) || [];
|
|
414
|
+
// Anti-loop
|
|
415
|
+
const streak = this.store.getBotStreak(chatId);
|
|
416
|
+
if (streak >= MAX_BOT_STREAK) {
|
|
417
|
+
console.log(`[${this.config.name}] Anti-loop: ${streak} consecutive bot msgs`);
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
// --- Queue-based sending: if agent is busy, just accumulate ---
|
|
421
|
+
const busySince = this.busyChats.get(chatId) || 0;
|
|
422
|
+
const BUSY_TIMEOUT_MS = 1_800_000; // 30 minutes, matches collectReply safety timeout
|
|
423
|
+
const isBusy = busySince > 0 && (Date.now() - busySince) < BUSY_TIMEOUT_MS;
|
|
424
|
+
if (isBusy) {
|
|
425
|
+
// Queued: show waiting reaction
|
|
426
|
+
await this.addReaction(messageId, "Typing").catch(() => { });
|
|
427
|
+
pending.push({ messageId, emoji: "Typing" });
|
|
428
|
+
this.pendingAckMessages.set(chatId, pending);
|
|
429
|
+
console.log(`[${this.config.name}] Agent busy for ${chatId.slice(-8)}, queuing: "${cleanText.substring(0, 50)}..."`);
|
|
430
|
+
return; // Message is in DB, will be picked up when agent finishes
|
|
431
|
+
}
|
|
432
|
+
if (busySince > 0) {
|
|
433
|
+
// Busy timeout expired — unlock but don't processQueue here;
|
|
434
|
+
// let the next new message trigger it naturally to avoid concurrent runs
|
|
435
|
+
console.warn(`[${this.config.name}] Busy timeout expired for ${chatId.slice(-8)} (${Math.round((Date.now() - busySince) / 1000)}s), unlocking (will process on next message)`);
|
|
436
|
+
this.busyChats.set(chatId, 0);
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
// --- Not busy, send now (with any accumulated messages) ---
|
|
440
|
+
// Acknowledge receipt: sent to OpenClaw (GET/了解)
|
|
441
|
+
await this.addReaction(messageId, "Get").catch(() => { });
|
|
442
|
+
pending.push({ messageId, emoji: "Get" });
|
|
443
|
+
this.pendingAckMessages.set(chatId, pending);
|
|
444
|
+
await this.processQueue(chatId);
|
|
445
|
+
}
|
|
446
|
+
catch (err) {
|
|
447
|
+
console.error(`[${this.config.name}] Error:`, err);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* Process queued messages for a chat: batch all unsynced messages and send to OpenClaw.
|
|
452
|
+
* Loops until no more unsynced human messages remain.
|
|
453
|
+
*/
|
|
454
|
+
async processQueue(chatId) {
|
|
455
|
+
const existing = this.queueRuns.get(chatId);
|
|
456
|
+
if (existing) {
|
|
457
|
+
console.log(`[${this.config.name}] processQueue already running for ${chatId.slice(-8)}, joining existing run`);
|
|
458
|
+
return existing;
|
|
459
|
+
}
|
|
460
|
+
const run = this.processQueueInner(chatId).finally(() => {
|
|
461
|
+
this.queueRuns.delete(chatId);
|
|
462
|
+
});
|
|
463
|
+
this.queueRuns.set(chatId, run);
|
|
464
|
+
return run;
|
|
465
|
+
}
|
|
466
|
+
async processQueueInner(chatId) {
|
|
467
|
+
while (true) {
|
|
468
|
+
const allUnsynced = this.store.getUnsyncedMessages(this.config.name, chatId);
|
|
469
|
+
const pendingTriggerIds = this.store.getPendingTriggerIds(this.config.name, chatId);
|
|
470
|
+
// Only proceed if there are unsynced human messages that should actively trigger this bot.
|
|
471
|
+
// Other unsynced messages remain as context for the next trigger.
|
|
472
|
+
const humanUnsynced = allUnsynced.filter((m) => m.senderType === "human" && m.id && pendingTriggerIds.has(m.id));
|
|
473
|
+
if (humanUnsynced.length === 0) {
|
|
474
|
+
break;
|
|
475
|
+
}
|
|
476
|
+
this.busyChats.set(chatId, Date.now());
|
|
477
|
+
// The last trigger message is the "current" one, everything else is context
|
|
478
|
+
const lastHuman = humanUnsynced[humanUnsynced.length - 1];
|
|
479
|
+
const triggerId = lastHuman.id || 0;
|
|
480
|
+
if (triggerId && this.store.hasDeliveredReply(this.config.name, chatId, triggerId)) {
|
|
481
|
+
console.warn(`[${this.config.name}] Duplicate trigger skipped for ${chatId.slice(-8)} msgId=${triggerId}`);
|
|
482
|
+
this.store.clearPendingTriggers(this.config.name, chatId, triggerId);
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
const contextMsgs = allUnsynced.filter((m) => m.id !== lastHuman.id);
|
|
486
|
+
const queueStartedAt = Date.now();
|
|
487
|
+
const sessionKey = await this.ensureSession(chatId);
|
|
488
|
+
console.log(`[${this.config.name}] Sending ${humanUnsynced.length} trigger(s) to OpenClaw for ${chatId.slice(-8)} (context=${contextMsgs.length})`);
|
|
489
|
+
// Update reactions: queued messages → sent (GET/了解)
|
|
490
|
+
const pendingAcks = this.pendingAckMessages.get(chatId) || [];
|
|
491
|
+
for (const ack of pendingAcks) {
|
|
492
|
+
if (ack.emoji !== "Get") {
|
|
493
|
+
await this.removeReaction(ack.messageId, ack.emoji).catch(() => { });
|
|
494
|
+
await this.addReaction(ack.messageId, "Get").catch(() => { });
|
|
495
|
+
ack.emoji = "Get";
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
try {
|
|
499
|
+
const reply = await this.openclawClient.chatSendWithContext({
|
|
500
|
+
sessionKey,
|
|
501
|
+
unsyncedMessages: contextMsgs,
|
|
502
|
+
currentMessage: lastHuman.content,
|
|
503
|
+
currentSenderName: lastHuman.senderName,
|
|
504
|
+
deliver: false,
|
|
505
|
+
// Keep bridge UX responsive; long agent/tool loops should surface a clear failure
|
|
506
|
+
// instead of leaving reactions stuck forever.
|
|
507
|
+
timeoutMs: 600000,
|
|
508
|
+
});
|
|
509
|
+
console.log(`[${this.config.name}] OpenClaw reply collected for ${chatId.slice(-8)} in ${Date.now() - queueStartedAt}ms`);
|
|
510
|
+
// Mark everything up to now as synced
|
|
511
|
+
const maxId = Math.max(...allUnsynced.map((m) => m.id || 0));
|
|
512
|
+
this.store.markSynced(this.config.name, chatId, maxId);
|
|
513
|
+
this.store.clearPendingTriggers(this.config.name, chatId, maxId);
|
|
514
|
+
const trimmedReply = reply.trim();
|
|
515
|
+
const shouldReply = trimmedReply.length > 0 && trimmedReply.toUpperCase() !== "NO_REPLY";
|
|
516
|
+
// Record bot reply only if it is user-visible/context-worthy.
|
|
517
|
+
if (shouldReply) {
|
|
518
|
+
const replyId = this.store.insert({
|
|
519
|
+
chatId,
|
|
520
|
+
messageId: `self-${this.config.name}-${Date.now()}`,
|
|
521
|
+
senderType: "bot",
|
|
522
|
+
senderName: this.config.name,
|
|
523
|
+
content: reply,
|
|
524
|
+
timestamp: Date.now(),
|
|
525
|
+
});
|
|
526
|
+
if (replyId > 0)
|
|
527
|
+
this.store.markSynced(this.config.name, chatId, replyId);
|
|
528
|
+
}
|
|
529
|
+
// Wait for all pending tool event messages to be delivered first
|
|
530
|
+
const toolSends = this.pendingToolSends.get(chatId) || [];
|
|
531
|
+
if (toolSends.length > 0) {
|
|
532
|
+
await Promise.allSettled(toolSends);
|
|
533
|
+
this.pendingToolSends.set(chatId, []);
|
|
534
|
+
}
|
|
535
|
+
// Reply to the last human message on Feishu (ordered after tool msgs)
|
|
536
|
+
// Skip empty replies and explicit NO_REPLY responses
|
|
537
|
+
if (shouldReply && lastHuman.messageId) {
|
|
538
|
+
if (triggerId && this.store.hasDeliveredReply(this.config.name, chatId, triggerId)) {
|
|
539
|
+
console.warn(`[${this.config.name}] Reply already delivered, skip duplicate for ${chatId.slice(-8)} msgId=${triggerId}`);
|
|
540
|
+
}
|
|
541
|
+
else {
|
|
542
|
+
await this.sendOrdered(chatId, async () => {
|
|
543
|
+
try {
|
|
544
|
+
await this.replyMessage(lastHuman.messageId, reply);
|
|
545
|
+
}
|
|
546
|
+
catch (err) {
|
|
547
|
+
// Reply can fail for historical messages sent before this bot joined the chat
|
|
548
|
+
// (Feishu 230002: Bot/User can NOT be out of the chat). Fall back to a normal
|
|
549
|
+
// chat message so queue processing still completes.
|
|
550
|
+
console.warn(`[${this.config.name}] replyMessage failed, fallback to sendMessage:`, err.message);
|
|
551
|
+
await this.sendMessage(chatId, reply);
|
|
552
|
+
}
|
|
553
|
+
if (triggerId)
|
|
554
|
+
this.store.markDeliveredReply(this.config.name, chatId, triggerId, lastHuman.messageId);
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
console.log(`[${this.config.name}] [${new Date().toISOString()}] ${shouldReply ? 'Replied' : 'Skipped (empty/NO_REPLY)'} (${reply.length} chars)`);
|
|
559
|
+
// Replace ack reactions with DONE for all pending messages in this chat
|
|
560
|
+
const pendingAcks = this.pendingAckMessages.get(chatId) || [];
|
|
561
|
+
for (const ack of pendingAcks) {
|
|
562
|
+
await this.removeReaction(ack.messageId, ack.emoji).catch(() => { });
|
|
563
|
+
await this.addReaction(ack.messageId, "DONE").catch(() => { });
|
|
564
|
+
}
|
|
565
|
+
this.pendingAckMessages.set(chatId, []);
|
|
566
|
+
}
|
|
567
|
+
catch (err) {
|
|
568
|
+
console.error(`[${this.config.name}] processQueue error:`, err);
|
|
569
|
+
break;
|
|
570
|
+
}
|
|
571
|
+
finally {
|
|
572
|
+
this.busyChats.set(chatId, 0);
|
|
573
|
+
}
|
|
574
|
+
// Check if more messages arrived while we were busy
|
|
575
|
+
// Small delay to let any in-flight messages settle
|
|
576
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
shouldRespond(chatType, message, isBot, chatId, rawText) {
|
|
580
|
+
if (chatType === "p2p")
|
|
581
|
+
return !isBot;
|
|
582
|
+
const mentions = message.mentions || [];
|
|
583
|
+
// Bot messages: only respond if this bot is mentioned
|
|
584
|
+
if (isBot) {
|
|
585
|
+
return this.isMentioned(mentions);
|
|
586
|
+
}
|
|
587
|
+
// @all in text: all bots respond
|
|
588
|
+
if (rawText && (rawText.includes("@_all") || rawText.includes("@all")))
|
|
589
|
+
return true;
|
|
590
|
+
// Check if this bot is explicitly mentioned
|
|
591
|
+
if (this.isMentioned(mentions))
|
|
592
|
+
return true;
|
|
593
|
+
// Check if any other bot is mentioned (not us) — don't respond
|
|
594
|
+
const anyBotMentioned = mentions.some((m) => {
|
|
595
|
+
for (const bot of FeishuBot.allBots.values()) {
|
|
596
|
+
if (m.id?.app_id === bot.config.appId)
|
|
597
|
+
return true;
|
|
598
|
+
if (bot.botOpenId && m.id?.open_id === bot.botOpenId)
|
|
599
|
+
return true;
|
|
600
|
+
}
|
|
601
|
+
return false;
|
|
602
|
+
});
|
|
603
|
+
if (anyBotMentioned && !this.isMentioned(mentions))
|
|
604
|
+
return false;
|
|
605
|
+
// No bot mentioned: check free discussion mode
|
|
606
|
+
if (chatId) {
|
|
607
|
+
const chatInfo = this.store.getChatInfo(chatId);
|
|
608
|
+
if (chatInfo?.freeDiscussion)
|
|
609
|
+
return true;
|
|
610
|
+
}
|
|
611
|
+
// Default: don't respond without @
|
|
612
|
+
return false;
|
|
613
|
+
}
|
|
614
|
+
isMentioned(mentions) {
|
|
615
|
+
return mentions.some((m) => {
|
|
616
|
+
// @ this specific bot
|
|
617
|
+
if (m.id?.app_id === this.config.appId)
|
|
618
|
+
return true;
|
|
619
|
+
if (this.botOpenId && m.id?.open_id === this.botOpenId)
|
|
620
|
+
return true;
|
|
621
|
+
// Feishu may provide only a display name in some mention payloads.
|
|
622
|
+
// Use this only as a fallback; open_id/app_id checks above remain primary.
|
|
623
|
+
if (typeof m.name === "string") {
|
|
624
|
+
const normalizedName = m.name.toLowerCase();
|
|
625
|
+
const normalizedConfigName = this.config.name.toLowerCase();
|
|
626
|
+
if (normalizedName === normalizedConfigName || normalizedName.includes(`(${normalizedConfigName})`) || normalizedName.includes(`(${normalizedConfigName})`))
|
|
627
|
+
return true;
|
|
628
|
+
}
|
|
629
|
+
// @all / @ 所有人 is handled by raw text gating, not by direct-bot mention matching.
|
|
630
|
+
if (m.key === "all" || m.key === "@_all" || m.id?.user_id === "all" || m.id?.open_id === "all" || m.name === "所有人")
|
|
631
|
+
return false;
|
|
632
|
+
return false;
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
resolveBotName(sender) {
|
|
636
|
+
const openId = sender?.sender_id?.open_id;
|
|
637
|
+
if (openId) {
|
|
638
|
+
const bot = FeishuBot.findByOpenId(openId);
|
|
639
|
+
if (bot)
|
|
640
|
+
return bot.config.name;
|
|
641
|
+
}
|
|
642
|
+
return null;
|
|
643
|
+
}
|
|
644
|
+
resolveHumanName(sender) {
|
|
645
|
+
return sender?.sender_id?.open_id || null;
|
|
646
|
+
}
|
|
647
|
+
cleanMentions(text) {
|
|
648
|
+
return text.replace(/@_user_\d+/g, "").trim();
|
|
649
|
+
}
|
|
650
|
+
async replyMessage(messageId, text) {
|
|
651
|
+
// Use interactive card for markdown rendering
|
|
652
|
+
const card = {
|
|
653
|
+
elements: [
|
|
654
|
+
{
|
|
655
|
+
tag: "markdown",
|
|
656
|
+
content: text,
|
|
657
|
+
},
|
|
658
|
+
],
|
|
659
|
+
};
|
|
660
|
+
try {
|
|
661
|
+
await this.client.im.message.reply({
|
|
662
|
+
path: { message_id: messageId },
|
|
663
|
+
data: {
|
|
664
|
+
content: JSON.stringify(card),
|
|
665
|
+
msg_type: "interactive",
|
|
666
|
+
},
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
catch {
|
|
670
|
+
// Fallback to plain text if card fails
|
|
671
|
+
await this.client.im.message.reply({
|
|
672
|
+
path: { message_id: messageId },
|
|
673
|
+
data: {
|
|
674
|
+
content: JSON.stringify({ text }),
|
|
675
|
+
msg_type: "text",
|
|
676
|
+
},
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* Send a proactive message to a chat (not a reply).
|
|
682
|
+
*/
|
|
683
|
+
async sendMessage(chatId, text) {
|
|
684
|
+
const card = {
|
|
685
|
+
elements: [
|
|
686
|
+
{
|
|
687
|
+
tag: "markdown",
|
|
688
|
+
content: text,
|
|
689
|
+
},
|
|
690
|
+
],
|
|
691
|
+
};
|
|
692
|
+
try {
|
|
693
|
+
await this.client.im.message.create({
|
|
694
|
+
params: { receive_id_type: "chat_id" },
|
|
695
|
+
data: {
|
|
696
|
+
receive_id: chatId,
|
|
697
|
+
content: JSON.stringify(card),
|
|
698
|
+
msg_type: "interactive",
|
|
699
|
+
},
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
catch {
|
|
703
|
+
// Fallback to plain text
|
|
704
|
+
await this.client.im.message.create({
|
|
705
|
+
params: { receive_id_type: "chat_id" },
|
|
706
|
+
data: {
|
|
707
|
+
receive_id: chatId,
|
|
708
|
+
content: JSON.stringify({ text }),
|
|
709
|
+
msg_type: "text",
|
|
710
|
+
},
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* Enqueue a message send to guarantee ordering per chat.
|
|
716
|
+
* All sends for a chat are serialized through this.
|
|
717
|
+
*/
|
|
718
|
+
sendOrdered(chatId, fn) {
|
|
719
|
+
const prev = this.sendQueue.get(chatId) || Promise.resolve();
|
|
720
|
+
const next = prev.then(fn, fn); // run even if previous failed
|
|
721
|
+
this.sendQueue.set(chatId, next);
|
|
722
|
+
return next;
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* Add a reaction (emoji) to a message.
|
|
726
|
+
*/
|
|
727
|
+
async addReaction(messageId, emojiType) {
|
|
728
|
+
const resp = await this.client.im.messageReaction.create({
|
|
729
|
+
path: { message_id: messageId },
|
|
730
|
+
data: {
|
|
731
|
+
reaction_type: { emoji_type: emojiType },
|
|
732
|
+
},
|
|
733
|
+
});
|
|
734
|
+
return resp.data?.reaction_id;
|
|
735
|
+
}
|
|
736
|
+
/**
|
|
737
|
+
* Remove a reaction by emoji type from a message.
|
|
738
|
+
* Finds the bot's own reaction of that type and deletes it.
|
|
739
|
+
*/
|
|
740
|
+
async removeReaction(messageId, emojiType) {
|
|
741
|
+
try {
|
|
742
|
+
const resp = await this.client.im.messageReaction.list({
|
|
743
|
+
path: { message_id: messageId },
|
|
744
|
+
params: { reaction_type: emojiType },
|
|
745
|
+
});
|
|
746
|
+
const items = resp.data?.items || [];
|
|
747
|
+
for (const item of items) {
|
|
748
|
+
if (item.reaction_id) {
|
|
749
|
+
await this.client.im.messageReaction.delete({
|
|
750
|
+
path: { message_id: messageId, reaction_id: item.reaction_id },
|
|
751
|
+
});
|
|
752
|
+
break;
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
catch {
|
|
757
|
+
// ignore
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
/**
|
|
761
|
+
* Handle /status command: show current session info.
|
|
762
|
+
*/
|
|
763
|
+
async handleStatusCommand(chatId, chatType, messageId) {
|
|
764
|
+
const sessionKey = this.getSessionKey(chatId);
|
|
765
|
+
const chatInfo = this.store.getChatInfo(chatId);
|
|
766
|
+
const msgCount = this.store.getMessageCount(chatId);
|
|
767
|
+
let session = null;
|
|
768
|
+
try {
|
|
769
|
+
const resp = await this.openclawClient.getSessionInfo(sessionKey);
|
|
770
|
+
session = resp?.session;
|
|
771
|
+
}
|
|
772
|
+
catch {
|
|
773
|
+
// session may not exist yet
|
|
774
|
+
}
|
|
775
|
+
// Always show the configured model — session.model may show gateway-injected internal name
|
|
776
|
+
const model = this.config.model;
|
|
777
|
+
const totalTokens = session?.totalTokens || 0;
|
|
778
|
+
const contextTokens = session?.contextTokens || 0;
|
|
779
|
+
const inputTokens = session?.inputTokens || 0;
|
|
780
|
+
const outputTokens = session?.outputTokens || 0;
|
|
781
|
+
const usedPct = contextTokens > 0 ? Math.round((totalTokens / contextTokens) * 100) : 0;
|
|
782
|
+
const tokenNote = !session?.totalTokensFresh ? " (待首次对话后更新)" : "";
|
|
783
|
+
const fmtK = (n) => n >= 1000 ? `${(n / 1000).toFixed(1)}K` : `${n}`;
|
|
784
|
+
const chatLabel = chatInfo?.chatName || (chatType === "p2p" ? "私聊" : chatId.slice(-8));
|
|
785
|
+
const sessionExists = session ? "✅ 活跃" : "⏳ 未初始化";
|
|
786
|
+
const status = session?.status || "unknown";
|
|
787
|
+
const verboseStatus = this.store.getBotVerbose(this.config.name, chatId) ? "🔊 开启" : "🔇 关闭";
|
|
788
|
+
const freeStatus = chatInfo?.freeDiscussion ? "🔓 开启" : "🔒 关闭";
|
|
789
|
+
const statusText = [
|
|
790
|
+
`📊 ${this.config.name} Bot Status`,
|
|
791
|
+
`━━━━━━━━━━━━━━━━━━`,
|
|
792
|
+
`🤖 Bot: ${this.config.name}`,
|
|
793
|
+
`🧠 模型: ${model}`,
|
|
794
|
+
`💬 会话: ${chatLabel} (${chatType === "p2p" ? "私聊" : "群聊"})`,
|
|
795
|
+
`📋 Session: ${sessionExists} | ${status}`,
|
|
796
|
+
`━━━━━━━━━━━━━━━━━━`,
|
|
797
|
+
`📝 本地消息: ${msgCount} 条`,
|
|
798
|
+
`🧮 上下文: ${fmtK(totalTokens)} / ${fmtK(contextTokens)} (${usedPct}%)${tokenNote}`,
|
|
799
|
+
`📥 输入: ${fmtK(inputTokens)} | 📤 输出: ${fmtK(outputTokens)}`,
|
|
800
|
+
`🔧 Verbose: ${verboseStatus}`,
|
|
801
|
+
`💬 Free Discussion: ${freeStatus}`,
|
|
802
|
+
].join("\n");
|
|
803
|
+
await this.replyMessage(messageId, statusText);
|
|
804
|
+
}
|
|
805
|
+
/**
|
|
806
|
+
* Handle /compact command: compress session context.
|
|
807
|
+
*/
|
|
808
|
+
async handleCompactCommand(chatId, messageId) {
|
|
809
|
+
const sessionKey = this.getSessionKey(chatId);
|
|
810
|
+
try {
|
|
811
|
+
await this.openclawClient.compactSession(sessionKey);
|
|
812
|
+
await this.replyMessage(messageId, `✅ Session 已压缩`);
|
|
813
|
+
}
|
|
814
|
+
catch (err) {
|
|
815
|
+
await this.replyMessage(messageId, `❌ 压缩失败: ${err.message}`);
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
/**
|
|
819
|
+
* Handle /reset command: fire sessions.reset and confirm.
|
|
820
|
+
* sessions.reset doesn't return a WS response, so we fire-and-forget
|
|
821
|
+
* then verify via describe.
|
|
822
|
+
*/
|
|
823
|
+
async handleResetCommand(chatId, messageId) {
|
|
824
|
+
const sessionKey = this.getSessionKey(chatId);
|
|
825
|
+
try {
|
|
826
|
+
// Fire reset (no response expected)
|
|
827
|
+
this.openclawClient.resetSession(sessionKey).catch(() => { });
|
|
828
|
+
// Wait a moment for it to take effect
|
|
829
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
830
|
+
// Re-init session
|
|
831
|
+
this.initializedSessions.delete(sessionKey);
|
|
832
|
+
await this.ensureSession(chatId);
|
|
833
|
+
await this.replyMessage(messageId, `✅ Session 已重置\n模型: ${this.config.model}`);
|
|
834
|
+
}
|
|
835
|
+
catch (err) {
|
|
836
|
+
await this.replyMessage(messageId, `❌ 重置失败: ${err.message}`);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
/**
|
|
840
|
+
* Fetch chat info (name, type, members) via Feishu API and cache in SQLite.
|
|
841
|
+
* Called once per chat on first message.
|
|
842
|
+
*/
|
|
843
|
+
async fetchAndCacheChatInfo(chatId, chatType) {
|
|
844
|
+
const existing = this.store.getChatInfo(chatId);
|
|
845
|
+
// Refresh at most once per hour
|
|
846
|
+
if (existing && Date.now() - existing.updatedAt < 3600_000)
|
|
847
|
+
return;
|
|
848
|
+
try {
|
|
849
|
+
if (chatType === "p2p") {
|
|
850
|
+
// For p2p, we don't have a group name or member list from chat API
|
|
851
|
+
this.store.upsertChatInfo({
|
|
852
|
+
chatId,
|
|
853
|
+
chatType: "p2p",
|
|
854
|
+
chatName: "私聊",
|
|
855
|
+
members: "",
|
|
856
|
+
memberNames: "",
|
|
857
|
+
ownerBot: this.config.name,
|
|
858
|
+
freeDiscussion: this.store.getChatInfo(chatId)?.freeDiscussion || false,
|
|
859
|
+
verbose: this.store.getChatInfo(chatId)?.verbose || false,
|
|
860
|
+
updatedAt: Date.now(),
|
|
861
|
+
});
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
// Fetch group info
|
|
865
|
+
const chatResp = await this.client.im.chat.get({
|
|
866
|
+
path: { chat_id: chatId },
|
|
867
|
+
});
|
|
868
|
+
const chatName = chatResp.data?.name || "";
|
|
869
|
+
// Fetch members
|
|
870
|
+
let members = [];
|
|
871
|
+
let memberNames = [];
|
|
872
|
+
try {
|
|
873
|
+
const membersResp = await this.client.im.chatMembers.get({
|
|
874
|
+
path: { chat_id: chatId },
|
|
875
|
+
params: { member_id_type: "open_id", page_size: 100 },
|
|
876
|
+
});
|
|
877
|
+
const items = membersResp.data?.items || [];
|
|
878
|
+
for (const item of items) {
|
|
879
|
+
if (item.member_id)
|
|
880
|
+
members.push(item.member_id);
|
|
881
|
+
if (item.name)
|
|
882
|
+
memberNames.push(item.name);
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
catch {
|
|
886
|
+
// Some bots may lack permission to list members
|
|
887
|
+
}
|
|
888
|
+
this.store.upsertChatInfo({
|
|
889
|
+
chatId,
|
|
890
|
+
chatType: "group",
|
|
891
|
+
chatName,
|
|
892
|
+
members: members.join(","),
|
|
893
|
+
memberNames: memberNames.join(","),
|
|
894
|
+
ownerBot: "", // group chats are shared, no owner
|
|
895
|
+
freeDiscussion: this.store.getChatInfo(chatId)?.freeDiscussion || false,
|
|
896
|
+
verbose: this.store.getChatInfo(chatId)?.verbose || false,
|
|
897
|
+
updatedAt: Date.now(),
|
|
898
|
+
});
|
|
899
|
+
console.log(`[${this.config.name}] Cached chat info: ${chatName} (${chatId.slice(-8)})`);
|
|
900
|
+
}
|
|
901
|
+
catch (err) {
|
|
902
|
+
console.warn(`[${this.config.name}] Failed to fetch chat info:`, err.message);
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
/**
|
|
906
|
+
* Send a model-drift notification to the affected chat.
|
|
907
|
+
*/
|
|
908
|
+
/**
|
|
909
|
+
* Download a resource (image/file/audio) from a Feishu message.
|
|
910
|
+
* Returns the local file path.
|
|
911
|
+
*/
|
|
912
|
+
async downloadResource(messageId, fileKey, type) {
|
|
913
|
+
const { mkdirSync, writeFileSync } = await import("fs");
|
|
914
|
+
const { resolve } = await import("path");
|
|
915
|
+
const dir = resolve(process.cwd(), "data", "media");
|
|
916
|
+
mkdirSync(dir, { recursive: true });
|
|
917
|
+
const resp = await this.client.im.messageResource.get({
|
|
918
|
+
path: { message_id: messageId, file_key: fileKey },
|
|
919
|
+
params: { type },
|
|
920
|
+
});
|
|
921
|
+
const result = resp;
|
|
922
|
+
const ext = type === "image" ? ".png" : "";
|
|
923
|
+
const filePath = resolve(dir, `${fileKey}${ext}`);
|
|
924
|
+
// SDK v1.62+ returns { writeFile, getReadableStream, headers }
|
|
925
|
+
if (result.writeFile) {
|
|
926
|
+
await result.writeFile(filePath);
|
|
927
|
+
}
|
|
928
|
+
else if (result.data && Buffer.isBuffer(result.data)) {
|
|
929
|
+
writeFileSync(filePath, result.data);
|
|
930
|
+
}
|
|
931
|
+
else {
|
|
932
|
+
throw new Error("Unsupported response format");
|
|
933
|
+
}
|
|
934
|
+
return filePath;
|
|
935
|
+
}
|
|
936
|
+
/**
|
|
937
|
+
* Extract text content from a rich text (post) message.
|
|
938
|
+
*/
|
|
939
|
+
extractPostText(content) {
|
|
940
|
+
const parts = [];
|
|
941
|
+
const title = content.title;
|
|
942
|
+
if (title)
|
|
943
|
+
parts.push(title);
|
|
944
|
+
const body = content.content || [];
|
|
945
|
+
for (const paragraph of body) {
|
|
946
|
+
if (!Array.isArray(paragraph))
|
|
947
|
+
continue;
|
|
948
|
+
for (const element of paragraph) {
|
|
949
|
+
if (element.tag === "text" && element.text) {
|
|
950
|
+
parts.push(element.text);
|
|
951
|
+
}
|
|
952
|
+
else if (element.tag === "a" && element.text) {
|
|
953
|
+
parts.push(`${element.text}(${element.href || ''})`);
|
|
954
|
+
}
|
|
955
|
+
else if (element.tag === "at" && element.user_name) {
|
|
956
|
+
parts.push(`@${element.user_name}`);
|
|
957
|
+
}
|
|
958
|
+
else if (element.tag === "img" && element.image_key) {
|
|
959
|
+
parts.push(`[Image: ${element.image_key}]`);
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
return this.cleanMentions(parts.join(" "));
|
|
964
|
+
}
|
|
965
|
+
async notifyModelDrift(chatId, _sessionKey) {
|
|
966
|
+
try {
|
|
967
|
+
const chatInfo = this.store.getChatInfo(chatId);
|
|
968
|
+
const chatLabel = chatInfo?.chatName || chatId.slice(-8);
|
|
969
|
+
await this.client.im.message.create({
|
|
970
|
+
params: { receive_id_type: "chat_id" },
|
|
971
|
+
data: {
|
|
972
|
+
receive_id: chatId,
|
|
973
|
+
content: JSON.stringify({
|
|
974
|
+
text: `⚠️ 模型漂移已自动纠正\n期望: ${this.config.model}\n已恢复`,
|
|
975
|
+
}),
|
|
976
|
+
msg_type: "text",
|
|
977
|
+
},
|
|
978
|
+
});
|
|
979
|
+
console.log(`[${this.config.name}] Drift notification sent to ${chatLabel}`);
|
|
980
|
+
}
|
|
981
|
+
catch (err) {
|
|
982
|
+
console.warn(`[${this.config.name}] Failed to notify drift:`, err.message);
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
}
|