@tobeyoureyes/feishu 1.0.0 → 1.1.0

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/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # @openclaw/feishu
1
+ # @tobeyoureyes/feishu
2
2
 
3
3
  飞书/Lark 企业消息平台的 OpenClaw 插件。支持私聊、群聊、消息回复、媒体处理和卡片消息渲染。
4
4
 
@@ -15,7 +15,7 @@
15
15
  ## 安装
16
16
 
17
17
  ```bash
18
- openclaw plugins install @openclaw/feishu
18
+ openclaw plugins install @tobeyoureyes/feishu
19
19
  ```
20
20
 
21
21
  ## 快速开始
@@ -141,7 +141,6 @@ openclaw start
141
141
  │ │ 渲染模式判断 │
142
142
  │ │ (auto/raw/card) │
143
143
  │ │ │ │
144
- │ │ ▼ │
145
144
  │ └─────────────────────► 发送回复 │
146
145
  │ │
147
146
  └─────────────────────────────────────────────────────────────┘
@@ -159,7 +158,7 @@ openclaw start
159
158
 
160
159
  自动检测以下内容并使用卡片渲染:
161
160
 
162
- - 代码块 (\`\`\`code\`\`\`)
161
+ - 代码块 (```code```)
163
162
  - 表格 (|header|)
164
163
  - Markdown 链接 [text](url)
165
164
  - 长文本 (>500 字符)
@@ -238,7 +237,7 @@ import {
238
237
  createTableCard,
239
238
  createCardWithButtons,
240
239
  createMultiSectionCard,
241
- } from "@openclaw/feishu/api";
240
+ } from "@tobeyoureyes/feishu/api";
242
241
 
243
242
  // 简单卡片
244
243
  const card1 = createSimpleCard("标题", "Markdown 内容", "blue");
@@ -267,7 +266,7 @@ const card4 = createCardWithButtons(
267
266
  ## 文件结构
268
267
 
269
268
  ```
270
- extensions/feishu/
269
+ chat_feishu/
271
270
  ├── src/
272
271
  │ ├── api.ts # Feishu API 封装
273
272
  │ ├── auth.ts # Token 认证管理
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tobeyoureyes/feishu",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "OpenClaw Feishu (Lark) channel plugin",
5
5
  "type": "module",
6
6
  "files": [
@@ -35,7 +35,7 @@
35
35
  },
36
36
  "install": {
37
37
  "npmSpec": "@tobeyoureyes/feishu",
38
- "localPath": "extensions/feishu",
38
+ "localPath": ".",
39
39
  "defaultChoice": "npm"
40
40
  }
41
41
  }
package/src/api.ts CHANGED
@@ -341,7 +341,7 @@ export async function getUserInfo(
341
341
  export async function probeFeishu(
342
342
  account: ResolvedFeishuAccount,
343
343
  timeoutMs = 10000,
344
- ): Promise<{ ok: boolean; error?: string }> {
344
+ ): Promise<{ ok: boolean; error?: string; bot?: { open_id: string; app_name?: string } }> {
345
345
  try {
346
346
  const controller = new AbortController();
347
347
  const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
@@ -374,6 +374,24 @@ export async function probeFeishu(
374
374
  return { ok: false, error: data.msg };
375
375
  }
376
376
 
377
+ // Get bot info for mention detection
378
+ const botInfoResponse = await fetch(`${apiBase}/bot/v3/info`, {
379
+ method: "GET",
380
+ headers: {
381
+ "Authorization": `Bearer ${await getTenantAccessToken(account)}`,
382
+ },
383
+ });
384
+
385
+ if (botInfoResponse.ok) {
386
+ const botData = (await botInfoResponse.json()) as FeishuApiResponse<{
387
+ app_name?: string;
388
+ open_id?: string;
389
+ }>;
390
+ if (botData.code === 0 && botData.data?.open_id) {
391
+ return { ok: true, bot: { open_id: botData.data.open_id, app_name: botData.data.app_name } };
392
+ }
393
+ }
394
+
377
395
  return { ok: true };
378
396
  } catch (error) {
379
397
  const message = error instanceof Error ? error.message : String(error);
package/src/channel.ts CHANGED
@@ -715,8 +715,12 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
715
715
  return;
716
716
  }
717
717
 
718
- // Get bot's open_id for mention detection
718
+ // Get bot's open_id/name for mention detection
719
719
  const botOpenId = (probe as { bot?: { open_id?: string } }).bot?.open_id;
720
+ const botName = (probe as { bot?: { app_name?: string } }).bot?.app_name;
721
+ if (!botOpenId && !botName) {
722
+ ctx.log?.warn(`${logPrefix} bot identity unavailable; mention detection may be limited`);
723
+ }
720
724
 
721
725
  // Initialize message deduplication cache
722
726
  const dedupe = createFeishuDedupeCache();
@@ -724,9 +728,8 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
724
728
  // Initialize group history manager
725
729
  const history = createGroupHistoryManager();
726
730
 
727
- // Create reply senders
728
- const sendReply = createDefaultReplySender(account);
729
- const sendMedia = createDefaultMediaSender(account);
731
+ // Note: Reply senders are created inside handleInboundMessage
732
+ // because they need chatId which is only available at message time
730
733
 
731
734
  /**
732
735
  * Handle inbound message - build context, dispatch to agent, send reply
@@ -766,6 +769,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
766
769
  account,
767
770
  cfg: ctx.cfg,
768
771
  botOpenId,
772
+ botName,
769
773
  pendingHistory,
770
774
  });
771
775
 
@@ -778,6 +782,10 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
778
782
  return;
779
783
  }
780
784
 
785
+ // Create reply senders with chatId for this message
786
+ const sendReply = createDefaultReplySender(account, inbound.chatId, isGroup);
787
+ const sendMedia = createDefaultMediaSender(account, inbound.chatId, isGroup);
788
+
781
789
  // Dispatch to agent using local module
782
790
  await dispatchFeishuMessage({
783
791
  context,
package/src/context.ts CHANGED
@@ -34,6 +34,7 @@ export interface BuildFeishuMessageContextParams {
34
34
  account: ResolvedFeishuAccount;
35
35
  cfg: OpenClawConfig;
36
36
  botOpenId?: string;
37
+ botName?: string;
37
38
  sendTyping?: () => Promise<void>;
38
39
  /** Pending group history entries for context */
39
40
  pendingHistory?: HistoryEntry[];
@@ -42,11 +43,39 @@ export interface BuildFeishuMessageContextParams {
42
43
  /**
43
44
  * Check if bot was mentioned in the message
44
45
  */
45
- function isBotMentioned(message: FeishuInboundMessage, botOpenId?: string): boolean {
46
- if (!botOpenId || !message.mentions) {
46
+ function normalizeMentionName(name: string): string {
47
+ return name.trim().toLowerCase();
48
+ }
49
+
50
+ function isBotMentioned(
51
+ message: FeishuInboundMessage,
52
+ botOpenId?: string,
53
+ botName?: string,
54
+ ): boolean {
55
+ if (!message.mentions || message.mentions.length === 0) {
47
56
  return false;
48
57
  }
49
- return message.mentions.some((m) => m.id === botOpenId);
58
+
59
+ if (botOpenId && message.mentions.some((m) => m.id === botOpenId)) {
60
+ return true;
61
+ }
62
+
63
+ if (botName) {
64
+ const normalized = normalizeMentionName(botName);
65
+ if (
66
+ message.mentions.some(
67
+ (m) => m.name && normalizeMentionName(m.name) === normalized,
68
+ )
69
+ ) {
70
+ return true;
71
+ }
72
+ const displayText = message.displayText ?? message.text ?? "";
73
+ if (displayText.toLowerCase().includes(`@${normalized}`)) {
74
+ return true;
75
+ }
76
+ }
77
+
78
+ return false;
50
79
  }
51
80
 
52
81
  /**
@@ -117,7 +146,7 @@ function buildHistoryContext(entries: HistoryEntry[], currentMessage: string): s
117
146
  export async function buildFeishuMessageContext(
118
147
  params: BuildFeishuMessageContextParams,
119
148
  ): Promise<FeishuMessageContext | null> {
120
- const { message, account, cfg, botOpenId, sendTyping, pendingHistory } = params;
149
+ const { message, account, cfg, botOpenId, botName, sendTyping, pendingHistory } = params;
121
150
  const core = getFeishuRuntime();
122
151
 
123
152
  const isGroup = message.chatType === "group";
@@ -171,14 +200,24 @@ export async function buildFeishuMessageContext(
171
200
  }
172
201
 
173
202
  const requireMention = account.requireMention;
174
- const wasMentioned = isBotMentioned(message, botOpenId);
203
+ const wasMentioned = isBotMentioned(message, botOpenId, botName);
204
+ const canIdentifyBot = Boolean(botOpenId || botName);
175
205
 
176
206
  // Simple mention gating: if requireMention is true and bot was not mentioned, skip
177
207
  if (requireMention && !wasMentioned) {
178
- if (core.logging.shouldLogVerbose()) {
179
- core.logging.getChildLogger({ module: "feishu" }).debug("skipping group message (no mention)");
208
+ if (!canIdentifyBot) {
209
+ // Bot identity not resolved; allow to avoid blocking all group messages
210
+ core.logging.getChildLogger({ module: "feishu" }).warn(
211
+ "bot open_id/name unavailable; allowing group message without mention check",
212
+ );
213
+ } else {
214
+ if (core.logging.shouldLogVerbose()) {
215
+ core.logging
216
+ .getChildLogger({ module: "feishu" })
217
+ .debug("skipping group message (no mention)");
218
+ }
219
+ return null;
180
220
  }
181
- return null;
182
221
  }
183
222
  }
184
223
 
package/src/dispatch.ts CHANGED
@@ -96,21 +96,25 @@ export async function dispatchFeishuMessage(
96
96
 
97
97
  logger.info(`deliver called: text=${text.slice(0, 100)} mediaUrl=${mediaUrl}`);
98
98
 
99
+ // Only use reply quote in group chats, not in DM/private chats
100
+ const replyToId = context.isGroup ? context.message.messageId : undefined;
101
+ logger.info(`dispatch deliver: isGroup=${context.isGroup} chatType=${context.message?.chatType} replyToId=${replyToId}`);
102
+
99
103
  if (mediaUrl && onSendMedia) {
100
- logger.info(`sending media to ${context.message.messageId}`);
104
+ logger.info(`sending media to ${context.message.messageId} (isGroup=${context.isGroup})`);
101
105
  const result = await onSendMedia({
102
106
  text,
103
107
  mediaUrl,
104
- replyToId: context.message.messageId,
108
+ replyToId,
105
109
  });
106
110
  logger.info(`media send result: ${JSON.stringify(result)}`);
107
111
  } else if (text && onSendReply) {
108
112
  // Determine render mode based on content
109
113
  const renderMode = api.shouldUseCardRendering(text) ? "card" : account.renderMode;
110
- logger.info(`sending reply to ${context.message.messageId} renderMode=${renderMode}`);
114
+ logger.info(`sending reply to ${context.message.messageId} renderMode=${renderMode} (isGroup=${context.isGroup})`);
111
115
  const result = await onSendReply({
112
116
  text,
113
- replyToId: context.message.messageId,
117
+ replyToId,
114
118
  renderMode,
115
119
  });
116
120
  logger.info(`reply send result: ${JSON.stringify(result)}`);
@@ -150,36 +154,79 @@ export async function dispatchFeishuMessage(
150
154
 
151
155
  /**
152
156
  * Create default reply sender using Feishu API
157
+ * @param account - Feishu account
158
+ * @param chatId - Chat ID for sending new messages (when not replying)
153
159
  */
154
- export function createDefaultReplySender(account: ResolvedFeishuAccount) {
160
+ export function createDefaultReplySender(
161
+ account: ResolvedFeishuAccount,
162
+ chatId: string,
163
+ isGroup: boolean,
164
+ ) {
165
+ const core = getFeishuRuntime();
166
+ const logger = core.logging.getChildLogger({ module: "feishu" });
167
+
155
168
  return async (params: {
156
169
  text: string;
157
170
  replyToId?: string;
158
171
  renderMode?: FeishuRenderMode;
159
172
  }) => {
160
- if (!params.replyToId) {
161
- return { ok: false, error: "Missing replyToId" };
173
+ logger.info(
174
+ `createDefaultReplySender: replyToId=${params.replyToId} chatId=${chatId} isGroup=${isGroup}`,
175
+ );
176
+
177
+ // If replyToId is provided, use reply API (quote reply)
178
+ // Otherwise, send as new message without quote
179
+ if (params.replyToId && isGroup) {
180
+ logger.info(`Using replyMessage API (quote reply)`);
181
+ return api.replyMessage(account, params.replyToId, params.text, {
182
+ renderMode: params.renderMode,
183
+ });
162
184
  }
163
- return api.replyMessage(account, params.replyToId, params.text, {
185
+ // Send as new message without quote (for DM/private chats)
186
+ // Determine receiveIdType based on chatId format
187
+ const receiveIdType = chatId.startsWith("ou_") ? "open_id" : "chat_id";
188
+ logger.info(`Using sendSmart API (no quote) receiveIdType=${receiveIdType}`);
189
+ return api.sendSmart(account, chatId, params.text, {
164
190
  renderMode: params.renderMode,
191
+ receiveIdType,
165
192
  });
166
193
  };
167
194
  }
168
195
 
169
196
  /**
170
197
  * Create default media sender using Feishu API
198
+ * @param account - Feishu account
199
+ * @param chatId - Chat ID for sending new messages (when not replying)
171
200
  */
172
- export function createDefaultMediaSender(account: ResolvedFeishuAccount) {
201
+ export function createDefaultMediaSender(
202
+ account: ResolvedFeishuAccount,
203
+ chatId: string,
204
+ isGroup: boolean,
205
+ ) {
206
+ const core = getFeishuRuntime();
207
+ const logger = core.logging.getChildLogger({ module: "feishu" });
208
+
173
209
  return async (params: {
174
210
  text?: string;
175
211
  mediaUrl: string;
176
212
  replyToId?: string;
177
213
  }) => {
178
- if (!params.replyToId) {
179
- return { ok: false, error: "Missing replyToId" };
180
- }
181
214
  // For now, send media URL as text
182
215
  const text = params.text ? `${params.text}\n\n${params.mediaUrl}` : params.mediaUrl;
183
- return api.replyMessage(account, params.replyToId, text);
216
+
217
+ logger.info(
218
+ `createDefaultMediaSender: replyToId=${params.replyToId} chatId=${chatId} isGroup=${isGroup}`,
219
+ );
220
+
221
+ // If replyToId is provided, use reply API (quote reply)
222
+ // Otherwise, send as new message without quote
223
+ if (params.replyToId && isGroup) {
224
+ logger.info(`Using replyMessage API (quote reply)`);
225
+ return api.replyMessage(account, params.replyToId, text);
226
+ }
227
+ // Send as new message without quote (for DM/private chats)
228
+ const receiveIdType = chatId.startsWith("ou_") ? "open_id" : "chat_id";
229
+ logger.info(`Using sendSmart API (no quote) receiveIdType=${receiveIdType}`);
230
+ return api.sendSmart(account, chatId, text, { receiveIdType });
184
231
  };
185
232
  }