@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 CHANGED
@@ -94,7 +94,7 @@ pi -e D:\ai_study\pi-feishu
94
94
  /feishu start
95
95
  ```
96
96
 
97
- `start` 会在本地 Pi 中显示六位一次性绑定码。使用计划作为 Owner 的飞书账号私聊机器人:
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hwj123weijian/pi-feishu",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Minimal Feishu private-chat bridge for Pi",
5
5
  "type": "module",
6
6
  "license": "MIT",
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
- export function renderFeishuStatus(status: FeishuStatus): string {
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
- return [
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
- ].join("\n");
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", trackContext);
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(`飞书凭据验证成功并已保存:${credentials.appId}`, "info");
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("飞书长连接已经在运行。", "info");
555
+ context.ui.notify("⚠️ 飞书 Bot 已在运行中。输入 /feishu stop 停止后再启动。", "info");
377
556
  return;
378
557
  }
379
- if (result.bindingCode) {
380
- context.ui.notify(
381
- `飞书长连接已启动。\n一次性绑定码:${result.bindingCode}\n请在飞书私聊 Bot 发送:/bind ${result.bindingCode}`,
382
- "warning",
383
- );
384
- } else {
385
- context.ui.notify("飞书长连接已启动,Owner 已绑定。", "info");
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()) ? "飞书长连接已停止。" : "飞书长连接未运行。", "info");
570
+ context.ui.notify((await controller.stop()) ? "✅ 飞书长连接已停止。" : "⚠️ 飞书 Bot 未运行。", "info");
391
571
  return;
392
- case "status":
393
- context.ui.notify(renderFeishuStatus(await controller.status(process.env)), "info");
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: true,
202
+ requireMention: false,
200
203
  },
201
204
  safety: {
202
205
  chatQueue: { enabled: false },