@larksuite/openclaw-lark 2026.5.7 → 2026.5.12

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.7",
3
+ "version": "2026.5.12",
4
4
  "description": "OpenClaw Lark/Feishu channel plugin",
5
5
  "exports": {
6
6
  ".": {
@@ -90,6 +90,8 @@ export interface CreateFeishuReplyDispatcherParams {
90
90
  skipTyping?: boolean;
91
91
  /** When true, replies are sent into the thread instead of main chat. */
92
92
  replyInThread?: boolean;
93
+ /** Thread root id when the reply lives inside a thread; used for sentinel keying. */
94
+ threadId?: string;
93
95
  toolUseDisplay: ToolUseDisplayConfig;
94
96
  }
95
97
  /**
@@ -33,7 +33,7 @@ const log = (0, lark_logger_1.larkLogger)('card/reply-dispatcher');
33
33
  // ---------------------------------------------------------------------------
34
34
  function createFeishuReplyDispatcher(params) {
35
35
  const core = lark_client_1.LarkClient.runtime;
36
- const { cfg, agentId, chatId, sessionKey, replyToMessageId, accountId, replyInThread } = params;
36
+ const { cfg, agentId, chatId, sessionKey, replyToMessageId, accountId, replyInThread, threadId } = params;
37
37
  // Resolve account so we can read per-account config (e.g. replyMode)
38
38
  const account = (0, accounts_1.getLarkAccount)(cfg, accountId);
39
39
  const feishuCfg = account.config;
@@ -243,6 +243,7 @@ function createFeishuReplyDispatcher(params) {
243
243
  replyToMessageId,
244
244
  replyInThread,
245
245
  accountId,
246
+ threadId,
246
247
  });
247
248
  }
248
249
  catch (fallbackErr) {
@@ -277,6 +278,7 @@ function createFeishuReplyDispatcher(params) {
277
278
  replyToMessageId,
278
279
  replyInThread,
279
280
  accountId,
281
+ threadId,
280
282
  });
281
283
  }
282
284
  catch (fallbackErr) {
@@ -306,6 +308,7 @@ function createFeishuReplyDispatcher(params) {
306
308
  replyToMessageId,
307
309
  replyInThread,
308
310
  accountId,
311
+ threadId,
309
312
  });
310
313
  }
311
314
  catch (err) {
@@ -12,16 +12,18 @@ import type { HistoryEntry } from 'openclaw/plugin-sdk/reply-history';
12
12
  import type { MessageContext } from '../types';
13
13
  import type { LarkClient } from '../../core/lark-client';
14
14
  import type { DispatchContext } from './dispatch-context';
15
+ import type { SentinelEntry } from './sentinel-store';
15
16
  /**
16
17
  * Build a `[System: ...]` mention annotation when the message @-mentions
17
- * non-bot users. Returns `undefined` when there are no user mentions.
18
+ * non-self-bot users or when the previous reply had unresolved mentions.
19
+ * Returns `undefined` when there is nothing to report.
18
20
  *
19
21
  * Sender identity / chat metadata are handled by the SDK's own
20
22
  * `buildInboundUserContextPrefix` (via SenderId, SenderName, ReplyToBody,
21
23
  * InboundHistory, etc.), so we only inject the mention data that the SDK
22
24
  * does not natively support.
23
25
  */
24
- export declare function buildMentionAnnotation(ctx: MessageContext): string | undefined;
26
+ export declare function buildMentionAnnotation(ctx: MessageContext, sentinels?: SentinelEntry[]): string | undefined;
25
27
  /**
26
28
  * Pure function: build the annotated message body with optional quote,
27
29
  * speaker prefix, and mention annotation (for the envelope Body).
@@ -31,7 +33,7 @@ export declare function buildMentionAnnotation(ctx: MessageContext): string | un
31
33
  * the body cleaner and avoiding misleading heuristics for non-text
32
34
  * message types (merge_forward, interactive cards, etc.).
33
35
  */
34
- export declare function buildMessageBody(ctx: MessageContext, quotedContent?: string): string;
36
+ export declare function buildMessageBody(ctx: MessageContext, quotedContent?: string, sentinels?: SentinelEntry[]): string;
35
37
  /**
36
38
  * Build the BodyForAgent value: the clean message content plus an
37
39
  * optional mention annotation.
@@ -51,7 +53,7 @@ export declare function buildMessageBody(ctx: MessageContext, quotedContent?: st
51
53
  * The SDK's `detectAndLoadPromptImages` will discover image paths from
52
54
  * the text and inject them as multimodal content blocks.
53
55
  */
54
- export declare function buildBodyForAgent(ctx: MessageContext): string;
56
+ export declare function buildBodyForAgent(ctx: MessageContext, sentinels?: SentinelEntry[]): string;
55
57
  /**
56
58
  * Unified call to `finalizeInboundContext`, eliminating the duplicated
57
59
  * field-mapping between permission notification and main message paths.
@@ -21,21 +21,48 @@ const mention_1 = require("./mention.js");
21
21
  // ---------------------------------------------------------------------------
22
22
  // Mention annotation
23
23
  // ---------------------------------------------------------------------------
24
+ const MENTION_USAGE_HINT = 'To @mention in a reply, use `<at user_id="ou_xxx">Name</at>`; plain "@Name" won\'t notify.';
24
25
  /**
25
26
  * Build a `[System: ...]` mention annotation when the message @-mentions
26
- * non-bot users. Returns `undefined` when there are no user mentions.
27
+ * non-self-bot users or when the previous reply had unresolved mentions.
28
+ * Returns `undefined` when there is nothing to report.
27
29
  *
28
30
  * Sender identity / chat metadata are handled by the SDK's own
29
31
  * `buildInboundUserContextPrefix` (via SenderId, SenderName, ReplyToBody,
30
32
  * InboundHistory, etc.), so we only inject the mention data that the SDK
31
33
  * does not natively support.
32
34
  */
33
- function buildMentionAnnotation(ctx) {
34
- const mentions = (0, mention_1.nonBotMentions)(ctx);
35
+ function buildMentionAnnotation(ctx, sentinels) {
36
+ const sections = [
37
+ formatMentionList((0, mention_1.nonBotMentions)(ctx)),
38
+ formatSentinelFeedback(sentinels),
39
+ ].filter((s) => !!s);
40
+ if (sections.length === 0)
41
+ return undefined;
42
+ sections.push(MENTION_USAGE_HINT);
43
+ return `[System: ${sections.join(' ')}]`;
44
+ }
45
+ function formatMentionList(mentions) {
35
46
  if (mentions.length === 0)
36
47
  return undefined;
37
- const mentionDetails = mentions.map((t) => `${t.name} (open_id: ${t.openId})`).join(', ');
38
- return `[System: This message @mentions the following users: ${mentionDetails}. Use these open_ids when performing actions involving these users. To @mention in a reply, use \`<at user_id="ou_xxx">Name</at>\`; plain "@Name" won't notify.]`;
48
+ const details = mentions.map((t) => `${t.name} (open_id: ${t.openId})`).join(', ');
49
+ return (`This message @mentions the following users: ${details}. ` +
50
+ `Use these open_ids when performing actions involving these users.`);
51
+ }
52
+ function formatSentinelFeedback(sentinels) {
53
+ if (!sentinels || sentinels.length === 0)
54
+ return undefined;
55
+ const lines = sentinels.map((s) => {
56
+ if (s.reason === 'not_found') {
57
+ return `"@${s.name}" was not recognized in the chat`;
58
+ }
59
+ if (s.reason === 'ambiguous' && s.candidates && s.candidates.length > 0) {
60
+ const ids = s.candidates.map((c) => c.openId).join(' / ');
61
+ return `"@${s.name}" matched multiple users (${ids}); use explicit <at user_id="...">`;
62
+ }
63
+ return `"@${s.name}" failed to resolve`;
64
+ });
65
+ return `Previous reply had unresolved mentions: ${lines.join('; ')}.`;
39
66
  }
40
67
  // ---------------------------------------------------------------------------
41
68
  // Message body builders
@@ -49,14 +76,14 @@ function buildMentionAnnotation(ctx) {
49
76
  * the body cleaner and avoiding misleading heuristics for non-text
50
77
  * message types (merge_forward, interactive cards, etc.).
51
78
  */
52
- function buildMessageBody(ctx, quotedContent) {
79
+ function buildMessageBody(ctx, quotedContent, sentinels) {
53
80
  let messageBody = ctx.content;
54
81
  if (quotedContent) {
55
82
  messageBody = `[Replying to: "${quotedContent}"]\n\n${ctx.content}`;
56
83
  }
57
84
  const speaker = ctx.senderName ?? ctx.senderId;
58
85
  messageBody = `${speaker}: ${messageBody}`;
59
- const mentionAnnotation = buildMentionAnnotation(ctx);
86
+ const mentionAnnotation = buildMentionAnnotation(ctx, sentinels);
60
87
  if (mentionAnnotation) {
61
88
  messageBody += `\n\n${mentionAnnotation}`;
62
89
  }
@@ -81,8 +108,8 @@ function buildMessageBody(ctx, quotedContent) {
81
108
  * The SDK's `detectAndLoadPromptImages` will discover image paths from
82
109
  * the text and inject them as multimodal content blocks.
83
110
  */
84
- function buildBodyForAgent(ctx) {
85
- const mentionAnnotation = buildMentionAnnotation(ctx);
111
+ function buildBodyForAgent(ctx, sentinels) {
112
+ const mentionAnnotation = buildMentionAnnotation(ctx, sentinels);
86
113
  if (mentionAnnotation) {
87
114
  return `${ctx.content}\n\n${mentionAnnotation}`;
88
115
  }
@@ -35,6 +35,7 @@ const index_1 = require("../../commands/index.js");
35
35
  const send_1 = require("../outbound/send.js");
36
36
  const dispatch_commands_1 = require("./dispatch-commands.js");
37
37
  const dispatch_builders_1 = require("./dispatch-builders.js");
38
+ const sentinel_store_1 = require("./sentinel-store.js");
38
39
  const dispatch_context_1 = require("./dispatch-context.js");
39
40
  const mention_1 = require("./mention.js");
40
41
  const gate_1 = require("./gate.js");
@@ -209,6 +210,7 @@ async function dispatchNormalMessage(dc, ctxPayload, chatHistories, historyKey,
209
210
  chatType: dc.ctx.chatType,
210
211
  skipTyping,
211
212
  replyInThread: dc.isThread,
213
+ threadId: dc.isThread ? dc.ctx.threadId : undefined,
212
214
  toolUseDisplay,
213
215
  });
214
216
  // Create an AbortController so the abort fast-path can cancel the
@@ -288,9 +290,14 @@ async function dispatchToAgent(params) {
288
290
  baseSessionKey: dc.route.sessionKey,
289
291
  });
290
292
  }
291
- // 2. Build annotated message body
292
- const messageBody = (0, dispatch_builders_1.buildMessageBody)(params.ctx, params.quotedContent);
293
- // 3. Permission-error notification (optional side-effect).
293
+ // Consume any pending mention sentinels for this thread. Take and
294
+ // delete is one shot per inbound — capture once, hand to both body
295
+ // builders below.
296
+ const sentinelKey = (0, chat_queue_1.threadScopedKey)(dc.ctx.chatId, dc.isThread ? dc.ctx.threadId : undefined);
297
+ const sentinels = (0, sentinel_store_1.getSentinelStore)(dc.account.accountId).consumeSentinels(sentinelKey);
298
+ // 3. Build annotated message body
299
+ const messageBody = (0, dispatch_builders_1.buildMessageBody)(params.ctx, params.quotedContent, sentinels);
300
+ // 4. Permission-error notification (optional side-effect).
294
301
  // Isolated so a failure here does not block the main message dispatch.
295
302
  // Skipped for comment targets: the streaming card dispatcher inside
296
303
  // dispatchPermissionNotification sends via IM APIs which don't
@@ -303,13 +310,13 @@ async function dispatchToAgent(params) {
303
310
  dc.error(`feishu[${dc.account.accountId}]: permission notification failed, continuing: ${String(err)}`);
304
311
  }
305
312
  }
306
- // 4. Build main envelope (with group chat history)
313
+ // 5. Build main envelope (with group chat history)
307
314
  const { combinedBody, historyKey } = (0, dispatch_builders_1.buildEnvelopeWithHistory)(dc, messageBody, params.chatHistories, params.historyLimit);
308
- // 5. Build BodyForAgent with mention annotation (if any).
315
+ // 6. Build BodyForAgent with mention annotation (if any).
309
316
  // SDK >= 2026.2.10 no longer falls back to Body for BodyForAgent,
310
317
  // so we must set it explicitly to preserve the annotation.
311
- const bodyForAgent = (0, dispatch_builders_1.buildBodyForAgent)(params.ctx);
312
- // 6. Build InboundHistory for SDK metadata injection (>= 2026.2.10).
318
+ const bodyForAgent = (0, dispatch_builders_1.buildBodyForAgent)(params.ctx, sentinels);
319
+ // 7. Build InboundHistory for SDK metadata injection (>= 2026.2.10).
313
320
  // The SDK's buildInboundUserContextPrefix renders these as structured
314
321
  // JSON blocks; earlier SDK versions simply ignore unknown fields.
315
322
  const threadHistoryKey = (0, chat_queue_1.threadScopedKey)(dc.ctx.chatId, dc.isThread ? dc.ctx.threadId : undefined);
@@ -320,7 +327,7 @@ async function dispatchToAgent(params) {
320
327
  timestamp: entry.timestamp ?? Date.now(),
321
328
  }))
322
329
  : undefined;
323
- // 7. Build inbound context payload
330
+ // 8. Build inbound context payload
324
331
  const isBareNewOrReset = /^\/(?:new|reset)\s*$/i.test((params.ctx.content ?? '').trim());
325
332
  const groupSystemPrompt = dc.isGroup
326
333
  ? params.groupConfig?.systemPrompt?.trim() || params.defaultGroupConfig?.systemPrompt?.trim() || undefined
@@ -357,7 +364,7 @@ async function dispatchToAgent(params) {
357
364
  ...(dc.ctx.threadId ? { MessageThreadId: dc.ctx.threadId } : {}),
358
365
  },
359
366
  });
360
- // 8a. Intercept /feishu commands for i18n multi-locale card dispatch
367
+ // 9a. Intercept /feishu commands for i18n multi-locale card dispatch
361
368
  // Must run BEFORE the SDK command check — the SDK does not recognise
362
369
  // plugin-registered commands via isControlCommandMessage, so
363
370
  // /feishu_* falls through to the AI agent otherwise.
@@ -4,14 +4,16 @@
4
4
  *
5
5
  * Inbound message handling pipeline for the Lark/Feishu channel plugin.
6
6
  *
7
- * Orchestrates a seven-stage pipeline:
7
+ * Orchestrates a nine-stage pipeline:
8
8
  * 1. Account resolution
9
9
  * 2. Event parsing → parse.ts (merge_forward expanded in-place)
10
- * 3. Sender enrichment enrich.ts (lightweight, before gate)
11
- * 4. Policy gate gate.ts
12
- * 5. User name prefetch enrich.ts (batch cache warm-up)
13
- * 6. Content resolution → enrich.ts (media / quote, parallel)
14
- * 7. Agent dispatch dispatch.ts
10
+ * 3. Empty-message guard early return for text-less, media-less messages
11
+ * 4. Sender enrichment enrich.ts (lightweight, before gate)
12
+ * 5. Policy gate gate.ts
13
+ * 6. User name prefetch → enrich.ts (batch cache warm-up)
14
+ * 7. Content resolution enrich.ts (media / quote, parallel)
15
+ * 8. Command authorization → plugin-sdk/command-auth
16
+ * 9. Agent dispatch → dispatch.ts
15
17
  */
16
18
  import type { ClawdbotConfig, RuntimeEnv } from 'openclaw/plugin-sdk';
17
19
  import type { HistoryEntry } from 'openclaw/plugin-sdk/reply-history';
@@ -5,14 +5,16 @@
5
5
  *
6
6
  * Inbound message handling pipeline for the Lark/Feishu channel plugin.
7
7
  *
8
- * Orchestrates a seven-stage pipeline:
8
+ * Orchestrates a nine-stage pipeline:
9
9
  * 1. Account resolution
10
10
  * 2. Event parsing → parse.ts (merge_forward expanded in-place)
11
- * 3. Sender enrichment enrich.ts (lightweight, before gate)
12
- * 4. Policy gate gate.ts
13
- * 5. User name prefetch enrich.ts (batch cache warm-up)
14
- * 6. Content resolution → enrich.ts (media / quote, parallel)
15
- * 7. Agent dispatch dispatch.ts
11
+ * 3. Empty-message guard early return for text-less, media-less messages
12
+ * 4. Sender enrichment enrich.ts (lightweight, before gate)
13
+ * 5. Policy gate gate.ts
14
+ * 6. User name prefetch → enrich.ts (batch cache warm-up)
15
+ * 7. Content resolution enrich.ts (media / quote, parallel)
16
+ * 8. Command authorization → plugin-sdk/command-auth
17
+ * 9. Agent dispatch → dispatch.ts
16
18
  */
17
19
  Object.defineProperty(exports, "__esModule", { value: true });
18
20
  exports.handleFeishuMessage = handleFeishuMessage;
@@ -60,7 +62,16 @@ async function handleFeishuMessage(params) {
60
62
  cfg: accountScopedCfg,
61
63
  accountId: account.accountId,
62
64
  });
63
- // 3. Enrich (lightweight): sender name + permission error tracking
65
+ // 3. Early reject: skip empty-text messages with no media resources.
66
+ // OpenClaw 2026.4.29 adds a core-side guard for this (##74634), but
67
+ // rejecting here avoids wasting cycles on enrichment, gate, and
68
+ // dispatch for messages that would be silently dropped at the deliver
69
+ // callback anyway.
70
+ if (!ctx.content.trim() && ctx.resources.length === 0) {
71
+ log(`feishu[${account.accountId}]: empty message ${ctx.messageId} (no text, no media), skipping`);
72
+ return;
73
+ }
74
+ // 4. Enrich (lightweight): sender name + permission error tracking
64
75
  const { ctx: enrichedCtx, permissionError } = await (0, enrich_1.resolveSenderInfo)({
65
76
  ctx,
66
77
  account,
@@ -70,7 +81,7 @@ async function handleFeishuMessage(params) {
70
81
  log(`feishu[${account.accountId}]: received message from ${ctx.senderId} in ${ctx.chatId} (${ctx.chatType})`);
71
82
  logger.info(`received from ${ctx.senderId} in ${ctx.chatId} (${ctx.chatType})`);
72
83
  const historyLimit = Math.max(0, accountFeishuCfg?.historyLimit ?? accountScopedCfg.messages?.groupChat?.historyLimit ?? reply_history_1.DEFAULT_GROUP_HISTORY_LIMIT);
73
- // 4. Gate: policy / access-control checks (skipped for synthetic messages)
84
+ // 5. Gate: policy / access-control checks (skipped for synthetic messages)
74
85
  const gate = forceMention
75
86
  ? { allowed: true }
76
87
  : await (0, gate_1.checkMessageGate)({ ctx, accountFeishuCfg, account, accountScopedCfg, log });
@@ -90,15 +101,15 @@ async function handleFeishuMessage(params) {
90
101
  }
91
102
  return;
92
103
  }
93
- // 5. Batch pre-warm user name cache (sender + mentions)
104
+ // 6. Batch pre-warm user name cache (sender + mentions)
94
105
  await (0, enrich_1.prefetchUserNames)({ ctx, account, log });
95
- // 6. Enrich (heavyweight, after gate — parallel where possible)
106
+ // 7. Enrich (heavyweight, after gate — parallel where possible)
96
107
  const enrichParams = { ctx, accountScopedCfg, account, log };
97
108
  const [mediaResult, quotedContent] = await Promise.all([
98
109
  (0, enrich_1.resolveMedia)(enrichParams),
99
110
  (0, enrich_1.resolveQuotedContent)(enrichParams),
100
111
  ]);
101
- // 6b. Replace Feishu file-key placeholders in content with local
112
+ // 7b. Replace Feishu file-key placeholders in content with local
102
113
  // file paths so the SDK can detect images for native vision and
103
114
  // the AI receives meaningful file references.
104
115
  if (mediaResult.mediaList.length > 0) {
@@ -107,7 +118,7 @@ async function handleFeishuMessage(params) {
107
118
  content: (0, enrich_1.substituteMediaPaths)(ctx.content, mediaResult.mediaList),
108
119
  };
109
120
  }
110
- // 7. Compute commandAuthorized via SDK access group command gating
121
+ // 8. Compute commandAuthorized via SDK access group command gating
111
122
  const core = lark_client_1.LarkClient.runtime;
112
123
  const isGroup = ctx.chatType === 'group';
113
124
  const dmPolicy = accountFeishuCfg?.dmPolicy ?? 'pairing';
@@ -152,7 +163,7 @@ async function handleFeishuMessage(params) {
152
163
  shouldComputeCommandAuthorized: core.channel.commands.shouldComputeCommandAuthorized,
153
164
  resolveCommandAuthorizedFromAuthorizers: core.channel.commands.resolveCommandAuthorizedFromAuthorizers,
154
165
  });
155
- // 8. Dispatch to agent
166
+ // 9. Dispatch to agent
156
167
  // groupConfig and defaultGroupConfig are already resolved above.
157
168
  try {
158
169
  await (0, dispatch_1.dispatchToAgent)({
@@ -20,9 +20,9 @@ export type { MentionInfo } from '../types';
20
20
  export declare function isMentionAll(mention: {
21
21
  key: string;
22
22
  }): boolean;
23
- /** Whether the bot was @-mentioned. */
23
+ /** Whether the receiving bot itself was @-mentioned. */
24
24
  export declare function mentionedBot(ctx: MessageContext): boolean;
25
- /** All non-bot mentions. */
25
+ /** All mentions excluding the receiving bot itself. */
26
26
  export declare function nonBotMentions(ctx: MessageContext): MentionInfo[];
27
27
  /**
28
28
  * Remove all @mention placeholder keys from the message text.
@@ -34,11 +34,11 @@ const utils_1 = require("../converters/utils.js");
34
34
  function isMentionAll(mention) {
35
35
  return mention.key === '@_all';
36
36
  }
37
- /** Whether the bot was @-mentioned. */
37
+ /** Whether the receiving bot itself was @-mentioned. */
38
38
  function mentionedBot(ctx) {
39
39
  return ctx.mentions.some((m) => m.isBot);
40
40
  }
41
- /** All non-bot mentions. */
41
+ /** All mentions excluding the receiving bot itself. */
42
42
  function nonBotMentions(ctx) {
43
43
  return ctx.mentions.filter((m) => !m.isBot);
44
44
  }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
3
+ * SPDX-License-Identifier: MIT
4
+ *
5
+ * Per-thread store for unresolved mention feedback. The outbound
6
+ * normalizer records a SentinelEntry whenever an `@Name` cannot be
7
+ * resolved; the next inbound message on the same thread consumes
8
+ * (take and delete) the entries, which buildMentionAnnotation surfaces
9
+ * as a system note so the next reply can disambiguate.
10
+ *
11
+ * Kept separate from UserNameCache because the lifecycle differs:
12
+ * 10-minute TTL, per-thread keying, and take-and-delete consumption.
13
+ */
14
+ export interface SentinelEntry {
15
+ /** Literal name as it appeared in the outbound text. */
16
+ name: string;
17
+ /** Why parsing failed. */
18
+ reason: 'not_found' | 'ambiguous';
19
+ /** Candidate open_ids when reason === 'ambiguous'. */
20
+ candidates?: Array<{
21
+ openId: string;
22
+ kind?: 'user' | 'bot';
23
+ }>;
24
+ }
25
+ export declare class SentinelStore {
26
+ private byThread;
27
+ private maxThreads;
28
+ private ttlMs;
29
+ constructor(maxThreads?: number, ttlMs?: number);
30
+ recordSentinels(threadKey: string, sentinels: SentinelEntry[]): void;
31
+ consumeSentinels(threadKey: string): SentinelEntry[];
32
+ clear(): void;
33
+ private evict;
34
+ }
35
+ export declare function getSentinelStore(accountId: string, maxThreads?: number, ttlMs?: number): SentinelStore;
36
+ export declare function clearSentinelStore(accountId?: string): void;
37
+ export declare function clearAllSentinelStores(): void;
@@ -0,0 +1,98 @@
1
+ "use strict";
2
+ /**
3
+ * Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
4
+ * SPDX-License-Identifier: MIT
5
+ *
6
+ * Per-thread store for unresolved mention feedback. The outbound
7
+ * normalizer records a SentinelEntry whenever an `@Name` cannot be
8
+ * resolved; the next inbound message on the same thread consumes
9
+ * (take and delete) the entries, which buildMentionAnnotation surfaces
10
+ * as a system note so the next reply can disambiguate.
11
+ *
12
+ * Kept separate from UserNameCache because the lifecycle differs:
13
+ * 10-minute TTL, per-thread keying, and take-and-delete consumption.
14
+ */
15
+ Object.defineProperty(exports, "__esModule", { value: true });
16
+ exports.SentinelStore = void 0;
17
+ exports.getSentinelStore = getSentinelStore;
18
+ exports.clearSentinelStore = clearSentinelStore;
19
+ exports.clearAllSentinelStores = clearAllSentinelStores;
20
+ const DEFAULT_TTL_MS = 10 * 60 * 1000; // 10 min — short, avoid stale feedback
21
+ const DEFAULT_MAX_THREADS = 200; // per-account
22
+ function dedup(entries) {
23
+ const seen = new Set();
24
+ const out = [];
25
+ for (const e of entries) {
26
+ const key = `${e.reason}:${e.name}`;
27
+ if (seen.has(key))
28
+ continue;
29
+ seen.add(key);
30
+ out.push(e);
31
+ }
32
+ return out;
33
+ }
34
+ class SentinelStore {
35
+ byThread = new Map();
36
+ maxThreads;
37
+ ttlMs;
38
+ constructor(maxThreads = DEFAULT_MAX_THREADS, ttlMs = DEFAULT_TTL_MS) {
39
+ this.maxThreads = maxThreads;
40
+ this.ttlMs = ttlMs;
41
+ }
42
+ recordSentinels(threadKey, sentinels) {
43
+ if (sentinels.length === 0)
44
+ return;
45
+ const existing = this.byThread.get(threadKey);
46
+ const merged = existing ? [...existing.entries, ...sentinels] : sentinels;
47
+ this.byThread.delete(threadKey); // bump LRU
48
+ this.byThread.set(threadKey, {
49
+ entries: dedup(merged),
50
+ expireAt: Date.now() + this.ttlMs,
51
+ });
52
+ this.evict();
53
+ }
54
+ consumeSentinels(threadKey) {
55
+ const stored = this.byThread.get(threadKey);
56
+ if (!stored)
57
+ return [];
58
+ this.byThread.delete(threadKey);
59
+ if (stored.expireAt <= Date.now())
60
+ return [];
61
+ return stored.entries;
62
+ }
63
+ clear() {
64
+ this.byThread.clear();
65
+ }
66
+ evict() {
67
+ while (this.byThread.size > this.maxThreads) {
68
+ const oldest = this.byThread.keys().next().value;
69
+ if (oldest === undefined)
70
+ break;
71
+ this.byThread.delete(oldest);
72
+ }
73
+ }
74
+ }
75
+ exports.SentinelStore = SentinelStore;
76
+ const registry = new Map();
77
+ function getSentinelStore(accountId, maxThreads, ttlMs) {
78
+ let store = registry.get(accountId);
79
+ if (!store) {
80
+ store = new SentinelStore(maxThreads, ttlMs);
81
+ registry.set(accountId, store);
82
+ }
83
+ return store;
84
+ }
85
+ function clearSentinelStore(accountId) {
86
+ if (accountId !== undefined) {
87
+ registry.get(accountId)?.clear();
88
+ registry.delete(accountId);
89
+ }
90
+ else {
91
+ clearAllSentinelStores();
92
+ }
93
+ }
94
+ function clearAllSentinelStores() {
95
+ for (const s of registry.values())
96
+ s.clear();
97
+ registry.clear();
98
+ }
@@ -3,19 +3,59 @@
3
3
  * SPDX-License-Identifier: MIT
4
4
  *
5
5
  * Account-scoped cache registry for Feishu user display names.
6
+ *
7
+ * Stores forward (openId → name + kind) and reverse (normalizedName → Set<openId>)
8
+ * indexes for mention resolution. Per-account, LRU + TTL.
6
9
  */
10
+ export type PrincipalKind = 'user' | 'bot';
11
+ export interface MentionMatch {
12
+ openId: string;
13
+ name: string;
14
+ kind?: PrincipalKind;
15
+ }
16
+ export interface ChatMember {
17
+ openId: string;
18
+ name: string;
19
+ kind: PrincipalKind;
20
+ }
21
+ export interface ChatMembersEntry {
22
+ members: ChatMember[];
23
+ expireAt: number;
24
+ }
7
25
  export declare class UserNameCache {
8
- private map;
26
+ private nameByOpenId;
27
+ private openIdsByName;
9
28
  private maxSize;
10
29
  private ttlMs;
11
- constructor(maxSize?: number, ttlMs?: number);
30
+ private chatBots;
31
+ private chatMembers;
32
+ private inFlight;
33
+ private maxChats;
34
+ constructor(maxSize?: number, ttlMs?: number, maxChats?: number);
12
35
  has(openId: string): boolean;
13
36
  get(openId: string): string | undefined;
14
37
  set(openId: string, name: string): void;
38
+ setWithKind(openId: string, name: string, kind: PrincipalKind): void;
39
+ lookupByName(name: string): MentionMatch[];
15
40
  setMany(entries: Iterable<[string, string]>): void;
16
41
  filterMissing(openIds: string[]): string[];
17
42
  getMany(openIds: string[]): Map<string, string>;
43
+ recordChatBots(chatId: string, members: Array<{
44
+ openId: string;
45
+ name: string;
46
+ }>): void;
47
+ recordChatMembers(chatId: string, members: ChatMember[]): void;
48
+ getChatBots(chatId: string): ChatMembersEntry | null;
49
+ getChatMembers(chatId: string): ChatMembersEntry | null;
50
+ getInflight(key: string): Promise<void> | undefined;
51
+ setInflight(key: string, promise: Promise<void>): void;
52
+ clearInflight(key: string): void;
18
53
  clear(): void;
54
+ private writeEntry;
55
+ private writeEntryNoEvict;
56
+ private deleteOpenId;
57
+ private removeFromReverse;
58
+ private evictChats;
19
59
  private evict;
20
60
  }
21
61
  export declare function getUserNameCache(accountId: string): UserNameCache;