@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 +1 -1
- package/src/card/reply-mode.js +8 -5
- package/src/channel/abort-detect.d.ts +13 -0
- package/src/channel/abort-detect.js +87 -0
- package/src/core/config-schema.d.ts +7 -0
- package/src/core/config-schema.js +6 -0
- package/src/messaging/converters/content-converter-helpers.d.ts +7 -2
- package/src/messaging/converters/content-converter-helpers.js +10 -4
- package/src/messaging/inbound/bot-content.d.ts +84 -0
- package/src/messaging/inbound/bot-content.js +117 -0
- package/src/messaging/inbound/bot-loop-guard.d.ts +48 -0
- package/src/messaging/inbound/bot-loop-guard.js +89 -0
- package/src/messaging/inbound/dispatch-builders.d.ts +18 -0
- package/src/messaging/inbound/dispatch-builders.js +55 -0
- package/src/messaging/inbound/dispatch.d.ts +2 -0
- package/src/messaging/inbound/dispatch.js +50 -28
- package/src/messaging/inbound/handler.js +97 -2
- package/src/messaging/inbound/mention-registry.d.ts +59 -0
- package/src/messaging/inbound/mention-registry.js +115 -0
- package/src/messaging/inbound/vc-meeting-invited-handler.js +11 -1
- package/src/messaging/outbound/bot-peer-context.d.ts +42 -0
- package/src/messaging/outbound/bot-peer-context.js +40 -0
- package/src/messaging/outbound/outbound-mention.d.ts +41 -0
- package/src/messaging/outbound/outbound-mention.js +112 -0
- package/src/messaging/outbound/outbound.js +30 -4
- package/src/messaging/outbound/send.js +26 -3
- package/src/messaging/types.d.ts +4 -0
package/package.json
CHANGED
package/src/card/reply-mode.js
CHANGED
|
@@ -59,7 +59,14 @@ function expandAutoMode(params) {
|
|
|
59
59
|
* markdown tables).
|
|
60
60
|
*/
|
|
61
61
|
function shouldUseCard(text) {
|
|
62
|
-
//
|
|
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
|
|
22
|
-
*
|
|
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
|
|
59
|
-
*
|
|
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(
|
|
69
|
-
|
|
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).
|