@hwj123weijian/pi-feishu 0.7.0 → 0.8.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 +5 -1
- package/package.json +1 -1
- package/src/controller.ts +13 -0
- package/src/extension.ts +213 -22
- package/src/gateway.ts +4 -1
package/README.md
CHANGED
|
@@ -94,7 +94,7 @@ pi -e D:\ai_study\pi-feishu
|
|
|
94
94
|
/feishu start
|
|
95
95
|
```
|
|
96
96
|
|
|
97
|
-
`start` 会在本地 Pi
|
|
97
|
+
`start` 会在本地 Pi 中显示六位一次性绑定码。若已配置凭据但启动 Pi 后尚未连接,扩展会在会话开始时提示执行 `/feishu start`;如需 Pi 启动后自动连接,可设置 `PI_FEISHU_AUTO_START=1`(或 `FEISHU_AUTO_START=1`)。使用计划作为 Owner 的飞书账号私聊机器人:
|
|
98
98
|
|
|
99
99
|
```text
|
|
100
100
|
/bind 123456
|
|
@@ -175,6 +175,10 @@ pi -e D:\ai_study\pi-feishu
|
|
|
175
175
|
|
|
176
176
|
确认已申请并发布表情回复相关权限。该权限缺失时扩展自动降级:消息照常收发,只是原消息上不会出现“思考中”表情。
|
|
177
177
|
|
|
178
|
+
### 群里必须 @ 才有响应
|
|
179
|
+
|
|
180
|
+
飞书默认只向 Bot 推送群内 @bot 消息。若希望扩展创建的项目群里普通消息也触发,需要额外申请并发布敏感权限 `im:message.group_msg`。本扩展已关闭 SDK 侧的强制 @ 过滤;权限生效后,已授权项目群内普通消息会直接进入 Pi。
|
|
181
|
+
|
|
178
182
|
### 远程 `/new` 提示暂不可用
|
|
179
183
|
|
|
180
184
|
远程会话切换依赖本地 Pi 提供的命令上下文。先在本地 Pi 执行任意 `/feishu` 子命令(例如 `/feishu status`),再从飞书发送 `/new`。切换完成后长连接自动延续,无需重新执行 `/feishu start`。
|
package/package.json
CHANGED
package/src/controller.ts
CHANGED
|
@@ -213,6 +213,19 @@ export class FeishuController {
|
|
|
213
213
|
await this.store.clear();
|
|
214
214
|
}
|
|
215
215
|
|
|
216
|
+
/** 读取应用已开通 scope 列表(用于权限健康度检查)。未连接或 probe 失败时返回空对象。 */
|
|
217
|
+
async probeScopes(): Promise<{ grantedScopes?: string[] }> {
|
|
218
|
+
try {
|
|
219
|
+
return (await this.gateway?.probeGrantedScopes?.()) ?? {};
|
|
220
|
+
} catch {
|
|
221
|
+
return {};
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
get appId(): string | undefined {
|
|
226
|
+
return this.credentials?.appId;
|
|
227
|
+
}
|
|
228
|
+
|
|
216
229
|
async status(environment: Environment): Promise<FeishuStatus> {
|
|
217
230
|
const stored = await this.store.load();
|
|
218
231
|
const resolved = resolveRuntimeCredentials(environment, stored);
|
package/src/extension.ts
CHANGED
|
@@ -6,6 +6,15 @@ import { CredentialError, FileCredentialStore } from "./credentials.js";
|
|
|
6
6
|
import { SdkFeishuGateway, validateSdkCredentials } from "./gateway.js";
|
|
7
7
|
import { PiAgentBridge } from "./pi-agent-bridge.js";
|
|
8
8
|
import { THINKING_LEVELS } from "./remote-commands.js";
|
|
9
|
+
import {
|
|
10
|
+
buildEventSubUrl,
|
|
11
|
+
buildPermissionPageUrl,
|
|
12
|
+
buildScopeApplyUrl,
|
|
13
|
+
hasScope,
|
|
14
|
+
missingScopes,
|
|
15
|
+
REQUIRED_APP_SCOPES,
|
|
16
|
+
SENSITIVE_GROUP_MSG_SCOPE,
|
|
17
|
+
} from "./scopes.js";
|
|
9
18
|
|
|
10
19
|
type FeishuCommandName = "help" | "setup" | "start" | "stop" | "status" | "logout";
|
|
11
20
|
type PiModel = NonNullable<ExtensionContext["model"]>;
|
|
@@ -154,24 +163,153 @@ export function renderFeishuHelp(): string {
|
|
|
154
163
|
].join("\n");
|
|
155
164
|
}
|
|
156
165
|
|
|
157
|
-
|
|
166
|
+
/** 对齐 easycodeclient:start 成功后的仪表盘式提示。 */
|
|
167
|
+
export function renderStartSuccess(status: FeishuStatus, bindingCode?: string): string {
|
|
168
|
+
const lines: string[] = ["🚀 飞书 Bot 已就绪!"];
|
|
169
|
+
if (status.appId) lines.push(` App ID:${status.appId}`);
|
|
170
|
+
lines.push(" 连接:WebSocket 长连接已建立");
|
|
171
|
+
if (bindingCode) {
|
|
172
|
+
lines.push(` Owner:未绑定,一次性绑定码 ${bindingCode}`);
|
|
173
|
+
lines.push(` 请在飞书私聊 Bot 发送:/bind ${bindingCode}`);
|
|
174
|
+
} else if (status.ownerOpenId) {
|
|
175
|
+
lines.push(` Owner:${status.ownerOpenId}`);
|
|
176
|
+
}
|
|
177
|
+
lines.push("", " 现在去飞书给 Bot 发消息试试 👋", " 输入 /feishu stop 停止");
|
|
178
|
+
return lines.join("\n");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** 对齐 easycodeclient:缺少凭据时给出可操作的配置指引。 */
|
|
182
|
+
export function renderMissingCredentials(): string {
|
|
183
|
+
return [
|
|
184
|
+
"⚠️ 未找到飞书凭证,请先配置:",
|
|
185
|
+
" /feishu setup <appId> <appSecret> # 验证并保存凭据",
|
|
186
|
+
" 或设置环境变量 FEISHU_APP_ID / FEISHU_APP_SECRET 后执行 /feishu setup",
|
|
187
|
+
].join("\n");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* 对齐 easycodeclient 的 appendPostSetupGuidance:setup 成功后的分步配置引导。
|
|
192
|
+
* grantedScopes 为 undefined 表示无法读取已开通列表(首次配置很常见),按全部缺失处理。
|
|
193
|
+
*/
|
|
194
|
+
export function renderPostSetupGuidance(appId: string, grantedScopes?: string[]): string {
|
|
195
|
+
const missing = grantedScopes ? missingScopes(grantedScopes, REQUIRED_APP_SCOPES) : [...REQUIRED_APP_SCOPES];
|
|
196
|
+
const lines: string[] = [
|
|
197
|
+
"",
|
|
198
|
+
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
|
|
199
|
+
"🔧 一键完成下一步配置(强烈建议)",
|
|
200
|
+
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
|
|
201
|
+
];
|
|
202
|
+
|
|
203
|
+
if (grantedScopes && missing.length === 0) {
|
|
204
|
+
lines.push(" ✅ 应用已开通全部必需 scope,无需额外申请。");
|
|
205
|
+
} else {
|
|
206
|
+
lines.push(
|
|
207
|
+
grantedScopes
|
|
208
|
+
? ` 📋 第 1 步:一键申请缺失的 ${missing.length} 项权限(自动预选 scope)`
|
|
209
|
+
: " 📋 第 1 步:一键申请应用所需权限(自动预选 scope)",
|
|
210
|
+
` 👉 ${buildScopeApplyUrl({ appId, scopes: missing })}`,
|
|
211
|
+
);
|
|
212
|
+
if (missing.length > 0 && missing.length <= 12) {
|
|
213
|
+
lines.push(" 需申请的 scope:");
|
|
214
|
+
for (const scope of missing) lines.push(` - ${scope}`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
lines.push(
|
|
219
|
+
"",
|
|
220
|
+
" 📡 第 2 步:在事件订阅页勾选必要事件",
|
|
221
|
+
` 👉 ${buildEventSubUrl(appId)}`,
|
|
222
|
+
" 需订阅事件:",
|
|
223
|
+
" - im.message.receive_v1(接收消息)",
|
|
224
|
+
" - im.message.recalled_v1(用户撤回消息 → 排队消息同步撤回)",
|
|
225
|
+
" - im.chat.member.bot.added_v1(被拉入群通知)",
|
|
226
|
+
" - card.action.trigger(卡片按钮回调)",
|
|
227
|
+
"",
|
|
228
|
+
" 🔄 第 3 步:申请发布版本",
|
|
229
|
+
" 在权限管理页申请版本发布,让 scope 生效:",
|
|
230
|
+
` 👉 ${buildPermissionPageUrl(appId)}`,
|
|
231
|
+
"",
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
// 🔔 免 @ 敏感权限提示
|
|
235
|
+
if (!grantedScopes || !hasScope(grantedScopes, SENSITIVE_GROUP_MSG_SCOPE)) {
|
|
236
|
+
lines.push(
|
|
237
|
+
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
|
|
238
|
+
"💬 想让 Bot 在群里「免 @ 直接响应所有消息」?",
|
|
239
|
+
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
|
|
240
|
+
" 默认:群里只有 @bot 时才会收到事件(飞书平台层硬规则)。",
|
|
241
|
+
" 要免 @ 直接响应,必须额外申请「敏感权限」:",
|
|
242
|
+
` 👉 ${buildScopeApplyUrl({ appId, scopes: [SENSITIVE_GROUP_MSG_SCOPE] })}`,
|
|
243
|
+
` 权限:\`${SENSITIVE_GROUP_MSG_SCOPE}\` —— 「读取关联群聊内所有消息」`,
|
|
244
|
+
" ⚠️ 这是飞书的敏感权限,需要人工审核(一般 1-3 天)。",
|
|
245
|
+
" 申请页「使用场景说明」可参考:用于 AI 编程助手在专属项目协作群中",
|
|
246
|
+
" 无需 @ 即可响应团队成员的编程请求和问题,提升协作效率。",
|
|
247
|
+
"",
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
lines.push(" 💡 步骤 1-3 完成后,回到 Pi 执行 /feishu start 即可使用!");
|
|
251
|
+
return lines.join("\n");
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* 对齐 easycodeclient 的 start 后权限健康检查:probe 权限并追加修复指引。
|
|
256
|
+
* probe 失败不阻塞主流程,返回原文案。
|
|
257
|
+
*/
|
|
258
|
+
export async function appendScopeHealthHint(
|
|
259
|
+
appId: string,
|
|
260
|
+
dashboard: string,
|
|
261
|
+
grantedScopes?: string[],
|
|
262
|
+
): Promise<string> {
|
|
263
|
+
if (!grantedScopes) return dashboard;
|
|
264
|
+
const missing = missingScopes(grantedScopes, REQUIRED_APP_SCOPES);
|
|
265
|
+
const hasGroupMsg = hasScope(grantedScopes, SENSITIVE_GROUP_MSG_SCOPE);
|
|
266
|
+
if (missing.length === 0 && hasGroupMsg) {
|
|
267
|
+
return `${dashboard}\n\n✅ 应用权限配置完整,所有功能均可正常使用。`;
|
|
268
|
+
}
|
|
269
|
+
const lines = ["", "⚠️ 以下应用权限尚未开通,对应功能会受限:"];
|
|
270
|
+
if (missing.length > 0) {
|
|
271
|
+
lines.push(`📋 缺失 ${missing.length} 项基础权限,点击一键申请:`);
|
|
272
|
+
lines.push(`👉 ${buildScopeApplyUrl({ appId, scopes: missing })}`);
|
|
273
|
+
}
|
|
274
|
+
if (!hasGroupMsg) {
|
|
275
|
+
lines.push("💬 「免 @ 响应」权限未开:群内需 @机器人 才能触发,点击开通:");
|
|
276
|
+
lines.push(`👉 ${buildScopeApplyUrl({ appId, scopes: [SENSITIVE_GROUP_MSG_SCOPE] })}`);
|
|
277
|
+
}
|
|
278
|
+
lines.push("🔄 权限生效(需发布应用版本):");
|
|
279
|
+
lines.push(`👉 ${buildPermissionPageUrl(appId)}`);
|
|
280
|
+
return `${dashboard}\n${lines.join("\n")}`;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function renderFeishuStatus(status: FeishuStatus, scopeHealth?: string[]): string {
|
|
158
284
|
if (!status.configured) {
|
|
159
285
|
return [
|
|
160
|
-
"
|
|
161
|
-
"
|
|
162
|
-
` 连接:${status.running ? "
|
|
286
|
+
"📊 飞书状态:",
|
|
287
|
+
" 配置:未配置,请运行 /feishu setup",
|
|
288
|
+
` 连接:${status.running ? "🟢 运行中" : "🔴 已停止"}`,
|
|
163
289
|
` 队列:${status.pendingMessages}`,
|
|
164
290
|
].join("\n");
|
|
165
291
|
}
|
|
166
|
-
|
|
167
|
-
"
|
|
168
|
-
"
|
|
292
|
+
const lines = [
|
|
293
|
+
"📊 飞书状态:",
|
|
294
|
+
" 配置:✅ 已配置",
|
|
169
295
|
` App ID:${status.appId ?? "未知"}`,
|
|
170
296
|
` 来源:${status.source === "environment" ? "环境变量" : "凭据文件"}`,
|
|
171
|
-
` 连接:${status.running ? "
|
|
297
|
+
` 连接:${status.running ? "🟢 运行中" : "🔴 已停止"}`,
|
|
172
298
|
` Owner:${status.ownerOpenId ?? "未绑定"}`,
|
|
173
299
|
` 队列:${status.pendingMessages}`,
|
|
174
|
-
]
|
|
300
|
+
];
|
|
301
|
+
// ✨ Mini-doctor:scope 健康度自检(对齐 easycodeclient 的 /feishu status)。
|
|
302
|
+
if (scopeHealth) {
|
|
303
|
+
lines.push("");
|
|
304
|
+
if (scopeHealth.length === 0) {
|
|
305
|
+
lines.push(" ✅ 应用权限:已开通全部必需 scope");
|
|
306
|
+
} else {
|
|
307
|
+
lines.push(` ⚠️ 应用权限:缺失 ${scopeHealth.length} 项必需 scope`);
|
|
308
|
+
for (const scope of scopeHealth) lines.push(` - ${scope}`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
if (!status.running) lines.push(" 运行 /feishu start 启动 Bot");
|
|
312
|
+
return lines.join("\n");
|
|
175
313
|
}
|
|
176
314
|
|
|
177
315
|
export default function feishuExtension(pi: ExtensionAPI): void {
|
|
@@ -230,11 +368,37 @@ export default function feishuExtension(pi: ExtensionAPI): void {
|
|
|
230
368
|
};
|
|
231
369
|
state.current = { runtime };
|
|
232
370
|
|
|
371
|
+
let startupNoticeShown = false;
|
|
372
|
+
const maybeNotifyFeishuStartup = async (context: ExtensionContext): Promise<void> => {
|
|
373
|
+
if (startupNoticeShown) return;
|
|
374
|
+
startupNoticeShown = true;
|
|
375
|
+
const status = await state.controller.status(process.env);
|
|
376
|
+
if (!status.configured || status.running) return;
|
|
377
|
+
if (shouldAutoStartFeishu(process.env)) {
|
|
378
|
+
const result = await state.controller.start(process.env);
|
|
379
|
+
const dashboard = renderStartSuccess(await state.controller.status(process.env), result.bindingCode);
|
|
380
|
+
context.ui.notify(
|
|
381
|
+
dashboard.replace("🚀 飞书 Bot 已就绪!", "🚀 飞书插件已自动启动,Bot 已就绪!"),
|
|
382
|
+
result.bindingCode ? "warning" : "info",
|
|
383
|
+
);
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
context.ui.notify(
|
|
387
|
+
"飞书插件已加载,但长连接未启动。请执行 /feishu start;如需 Pi 启动后自动连接,可设置 PI_FEISHU_AUTO_START=1。",
|
|
388
|
+
"warning",
|
|
389
|
+
);
|
|
390
|
+
};
|
|
391
|
+
|
|
233
392
|
// 每个事件都会带来新的 ExtensionContext;持续刷新,保证远程命令拿到的能力不失效。
|
|
234
393
|
const trackContext = (_event: unknown, context: ExtensionContext): void => {
|
|
235
394
|
latestContext = context;
|
|
236
395
|
};
|
|
237
|
-
pi.on("session_start",
|
|
396
|
+
pi.on("session_start", (event, context) => {
|
|
397
|
+
trackContext(event, context);
|
|
398
|
+
void maybeNotifyFeishuStartup(context).catch((error) => {
|
|
399
|
+
context.ui.notify(`飞书启动检查失败:${state.controller.sanitizeError(error)}`, "error");
|
|
400
|
+
});
|
|
401
|
+
});
|
|
238
402
|
pi.on("agent_start", trackContext);
|
|
239
403
|
pi.on("message_end", (event, context) => {
|
|
240
404
|
trackContext(event, context);
|
|
@@ -337,6 +501,11 @@ function toModelInfo(model: PiModel): PiModelInfo {
|
|
|
337
501
|
return { id: model.id, name: model.name || model.id, provider: String(model.provider) };
|
|
338
502
|
}
|
|
339
503
|
|
|
504
|
+
function shouldAutoStartFeishu(environment: NodeJS.ProcessEnv): boolean {
|
|
505
|
+
const value = environment.PI_FEISHU_AUTO_START ?? environment.FEISHU_AUTO_START;
|
|
506
|
+
return value === "1" || value?.toLowerCase() === "true" || value?.toLowerCase() === "yes";
|
|
507
|
+
}
|
|
508
|
+
|
|
340
509
|
function isThinkingLevel(value: string): value is PiThinkingLevel {
|
|
341
510
|
return (THINKING_LEVELS as readonly string[]).includes(value);
|
|
342
511
|
}
|
|
@@ -367,31 +536,53 @@ async function handleFeishuCommand(
|
|
|
367
536
|
throw new CredentialError("请先执行 /feishu stop,再修改飞书凭据。");
|
|
368
537
|
}
|
|
369
538
|
const credentials = await controller.setup(command.args, process.env);
|
|
370
|
-
context.ui.notify(
|
|
539
|
+
context.ui.notify(`✅ 飞书凭据验证成功并已保存:${credentials.appId}`, "info");
|
|
540
|
+
// 对齐 easycodeclient:setup 成功后输出分步配置引导(一键申请权限/事件订阅/发布版本)。
|
|
541
|
+
context.ui.notify(renderPostSetupGuidance(credentials.appId), "info");
|
|
542
|
+
// 并自动拉起长连接,省去手动 /feishu start。
|
|
543
|
+
await handleFeishuCommand({ name: "start", args: "" }, controller, context);
|
|
371
544
|
return;
|
|
372
545
|
}
|
|
373
546
|
case "start": {
|
|
547
|
+
// 对齐 easycodeclient:未配置凭据时给出可操作的配置指引,而非报错。
|
|
548
|
+
const precheck = await controller.status(process.env);
|
|
549
|
+
if (!precheck.configured) {
|
|
550
|
+
context.ui.notify(renderMissingCredentials(), "warning");
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
374
553
|
const result = await controller.start(process.env);
|
|
375
554
|
if (result.alreadyRunning) {
|
|
376
|
-
context.ui.notify("
|
|
555
|
+
context.ui.notify("⚠️ 飞书 Bot 已在运行中。输入 /feishu stop 停止后再启动。", "info");
|
|
377
556
|
return;
|
|
378
557
|
}
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
558
|
+
const postStart = await controller.status(process.env);
|
|
559
|
+
let dashboard = renderStartSuccess(postStart, result.bindingCode);
|
|
560
|
+
// 对齐 easycodeclient:start 后 probe 权限健康度,缺失时追加一键申请链接。
|
|
561
|
+
const appId = controller.appId;
|
|
562
|
+
if (appId) {
|
|
563
|
+
const probe = await controller.probeScopes();
|
|
564
|
+
dashboard = await appendScopeHealthHint(appId, dashboard, probe.grantedScopes);
|
|
386
565
|
}
|
|
566
|
+
context.ui.notify(dashboard, result.bindingCode ? "warning" : "info");
|
|
387
567
|
return;
|
|
388
568
|
}
|
|
389
569
|
case "stop":
|
|
390
|
-
context.ui.notify((await controller.stop()) ? "飞书长连接已停止。" : "
|
|
570
|
+
context.ui.notify((await controller.stop()) ? "✅ 飞书长连接已停止。" : "⚠️ 飞书 Bot 未运行。", "info");
|
|
391
571
|
return;
|
|
392
|
-
case "status":
|
|
393
|
-
|
|
572
|
+
case "status": {
|
|
573
|
+
const status = await controller.status(process.env);
|
|
574
|
+
// ✨ Mini-doctor:probe scope 健康度(失败不影响 status 输出)。
|
|
575
|
+
let scopeHealth: string[] | undefined;
|
|
576
|
+
const appId = controller.appId;
|
|
577
|
+
if (status.configured && appId) {
|
|
578
|
+
const probe = await controller.probeScopes();
|
|
579
|
+
if (probe.grantedScopes) {
|
|
580
|
+
scopeHealth = missingScopes(probe.grantedScopes, REQUIRED_APP_SCOPES);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
context.ui.notify(renderFeishuStatus(status, scopeHealth), "info");
|
|
394
584
|
return;
|
|
585
|
+
}
|
|
395
586
|
case "logout": {
|
|
396
587
|
if (context.hasUI) {
|
|
397
588
|
const confirmed = await context.ui.confirm("退出飞书", "停止连接并清除本地飞书凭据?");
|
package/src/gateway.ts
CHANGED
|
@@ -195,8 +195,11 @@ function createOfficialChannel(credentials: FeishuCredentials): ChannelLike {
|
|
|
195
195
|
policy: {
|
|
196
196
|
dmMode: "open",
|
|
197
197
|
// Controller enforces owner identity and only accepts groups it created.
|
|
198
|
+
// Keep SDK-side mention filtering off so apps with im:message.group_msg
|
|
199
|
+
// can receive normal managed-group messages without @. Without that
|
|
200
|
+
// sensitive scope, Feishu only pushes @bot group events anyway.
|
|
198
201
|
groupAllowlist: [],
|
|
199
|
-
requireMention:
|
|
202
|
+
requireMention: false,
|
|
200
203
|
},
|
|
201
204
|
safety: {
|
|
202
205
|
chatQueue: { enabled: false },
|