@tobeyoureyes/feishu 1.0.0 → 1.1.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/README.md +5 -6
- package/package.json +5 -11
- package/src/api.ts +19 -1
- package/src/channel.ts +12 -4
- package/src/context.ts +47 -8
- package/src/dispatch.ts +60 -13
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# @
|
|
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 @
|
|
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
|
-
- 代码块 (
|
|
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 "@
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"description": "OpenClaw Feishu (Lark) channel plugin",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -8,17 +8,11 @@
|
|
|
8
8
|
"src",
|
|
9
9
|
"openclaw.plugin.json"
|
|
10
10
|
],
|
|
11
|
-
"
|
|
12
|
-
"@larksuiteoapi/node-sdk": "^1.58.0",
|
|
13
|
-
"openclaw": "workspace:*"
|
|
14
|
-
},
|
|
15
|
-
"peerDependencies": {
|
|
11
|
+
"dependencies": {
|
|
16
12
|
"@larksuiteoapi/node-sdk": "^1.58.0"
|
|
17
13
|
},
|
|
18
|
-
"
|
|
19
|
-
"
|
|
20
|
-
"optional": true
|
|
21
|
-
}
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"openclaw": "workspace:*"
|
|
22
16
|
},
|
|
23
17
|
"openclaw": {
|
|
24
18
|
"extensions": [
|
|
@@ -35,7 +29,7 @@
|
|
|
35
29
|
},
|
|
36
30
|
"install": {
|
|
37
31
|
"npmSpec": "@tobeyoureyes/feishu",
|
|
38
|
-
"localPath": "
|
|
32
|
+
"localPath": ".",
|
|
39
33
|
"defaultChoice": "npm"
|
|
40
34
|
}
|
|
41
35
|
}
|
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
|
-
//
|
|
728
|
-
|
|
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
|
|
46
|
-
|
|
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
|
-
|
|
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 (
|
|
179
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
161
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
}
|