openclaw-lark-multi-agent 0.1.8 → 0.1.10

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.
@@ -68,6 +68,7 @@ export declare class FeishuBot {
68
68
  private shouldRespond;
69
69
  private isMentioned;
70
70
  private isAllMention;
71
+ private isAllMentionItem;
71
72
  private mentionedBotName;
72
73
  private resolveBotName;
73
74
  private resolveHumanName;
@@ -2,6 +2,7 @@ import * as lark from "@larksuiteoapi/node-sdk";
2
2
  import { existsSync, readFileSync, statSync } from "fs";
3
3
  import { basename, extname, resolve } from "path";
4
4
  import { getBridgeAttachmentsDir } from "./paths.js";
5
+ import { buildFeishuCardElements } from "./markdown.js";
5
6
  const MAX_BOT_STREAK = 10;
6
7
  const BRIDGE_ATTACHMENTS_DIR = getBridgeAttachmentsDir();
7
8
  /**
@@ -263,6 +264,12 @@ export class FeishuBot {
263
264
  if (messageType === "text") {
264
265
  const rawText = content.text || "";
265
266
  cleanText = this.cleanMentions(rawText);
267
+ // A mention-only text message is still a valid routing trigger. Feishu may
268
+ // expose mentions as display text like "@万万(Claude)" rather than @_user_xxx,
269
+ // so decide emptiness after stripping leading routing mentions.
270
+ if ((this.isMentioned(message.mentions || []) || this.isAllMention(rawText, message.mentions || [])) && !this.stripLeadingCommandMentions(cleanText).trim()) {
271
+ cleanText = "请回复上面最近一条用户消息。";
272
+ }
266
273
  }
267
274
  else if (messageType === "image") {
268
275
  // Download image and pass local path
@@ -494,7 +501,7 @@ export class FeishuBot {
494
501
  // Track this message for reaction status updates
495
502
  const pending = this.pendingAckMessages.get(chatId) || [];
496
503
  // Anti-loop
497
- const streak = this.store.getBotStreak(chatId);
504
+ const streak = this.store.getBotStreak(chatId, this.config.name);
498
505
  if (streak >= MAX_BOT_STREAK) {
499
506
  console.log(`[${this.config.name}] Anti-loop: ${streak} consecutive bot msgs`);
500
507
  return;
@@ -693,9 +700,11 @@ export class FeishuBot {
693
700
  // Check if this bot is explicitly mentioned
694
701
  if (this.isMentioned(mentions))
695
702
  return true;
696
- // Check if any other bot is mentioned (not us) don't respond
697
- const anyBotMentioned = mentions.some((m) => this.mentionedBotName(m) !== null);
698
- if (anyBotMentioned && !this.isMentioned(mentions))
703
+ // Targeted mentions are exclusive. If a human mentions another person or
704
+ // another bot, free-mode bots must not steal that message. Free mode only
705
+ // applies to plain human messages with no targeted mentions.
706
+ const hasTargetedMention = mentions.some((m) => !this.isAllMentionItem(m));
707
+ if (hasTargetedMention)
699
708
  return false;
700
709
  // No bot mentioned: check current per-bot mode
701
710
  if (chatId) {
@@ -711,10 +720,13 @@ export class FeishuBot {
711
720
  isAllMention(rawText, mentions = []) {
712
721
  if (rawText && (rawText.includes("@_all") || rawText.includes("@all") || rawText.includes("@所有人")))
713
722
  return true;
714
- return mentions.some((m) => m.key === "all" || m.key === "@_all" || m.id?.user_id === "all" || m.id?.open_id === "all" || m.name === "所有人");
723
+ return mentions.some((m) => this.isAllMentionItem(m));
724
+ }
725
+ isAllMentionItem(mention) {
726
+ return mention.key === "all" || mention.key === "@_all" || mention.id?.user_id === "all" || mention.id?.open_id === "all" || mention.name === "所有人";
715
727
  }
716
728
  mentionedBotName(mention) {
717
- if (mention.key === "all" || mention.key === "@_all" || mention.id?.user_id === "all" || mention.id?.open_id === "all" || mention.name === "所有人")
729
+ if (this.isAllMentionItem(mention))
718
730
  return null;
719
731
  const candidates = [this, ...Array.from(FeishuBot.allBots.values()).filter((bot) => bot !== this)];
720
732
  for (const bot of candidates) {
@@ -763,12 +775,7 @@ export class FeishuBot {
763
775
  schema: "2.0",
764
776
  config: { wide_screen_mode: true },
765
777
  body: {
766
- elements: [
767
- {
768
- tag: "markdown",
769
- content: text,
770
- },
771
- ],
778
+ elements: buildFeishuCardElements(text),
772
779
  },
773
780
  };
774
781
  }
@@ -0,0 +1,31 @@
1
+ declare function optimizeMarkdownStyle(text: string, cardVersion?: number): string;
2
+ declare function convertMarkdownTables(markdown: string): string;
3
+ type FeishuMarkdownElement = {
4
+ tag: "markdown";
5
+ content: string;
6
+ };
7
+ type FeishuTableElement = {
8
+ tag: "table";
9
+ page_size?: number;
10
+ row_height?: "low";
11
+ header_style?: Record<string, unknown>;
12
+ columns: Array<{
13
+ name: string;
14
+ display_name: string;
15
+ data_type: "lark_md";
16
+ width?: string;
17
+ vertical_align?: "top" | "center" | "bottom";
18
+ horizontal_align?: "left" | "center" | "right";
19
+ }>;
20
+ rows: Array<Record<string, string>>;
21
+ };
22
+ export type FeishuCardElement = FeishuMarkdownElement | FeishuTableElement;
23
+ declare function buildTableElement(lines: string[], index: number): FeishuTableElement | null;
24
+ export declare function buildFeishuCardElements(markdown: string): FeishuCardElement[];
25
+ export declare function prepareMarkdownForFeishu(text: string): string;
26
+ export declare const __test__: {
27
+ convertMarkdownTables: typeof convertMarkdownTables;
28
+ optimizeMarkdownStyle: typeof optimizeMarkdownStyle;
29
+ buildTableElement: typeof buildTableElement;
30
+ };
31
+ export {};
@@ -0,0 +1,203 @@
1
+ const MARKDOWN_STYLE_MARKERS = {
2
+ bold: { open: "**", close: "**" },
3
+ italic: { open: "_", close: "_" },
4
+ strikethrough: { open: "~~", close: "~~" },
5
+ code: { open: "`", close: "`" },
6
+ code_block: { open: "```\n", close: "```" },
7
+ };
8
+ const IMAGE_RE = /!\[([^\]]*)\]\(([^)\s]+)\)/g;
9
+ function protectCodeBlocks(text) {
10
+ const blocks = [];
11
+ const protectedText = text.replace(/(^|\n)(`{3,})([^\n]*)\n[\s\S]*?\n\2(?=\n|$)/g, (match, prefix = "") => {
12
+ const block = match.slice(String(prefix).length);
13
+ const token = `___LMA_CB_${blocks.length}___`;
14
+ blocks.push({ token, text: block });
15
+ return `${prefix}${token}`;
16
+ });
17
+ return { text: protectedText, blocks };
18
+ }
19
+ function restoreCodeBlocks(text, blocks, cardVersion = 2) {
20
+ let restored = text;
21
+ for (const { token, text: block } of blocks) {
22
+ restored = restored.replace(token, cardVersion >= 2 ? `\n<br>\n${block}\n<br>\n` : block);
23
+ }
24
+ return restored;
25
+ }
26
+ function stripInvalidImageKeys(text) {
27
+ if (!text.includes("!["))
28
+ return text;
29
+ return text.replace(IMAGE_RE, (fullMatch, _alt, value) => {
30
+ if (String(value).startsWith("img_"))
31
+ return fullMatch;
32
+ return "";
33
+ });
34
+ }
35
+ function optimizeMarkdownStyle(text, cardVersion = 2) {
36
+ try {
37
+ const protectedResult = protectCodeBlocks(text);
38
+ let r = protectedResult.text;
39
+ const hasH1toH3 = /^#{1,3} /m.test(text);
40
+ if (hasH1toH3) {
41
+ r = r.replace(/^#{2,6} (.+)$/gm, "##### $1");
42
+ r = r.replace(/^# (.+)$/gm, "#### $1");
43
+ }
44
+ if (cardVersion >= 2) {
45
+ r = r.replace(/^(#{4,5} .+)\n{1,2}(#{4,5} )/gm, "$1\n<br>\n$2");
46
+ r = r.replace(/^([^|\n].*)\n(\|.+\|)/gm, "$1\n\n$2");
47
+ r = r.replace(/\n\n((?:\|.+\|[^\S\n]*\n?)+)/g, "\n\n<br>\n\n$1");
48
+ r = r.replace(/((?:^\|.+\|[^\S\n]*\n?)+)/gm, (match, _table, offset) => {
49
+ const after = r.slice(offset + match.length).replace(/^\n+/, "");
50
+ if (!after || /^(---|#{4,5} |\*\*)/.test(after))
51
+ return match;
52
+ return `${match}\n<br>\n`;
53
+ });
54
+ r = r.replace(/^((?!#{4,5} )(?!\*\*).+)\n\n(<br>)\n\n(\|)/gm, "$1\n$2\n$3");
55
+ r = r.replace(/^(\*\*.+)\n\n(<br>)\n\n(\|)/gm, "$1\n$2\n\n$3");
56
+ r = r.replace(/(\|[^\n]*\n)\n(<br>\n)((?!#{4,5} )(?!\*\*))/gm, "$1$2$3");
57
+ }
58
+ r = restoreCodeBlocks(r, protectedResult.blocks, cardVersion);
59
+ r = r.replace(/\n{3,}/g, "\n\n");
60
+ return stripInvalidImageKeys(r);
61
+ }
62
+ catch {
63
+ return text;
64
+ }
65
+ }
66
+ function isMarkdownTableSeparator(line) {
67
+ const trimmed = line.trim();
68
+ if (!trimmed.includes("|"))
69
+ return false;
70
+ const cells = trimmed.replace(/^\|/, "").replace(/\|$/, "").split("|").map((cell) => cell.trim());
71
+ return cells.length >= 2 && cells.every((cell) => /^:?-{3,}:?$/.test(cell));
72
+ }
73
+ function isMarkdownTableRow(line) {
74
+ const trimmed = line.trim();
75
+ return trimmed.includes("|") && trimmed.replace(/^\|/, "").replace(/\|$/, "").split("|").length >= 2;
76
+ }
77
+ function splitMarkdownTableRow(line) {
78
+ return line.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map((cell) => cell.trim());
79
+ }
80
+ function formatTableAsCodeBlock(lines) {
81
+ const rows = lines.filter((line) => !isMarkdownTableSeparator(line)).map(splitMarkdownTableRow);
82
+ if (rows.length === 0)
83
+ return lines.join("\n");
84
+ const width = Math.max(...rows.map((row) => row.length));
85
+ const widths = Array.from({ length: width }, (_, i) => Math.max(...rows.map((row) => row[i]?.length ?? 0), 1));
86
+ const renderedRows = rows.map((row, rowIndex) => {
87
+ const rendered = widths.map((w, i) => (row[i] ?? "").padEnd(w)).join(" ").trimEnd();
88
+ if (rowIndex === 0 && rows.length > 1) {
89
+ const sep = widths.map((w) => "─".repeat(w)).join(" ");
90
+ return `${rendered}\n${sep}`;
91
+ }
92
+ return rendered;
93
+ });
94
+ return `\n\`\`\`\n${renderedRows.join("\n")}\n\`\`\`\n`;
95
+ }
96
+ function convertMarkdownTables(markdown) {
97
+ const { text, blocks } = protectCodeBlocks(markdown);
98
+ const lines = text.split("\n");
99
+ const out = [];
100
+ let i = 0;
101
+ while (i < lines.length) {
102
+ if (i + 1 < lines.length && isMarkdownTableRow(lines[i]) && isMarkdownTableSeparator(lines[i + 1])) {
103
+ const tableLines = [lines[i], lines[i + 1]];
104
+ i += 2;
105
+ while (i < lines.length && isMarkdownTableRow(lines[i])) {
106
+ tableLines.push(lines[i]);
107
+ i++;
108
+ }
109
+ out.push(formatTableAsCodeBlock(tableLines));
110
+ continue;
111
+ }
112
+ out.push(lines[i]);
113
+ i++;
114
+ }
115
+ return restoreCodeBlocks(out.join("\n"), blocks, 1);
116
+ }
117
+ function buildTableElement(lines, index) {
118
+ const rows = lines.filter((line) => !isMarkdownTableSeparator(line)).map(splitMarkdownTableRow);
119
+ if (rows.length < 2)
120
+ return null;
121
+ const headers = rows[0];
122
+ if (headers.length === 0)
123
+ return null;
124
+ const width = Math.min(Math.max(...rows.map((row) => row.length)), 50);
125
+ const columns = Array.from({ length: width }, (_, i) => ({
126
+ name: `c${index}_${i}`,
127
+ display_name: headers[i] || `列 ${i + 1}`,
128
+ data_type: "lark_md",
129
+ width: "auto",
130
+ vertical_align: "top",
131
+ horizontal_align: "left",
132
+ }));
133
+ const dataRows = rows.slice(1).map((row) => {
134
+ const item = {};
135
+ for (let i = 0; i < columns.length; i++)
136
+ item[columns[i].name] = row[i] || "";
137
+ return item;
138
+ });
139
+ return {
140
+ tag: "table",
141
+ page_size: Math.min(Math.max(dataRows.length, 1), 10),
142
+ header_style: {
143
+ text_align: "left",
144
+ text_size: "normal",
145
+ background_style: "grey",
146
+ text_color: "default",
147
+ bold: true,
148
+ lines: 2,
149
+ },
150
+ columns,
151
+ rows: dataRows,
152
+ };
153
+ }
154
+ function pushMarkdownElement(elements, text) {
155
+ const content = optimizeMarkdownStyle(text.trim(), 2).trim();
156
+ if (!content)
157
+ return;
158
+ elements.push({ tag: "markdown", content });
159
+ }
160
+ export function buildFeishuCardElements(markdown) {
161
+ const { text, blocks } = protectCodeBlocks(markdown);
162
+ const lines = text.split("\n");
163
+ const elements = [];
164
+ const buffer = [];
165
+ let tableCount = 0;
166
+ let i = 0;
167
+ while (i < lines.length) {
168
+ if (i + 1 < lines.length && isMarkdownTableRow(lines[i]) && isMarkdownTableSeparator(lines[i + 1])) {
169
+ const tableLines = [lines[i], lines[i + 1]];
170
+ i += 2;
171
+ while (i < lines.length && isMarkdownTableRow(lines[i])) {
172
+ tableLines.push(lines[i]);
173
+ i++;
174
+ }
175
+ pushMarkdownElement(elements, restoreCodeBlocks(buffer.join("\n"), blocks, 1));
176
+ buffer.length = 0;
177
+ // Feishu cards support up to 5 table components per card. Fall back to a
178
+ // readable code-block table after that instead of sending an invalid card.
179
+ if (tableCount < 5) {
180
+ const table = buildTableElement(tableLines, tableCount);
181
+ if (table) {
182
+ elements.push(table);
183
+ tableCount++;
184
+ }
185
+ else {
186
+ buffer.push(formatTableAsCodeBlock(tableLines));
187
+ }
188
+ }
189
+ else {
190
+ buffer.push(formatTableAsCodeBlock(tableLines));
191
+ }
192
+ continue;
193
+ }
194
+ buffer.push(lines[i]);
195
+ i++;
196
+ }
197
+ pushMarkdownElement(elements, restoreCodeBlocks(buffer.join("\n"), blocks, 1));
198
+ return elements.length > 0 ? elements : [{ tag: "markdown", content: "" }];
199
+ }
200
+ export function prepareMarkdownForFeishu(text) {
201
+ return optimizeMarkdownStyle(convertMarkdownTables(text), 2);
202
+ }
203
+ export const __test__ = { convertMarkdownTables, optimizeMarkdownStyle, buildTableElement };
@@ -51,9 +51,13 @@ export declare class MessageStore {
51
51
  */
52
52
  getRecent(chatId: string, maxCount?: number): ChatMessage[];
53
53
  /**
54
- * Count consecutive bot messages at the tail of a chat.
54
+ * Count consecutive messages from one bot at the tail of a chat.
55
+ *
56
+ * Other bots do not consume this bot's anti-loop budget. Human messages reset
57
+ * the streak. This lets multiple bots free-discuss without a global bot-streak
58
+ * guard shutting everyone down after N total bot messages.
55
59
  */
56
- getBotStreak(chatId: string): number;
60
+ getBotStreak(chatId: string, botName: string): number;
57
61
  upsertChatInfo(info: ChatInfo): void;
58
62
  setFreeDiscussion(chatId: string, on: boolean): void;
59
63
  setVerbose(chatId: string, verbose: boolean): void;
@@ -233,21 +233,25 @@ export class MessageStore {
233
233
  }));
234
234
  }
235
235
  /**
236
- * Count consecutive bot messages at the tail of a chat.
236
+ * Count consecutive messages from one bot at the tail of a chat.
237
+ *
238
+ * Other bots do not consume this bot's anti-loop budget. Human messages reset
239
+ * the streak. This lets multiple bots free-discuss without a global bot-streak
240
+ * guard shutting everyone down after N total bot messages.
237
241
  */
238
- getBotStreak(chatId) {
242
+ getBotStreak(chatId, botName) {
239
243
  const rows = this.db.prepare(`
240
- SELECT sender_type FROM messages
244
+ SELECT sender_type, sender_name FROM messages
241
245
  WHERE chat_id = ?
242
246
  ORDER BY timestamp DESC
243
- LIMIT 20
247
+ LIMIT 50
244
248
  `).all(chatId);
245
249
  let count = 0;
246
250
  for (const r of rows) {
247
- if (r.sender_type === "bot")
248
- count++;
249
- else
251
+ if (r.sender_type === "human")
250
252
  break;
253
+ if (r.sender_type === "bot" && r.sender_name === botName)
254
+ count++;
251
255
  }
252
256
  return count;
253
257
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-lark-multi-agent",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
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": {