@wu529778790/open-im 1.9.4-beta.12 → 1.9.4-beta.13

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,5 +1,12 @@
1
1
  import { Client } from '@larksuiteoapi/node-sdk';
2
2
  import type { Config } from '../config.js';
3
3
  export declare function getClient(): Client;
4
+ /** 获取当前飞书应用 appId(用于构建权限链接等) */
5
+ export declare function getAppId(): string;
6
+ /**
7
+ * 格式化飞书初始化错误(供 index.ts 平台注册使用)
8
+ * 参照 DingTalk 的 formatDingTalkInitError 模式
9
+ */
10
+ export declare function formatFeishuInitError(err: unknown): string;
4
11
  export declare function initFeishu(config: Config, eventHandler: (data: unknown) => Promise<void | Record<string, unknown>>): Promise<void>;
5
12
  export declare function stopFeishu(): void;
@@ -1,5 +1,6 @@
1
1
  import { Client, WSClient, EventDispatcher, LoggerLevel } from '@larksuiteoapi/node-sdk';
2
2
  import { createLogger } from '../logger.js';
3
+ import { isPermissionError, logPermissionGuide } from './permission.js';
3
4
  const log = createLogger('Feishu');
4
5
  let client = null;
5
6
  let wsClient = null;
@@ -8,6 +9,30 @@ export function getClient() {
8
9
  throw new Error('Feishu client not initialized');
9
10
  return client;
10
11
  }
12
+ /** 获取当前飞书应用 appId(用于构建权限链接等) */
13
+ export function getAppId() {
14
+ if (!client)
15
+ throw new Error('Feishu client not initialized');
16
+ return client.appId;
17
+ }
18
+ /**
19
+ * 格式化飞书初始化错误(供 index.ts 平台注册使用)
20
+ * 参照 DingTalk 的 formatDingTalkInitError 模式
21
+ */
22
+ export function formatFeishuInitError(err) {
23
+ if (isPermissionError(err)) {
24
+ const appId = client?.appId;
25
+ const permUrl = appId ? `https://open.feishu.cn/app/${appId}/permission` : 'https://open.feishu.cn/app';
26
+ return [
27
+ '飞书应用权限不足。',
28
+ `请前往开通权限: ${permUrl}`,
29
+ '需要: im:message, im:message:send_as_bot, im:resource, im:chat',
30
+ ].join(' ');
31
+ }
32
+ if (err instanceof Error)
33
+ return err.message;
34
+ return String(err).slice(0, 200);
35
+ }
11
36
  export async function initFeishu(config, eventHandler) {
12
37
  if (!config.feishuAppId || !config.feishuAppSecret) {
13
38
  throw new Error('Feishu app_id and app_secret are required');
@@ -70,6 +95,30 @@ export async function initFeishu(config, eventHandler) {
70
95
  log.error('Failed to start Feishu WebSocket:', err);
71
96
  throw err;
72
97
  }
98
+ // 启动时校验凭证有效性并输出权限要求提示
99
+ try {
100
+ const tokenResp = await client.auth.tenantAccessToken.internal({
101
+ data: {
102
+ app_id: config.feishuAppId,
103
+ app_secret: config.feishuAppSecret,
104
+ },
105
+ });
106
+ if (tokenResp.code !== 0 || !tokenResp.data) {
107
+ throw new Error(`Feishu credentials invalid: ${tokenResp.msg} (code: ${tokenResp.code})`);
108
+ }
109
+ log.info('Feishu credentials validated successfully');
110
+ }
111
+ catch (err) {
112
+ if (isPermissionError(err)) {
113
+ log.error('飞书应用凭证校验失败 — 权限不足');
114
+ }
115
+ else {
116
+ log.error('飞书应用凭证校验失败:', err instanceof Error ? err.message : err);
117
+ }
118
+ throw err;
119
+ }
120
+ // 输出权限要求提示(连接成功后)
121
+ logPermissionGuide(config.feishuAppId);
73
122
  log.info('Feishu client initialized');
74
123
  }
75
124
  export function stopFeishu() {
@@ -11,102 +11,8 @@ import { buildProgressNote } from '../shared/message-note.js';
11
11
  import { createPlatformEventContext } from '../platform/create-event-context.js';
12
12
  import { createPlatformAIRequestHandler } from '../platform/handle-ai-request.js';
13
13
  import { handleTextFlow } from '../platform/handle-text-flow.js';
14
+ import { isPermissionError, handlePermissionError } from './permission.js';
14
15
  const log = createLogger('FeishuHandler');
15
- /**
16
- /**
17
- *
18
- 从异常中提取飞书 API 错误码
19
- */
20
- function extractFeishuErrorCode(err) {
21
- const e = err;
22
- if (e?.response?.data?.code)
23
- return e.response.data.code;
24
- if (e?.code)
25
- return e.code;
26
- return undefined;
27
- }
28
- /**
29
- * 根据错误码判断是否为权限不足
30
- */
31
- function isPermissionError(err) {
32
- const code = extractFeishuErrorCode(err);
33
- if (!code) {
34
- // 非标准错误:检查 message 中是否包含权限关键词
35
- const msg = err?.message ?? String(err);
36
- return /permission|权限|scope|not authorized|no access|forbidden/i.test(msg);
37
- }
38
- // 飞书常见权限错误码
39
- return [
40
- 99991400, // 权限不足
41
- 99991401, // 没有API权限
42
- 99991663, // 应用未获取 scope
43
- 99991672, // 应用未开通相关能力
44
- 99991670, // 应用未上架/未授权
45
- 99991668, // 应用可见性限制
46
- ].includes(code);
47
- }
48
- /**
49
- * 构建飞书权限配置指引消息
50
- */
51
- function buildPermissionGuideMessage(err) {
52
- const code = extractFeishuErrorCode(err);
53
- const codeHint = code ? ` (错误码: ${code})` : '';
54
- return [
55
- '⚠️ **飞书应用权限不足,无法发送消息**' + codeHint,
56
- '',
57
- '请按以下步骤开通所需权限:',
58
- '',
59
- '**1. 进入飞书开放平台**',
60
- '👉 https://open.feishu.cn/app',
61
- '',
62
- '**2. 找到你的应用,进入「权限管理」**',
63
- '',
64
- '**3. 开通以下权限(搜索权限名称添加):**',
65
- '• `im:message` — 获取与发送单聊、群组消息',
66
- '• `im:message:send_as_bot` — 以应用身份发消息',
67
- '• `im:resource` — 获取与上传图片或文件资源',
68
- '• `im:chat` — 获取群组信息',
69
- '',
70
- '**4. 如需使用卡片打字机效果,还需开通:**',
71
- '• `cardkit:card` — CardKit 卡片管理',
72
- '',
73
- '**5. 发布版本**',
74
- '权限修改后需点击「创建版本」→「发布」,管理员审批后生效。',
75
- '',
76
- '📖 详细文档:https://open.feishu.cn/document/ukTMukTMukTM/ucTM5YjL3ETO04yNxkDN',
77
- ].join('\n');
78
- }
79
- /**
80
- * 发送权限错误提示,依次尝试:卡片 → 纯文本 → open_id
81
- */
82
- async function sendPermissionFallback(chatId, guide) {
83
- // 1. 先尝试 sendTextReply(发卡片消息)
84
- try {
85
- await sendTextReply(chatId, guide);
86
- return;
87
- }
88
- catch (err) {
89
- log.warn('Card-based reply failed, falling back to plain text:', err);
90
- }
91
- // 2. 降级为纯文本消息
92
- try {
93
- const client = (await import('./client.js')).getClient();
94
- const plainGuide = guide.replace(/\*\*/g, '').replace(/`/g, '');
95
- await client.im.message.create({
96
- data: {
97
- receive_id: chatId,
98
- msg_type: 'text',
99
- content: JSON.stringify({ text: plainGuide }),
100
- },
101
- params: { receive_id_type: 'chat_id' },
102
- });
103
- return;
104
- }
105
- catch (err) {
106
- log.warn('Plain text reply also failed:', err);
107
- }
108
- log.error('All fallback methods failed to send permission guide');
109
- }
110
16
  async function downloadFeishuMessageResource(client, messageId, fileKey, type, options) {
111
17
  const targetPath = createMediaTargetPath(options?.fallbackExtension ?? 'bin', options?.basenameHint ?? fileKey);
112
18
  const response = await client.im.messageResource.get({
@@ -152,12 +58,9 @@ export function setupFeishuHandlers(config, sessionManager) {
152
58
  continue;
153
59
  }
154
60
  log.error(`Failed to send thinking card after ${attempt} attempts:`, err);
155
- // Detect permission errors
61
+ // 检测权限错误并输出友好提示
156
62
  if (isPermissionError(err)) {
157
- const guide = buildPermissionGuideMessage(err);
158
- await sendPermissionFallback(chatId, guide).catch((e) => {
159
- log.warn('Permission fallback send failed:', e);
160
- });
63
+ handlePermissionError(err, chatId);
161
64
  }
162
65
  throw err;
163
66
  }
@@ -8,6 +8,7 @@ import { getAIToolDisplayName } from '../shared/utils.js';
8
8
  import { buildMessageTitle, OPEN_IM_SYSTEM_TITLE } from '../shared/message-title.js';
9
9
  import { buildTextNote } from '../shared/message-note.js';
10
10
  import { createCard, enableStreaming, sendCardMessage, streamContent as cardkitStreamContent, updateCardFull, markCompleted, disableStreaming, destroySession, } from './cardkit-manager.js';
11
+ import { isPermissionError, handlePermissionError } from './permission.js';
11
12
  const log = createLogger('FeishuSender');
12
13
  const STATUS_CONFIG = {
13
14
  thinking: { icon: '🔵', template: 'blue', title: '思考中' },
@@ -101,6 +102,9 @@ export async function sendThinkingMessage(chatId, replyToMessageId, toolId = 'cl
101
102
  }
102
103
  catch (err) {
103
104
  log.error('Failed to send thinking message:', err);
105
+ if (isPermissionError(err)) {
106
+ handlePermissionError(err, chatId);
107
+ }
104
108
  throw err;
105
109
  }
106
110
  }
@@ -137,14 +141,23 @@ export async function sendFinalCards(chatId, _messageId, cardId, fullContent, no
137
141
  for (let i = 1; i < parts.length; i++) {
138
142
  const overflowContent = `${parts[i]}\n\n_*(续 ${i + 1}/${parts.length})*_`;
139
143
  const overflowCard = createFeishuCard(getToolTitle(toolId, 'done'), overflowContent, 'done', note);
140
- await client.im.message.create({
141
- data: {
142
- receive_id: chatId,
143
- msg_type: 'interactive',
144
- content: overflowCard,
145
- },
146
- params: { receive_id_type: 'chat_id' },
147
- });
144
+ try {
145
+ await client.im.message.create({
146
+ data: {
147
+ receive_id: chatId,
148
+ msg_type: 'interactive',
149
+ content: overflowCard,
150
+ },
151
+ params: { receive_id_type: 'chat_id' },
152
+ });
153
+ }
154
+ catch (err) {
155
+ if (isPermissionError(err)) {
156
+ handlePermissionError(err, chatId);
157
+ break;
158
+ }
159
+ log.error(`Failed to send overflow card ${i + 1}:`, err);
160
+ }
148
161
  }
149
162
  destroySession(cardId);
150
163
  }
@@ -285,6 +298,10 @@ export async function sendFinalMessages(chatId, messageId, fullContent, note, to
285
298
  });
286
299
  }
287
300
  catch (err) {
301
+ if (isPermissionError(err)) {
302
+ handlePermissionError(err, chatId);
303
+ break;
304
+ }
288
305
  log.error(`Failed to send part ${i + 1}:`, err);
289
306
  }
290
307
  }
@@ -303,7 +320,12 @@ export async function sendTextReply(chatId, text) {
303
320
  });
304
321
  }
305
322
  catch (err) {
306
- log.error('Failed to send text:', err);
323
+ if (isPermissionError(err)) {
324
+ handlePermissionError(err, chatId);
325
+ }
326
+ else {
327
+ log.error('Failed to send text:', err);
328
+ }
307
329
  }
308
330
  }
309
331
  /** 使用 open_id 发送(私聊时 context 可能只有 open_id) */
@@ -321,7 +343,12 @@ export async function sendTextReplyByOpenId(openId, text) {
321
343
  });
322
344
  }
323
345
  catch (err) {
324
- log.error('Failed to send text by open_id:', err);
346
+ if (isPermissionError(err)) {
347
+ handlePermissionError(err);
348
+ }
349
+ else {
350
+ log.error('Failed to send text by open_id:', err);
351
+ }
325
352
  }
326
353
  }
327
354
  export async function sendImageReply(chatId, imagePath) {
@@ -360,7 +387,12 @@ export async function sendImageReply(chatId, imagePath) {
360
387
  });
361
388
  }
362
389
  catch (err) {
363
- log.error('Failed to send image:', err);
390
+ if (isPermissionError(err)) {
391
+ handlePermissionError(err, chatId);
392
+ }
393
+ else {
394
+ log.error('Failed to send image:', err);
395
+ }
364
396
  }
365
397
  }
366
398
  export function startTypingLoop(_chatId) {
@@ -0,0 +1,35 @@
1
+ /**
2
+ * 飞书权限检测与友好提示
3
+ *
4
+ * 统一管理权限错误码、提示消息构建、控制台输出。
5
+ * 所有飞书发送函数遇到错误时应通过此模块检测权限问题。
6
+ */
7
+ /**
8
+ * 从异常中提取飞书 API 错误码
9
+ */
10
+ export declare function extractFeishuErrorCode(err: unknown): number | undefined;
11
+ /**
12
+ * 根据错误码判断是否为权限不足
13
+ */
14
+ export declare function isPermissionError(err: unknown): boolean;
15
+ /**
16
+ * 构建飞书应用权限设置页直达链接
17
+ */
18
+ export declare function buildPermissionUrl(appId: string): string;
19
+ /**
20
+ * 构建飞书开放平台应用列表页链接
21
+ */
22
+ export declare function buildAppListUrl(): string;
23
+ /**
24
+ * 构建飞书卡片用的权限指引消息(lark_md 格式)
25
+ */
26
+ export declare function buildPermissionGuideMessage(err: unknown, appId?: string): string;
27
+ /**
28
+ * 启动时输出权限要求提示(仅输出一次)
29
+ */
30
+ export declare function logPermissionGuide(appId: string): void;
31
+ /**
32
+ * 统一权限错误处理入口。
33
+ * 始终输出到控制台(保证可见),尽力尝试通过飞书 API 发送提示。
34
+ */
35
+ export declare function handlePermissionError(err: unknown, chatId?: string): void;
@@ -0,0 +1,178 @@
1
+ /**
2
+ * 飞书权限检测与友好提示
3
+ *
4
+ * 统一管理权限错误码、提示消息构建、控制台输出。
5
+ * 所有飞书发送函数遇到错误时应通过此模块检测权限问题。
6
+ */
7
+ import { createLogger } from '../logger.js';
8
+ const log = createLogger('FeishuPerm');
9
+ // ── 权限错误码 ──
10
+ const PERMISSION_ERROR_CODES = [
11
+ 99991400, // 权限不足
12
+ 99991401, // 没有API权限
13
+ 99991663, // 应用未获取 scope
14
+ 99991672, // 应用未开通相关能力
15
+ 99991670, // 应用未上架/未授权
16
+ 99991668, // 应用可见性限制
17
+ ];
18
+ const REQUIRED_SCOPES = [
19
+ { scope: 'im:message', label: '获取与发送单聊、群组消息' },
20
+ { scope: 'im:message:send_as_bot', label: '以应用身份发消息' },
21
+ { scope: 'im:resource', label: '获取与上传图片或文件资源' },
22
+ { scope: 'im:chat', label: '获取群组信息' },
23
+ ];
24
+ const OPTIONAL_SCOPES = [
25
+ { scope: 'cardkit:card', label: 'CardKit 卡片管理(打字机效果)' },
26
+ ];
27
+ /**
28
+ * 从异常中提取飞书 API 错误码
29
+ */
30
+ export function extractFeishuErrorCode(err) {
31
+ const e = err;
32
+ if (e?.response?.data?.code)
33
+ return e.response.data.code;
34
+ if (e?.code)
35
+ return e.code;
36
+ return undefined;
37
+ }
38
+ /**
39
+ * 根据错误码判断是否为权限不足
40
+ */
41
+ export function isPermissionError(err) {
42
+ const code = extractFeishuErrorCode(err);
43
+ if (!code) {
44
+ const msg = err?.message ?? String(err);
45
+ return /permission|权限|scope|not authorized|no access|forbidden/i.test(msg);
46
+ }
47
+ return PERMISSION_ERROR_CODES.includes(code);
48
+ }
49
+ // ── 权限直达链接 ──
50
+ /**
51
+ * 构建飞书应用权限设置页直达链接
52
+ */
53
+ export function buildPermissionUrl(appId) {
54
+ return `https://open.feishu.cn/app/${appId}/permission`;
55
+ }
56
+ /**
57
+ * 构建飞书开放平台应用列表页链接
58
+ */
59
+ export function buildAppListUrl() {
60
+ return 'https://open.feishu.cn/app';
61
+ }
62
+ // ── 消息构建 ──
63
+ /**
64
+ * 构建飞书卡片用的权限指引消息(lark_md 格式)
65
+ */
66
+ export function buildPermissionGuideMessage(err, appId) {
67
+ const code = extractFeishuErrorCode(err);
68
+ const codeHint = code ? ` (错误码: ${code})` : '';
69
+ const lines = [
70
+ '⚠️ **飞书应用权限不足,无法发送消息**' + codeHint,
71
+ '',
72
+ '请按以下步骤开通所需权限:',
73
+ '',
74
+ '**1. 进入飞书开放平台**',
75
+ '👉 https://open.feishu.cn/app',
76
+ ];
77
+ if (appId) {
78
+ lines.push('', `**2. 进入你的应用权限管理页面**`, `👉 [点击直接打开](${buildPermissionUrl(appId)})`);
79
+ lines.push('', '**3. 搜索并添加以下权限:**');
80
+ }
81
+ else {
82
+ lines.push('', '**2. 找到你的应用,进入「权限管理」**');
83
+ lines.push('', '**3. 开通以下权限(搜索权限名称添加):**');
84
+ }
85
+ for (const s of REQUIRED_SCOPES) {
86
+ lines.push(`• \`${s.scope}\` — ${s.label}`);
87
+ }
88
+ lines.push('', '**4. 如需使用卡片打字机效果,还需开通:**');
89
+ for (const s of OPTIONAL_SCOPES) {
90
+ lines.push(`• \`${s.scope}\` — ${s.label}`);
91
+ }
92
+ lines.push('', '**5. 发布版本**', '权限修改后需点击「创建版本」→「发布」,管理员审批后生效。');
93
+ return lines.join('\n');
94
+ }
95
+ // ── 控制台输出 ──
96
+ /**
97
+ * 启动时输出权限要求提示(仅输出一次)
98
+ */
99
+ export function logPermissionGuide(appId) {
100
+ const permUrl = buildPermissionUrl(appId);
101
+ log.info('─── 飞书权限配置 ───');
102
+ log.info(`权限设置页: ${permUrl}`);
103
+ log.info('必需权限:');
104
+ for (const s of REQUIRED_SCOPES) {
105
+ log.info(` · ${s.scope} — ${s.label}`);
106
+ }
107
+ log.info('可选权限:');
108
+ for (const s of OPTIONAL_SCOPES) {
109
+ log.info(` · ${s.scope} — ${s.label}`);
110
+ }
111
+ log.info('权限修改后需发布版本,管理员审批后生效。');
112
+ log.info('───────────────────');
113
+ }
114
+ // ── 统一错误处理 ──
115
+ let lastPermissionLogTime = 0;
116
+ const PERMISSION_LOG_COOLDOWN_MS = 60_000; // 60 秒内不重复输出
117
+ /**
118
+ * 统一权限错误处理入口。
119
+ * 始终输出到控制台(保证可见),尽力尝试通过飞书 API 发送提示。
120
+ */
121
+ export function handlePermissionError(err, chatId) {
122
+ const now = Date.now();
123
+ const silenced = now - lastPermissionLogTime < PERMISSION_LOG_COOLDOWN_MS;
124
+ if (!silenced) {
125
+ lastPermissionLogTime = now;
126
+ const code = extractFeishuErrorCode(err);
127
+ const codeHint = code ? ` (错误码: ${code})` : '';
128
+ log.error(`飞书权限不足${codeHint},无法发送消息。`);
129
+ // 动态 import 避免循环依赖
130
+ import('./client.js').then(({ getAppId }) => {
131
+ const appId = getAppId();
132
+ log.error(`请前往开通权限: ${buildPermissionUrl(appId)}`);
133
+ }).catch(() => {
134
+ log.error(`请前往开通权限: ${buildAppListUrl()}`);
135
+ });
136
+ }
137
+ // Best-effort: 尝试通过飞书 API 发送权限指引
138
+ if (chatId) {
139
+ sendPermissionFallback(chatId, err).catch(() => {
140
+ // 预期会失败(权限不足本身就是根因),log 已经输出过了
141
+ });
142
+ }
143
+ }
144
+ /**
145
+ * 尝试通过飞书 API 发送权限指引消息(降级链:卡片 → 纯文本)
146
+ */
147
+ async function sendPermissionFallback(chatId, err) {
148
+ // 动态 import 避免循环依赖
149
+ const { sendTextReply } = await import('./message-sender.js');
150
+ const { getAppId } = await import('./client.js');
151
+ const guide = buildPermissionGuideMessage(err, getAppId());
152
+ // 1. 尝试卡片消息
153
+ try {
154
+ await sendTextReply(chatId, guide);
155
+ return;
156
+ }
157
+ catch {
158
+ // 降级
159
+ }
160
+ // 2. 尝试纯文本消息
161
+ try {
162
+ const { getClient } = await import('./client.js');
163
+ const client = getClient();
164
+ const plainGuide = guide.replace(/\*\*/g, '').replace(/`/g, '');
165
+ await client.im.message.create({
166
+ data: {
167
+ receive_id: chatId,
168
+ msg_type: 'text',
169
+ content: JSON.stringify({ text: plainGuide }),
170
+ },
171
+ params: { receive_id_type: 'chat_id' },
172
+ });
173
+ return;
174
+ }
175
+ catch {
176
+ // 全部失败,控制台已输出
177
+ }
178
+ }
package/dist/index.js CHANGED
@@ -10,7 +10,7 @@ export { needsSetup, runInteractiveSetup };
10
10
  import { initTelegram, stopTelegram } from "./telegram/client.js";
11
11
  import { setupTelegramHandlers } from "./telegram/event-handler.js";
12
12
  import { sendTextReply as sendTelegramTextReply } from "./telegram/message-sender.js";
13
- import { initFeishu, stopFeishu } from "./feishu/client.js";
13
+ import { initFeishu, stopFeishu, formatFeishuInitError } from "./feishu/client.js";
14
14
  import { setupFeishuHandlers } from "./feishu/event-handler.js";
15
15
  import { sendTextReply as sendFeishuTextReply } from "./feishu/message-sender.js";
16
16
  import { initQQ, stopQQ } from "./qq/client.js";
@@ -52,6 +52,7 @@ const PLATFORM_MODULES = {
52
52
  },
53
53
  stop: () => stopFeishu(),
54
54
  sendNotification: (chatId, msg) => sendFeishuTextReply(chatId, msg),
55
+ formatError: (err) => formatFeishuInitError(err),
55
56
  },
56
57
  qq: {
57
58
  init: async (config, sessionManager) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wu529778790/open-im",
3
- "version": "1.9.4-beta.12",
3
+ "version": "1.9.4-beta.13",
4
4
  "description": "Multi-platform IM bridge for AI CLI tools (Claude, Codex, CodeBuddy)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",