@larksuite/openclaw-lark 2026.4.10 → 2026.5.7

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.
@@ -1,12 +1,59 @@
1
1
  {
2
2
  "id": "openclaw-lark",
3
- "channels": ["feishu"],
4
- "skills": ["./skills"],
3
+ "channels": [
4
+ "feishu"
5
+ ],
6
+ "skills": [
7
+ "./skills"
8
+ ],
5
9
  "configSchema": {
6
10
  "type": "object",
7
11
  "additionalProperties": false,
8
12
  "properties": {}
9
13
  },
14
+ "contracts": {
15
+ "tools": [
16
+ "feishu_bitable_app",
17
+ "feishu_bitable_app_table",
18
+ "feishu_bitable_app_table_field",
19
+ "feishu_bitable_app_table_record",
20
+ "feishu_bitable_app_table_view",
21
+ "feishu_calendar_calendar",
22
+ "feishu_calendar_event",
23
+ "feishu_calendar_event_attendee",
24
+ "feishu_calendar_freebusy",
25
+ "feishu_chat",
26
+ "feishu_chat_members",
27
+ "feishu_create_doc",
28
+ "feishu_doc_comments",
29
+ "feishu_doc_media",
30
+ "feishu_drive_file",
31
+ "feishu_fetch_doc",
32
+ "feishu_get_user",
33
+ "feishu_im_bot_image",
34
+ "feishu_im_user_fetch_resource",
35
+ "feishu_im_user_get_messages",
36
+ "feishu_im_user_get_thread_messages",
37
+ "feishu_im_user_message",
38
+ "feishu_im_user_search_messages",
39
+ "feishu_oauth",
40
+ "feishu_oauth_batch_auth",
41
+ "feishu_search_doc_wiki",
42
+ "feishu_search_user",
43
+ "feishu_sheet",
44
+ "feishu_task_comment",
45
+ "feishu_task_subtask",
46
+ "feishu_task_task",
47
+ "feishu_task_agent",
48
+ "feishu_task_attachment",
49
+ "feishu_task_tasklist",
50
+ "feishu_update_doc",
51
+ "feishu_wiki_space",
52
+ "feishu_wiki_space_node",
53
+ "feishu_task_section",
54
+ "feishu_ask_user_question"
55
+ ]
56
+ },
10
57
  "channelConfigs": {
11
58
  "feishu": {
12
59
  "schema": {
@@ -14,4 +61,4 @@
14
61
  }
15
62
  }
16
63
  }
17
- }
64
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@larksuite/openclaw-lark",
3
- "version": "2026.4.10",
3
+ "version": "2026.5.7",
4
4
  "description": "OpenClaw Lark/Feishu channel plugin",
5
5
  "exports": {
6
6
  ".": {
@@ -69,6 +69,21 @@ async function handleMessageEvent(ctx, data) {
69
69
  const { accountId, log, error } = ctx;
70
70
  try {
71
71
  const event = data;
72
+ // Self-echo hard filter — drop messages authored by this very bot before
73
+ // dedup and enqueue. Prevents self-reply loops; the primary guardrail
74
+ // against bot-to-bot ping-pong.
75
+ //
76
+ // NOTE: if botOpenId is not yet populated (startup race before probe
77
+ // resolves), this filter is skipped. The downstream bot-sender gate
78
+ // (checkBotSenderGate) acts as fallback — bot messages default to
79
+ // `allowBots='mentions'`, so in groups they require an explicit @-mention
80
+ // of this bot to pass; DMs are pass-through under the default.
81
+ const senderOpenId = event.sender?.sender_id?.open_id;
82
+ const botOpenId = ctx.lark.botOpenId;
83
+ if (botOpenId && senderOpenId && senderOpenId === botOpenId) {
84
+ log(`feishu[${accountId}]: drop self-echo message ${event.message?.message_id ?? 'unknown'}`);
85
+ return;
86
+ }
72
87
  const msgId = event.message?.message_id ?? 'unknown';
73
88
  const chatId = event.message?.chat_id ?? '';
74
89
  // In topic groups, reply events carry root_id but not thread_id.
@@ -13,9 +13,9 @@ Object.defineProperty(exports, "__esModule", { value: true });
13
13
  exports.monitorFeishuProvider = monitorFeishuProvider;
14
14
  const accounts_1 = require("../core/accounts.js");
15
15
  const lark_client_1 = require("../core/lark-client.js");
16
- const dedup_1 = require("../messaging/inbound/dedup.js");
17
16
  const lark_logger_1 = require("../core/lark-logger.js");
18
17
  const shutdown_hooks_1 = require("../core/shutdown-hooks.js");
18
+ const dedup_1 = require("../messaging/inbound/dedup.js");
19
19
  const event_handlers_1 = require("./event-handlers.js");
20
20
  const mlog = (0, lark_logger_1.larkLogger)('channel/monitor');
21
21
  // ---------------------------------------------------------------------------
@@ -30,6 +30,7 @@ export declare const FeishuGroupSchema: z.ZodObject<{
30
30
  enabled: z.ZodOptional<z.ZodBoolean>;
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
+ allowBots: z.ZodOptional<z.ZodUnion<readonly [z.ZodBoolean, z.ZodLiteral<"mentions">]>>;
33
34
  }, z.core.$strip>;
34
35
  export declare const FeishuAccountConfigSchema: z.ZodObject<{
35
36
  appId: z.ZodOptional<z.ZodString>;
@@ -76,6 +77,7 @@ export declare const FeishuAccountConfigSchema: z.ZodObject<{
76
77
  enabled: z.ZodOptional<z.ZodBoolean>;
77
78
  allowFrom: z.ZodPipe<z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>, z.ZodTransform<string[] | undefined, string | string[] | undefined>>;
78
79
  systemPrompt: z.ZodOptional<z.ZodString>;
80
+ allowBots: z.ZodOptional<z.ZodUnion<readonly [z.ZodBoolean, z.ZodLiteral<"mentions">]>>;
79
81
  }, z.core.$strip>>>;
80
82
  historyLimit: z.ZodOptional<z.ZodNumber>;
81
83
  dmHistoryLimit: z.ZodOptional<z.ZodNumber>;
@@ -170,6 +172,7 @@ export declare const FeishuAccountConfigSchema: z.ZodObject<{
170
172
  all: "all";
171
173
  }>>;
172
174
  threadSession: z.ZodOptional<z.ZodBoolean>;
175
+ allowBots: z.ZodOptional<z.ZodUnion<readonly [z.ZodBoolean, z.ZodLiteral<"mentions">]>>;
173
176
  uat: z.ZodOptional<z.ZodObject<{
174
177
  enabled: z.ZodOptional<z.ZodBoolean>;
175
178
  allowedScopes: z.ZodOptional<z.ZodArray<z.ZodString>>;
@@ -221,6 +224,7 @@ export declare const FeishuConfigSchema: z.ZodObject<{
221
224
  enabled: z.ZodOptional<z.ZodBoolean>;
222
225
  allowFrom: z.ZodPipe<z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>, z.ZodTransform<string[] | undefined, string | string[] | undefined>>;
223
226
  systemPrompt: z.ZodOptional<z.ZodString>;
227
+ allowBots: z.ZodOptional<z.ZodUnion<readonly [z.ZodBoolean, z.ZodLiteral<"mentions">]>>;
224
228
  }, z.core.$strip>>>;
225
229
  historyLimit: z.ZodOptional<z.ZodNumber>;
226
230
  dmHistoryLimit: z.ZodOptional<z.ZodNumber>;
@@ -315,6 +319,7 @@ export declare const FeishuConfigSchema: z.ZodObject<{
315
319
  all: "all";
316
320
  }>>;
317
321
  threadSession: z.ZodOptional<z.ZodBoolean>;
322
+ allowBots: z.ZodOptional<z.ZodUnion<readonly [z.ZodBoolean, z.ZodLiteral<"mentions">]>>;
318
323
  uat: z.ZodOptional<z.ZodObject<{
319
324
  enabled: z.ZodOptional<z.ZodBoolean>;
320
325
  allowedScopes: z.ZodOptional<z.ZodArray<z.ZodString>>;
@@ -365,6 +370,7 @@ export declare const FeishuConfigSchema: z.ZodObject<{
365
370
  enabled: z.ZodOptional<z.ZodBoolean>;
366
371
  allowFrom: z.ZodPipe<z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>, z.ZodTransform<string[] | undefined, string | string[] | undefined>>;
367
372
  systemPrompt: z.ZodOptional<z.ZodString>;
373
+ allowBots: z.ZodOptional<z.ZodUnion<readonly [z.ZodBoolean, z.ZodLiteral<"mentions">]>>;
368
374
  }, z.core.$strip>>>;
369
375
  historyLimit: z.ZodOptional<z.ZodNumber>;
370
376
  dmHistoryLimit: z.ZodOptional<z.ZodNumber>;
@@ -459,6 +465,7 @@ export declare const FeishuConfigSchema: z.ZodObject<{
459
465
  all: "all";
460
466
  }>>;
461
467
  threadSession: z.ZodOptional<z.ZodBoolean>;
468
+ allowBots: z.ZodOptional<z.ZodUnion<readonly [z.ZodBoolean, z.ZodLiteral<"mentions">]>>;
462
469
  uat: z.ZodOptional<z.ZodObject<{
463
470
  enabled: z.ZodOptional<z.ZodBoolean>;
464
471
  allowedScopes: z.ZodOptional<z.ZodArray<z.ZodString>>;
@@ -105,6 +105,7 @@ const DedupSchema = zod_1.z
105
105
  maxEntries: zod_1.z.number().optional(), // default 5000
106
106
  })
107
107
  .optional();
108
+ const AllowBotsSchema = zod_1.z.union([zod_1.z.boolean(), zod_1.z.literal('mentions')]).optional();
108
109
  const ReactionNotificationModeSchema = zod_1.z.enum(['off', 'own', 'all']).optional();
109
110
  exports.UATConfigSchema = zod_1.z
110
111
  .object({
@@ -130,6 +131,7 @@ exports.FeishuGroupSchema = zod_1.z.object({
130
131
  enabled: zod_1.z.boolean().optional(),
131
132
  allowFrom: AllowFromSchema,
132
133
  systemPrompt: zod_1.z.string().optional(),
134
+ allowBots: AllowBotsSchema,
133
135
  });
134
136
  // ---------------------------------------------------------------------------
135
137
  // Account config schema (same shape as top-level minus `accounts`)
@@ -176,6 +178,7 @@ exports.FeishuAccountConfigSchema = zod_1.z.object({
176
178
  dedup: DedupSchema,
177
179
  reactionNotifications: ReactionNotificationModeSchema,
178
180
  threadSession: zod_1.z.boolean().optional(),
181
+ allowBots: AllowBotsSchema,
179
182
  uat: exports.UATConfigSchema,
180
183
  });
181
184
  // ---------------------------------------------------------------------------
@@ -153,4 +153,4 @@ export declare function filterSensitiveScopes(scopes: string[]): string[];
153
153
  * 唯一 scope 总数: 74
154
154
  * 必需应用权限总数: 20
155
155
  * 高敏感权限总数: 4
156
- */
156
+ */
@@ -341,4 +341,4 @@ function filterSensitiveScopes(scopes) {
341
341
  * 唯一 scope 总数: 74
342
342
  * 必需应用权限总数: 20
343
343
  * 高敏感权限总数: 4
344
- */
344
+ */
@@ -35,7 +35,7 @@ function buildMentionAnnotation(ctx) {
35
35
  if (mentions.length === 0)
36
36
  return undefined;
37
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.]`;
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.]`;
39
39
  }
40
40
  // ---------------------------------------------------------------------------
41
41
  // Message body builders
@@ -44,18 +44,21 @@ const media_resolver_1 = require("./media-resolver.js");
44
44
  async function resolveSenderInfo(params) {
45
45
  const { account, log } = params;
46
46
  let ctx = params.ctx;
47
- // Only resolve display name for real users the contact API
48
- // does not return results for app/bot accounts.
49
- if (ctx.rawSender?.sender_type !== 'user') {
50
- log(`sender_type is "${ctx.rawSender?.sender_type}", skipping name resolution`);
47
+ // Bots and users have separate name-resolution endpoints. The contact API
48
+ // does not return bot info, so dispatch on senderIsBot. Both endpoints
49
+ // populate the same account-scoped cache (keyed by openId).
50
+ //
51
+ // Skip resolution for unknown sender_types (e.g. anonymous, missing) — the
52
+ // contact API would 4xx and the bot API would not match. This preserves the
53
+ // pre-bot-support behavior of only resolving names for `sender_type === 'user'`.
54
+ const senderType = ctx.rawSender?.sender_type;
55
+ if (!ctx.senderIsBot && senderType !== 'user') {
56
+ log(`sender_type is "${senderType ?? 'undefined'}", skipping name resolution`);
51
57
  return { ctx };
52
58
  }
53
- // Resolve sender display name (best-effort)
54
- const senderResult = await (0, user_name_cache_1.resolveUserName)({
55
- account,
56
- openId: ctx.senderId,
57
- log,
58
- });
59
+ const senderResult = ctx.senderIsBot
60
+ ? await (0, user_name_cache_1.resolveBotName)({ account, openId: ctx.senderId, log })
61
+ : await (0, user_name_cache_1.resolveUserName)({ account, openId: ctx.senderId, log });
59
62
  if (senderResult.name) {
60
63
  ctx = { ...ctx, senderName: senderResult.name };
61
64
  log(`sender resolved: ${senderResult.name}`);
@@ -25,7 +25,7 @@
25
25
  import type { ClawdbotConfig } from 'openclaw/plugin-sdk';
26
26
  import type { HistoryEntry } from 'openclaw/plugin-sdk/reply-history';
27
27
  import type { MessageContext } from '../types';
28
- import type { FeishuConfig, LarkAccount } from '../../core/types';
28
+ import type { FeishuConfig, FeishuGroupConfig, LarkAccount } from '../../core/types';
29
29
  /**
30
30
  * Resolve the effective `respondToMentionAll` setting.
31
31
  *
@@ -42,6 +42,21 @@ export declare function resolveRespondToMentionAll(params: {
42
42
  respondToMentionAll?: boolean;
43
43
  };
44
44
  }): boolean;
45
+ /**
46
+ * Resolve the effective allowBots setting.
47
+ *
48
+ * Precedence: per-group > default ("*") > account > 'mentions'.
49
+ *
50
+ * The `'mentions'` default lets bot-to-bot interaction work out of the box
51
+ * while still requiring an explicit @-mention in groups; DMs treat it as
52
+ * pass-through. Operators can opt into fully-open (`true`) or fully-closed
53
+ * (`false`) explicitly.
54
+ */
55
+ export declare function resolveAllowBots(params: {
56
+ groupConfig?: FeishuGroupConfig;
57
+ defaultConfig?: FeishuGroupConfig;
58
+ accountFeishuCfg?: FeishuConfig;
59
+ }): boolean | 'mentions';
45
60
  /**
46
61
  * Read the pairing allowFrom store for the Feishu channel via the SDK runtime.
47
62
  */
@@ -25,6 +25,7 @@
25
25
  */
26
26
  Object.defineProperty(exports, "__esModule", { value: true });
27
27
  exports.resolveRespondToMentionAll = resolveRespondToMentionAll;
28
+ exports.resolveAllowBots = resolveAllowBots;
28
29
  exports.readFeishuAllowFromStore = readAllowFromStore;
29
30
  exports.checkMessageGate = checkMessageGate;
30
31
  const lark_client_1 = require("../../core/lark-client.js");
@@ -42,6 +43,22 @@ function resolveRespondToMentionAll(params) {
42
43
  params.accountFeishuCfg?.respondToMentionAll ??
43
44
  false);
44
45
  }
46
+ /**
47
+ * Resolve the effective allowBots setting.
48
+ *
49
+ * Precedence: per-group > default ("*") > account > 'mentions'.
50
+ *
51
+ * The `'mentions'` default lets bot-to-bot interaction work out of the box
52
+ * while still requiring an explicit @-mention in groups; DMs treat it as
53
+ * pass-through. Operators can opt into fully-open (`true`) or fully-closed
54
+ * (`false`) explicitly.
55
+ */
56
+ function resolveAllowBots(params) {
57
+ return (params.groupConfig?.allowBots ??
58
+ params.defaultConfig?.allowBots ??
59
+ params.accountFeishuCfg?.allowBots ??
60
+ 'mentions');
61
+ }
45
62
  /** Prevent spamming the legacy groupAllowFrom migration warning. */
46
63
  let legacyGroupAllowFromWarned = false;
47
64
  // ---------------------------------------------------------------------------
@@ -64,23 +81,34 @@ async function readAllowFromStore(accountId) {
64
81
  * and send pairing request messages.
65
82
  */
66
83
  async function checkMessageGate(params) {
67
- const { ctx, accountFeishuCfg, account, accountScopedCfg, log } = params;
84
+ const { ctx } = params;
85
+ if (ctx.senderIsBot) {
86
+ return checkBotSenderGate(params);
87
+ }
68
88
  const isGroup = ctx.chatType === 'group';
69
89
  if (isGroup) {
70
- return checkGroupGate({ ctx, accountFeishuCfg, account, accountScopedCfg, log });
90
+ return checkGroupGate(params);
71
91
  }
72
- return checkDmGate({ ctx, accountFeishuCfg, account, accountScopedCfg, log });
92
+ return checkDmGate(params);
73
93
  }
74
- // ---------------------------------------------------------------------------
75
- // Internal: group gate
76
- // ---------------------------------------------------------------------------
77
- function checkGroupGate(params) {
94
+ /**
95
+ * Layer 1 group-level admission check, shared between human and bot sender paths.
96
+ *
97
+ * Computes:
98
+ * - `groupPolicy` access via SDK (`resolveGroupPolicy`)
99
+ * - Legacy chat-id-in-`groupAllowFrom` compat
100
+ * - Per-group `enabled === false` kill switch
101
+ *
102
+ * Returns `rejected` non-null when the caller should reject with that result;
103
+ * otherwise the resolved per-group config is returned for downstream use.
104
+ *
105
+ * Bot senders go through the same Layer 1 as humans — `allowBots` only governs
106
+ * sender-axis admission, not which groups the account responds in.
107
+ */
108
+ function resolveFeishuGroupAccess(params) {
78
109
  const { ctx, accountFeishuCfg, account, accountScopedCfg, log } = params;
79
110
  const core = lark_client_1.LarkClient.runtime;
80
- // ---- Legacy compat: groupAllowFrom with chat_id entries ----
81
- // Older Feishu configs used groupAllowFrom with chat_ids (oc_xxx) to
82
- // control which groups are allowed. The correct semantic (aligned with
83
- // Telegram) is sender_ids. Detect and split so both layers still work.
111
+ // Legacy compat: groupAllowFrom with chat_id entries.
84
112
  const rawGroupAllowFrom = accountFeishuCfg?.groupAllowFrom ?? [];
85
113
  const { legacyChatIds, senderAllowFrom: senderGroupAllowFrom } = (0, policy_1.splitLegacyGroupAllowFrom)(rawGroupAllowFrom);
86
114
  if (legacyChatIds.length > 0 && !legacyGroupAllowFromWarned) {
@@ -92,11 +120,9 @@ function checkGroupGate(params) {
92
120
  legacyChatIds.map((id) => ` "${id}": {},`).join('\n') +
93
121
  `\n }`);
94
122
  }
95
- // ---- Layer 1: Group-level access (SDK) ----
96
- // The SDK reads `channels.feishu.groups` as an allowlist of group IDs.
97
- // - No groups configured + groupPolicy "open" any group passes
98
- // - groupPolicy "allowlist" (or groups configured) → only listed groups pass
99
- // - groupPolicy "disabled" → all groups blocked
123
+ const groupConfig = (0, policy_1.resolveFeishuGroupConfig)({ cfg: accountFeishuCfg, groupId: ctx.chatId });
124
+ const defaultConfig = accountFeishuCfg?.groups?.['*'];
125
+ // SDK group-level policy (groupPolicy disabled / allowlist / open).
100
126
  const groupAccess = core.channel.groups.resolveGroupPolicy({
101
127
  cfg: accountScopedCfg ?? {},
102
128
  channel: 'feishu',
@@ -105,33 +131,98 @@ function checkGroupGate(params) {
105
131
  groupIdCaseInsensitive: true,
106
132
  hasGroupAllowFrom: senderGroupAllowFrom.length > 0,
107
133
  });
108
- // Legacy compat: if SDK rejects the group but the chat_id is in the
109
- // old-style groupAllowFrom, allow it (backward compatibility).
110
- // Track whether this group was admitted via legacy path so we can skip
111
- // sender filtering below (old semantic: chat_id in groupAllowFrom meant
112
- // "allow this group for any sender").
113
134
  let legacyGroupAdmit = false;
114
135
  if (!groupAccess.allowed) {
115
136
  const chatIdLower = ctx.chatId.toLowerCase();
116
137
  const legacyMatch = legacyChatIds.some((id) => String(id).toLowerCase() === chatIdLower);
117
138
  if (!legacyMatch) {
118
139
  log(`feishu[${account.accountId}]: group ${ctx.chatId} blocked by group-level policy`);
119
- return { allowed: false, reason: 'group_not_allowed' };
140
+ return {
141
+ rejected: { allowed: false, reason: 'group_not_allowed' },
142
+ legacyGroupAdmit: false,
143
+ senderGroupAllowFrom,
144
+ groupConfig,
145
+ defaultConfig,
146
+ };
120
147
  }
121
148
  legacyGroupAdmit = true;
122
149
  }
123
- // ---- Per-group config (Feishu-specific fields) ----
124
- const groupConfig = (0, policy_1.resolveFeishuGroupConfig)({
125
- cfg: accountFeishuCfg,
126
- groupId: ctx.chatId,
127
- });
128
- const defaultConfig = accountFeishuCfg?.groups?.['*'];
129
- // Per-group enabled flag
130
150
  const enabled = groupConfig?.enabled ?? defaultConfig?.enabled;
131
151
  if (enabled === false) {
132
152
  log(`feishu[${account.accountId}]: group ${ctx.chatId} disabled by per-group config`);
133
- return { allowed: false, reason: 'group_disabled' };
153
+ return {
154
+ rejected: { allowed: false, reason: 'group_disabled' },
155
+ legacyGroupAdmit,
156
+ senderGroupAllowFrom,
157
+ groupConfig,
158
+ defaultConfig,
159
+ };
160
+ }
161
+ return { rejected: null, legacyGroupAdmit, senderGroupAllowFrom, groupConfig, defaultConfig };
162
+ }
163
+ // ---------------------------------------------------------------------------
164
+ // Internal: bot sender gate
165
+ // ---------------------------------------------------------------------------
166
+ function checkBotSenderGate(params) {
167
+ const { ctx, accountFeishuCfg, account, log } = params;
168
+ const isGroup = ctx.chatType === 'group';
169
+ // 1. Layer 1 group access — bot senders are subject to the same group-level
170
+ // admission as humans. `allowBots` is a sender-axis filter, not a group-axis
171
+ // filter; an account configured to ignore a group must ignore bots there too.
172
+ let groupConfig;
173
+ let defaultConfig;
174
+ if (isGroup) {
175
+ const access = resolveFeishuGroupAccess(params);
176
+ if (access.rejected)
177
+ return access.rejected;
178
+ groupConfig = access.groupConfig;
179
+ defaultConfig = access.defaultConfig;
134
180
  }
181
+ // 2. Resolve allowBots (per-group > default > account > 'mentions')
182
+ const allowBots = resolveAllowBots({ groupConfig, defaultConfig, accountFeishuCfg });
183
+ // 3. allowBots === false → drop
184
+ if (allowBots === false) {
185
+ log(`feishu[${account.accountId}]: drop bot sender ${ctx.senderId} in ${ctx.chatId} (allowBots=false)`);
186
+ return { allowed: false, reason: 'bot_sender_disabled' };
187
+ }
188
+ // 4. allowBots === 'mentions' + bot not mentioned → drop (group only;
189
+ // DMs have no @-mention concept, so mention-mode is a pass-through there).
190
+ if (isGroup && allowBots === 'mentions' && !(0, mention_1.mentionedBot)(ctx)) {
191
+ log(`feishu[${account.accountId}]: drop bot sender ${ctx.senderId} in ${ctx.chatId} (allowBots=mentions, not mentioned)`);
192
+ return { allowed: false, reason: 'bot_sender_not_mentioned' };
193
+ }
194
+ // 5. Group requireMention check — redundant with allowBots='mentions' but
195
+ // necessary for the explicit `allowBots=true + requireMention=true` combo.
196
+ //
197
+ // NOTE: this intentionally diverges from the human-sender path (checkGroupGate),
198
+ // which delegates to SDK's resolveRequireMention that defaults to true.
199
+ // For bot senders, `requireMention` must be explicitly set to true — the
200
+ // rationale being: if the operator opts into `allowBots=true`, they want
201
+ // bot traffic through by default. Holding bots to a true-default mention
202
+ // requirement would silently negate `allowBots=true` in most configs.
203
+ if (isGroup) {
204
+ const requireMention = groupConfig?.requireMention ??
205
+ defaultConfig?.requireMention ??
206
+ accountFeishuCfg?.requireMention;
207
+ if (requireMention === true && !(0, mention_1.mentionedBot)(ctx)) {
208
+ log(`feishu[${account.accountId}]: drop bot sender ${ctx.senderId} (no_mention)`);
209
+ // Intentionally NO historyEntry — bot messages never enter chat history.
210
+ return { allowed: false, reason: 'no_mention' };
211
+ }
212
+ }
213
+ return { allowed: true };
214
+ }
215
+ // ---------------------------------------------------------------------------
216
+ // Internal: group gate
217
+ // ---------------------------------------------------------------------------
218
+ function checkGroupGate(params) {
219
+ const { ctx, accountFeishuCfg, account, accountScopedCfg, log } = params;
220
+ const core = lark_client_1.LarkClient.runtime;
221
+ // ---- Layer 1: Group-level admission (shared with bot path) ----
222
+ const access = resolveFeishuGroupAccess(params);
223
+ if (access.rejected)
224
+ return access.rejected;
225
+ const { legacyGroupAdmit, senderGroupAllowFrom, groupConfig, defaultConfig } = access;
135
226
  // ---- Layer 2: Sender-level access ----
136
227
  // Per-group groupPolicy overrides the global groupPolicy for sender filtering.
137
228
  // senderGroupAllowFrom (global, oc_ entries excluded) + per-group allowFrom.
@@ -117,6 +117,10 @@ async function parseMessageEvent(event, botOpenId, expandCtx) {
117
117
  resources,
118
118
  mentions: mentionList,
119
119
  mentionAll,
120
+ // Per Feishu docs, im.message.receive_v1 sets sender_type to 'user' or
121
+ // 'bot'. We also accept 'app' defensively for any SDK/legacy variant that
122
+ // surfaces the older value.
123
+ senderIsBot: event.sender.sender_type === 'bot' || event.sender.sender_type === 'app',
120
124
  createTime: Number.isNaN(createTime) ? undefined : createTime,
121
125
  rawMessage: effectiveContent !== event.message.content ? { ...event.message, content: effectiveContent } : event.message,
122
126
  rawSender: event.sender,
@@ -41,6 +41,18 @@ export interface ResolveUserNameResult {
41
41
  name?: string;
42
42
  permissionError?: PermissionError;
43
43
  }
44
+ /**
45
+ * Resolve a single bot's display name via `/open-apis/bot/v3/bots/basic_batch`.
46
+ *
47
+ * Bots are not returned by the contact API, so they have their own endpoint.
48
+ * Names share the same account-scoped cache (keyed by openId) since both
49
+ * bots and users have `ou_` prefixed openIds and a single display name.
50
+ */
51
+ export declare function resolveBotName(params: {
52
+ account: LarkAccount;
53
+ openId: string;
54
+ log: (...args: unknown[]) => void;
55
+ }): Promise<ResolveUserNameResult>;
44
56
  /**
45
57
  * Resolve a single user's display name.
46
58
  *
@@ -16,6 +16,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
16
16
  exports.getUserNameCache = exports.clearUserNameCache = exports.UserNameCache = void 0;
17
17
  exports.batchResolveUserNames = batchResolveUserNames;
18
18
  exports.createBatchResolveNames = createBatchResolveNames;
19
+ exports.resolveBotName = resolveBotName;
19
20
  exports.resolveUserName = resolveUserName;
20
21
  const lark_client_1 = require("../../core/lark-client.js");
21
22
  const user_name_cache_store_1 = require("./user-name-cache-store.js");
@@ -100,6 +101,51 @@ function createBatchResolveNames(account, log) {
100
101
  await batchResolveUserNames({ account, openIds, log });
101
102
  };
102
103
  }
104
+ /**
105
+ * Resolve a single bot's display name via `/open-apis/bot/v3/bots/basic_batch`.
106
+ *
107
+ * Bots are not returned by the contact API, so they have their own endpoint.
108
+ * Names share the same account-scoped cache (keyed by openId) since both
109
+ * bots and users have `ou_` prefixed openIds and a single display name.
110
+ */
111
+ async function resolveBotName(params) {
112
+ const { account, openId, log } = params;
113
+ if (!account.configured || !openId)
114
+ return {};
115
+ const cache = (0, user_name_cache_store_1.getUserNameCache)(account.accountId);
116
+ if (cache.has(openId))
117
+ return { name: cache.get(openId) ?? '' };
118
+ try {
119
+ const client = lark_client_1.LarkClient.fromAccount(account).sdk;
120
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
121
+ const res = await client.request({
122
+ method: 'GET',
123
+ url: '/open-apis/bot/v3/bots/basic_batch',
124
+ params: { bot_ids: [openId] },
125
+ });
126
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
127
+ const bot = res?.data?.bots?.[openId];
128
+ const name = bot?.name || bot?.i18n_names?.zh_cn || bot?.i18n_names?.en_us || '';
129
+ // Cache even empty names to avoid repeated API calls for bots
130
+ // whose names we cannot resolve.
131
+ cache.set(openId, name);
132
+ return { name: name || undefined };
133
+ }
134
+ catch (err) {
135
+ // Bot name resolution is best-effort: missing `bot:basic_info` scope
136
+ // should not surface as a permission notification to the agent. Log
137
+ // and cache an empty name so we don't retry, then fall back to openId.
138
+ const permErr = (0, permission_1.extractPermissionError)(err);
139
+ if (permErr) {
140
+ log(`feishu: permission error resolving bot name (best-effort, ignored): code=${permErr.code}`);
141
+ }
142
+ else {
143
+ log(`feishu: failed to resolve bot name for ${openId}: ${String(err)}`);
144
+ }
145
+ cache.set(openId, '');
146
+ return {};
147
+ }
148
+ }
103
149
  /**
104
150
  * Resolve a single user's display name.
105
151
  *
@@ -270,6 +270,14 @@ export interface MessageContext {
270
270
  mentions: MentionInfo[];
271
271
  /** Whether an @all / @所有人 mention was detected in the message. */
272
272
  mentionAll: boolean;
273
+ /**
274
+ * True when the event sender is a bot/app (sender_type === 'app').
275
+ *
276
+ * Set by parseMessageEvent. Optional because synthetic construction sites
277
+ * (comment / reaction / vc-invited handlers) omit it — absence is treated
278
+ * as `false` since those paths only originate from human actors today.
279
+ */
280
+ senderIsBot?: boolean;
273
281
  rootId?: string;
274
282
  parentId?: string;
275
283
  threadId?: string;
@@ -324,7 +324,7 @@ function registerFeishuTaskTaskTool(api) {
324
324
  page_token: p.page_token,
325
325
  completed: p.completed,
326
326
  agent_task_status: p.agent_task_status,
327
- user_id_type: (p.user_id_type || 'open_id'),
327
+ user_id_type: p.user_id_type || 'open_id',
328
328
  },
329
329
  }, opts), { as: authType });
330
330
  (0, helpers_1.assertLarkOk)(res);