@larksuite/openclaw-lark 2026.5.20 → 2026.6.10-beta.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@larksuite/openclaw-lark",
3
- "version": "2026.5.20",
3
+ "version": "2026.6.10-beta.1",
4
4
  "description": "OpenClaw Lark/Feishu channel plugin",
5
5
  "exports": {
6
6
  ".": {
@@ -59,7 +59,14 @@ function expandAutoMode(params) {
59
59
  * markdown tables).
60
60
  */
61
61
  function shouldUseCard(text) {
62
- // Table limit takes priority -- even with code blocks, too many tables will fail
62
+ // Markdown tables NO LONGER force a card. Feishu messages render markdown
63
+ // tables natively, and wrapping a reply in a card breaks bot-at-bot @
64
+ // delivery (cards have limited @ support). Only fenced code blocks still
65
+ // benefit from card rendering.
66
+ //
67
+ // The table-count guard is kept as a safety valve: when a reply also
68
+ // contains an excessive number of markdown tables, skip the card entirely
69
+ // rather than risk a card-render failure.
63
70
  const tableMatches = (0, card_error_1.findMarkdownTablesOutsideCodeBlocks)(text);
64
71
  if (tableMatches.length > card_error_1.FEISHU_CARD_TABLE_LIMIT) {
65
72
  return false;
@@ -68,9 +75,5 @@ function shouldUseCard(text) {
68
75
  if (/```[\s\S]*?```/.test(text)) {
69
76
  return true;
70
77
  }
71
- // Markdown tables (header + separator rows separated by pipes)
72
- if (tableMatches.length > 0) {
73
- return true;
74
- }
75
78
  return false;
76
79
  }
@@ -21,6 +21,19 @@ export declare function isAbortTrigger(text: string): boolean;
21
21
  * `/stop` command form. Used by the monitor fast-path.
22
22
  */
23
23
  export declare function isLikelyAbortText(text: string): boolean;
24
+ /**
25
+ * Whether an inbound message expresses intent to stop / interrupt the ongoing
26
+ * (bot-to-bot) exchange. Superset of {@link isLikelyAbortText} plus the
27
+ * conversational phrases above.
28
+ *
29
+ * Two consumers: (1) suppress the deterministic peer-@ backstop so a stop
30
+ * acknowledgement doesn't re-wake the peer bot; (2) mute an active bot loop so
31
+ * the in-flight ping-pong drains instead of being re-armed. Substring match —
32
+ * keep the list distinctive (no bare "停"/"stop") to limit false positives;
33
+ * the worst case is a missed forced-@ or a self-healing mute (any normal
34
+ * message lifts it).
35
+ */
36
+ export declare function isConversationStopIntent(text: string): boolean;
24
37
  /**
25
38
  * Extract the raw text payload from a Feishu message event.
26
39
  *
@@ -17,6 +17,7 @@
17
17
  Object.defineProperty(exports, "__esModule", { value: true });
18
18
  exports.isAbortTrigger = isAbortTrigger;
19
19
  exports.isLikelyAbortText = isLikelyAbortText;
20
+ exports.isConversationStopIntent = isConversationStopIntent;
20
21
  exports.extractRawTextFromEvent = extractRawTextFromEvent;
21
22
  // ---------------------------------------------------------------------------
22
23
  // Trigger word list (synced with OpenClaw core abort.ts)
@@ -100,6 +101,92 @@ function isLikelyAbortText(text) {
100
101
  return true;
101
102
  return isAbortTrigger(trimmed);
102
103
  }
104
+ // ---------------------------------------------------------------------------
105
+ // Conversation stop-intent (broader than the exact abort triggers)
106
+ // ---------------------------------------------------------------------------
107
+ /**
108
+ * Conversational "please stop / interrupt this exchange" phrases.
109
+ *
110
+ * Deliberately SEPARATE from {@link ABORT_TRIGGERS} (which is synced word-for-
111
+ * word with OpenClaw core and matched by exact equality, e.g. `/stop`). These
112
+ * are matched by substring so natural phrasings like "中断对话" or "stop
113
+ * talking" are caught. The list is intentionally distinctive to avoid false
114
+ * positives — a false positive only means we skip the deterministic peer-@
115
+ * backstop for that turn (the model can still @ on its own), which is mild.
116
+ */
117
+ const STOP_INTENT_PHRASES = [
118
+ // zh — stop / terminate / pause
119
+ '中断',
120
+ '中止',
121
+ '终止',
122
+ '停止',
123
+ '停下',
124
+ '停一下',
125
+ '暂停',
126
+ '打住',
127
+ '停手',
128
+ '收手',
129
+ // zh — "don't keep going / replying"
130
+ '别聊',
131
+ '别说了',
132
+ '别回复',
133
+ '别继续',
134
+ '别再聊',
135
+ '别再说',
136
+ '别吵',
137
+ '别争',
138
+ '不要回复',
139
+ '不要继续',
140
+ '不用回复',
141
+ '不用继续',
142
+ // zh — "wrap up / be quiet"
143
+ '结束对话',
144
+ '结束讨论',
145
+ '结束辩论',
146
+ '到此为止',
147
+ '闭嘴',
148
+ // en
149
+ 'stop talking',
150
+ 'stop chatting',
151
+ 'stop debating',
152
+ 'stop the debate',
153
+ 'stop the conversation',
154
+ 'stop this conversation',
155
+ 'stop responding',
156
+ 'stop replying',
157
+ 'end the conversation',
158
+ 'end conversation',
159
+ 'end the debate',
160
+ 'shut up',
161
+ 'be quiet',
162
+ 'cut it out',
163
+ 'knock it off',
164
+ 'wrap it up',
165
+ 'stand down',
166
+ ];
167
+ /**
168
+ * Whether an inbound message expresses intent to stop / interrupt the ongoing
169
+ * (bot-to-bot) exchange. Superset of {@link isLikelyAbortText} plus the
170
+ * conversational phrases above.
171
+ *
172
+ * Two consumers: (1) suppress the deterministic peer-@ backstop so a stop
173
+ * acknowledgement doesn't re-wake the peer bot; (2) mute an active bot loop so
174
+ * the in-flight ping-pong drains instead of being re-armed. Substring match —
175
+ * keep the list distinctive (no bare "停"/"stop") to limit false positives;
176
+ * the worst case is a missed forced-@ or a self-healing mute (any normal
177
+ * message lifts it).
178
+ */
179
+ function isConversationStopIntent(text) {
180
+ if (!text)
181
+ return false;
182
+ // Drop bot mention placeholders so "@Bot 中断对话" → "中断对话".
183
+ const normalized = text.replace(/@_user_\d+/g, '').trim().toLowerCase();
184
+ if (!normalized)
185
+ return false;
186
+ if (isLikelyAbortText(normalized))
187
+ return true;
188
+ return STOP_INTENT_PHRASES.some((p) => normalized.includes(p));
189
+ }
103
190
  /**
104
191
  * Extract the raw text payload from a Feishu message event.
105
192
  *
@@ -31,6 +31,7 @@ export declare const FeishuGroupSchema: z.ZodObject<{
31
31
  allowFrom: z.ZodPipe<z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>, z.ZodTransform<string[] | undefined, string | string[] | undefined>>;
32
32
  systemPrompt: z.ZodOptional<z.ZodString>;
33
33
  allowBots: z.ZodOptional<z.ZodUnion<readonly [z.ZodBoolean, z.ZodLiteral<"mentions">]>>;
34
+ replyInThread: z.ZodOptional<z.ZodBoolean>;
34
35
  }, z.core.$strip>;
35
36
  export declare const FeishuAccountConfigSchema: z.ZodObject<{
36
37
  appId: z.ZodOptional<z.ZodString>;
@@ -78,6 +79,7 @@ export declare const FeishuAccountConfigSchema: z.ZodObject<{
78
79
  allowFrom: z.ZodPipe<z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>, z.ZodTransform<string[] | undefined, string | string[] | undefined>>;
79
80
  systemPrompt: z.ZodOptional<z.ZodString>;
80
81
  allowBots: z.ZodOptional<z.ZodUnion<readonly [z.ZodBoolean, z.ZodLiteral<"mentions">]>>;
82
+ replyInThread: z.ZodOptional<z.ZodBoolean>;
81
83
  }, z.core.$strip>>>;
82
84
  historyLimit: z.ZodOptional<z.ZodNumber>;
83
85
  dmHistoryLimit: z.ZodOptional<z.ZodNumber>;
@@ -173,6 +175,7 @@ export declare const FeishuAccountConfigSchema: z.ZodObject<{
173
175
  }>>;
174
176
  threadSession: z.ZodOptional<z.ZodBoolean>;
175
177
  allowBots: z.ZodOptional<z.ZodUnion<readonly [z.ZodBoolean, z.ZodLiteral<"mentions">]>>;
178
+ replyInThread: z.ZodOptional<z.ZodBoolean>;
176
179
  uat: z.ZodOptional<z.ZodObject<{
177
180
  enabled: z.ZodOptional<z.ZodBoolean>;
178
181
  allowedScopes: z.ZodOptional<z.ZodArray<z.ZodString>>;
@@ -225,6 +228,7 @@ export declare const FeishuConfigSchema: z.ZodObject<{
225
228
  allowFrom: z.ZodPipe<z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>, z.ZodTransform<string[] | undefined, string | string[] | undefined>>;
226
229
  systemPrompt: z.ZodOptional<z.ZodString>;
227
230
  allowBots: z.ZodOptional<z.ZodUnion<readonly [z.ZodBoolean, z.ZodLiteral<"mentions">]>>;
231
+ replyInThread: z.ZodOptional<z.ZodBoolean>;
228
232
  }, z.core.$strip>>>;
229
233
  historyLimit: z.ZodOptional<z.ZodNumber>;
230
234
  dmHistoryLimit: z.ZodOptional<z.ZodNumber>;
@@ -320,6 +324,7 @@ export declare const FeishuConfigSchema: z.ZodObject<{
320
324
  }>>;
321
325
  threadSession: z.ZodOptional<z.ZodBoolean>;
322
326
  allowBots: z.ZodOptional<z.ZodUnion<readonly [z.ZodBoolean, z.ZodLiteral<"mentions">]>>;
327
+ replyInThread: z.ZodOptional<z.ZodBoolean>;
323
328
  uat: z.ZodOptional<z.ZodObject<{
324
329
  enabled: z.ZodOptional<z.ZodBoolean>;
325
330
  allowedScopes: z.ZodOptional<z.ZodArray<z.ZodString>>;
@@ -371,6 +376,7 @@ export declare const FeishuConfigSchema: z.ZodObject<{
371
376
  allowFrom: z.ZodPipe<z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>, z.ZodTransform<string[] | undefined, string | string[] | undefined>>;
372
377
  systemPrompt: z.ZodOptional<z.ZodString>;
373
378
  allowBots: z.ZodOptional<z.ZodUnion<readonly [z.ZodBoolean, z.ZodLiteral<"mentions">]>>;
379
+ replyInThread: z.ZodOptional<z.ZodBoolean>;
374
380
  }, z.core.$strip>>>;
375
381
  historyLimit: z.ZodOptional<z.ZodNumber>;
376
382
  dmHistoryLimit: z.ZodOptional<z.ZodNumber>;
@@ -466,6 +472,7 @@ export declare const FeishuConfigSchema: z.ZodObject<{
466
472
  }>>;
467
473
  threadSession: z.ZodOptional<z.ZodBoolean>;
468
474
  allowBots: z.ZodOptional<z.ZodUnion<readonly [z.ZodBoolean, z.ZodLiteral<"mentions">]>>;
475
+ replyInThread: z.ZodOptional<z.ZodBoolean>;
469
476
  uat: z.ZodOptional<z.ZodObject<{
470
477
  enabled: z.ZodOptional<z.ZodBoolean>;
471
478
  allowedScopes: z.ZodOptional<z.ZodArray<z.ZodString>>;
@@ -132,6 +132,9 @@ exports.FeishuGroupSchema = zod_1.z.object({
132
132
  allowFrom: AllowFromSchema,
133
133
  systemPrompt: zod_1.z.string().optional(),
134
134
  allowBots: AllowBotsSchema,
135
+ // When true, bot-to-bot replies are allowed to stay inside a thread/topic
136
+ // instead of being forced to the main chat (relaxes the #32980 guard).
137
+ replyInThread: zod_1.z.boolean().optional(),
135
138
  });
136
139
  // ---------------------------------------------------------------------------
137
140
  // Account config schema (same shape as top-level minus `accounts`)
@@ -179,6 +182,9 @@ exports.FeishuAccountConfigSchema = zod_1.z.object({
179
182
  reactionNotifications: ReactionNotificationModeSchema,
180
183
  threadSession: zod_1.z.boolean().optional(),
181
184
  allowBots: AllowBotsSchema,
185
+ // Account-level default for letting bot-to-bot replies stay in a thread
186
+ // (per-group `replyInThread` overrides this).
187
+ replyInThread: zod_1.z.boolean().optional(),
182
188
  uat: exports.UATConfigSchema,
183
189
  });
184
190
  // ---------------------------------------------------------------------------
@@ -18,8 +18,13 @@ export declare function buildConvertContextFromItem(item: ApiMessageItem, fallba
18
18
  /**
19
19
  * Resolve mention placeholders in text.
20
20
  *
21
- * - Bot mentions: remove the placeholder key and any preceding `@botName`
22
- * entirely (with trailing whitespace).
21
+ * - Bot self-mention + stripBotMentions: leading-only strip. When the
22
+ * self-mention sits at the very start of the message, drop it (the
23
+ * `WasMentioned` envelope field already tells the agent "this message
24
+ * was addressed to you", so the anchor is redundant). When it appears
25
+ * mid-text, render it as plain `@Name` so the surrounding context still
26
+ * reads naturally without leaving an inline anchor the LLM might echo
27
+ * back into its reply.
23
28
  * - Non-bot mentions: replace the placeholder key with readable `@name`.
24
29
  */
25
30
  export declare function resolveMentions(text: string, ctx: ConvertContext): string;
@@ -55,8 +55,13 @@ function buildConvertContextFromItem(item, fallbackMessageId, accountId) {
55
55
  /**
56
56
  * Resolve mention placeholders in text.
57
57
  *
58
- * - Bot mentions: remove the placeholder key and any preceding `@botName`
59
- * entirely (with trailing whitespace).
58
+ * - Bot self-mention + stripBotMentions: leading-only strip. When the
59
+ * self-mention sits at the very start of the message, drop it (the
60
+ * `WasMentioned` envelope field already tells the agent "this message
61
+ * was addressed to you", so the anchor is redundant). When it appears
62
+ * mid-text, render it as plain `@Name` so the surrounding context still
63
+ * reads naturally without leaving an inline anchor the LLM might echo
64
+ * back into its reply.
60
65
  * - Non-bot mentions: replace the placeholder key with readable `@name`.
61
66
  */
62
67
  function resolveMentions(text, ctx) {
@@ -65,8 +70,9 @@ function resolveMentions(text, ctx) {
65
70
  let result = text;
66
71
  for (const [key, info] of ctx.mentions) {
67
72
  if (info.isBot && ctx.stripBotMentions) {
68
- result = result.replace(new RegExp(`@${(0, utils_1.escapeRegExp)(info.name)}\\s*`, 'g'), '').trim();
69
- result = result.replace(new RegExp((0, utils_1.escapeRegExp)(key) + '\\s*', 'g'), '').trim();
73
+ result = result.replace(new RegExp((0, utils_1.escapeRegExp)(key), 'g'), `@${info.name}`);
74
+ const leadingPattern = new RegExp(`^\\s*@${(0, utils_1.escapeRegExp)(info.name)}[\\s,,::]*`);
75
+ result = result.replace(leadingPattern, '').trimStart();
70
76
  }
71
77
  else {
72
78
  result = result.replace(new RegExp((0, utils_1.escapeRegExp)(key), 'g'), `@${info.name}`);
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
3
+ * SPDX-License-Identifier: MIT
4
+ *
5
+ * Reply-routing decisions for the Feishu dispatch path.
6
+ *
7
+ * In bot-to-bot group scenarios, Feishu will pull thread-style replies into
8
+ * a hidden "topic view" that group members cannot see (#32980). The peer bot
9
+ * keeps receiving messages but humans in the chat see nothing — a silent
10
+ * failure mode that turns bot↔bot chat into a black hole.
11
+ *
12
+ * This module centralizes the three signals that decide where a reply lands:
13
+ * 1. isGroup — DMs never have the topic-view trap
14
+ * 2. senderIsBot — only bot→bot triggers it
15
+ * 3. dc.isThread — inbound was a thread reply (also inferred from
16
+ * root_id in topic groups when threadSession=true)
17
+ *
18
+ * Output: a routing object consumed by every outbound call site (main
19
+ * dispatcher + i18n command card + i18n command text fallback), so the
20
+ * three call sites never drift out of sync again.
21
+ */
22
+ import type { MentionInfo } from '../types';
23
+ import type { DispatchContext } from './dispatch-context';
24
+ /** The peer a reply must explicitly @-mention so it actually reaches them. */
25
+ export interface BotPeerTarget {
26
+ peerOpenId: string;
27
+ peerName: string;
28
+ }
29
+ /**
30
+ * Decide which peer (if any) an outbound reply must be guaranteed to
31
+ * @-mention, so the deterministic `ensureMention` backstop can wake them up.
32
+ *
33
+ * Deliberately INDEPENDENT of `suppressForBotPeer` (which only governs
34
+ * thread-vs-main routing): the addressee must be resolvable even on a
35
+ * human-orchestrated kickoff, where the triggering sender is a person but the
36
+ * conversation is meant to continue between bots.
37
+ *
38
+ * 1. Bot sender → @ the sender back, continuing the exchange.
39
+ * 2. Otherwise (e.g. a human kicking off a bot debate) → if the inbound
40
+ * message @-mentions exactly ONE party other than ourselves, treat that
41
+ * party as the designated peer. Zero / multiple non-self mentions are
42
+ * ambiguous, so we add no forced @ (avoids spamming unrelated members).
43
+ *
44
+ * Group-only: bot-at-bot @ delivery semantics don't apply to DMs.
45
+ */
46
+ export declare function resolveBotPeerForMention(params: {
47
+ isGroup: boolean;
48
+ senderIsBot?: boolean;
49
+ senderId?: string;
50
+ senderName?: string;
51
+ mentions: MentionInfo[];
52
+ botOpenId?: string;
53
+ }): BotPeerTarget | undefined;
54
+ export interface FeishuReplyRouting {
55
+ /** Whether to send the reply as a thread-scoped message. */
56
+ replyInThread: boolean;
57
+ /** Effective thread_id when replying in-thread; undefined otherwise. */
58
+ threadId: string | undefined;
59
+ /** True when the peer is a bot in a group chat: suppress thread-mode reply
60
+ * so the message lands in the main chat (avoiding the hidden topic view,
61
+ * #32980). Consumers may also use this signal for additional bot-peer-
62
+ * specific behavior (e.g. ensureMention in outbound-mention). */
63
+ suppressForBotPeer: boolean;
64
+ }
65
+ /**
66
+ * Resolve reply routing for the current dispatch, performing two tasks:
67
+ *
68
+ * 1. **Topic-group thread inference (may mutate `dc`).** In topic groups
69
+ * (chat_mode=topic), reply events may carry `root_id` without
70
+ * `thread_id`. When `threadSession` is enabled and the chat is
71
+ * thread-capable, treat `root_id` as a synthetic `threadId` so replies
72
+ * stay inside the topic instead of creating a new top-level message.
73
+ * This step mutates `dc.isThread` and `dc.ctx.threadId` so subsequent
74
+ * code (session-key resolution, sentinel scoping, history scoping)
75
+ * observes the same routing decision.
76
+ *
77
+ * 2. **Reply routing decision (pure).** Computes `replyInThread` and
78
+ * `suppressForBotPeer` from the post-inference state. In bot→bot group
79
+ * chats `replyInThread` is forced to `false` regardless of the inbound
80
+ * shape, preventing the topic-view trap.
81
+ */
82
+ export declare function resolveFeishuReplyRouting(dc: DispatchContext, opts?: {
83
+ replyInThreadConfig?: boolean;
84
+ }): Promise<FeishuReplyRouting>;
@@ -0,0 +1,117 @@
1
+ "use strict";
2
+ /**
3
+ * Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
4
+ * SPDX-License-Identifier: MIT
5
+ *
6
+ * Reply-routing decisions for the Feishu dispatch path.
7
+ *
8
+ * In bot-to-bot group scenarios, Feishu will pull thread-style replies into
9
+ * a hidden "topic view" that group members cannot see (#32980). The peer bot
10
+ * keeps receiving messages but humans in the chat see nothing — a silent
11
+ * failure mode that turns bot↔bot chat into a black hole.
12
+ *
13
+ * This module centralizes the three signals that decide where a reply lands:
14
+ * 1. isGroup — DMs never have the topic-view trap
15
+ * 2. senderIsBot — only bot→bot triggers it
16
+ * 3. dc.isThread — inbound was a thread reply (also inferred from
17
+ * root_id in topic groups when threadSession=true)
18
+ *
19
+ * Output: a routing object consumed by every outbound call site (main
20
+ * dispatcher + i18n command card + i18n command text fallback), so the
21
+ * three call sites never drift out of sync again.
22
+ */
23
+ Object.defineProperty(exports, "__esModule", { value: true });
24
+ exports.resolveBotPeerForMention = resolveBotPeerForMention;
25
+ exports.resolveFeishuReplyRouting = resolveFeishuReplyRouting;
26
+ const chat_info_cache_1 = require("../../core/chat-info-cache.js");
27
+ const lark_logger_1 = require("../../core/lark-logger.js");
28
+ const log = (0, lark_logger_1.larkLogger)('inbound/bot-content');
29
+ /**
30
+ * Decide which peer (if any) an outbound reply must be guaranteed to
31
+ * @-mention, so the deterministic `ensureMention` backstop can wake them up.
32
+ *
33
+ * Deliberately INDEPENDENT of `suppressForBotPeer` (which only governs
34
+ * thread-vs-main routing): the addressee must be resolvable even on a
35
+ * human-orchestrated kickoff, where the triggering sender is a person but the
36
+ * conversation is meant to continue between bots.
37
+ *
38
+ * 1. Bot sender → @ the sender back, continuing the exchange.
39
+ * 2. Otherwise (e.g. a human kicking off a bot debate) → if the inbound
40
+ * message @-mentions exactly ONE party other than ourselves, treat that
41
+ * party as the designated peer. Zero / multiple non-self mentions are
42
+ * ambiguous, so we add no forced @ (avoids spamming unrelated members).
43
+ *
44
+ * Group-only: bot-at-bot @ delivery semantics don't apply to DMs.
45
+ */
46
+ function resolveBotPeerForMention(params) {
47
+ if (!params.isGroup)
48
+ return undefined;
49
+ // 1. Bot sender → keep the ping-pong going by @-ing them back.
50
+ if (params.senderIsBot && params.senderId) {
51
+ return { peerOpenId: params.senderId, peerName: params.senderName ?? params.senderId };
52
+ }
53
+ // 2. Human-orchestrated kickoff → the single non-self @-mentioned party.
54
+ const seen = new Set();
55
+ const others = [];
56
+ for (const m of params.mentions) {
57
+ if (!m.openId || m.isBot || m.openId === params.botOpenId)
58
+ continue;
59
+ if (seen.has(m.openId))
60
+ continue;
61
+ seen.add(m.openId);
62
+ others.push(m);
63
+ }
64
+ if (others.length === 1) {
65
+ return { peerOpenId: others[0].openId, peerName: others[0].name || others[0].openId };
66
+ }
67
+ return undefined;
68
+ }
69
+ /**
70
+ * Resolve reply routing for the current dispatch, performing two tasks:
71
+ *
72
+ * 1. **Topic-group thread inference (may mutate `dc`).** In topic groups
73
+ * (chat_mode=topic), reply events may carry `root_id` without
74
+ * `thread_id`. When `threadSession` is enabled and the chat is
75
+ * thread-capable, treat `root_id` as a synthetic `threadId` so replies
76
+ * stay inside the topic instead of creating a new top-level message.
77
+ * This step mutates `dc.isThread` and `dc.ctx.threadId` so subsequent
78
+ * code (session-key resolution, sentinel scoping, history scoping)
79
+ * observes the same routing decision.
80
+ *
81
+ * 2. **Reply routing decision (pure).** Computes `replyInThread` and
82
+ * `suppressForBotPeer` from the post-inference state. In bot→bot group
83
+ * chats `replyInThread` is forced to `false` regardless of the inbound
84
+ * shape, preventing the topic-view trap.
85
+ */
86
+ async function resolveFeishuReplyRouting(dc, opts = {}) {
87
+ // Step 1: topic-group thread inference (async + side effects on dc)
88
+ if (!dc.isThread &&
89
+ dc.isGroup &&
90
+ dc.ctx.rootId &&
91
+ dc.account.config?.threadSession === true) {
92
+ const threadCapable = await (0, chat_info_cache_1.isThreadCapableGroup)({
93
+ cfg: dc.accountScopedCfg,
94
+ chatId: dc.ctx.chatId,
95
+ accountId: dc.account.accountId,
96
+ });
97
+ if (threadCapable) {
98
+ log.info(`inferred thread from root_id=${dc.ctx.rootId} in topic group ${dc.ctx.chatId}`);
99
+ dc.isThread = true;
100
+ dc.ctx = { ...dc.ctx, threadId: dc.ctx.rootId };
101
+ }
102
+ }
103
+ // Step 2: bot-peer suppression decision (pure read of dc state).
104
+ //
105
+ // We force a bot→bot reply out of the thread only when it would otherwise
106
+ // snowball an *auto-detected* threadReply into a hidden topic view (#32980).
107
+ // Two escape hatches keep parity with openclaw core (PR #89783):
108
+ // - isTopicSession: a deliberate topic session (threadSession enabled +
109
+ // inbound is in a thread) is human-visible by design — keep it threaded.
110
+ // - replyInThread config: operators can opt in per-group/account.
111
+ const isTopicSession = dc.isThread && dc.account.config?.threadSession === true;
112
+ const configReplyInThread = opts.replyInThreadConfig === true;
113
+ const suppressForBotPeer = dc.isGroup && !!dc.ctx.senderIsBot && !isTopicSession && !configReplyInThread;
114
+ const replyInThread = !suppressForBotPeer && dc.isThread;
115
+ const threadId = replyInThread ? dc.ctx.threadId : undefined;
116
+ return { replyInThread, threadId, suppressForBotPeer };
117
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
3
+ * SPDX-License-Identifier: MIT
4
+ *
5
+ * Cross-bot loop guard for bot-at-bot (ping-pong) conversations.
6
+ *
7
+ * Background: when two different bots @-mention each other in a group, each
8
+ * reply wakes the other, which replies again — an endless debate. The
9
+ * existing self-echo filter only drops a bot's *own* echo; it does nothing
10
+ * for A↔B loops. This module adds a deterministic hard brake: count the
11
+ * consecutive turns whose sender is a bot per (chat, thread), and stop
12
+ * auto-replying once the count exceeds a cap. Any human turn resets the
13
+ * counter, so a new human-driven exchange starts fresh.
14
+ *
15
+ * State is process-local and best-effort (each bot process keeps its own
16
+ * counter for the peer's messages it receives). Idle conversations decay so
17
+ * a long-quiet chat doesn't carry a stale count into a new exchange.
18
+ */
19
+ /** Max consecutive bot-originated turns before auto-reply is suppressed. */
20
+ export declare const MAX_CONSECUTIVE_BOT_TURNS = 10;
21
+ /** Idle window after which a conversation's counter is considered stale. */
22
+ export declare const BOT_LOOP_IDLE_RESET_MS: number;
23
+ export interface BotTurnVerdict {
24
+ /** False once the consecutive bot-turn count exceeds the cap. */
25
+ allowed: boolean;
26
+ /** The current consecutive bot-turn count after this turn. */
27
+ count: number;
28
+ /** The configured cap, for logging. */
29
+ limit: number;
30
+ }
31
+ /**
32
+ * Record one bot-originated turn for the given conversation and decide
33
+ * whether the bot should still auto-reply.
34
+ *
35
+ * Increments the consecutive-bot-turn counter (resetting first if the
36
+ * conversation has been idle past the decay window), then returns
37
+ * `allowed: false` once the count exceeds {@link MAX_CONSECUTIVE_BOT_TURNS}.
38
+ */
39
+ export declare function noteBotTurnAndCheck(chatId: string, threadId?: string, now?: number): BotTurnVerdict;
40
+ /**
41
+ * Reset the consecutive bot-turn counter for a conversation. Called on every
42
+ * human turn so a human stepping in always re-arms the debate budget.
43
+ */
44
+ export declare function resetBotLoop(chatId: string, threadId?: string): void;
45
+ /** Clear all loop state. Intended for tests. */
46
+ export declare function resetAllBotLoops(): void;
47
+ /** Number of tracked conversations. Intended for tests. */
48
+ export declare function botLoopStateSize(): number;
@@ -0,0 +1,89 @@
1
+ "use strict";
2
+ /**
3
+ * Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
4
+ * SPDX-License-Identifier: MIT
5
+ *
6
+ * Cross-bot loop guard for bot-at-bot (ping-pong) conversations.
7
+ *
8
+ * Background: when two different bots @-mention each other in a group, each
9
+ * reply wakes the other, which replies again — an endless debate. The
10
+ * existing self-echo filter only drops a bot's *own* echo; it does nothing
11
+ * for A↔B loops. This module adds a deterministic hard brake: count the
12
+ * consecutive turns whose sender is a bot per (chat, thread), and stop
13
+ * auto-replying once the count exceeds a cap. Any human turn resets the
14
+ * counter, so a new human-driven exchange starts fresh.
15
+ *
16
+ * State is process-local and best-effort (each bot process keeps its own
17
+ * counter for the peer's messages it receives). Idle conversations decay so
18
+ * a long-quiet chat doesn't carry a stale count into a new exchange.
19
+ */
20
+ Object.defineProperty(exports, "__esModule", { value: true });
21
+ exports.BOT_LOOP_IDLE_RESET_MS = exports.MAX_CONSECUTIVE_BOT_TURNS = void 0;
22
+ exports.noteBotTurnAndCheck = noteBotTurnAndCheck;
23
+ exports.resetBotLoop = resetBotLoop;
24
+ exports.resetAllBotLoops = resetAllBotLoops;
25
+ exports.botLoopStateSize = botLoopStateSize;
26
+ /** Max consecutive bot-originated turns before auto-reply is suppressed. */
27
+ exports.MAX_CONSECUTIVE_BOT_TURNS = 10;
28
+ /** Idle window after which a conversation's counter is considered stale. */
29
+ exports.BOT_LOOP_IDLE_RESET_MS = 10 * 60 * 1000; // 10 min
30
+ // `${chatId}:${threadId ?? ''}` -> consecutive bot-turn state
31
+ const states = new Map();
32
+ // Timestamp of the last stale-entry sweep, to bound sweep frequency.
33
+ let lastSweepAt = 0;
34
+ function loopKey(chatId, threadId) {
35
+ return `${chatId}:${threadId ?? ''}`;
36
+ }
37
+ /**
38
+ * Evict entries idle past the decay window. Called opportunistically from
39
+ * noteBotTurnAndCheck (at most once per idle window) so the Map can't grow
40
+ * unbounded for bot-only chats that never see a human turn to reset them.
41
+ * Dropping a stale entry is equivalent to leaving it: the next access would
42
+ * reset its count to 1 via the freshness check anyway.
43
+ */
44
+ function sweepStale(now) {
45
+ if (now - lastSweepAt < exports.BOT_LOOP_IDLE_RESET_MS)
46
+ return;
47
+ lastSweepAt = now;
48
+ for (const [key, state] of states) {
49
+ if (now - state.updatedAt > exports.BOT_LOOP_IDLE_RESET_MS)
50
+ states.delete(key);
51
+ }
52
+ }
53
+ /**
54
+ * Record one bot-originated turn for the given conversation and decide
55
+ * whether the bot should still auto-reply.
56
+ *
57
+ * Increments the consecutive-bot-turn counter (resetting first if the
58
+ * conversation has been idle past the decay window), then returns
59
+ * `allowed: false` once the count exceeds {@link MAX_CONSECUTIVE_BOT_TURNS}.
60
+ */
61
+ function noteBotTurnAndCheck(chatId, threadId, now = Date.now()) {
62
+ sweepStale(now);
63
+ const key = loopKey(chatId, threadId);
64
+ const prev = states.get(key);
65
+ const fresh = prev && now - prev.updatedAt <= exports.BOT_LOOP_IDLE_RESET_MS;
66
+ const count = (fresh ? prev.count : 0) + 1;
67
+ states.set(key, { count, updatedAt: now });
68
+ return {
69
+ allowed: count <= exports.MAX_CONSECUTIVE_BOT_TURNS,
70
+ count,
71
+ limit: exports.MAX_CONSECUTIVE_BOT_TURNS,
72
+ };
73
+ }
74
+ /**
75
+ * Reset the consecutive bot-turn counter for a conversation. Called on every
76
+ * human turn so a human stepping in always re-arms the debate budget.
77
+ */
78
+ function resetBotLoop(chatId, threadId) {
79
+ states.delete(loopKey(chatId, threadId));
80
+ }
81
+ /** Clear all loop state. Intended for tests. */
82
+ function resetAllBotLoops() {
83
+ states.clear();
84
+ lastSweepAt = 0;
85
+ }
86
+ /** Number of tracked conversations. Intended for tests. */
87
+ function botLoopStateSize() {
88
+ return states.size;
89
+ }
@@ -76,6 +76,24 @@ export declare function buildInboundPayload(dc: DispatchContext, opts: {
76
76
  }[];
77
77
  extraFields?: Record<string, unknown>;
78
78
  }): ReturnType<typeof LarkClient.runtime.channel.reply.finalizeInboundContext>;
79
+ /**
80
+ * Structured identity signals injected into the agent envelope so the LLM
81
+ * can tell "who is talking to me" apart — in particular whether the sender
82
+ * is a bot, and what the bot's own open_id is.
83
+ *
84
+ * BotOpenId is omitted when unknown (e.g. startup race before the bot info
85
+ * probe completes) to avoid surfacing an empty identity to the agent.
86
+ */
87
+ export declare function buildFeishuIdentityFields(ctx: MessageContext, botOpenId?: string): Record<string, unknown>;
88
+ /**
89
+ * Build the effective group system prompt for a Feishu group chat.
90
+ *
91
+ * Always prepends bot-at-bot guidance (self-identity + @ semantics + loop
92
+ * hygiene) so the agent knows which open_id is itself, how Feishu @-delivery
93
+ * works, and when to stop; then appends any operator-configured group
94
+ * systemPrompt. Returns `undefined` only when there is nothing to inject.
95
+ */
96
+ export declare function buildFeishuGroupSystemPrompt(configured: string | undefined, botOpenId?: string): string | undefined;
79
97
  /**
80
98
  * Format the agent envelope and prepend group chat history if applicable.
81
99
  * Returns the combined body and the history key (undefined for DMs).