openclaw-lark-multi-agent 1.5.0 → 1.5.1

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.
@@ -290,6 +290,15 @@ export declare class FeishuBot {
290
290
  */
291
291
  private sendErrorNotice;
292
292
  private sendFinalMessage;
293
+ /** Feishu code 230025: message content over the per-message limit. */
294
+ private isContentTooLongError;
295
+ /** Split text into Feishu-safe chunks (default well under the 4000-char text limit).
296
+ * Prefers paragraph, then line boundaries; hard-cuts only when a single line
297
+ * is longer than the limit. Joining all chunks reproduces the original text. */
298
+ private splitIntoChunks;
299
+ /** Deliver oversized content as multiple plain-text messages (used on 230025).
300
+ * Keeps the model footer on the final chunk. Returns the first message id. */
301
+ private sendChunkedText;
293
302
  private sendMessage;
294
303
  /**
295
304
  * Enqueue a message send to guarantee ordering per chat.
@@ -37,6 +37,40 @@ const AUTO_RETRY_DONE_PHRASE_EN = process.env.OPENCLAW_LARK_MULTI_AGENT_AUTO_RET
37
37
  // it is a NEW user message that arrived while it was still working, and should
38
38
  // be handled together with the current task. The live-status card and reactions
39
39
  // still key off the ORIGINAL text; only what OpenClaw receives is wrapped.
40
+ /** Damerau-Levenshtein distance (insert/delete/substitute/transpose), bounded. */
41
+ function damerauLevenshtein(a, b) {
42
+ const la = a.length, lb = b.length;
43
+ if (Math.abs(la - lb) > 1)
44
+ return 2; // fast reject for threshold-1 use
45
+ const d = Array.from({ length: la + 1 }, () => new Array(lb + 1).fill(0));
46
+ for (let i = 0; i <= la; i++)
47
+ d[i][0] = i;
48
+ for (let j = 0; j <= lb; j++)
49
+ d[0][j] = j;
50
+ for (let i = 1; i <= la; i++) {
51
+ for (let j = 1; j <= lb; j++) {
52
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
53
+ d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + cost);
54
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
55
+ d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1);
56
+ }
57
+ }
58
+ }
59
+ return d[la][lb];
60
+ }
61
+ /** Find a bridge command within one edit of `name` (typo hint); undefined if none. */
62
+ function findNearMissCommand(name, commands) {
63
+ if (!name.startsWith("/") || name.length < 3)
64
+ return undefined;
65
+ let best;
66
+ for (const cmd of commands) {
67
+ if (damerauLevenshtein(name, cmd) <= 1) {
68
+ if (best === undefined || cmd.length < best.length)
69
+ best = cmd;
70
+ }
71
+ }
72
+ return best;
73
+ }
40
74
  function steerInjectionPrefix(en) {
41
75
  const zh = process.env.OPENCLAW_LARK_MULTI_AGENT_STEER_PREFIX
42
76
  || "【用户中途插入的新消息,请与当前任务一起处理】";
@@ -570,6 +604,28 @@ export class FeishuBot {
570
604
  const commandName = preliminaryCommandName;
571
605
  const bridgeCommands = preliminaryBridgeCommands;
572
606
  const isCommand = isBridgeCommand && bridgeCommands.has(commandName);
607
+ // Typo hint: a near-miss single-slash command (e.g. "/chiarman") must not
608
+ // silently fall through into the chairman/model chat path — that is how
609
+ // "I set the chairman but someone else keeps answering" confusion starts.
610
+ if (!isCommand && isBridgeCommand) {
611
+ const nearMiss = findNearMissCommand(commandName, bridgeCommands);
612
+ if (nearMiss) {
613
+ const groupBotCount = Array.from(FeishuBot.allBots.values()).filter((bot) => bot.store === this.store).length;
614
+ const shouldHint = chatType === "p2p"
615
+ || (routing.hasTargetedMention ? routing.isCurrentBotMentioned : (groupBotCount === 1 || this.isDiscussionCoordinator()));
616
+ if (shouldHint) {
617
+ const en = this.isEn(chatId);
618
+ await this.replyMessage(messageId, en
619
+ ? `❓ Unknown command \`${commandName}\`. Did you mean \`${nearMiss}\`?\nRe-send the correct command to execute it. Commands starting with // are passed to OpenClaw.`
620
+ : `❓ 未知命令 \`${commandName}\`,你是想输入 \`${nearMiss}\` 吗?\n重新发送正确命令即可执行;双斜杠 // 开头的命令会转交给 OpenClaw。`);
621
+ if (insertedId > 0) {
622
+ this.store.markSynced(this.config.name, chatId, insertedId);
623
+ this.store.clearPendingTrigger(this.config.name, chatId, insertedId);
624
+ }
625
+ return;
626
+ }
627
+ }
628
+ }
573
629
  if (isCommand) {
574
630
  // In group chats, most bridge commands must be explicitly routed to this
575
631
  // bot or @all. /discuss is a group-level command, so an unmentioned
@@ -3149,6 +3205,16 @@ export class FeishuBot {
3149
3205
  const model = this.replyModelFooters.get(messageId);
3150
3206
  const status = this.replyStatusFooters.get(messageId);
3151
3207
  const card = this.buildMarkdownCard(text, model, status);
3208
+ // Best-effort chat resolution for chunked/error paths: the reply target is
3209
+ // normally a stored inbound message, so the store knows its chat.
3210
+ const resolveChatId = () => {
3211
+ try {
3212
+ return this.store.getMessageByMessageId(messageId)?.chatId || undefined;
3213
+ }
3214
+ catch {
3215
+ return undefined;
3216
+ }
3217
+ };
3152
3218
  try {
3153
3219
  const res = await this.client.im.message.reply({
3154
3220
  path: { message_id: messageId },
@@ -3159,17 +3225,35 @@ export class FeishuBot {
3159
3225
  });
3160
3226
  return res?.data?.message_id || res?.message_id;
3161
3227
  }
3162
- catch {
3228
+ catch (err) {
3229
+ const chatId = resolveChatId();
3230
+ if (this.isOutOfChatError(err) && chatId)
3231
+ this.markCurrentBotUnavailable(chatId, err);
3232
+ // Content over Feishu's per-message limit: chunk and deliver instead of
3233
+ // retrying the same oversized payload (retries would fail forever).
3234
+ if (this.isContentTooLongError(err) && chatId) {
3235
+ return await this.sendChunkedText(chatId, text, model, status, messageId);
3236
+ }
3163
3237
  // Fallback to plain text if card fails; retain model attribution.
3164
3238
  const fallbackText = model?.trim() ? `${text}\n\n🧠 ${model.trim()}${status ? ` · ${status}` : ""}` : text;
3165
- const res = await this.client.im.message.reply({
3166
- path: { message_id: messageId },
3167
- data: {
3168
- content: JSON.stringify({ text: fallbackText }),
3169
- msg_type: "text",
3170
- },
3171
- });
3172
- return res?.data?.message_id || res?.message_id;
3239
+ try {
3240
+ const res = await this.client.im.message.reply({
3241
+ path: { message_id: messageId },
3242
+ data: {
3243
+ content: JSON.stringify({ text: fallbackText }),
3244
+ msg_type: "text",
3245
+ },
3246
+ });
3247
+ return res?.data?.message_id || res?.message_id;
3248
+ }
3249
+ catch (fallbackErr) {
3250
+ if (this.isOutOfChatError(fallbackErr) && chatId)
3251
+ this.markCurrentBotUnavailable(chatId, fallbackErr);
3252
+ if (this.isContentTooLongError(fallbackErr) && chatId) {
3253
+ return await this.sendChunkedText(chatId, text, model, status, messageId);
3254
+ }
3255
+ throw fallbackErr;
3256
+ }
3173
3257
  }
3174
3258
  }
3175
3259
  extractBridgeAttachments(reply) {
@@ -3517,6 +3601,59 @@ export class FeishuBot {
3517
3601
  this.sendStatusFooters.delete(chatId);
3518
3602
  }
3519
3603
  }
3604
+ /** Feishu code 230025: message content over the per-message limit. */
3605
+ isContentTooLongError(err) {
3606
+ const e = err;
3607
+ const code = e?.response?.data?.code ?? e?.data?.code ?? e?.code;
3608
+ const msg = String(e?.response?.data?.msg ?? e?.data?.msg ?? e?.message ?? e?.msg ?? "");
3609
+ return code === 230025 || msg.includes("230025") || /length of the message content/i.test(msg);
3610
+ }
3611
+ /** Split text into Feishu-safe chunks (default well under the 4000-char text limit).
3612
+ * Prefers paragraph, then line boundaries; hard-cuts only when a single line
3613
+ * is longer than the limit. Joining all chunks reproduces the original text. */
3614
+ splitIntoChunks(text, limit = 3200) {
3615
+ if (text.length <= limit)
3616
+ return [text];
3617
+ const chunks = [];
3618
+ let rest = text;
3619
+ while (rest.length > limit) {
3620
+ let cut = rest.lastIndexOf("\n\n", limit);
3621
+ if (cut < limit * 0.5)
3622
+ cut = rest.lastIndexOf("\n", limit);
3623
+ if (cut < limit * 0.5)
3624
+ cut = rest.lastIndexOf(" ", limit);
3625
+ if (cut < limit * 0.5)
3626
+ cut = limit;
3627
+ chunks.push(rest.slice(0, cut));
3628
+ rest = rest.slice(cut).replace(/^\n+/, "");
3629
+ }
3630
+ if (rest.length > 0)
3631
+ chunks.push(rest);
3632
+ return chunks;
3633
+ }
3634
+ /** Deliver oversized content as multiple plain-text messages (used on 230025).
3635
+ * Keeps the model footer on the final chunk. Returns the first message id. */
3636
+ async sendChunkedText(chatId, text, model, status, replyTo) {
3637
+ const suffix = model?.trim() ? `\n\n🧠 ${model.trim()}${status ? ` · ${status}` : ""}` : "";
3638
+ const chunks = this.splitIntoChunks(text);
3639
+ if (suffix && chunks.length > 0)
3640
+ chunks[chunks.length - 1] += suffix;
3641
+ let firstId;
3642
+ for (let i = 0; i < chunks.length; i++) {
3643
+ const payload = {
3644
+ content: JSON.stringify({ text: chunks[i] }),
3645
+ msg_type: "text",
3646
+ };
3647
+ const res = replyTo
3648
+ ? await this.client.im.message.reply({ path: { message_id: replyTo }, data: payload })
3649
+ : await this.client.im.message.create({ params: { receive_id_type: "chat_id" }, data: { receive_id: chatId, ...payload } });
3650
+ const id = res?.data?.message_id || res?.message_id;
3651
+ if (i === 0)
3652
+ firstId = id;
3653
+ }
3654
+ this.store.clearBotUnavailableInChat(this.config.name, chatId);
3655
+ return firstId;
3656
+ }
3520
3657
  async sendMessage(chatId, text) {
3521
3658
  const model = this.sendModelFooters.get(chatId);
3522
3659
  const status = this.sendStatusFooters.get(chatId);
@@ -3537,6 +3674,17 @@ export class FeishuBot {
3537
3674
  console.warn(`[${this.config.name}] sendMessage interactive failed:`, JSON.stringify(err?.response?.data || err?.data || { message: err.message }));
3538
3675
  if (this.isOutOfChatError(err))
3539
3676
  this.markCurrentBotUnavailable(chatId, err);
3677
+ // Content over Feishu's per-message limit: chunk and deliver instead of
3678
+ // retrying the same oversized payload (retries would fail forever).
3679
+ if (this.isContentTooLongError(err)) {
3680
+ try {
3681
+ return await this.sendChunkedText(chatId, text, model, status);
3682
+ }
3683
+ catch (chunkErr) {
3684
+ console.warn(`[${this.config.name}] sendMessage chunked delivery failed:`, chunkErr.message);
3685
+ throw chunkErr;
3686
+ }
3687
+ }
3540
3688
  // Fallback to plain text; retain model attribution.
3541
3689
  try {
3542
3690
  const fallbackText = model?.trim() ? `${text}\n\n🧠 ${model.trim()}${status ? ` · ${status}` : ""}` : text;
@@ -3555,6 +3703,9 @@ export class FeishuBot {
3555
3703
  console.warn(`[${this.config.name}] sendMessage text failed:`, JSON.stringify(fallbackErr?.response?.data || fallbackErr?.data || { message: fallbackErr.message }));
3556
3704
  if (this.isOutOfChatError(fallbackErr))
3557
3705
  this.markCurrentBotUnavailable(chatId, fallbackErr);
3706
+ if (this.isContentTooLongError(fallbackErr)) {
3707
+ return await this.sendChunkedText(chatId, text, model, status);
3708
+ }
3558
3709
  throw fallbackErr;
3559
3710
  }
3560
3711
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-lark-multi-agent",
3
- "version": "1.5.0",
3
+ "version": "1.5.1",
4
4
  "description": "Multi-bot Lark/Feishu bridge for OpenClaw, with per-bot model routing and isolated sessions",
5
5
  "type": "module",
6
6
  "scripts": {