@hwj123weijian/pi-feishu 0.6.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
@@ -10,7 +10,7 @@
10
10
  - 首次启动生成一次性绑定码,只允许一个 Owner
11
11
  - 收到消息后立刻在原消息上添加“思考中”表情作为已读回执,回复送达后自动移除
12
12
  - 以 `/` 开头的消息走远程命令快速通道,不排队、不进入 LLM 上下文
13
- - 所有消息进入当前 Pi 会话,不创建额外 Agent 或会话
13
+ - 私聊消息进入当前 Pi 会话;扩展创建的每个群聊会在首次收到群消息时创建并绑定一个独立 Pi session,后续该群消息自动切回自己的 session,避免群之间和主对话串上下文
14
14
  - 消息需要排队时发送“排队中(第 N 位)”提示,轮到处理时自动撤回;队列变化时提示自动改写为新位数
15
15
  - Owner 在排队中的消息上点 ❌(`CrossMark`)表情即可取消该消息
16
16
  - Assistant 文本通过 Pi 的 `message_update` 增量渲染到同一张飞书卡片
@@ -22,7 +22,7 @@
22
22
  - 长连接由跨会话的全局控制器保管:本地 `/new`、远程 `/new` 等会话切换不会断开;`/feishu stop`、`/feishu logout` 或 Pi 进程退出时释放
23
23
  - 凭据优先从环境变量读取,也可保存到本地凭据文件
24
24
 
25
- 支持 Owner 在飞书私聊中请求创建群聊;扩展会把 Owner 拉入新群、发送欢迎消息,并允许 Owner 在该扩展创建的群里 @机器人继续协作。其他群聊不会被处理。图片、文件、卡片、语音、多用户、多会话、进程级沙箱或远程命令权限管理仍不支持。Pi 本身仍拥有当前本地进程的权限。
25
+ 支持 Owner 在飞书私聊中请求创建群聊;扩展会把 Owner 拉入新群、发送欢迎消息,并允许 Owner 在该扩展创建的群里 @机器人继续协作。建群后会私聊 Owner 做一次权限体检,提示缺失的基础 scope、事件订阅页和 `im:message.group_msg` 免 @ 权限申请链接。每个扩展创建的群聊会绑定自己的 Pi session,群聊之间、群聊与本地主对话之间不会共享上下文。其他群聊不会被处理。图片、文件、卡片、语音、多用户、进程级沙箱或远程命令权限管理仍不支持。Pi 本身仍拥有当前本地进程的权限。
26
26
 
27
27
  ## 环境要求
28
28
 
@@ -40,12 +40,13 @@
40
40
  - `im:message:send_as_bot`:以机器人身份回复
41
41
  - `im:chat`:创建群聊并邀请 Owner
42
42
  - `im:message:update`:持续更新机器人发出的交互卡片
43
+ - `application:application:self_manage`:启动/建群后读取应用已授权 scope,用于自动提示缺失权限
43
44
  - 表情回复相关权限:在原消息上添加/移除“思考中”已读回执
44
45
  3. 在“事件与回调”中选择“使用长连接接收事件”。
45
46
  4. 添加事件 `im.message.receive_v1`。
46
47
  5. 创建并发布一个应用版本,使权限和事件订阅在企业内生效。
47
48
 
48
- 这个扩展不需要公网回调地址,也不需要加密密钥或 Verification Token。若未授予 `im:message:update`,扩展仍可工作,但会降级为最终文本一次性回复。若未授予表情回复权限,扩展同样可工作,只是原消息上不会出现已读表情。
49
+ 这个扩展不需要公网回调地址,也不需要加密密钥或 Verification Token。若未授予 `im:message:update`,扩展仍可工作,但会降级为最终文本一次性回复。若未授予表情回复权限,扩展同样可工作,只是原消息上不会出现已读表情。若希望已创建项目群里无需 @ 机器人即可触发,需要额外申请敏感权限 `im:message.group_msg`;未开通时请在群里使用 `@机器人 你的问题`。
49
50
 
50
51
  ## 安装
51
52
 
@@ -93,7 +94,7 @@ pi -e D:\ai_study\pi-feishu
93
94
  /feishu start
94
95
  ```
95
96
 
96
- `start` 会在本地 Pi 中显示六位一次性绑定码。使用计划作为 Owner 的飞书账号私聊机器人:
97
+ `start` 会在本地 Pi 中显示六位一次性绑定码。若已配置凭据但启动 Pi 后尚未连接,扩展会在会话开始时提示执行 `/feishu start`;如需 Pi 启动后自动连接,可设置 `PI_FEISHU_AUTO_START=1`(或 `FEISHU_AUTO_START=1`)。使用计划作为 Owner 的飞书账号私聊机器人:
97
98
 
98
99
  ```text
99
100
  /bind 123456
@@ -142,7 +143,7 @@ pi -e D:\ai_study\pi-feishu
142
143
  | `/thinking` | 查看思考档位,或切换:`/thinking off|minimal|low|medium|high|xhigh|max` |
143
144
  | `/model` | 查看当前模型,或模糊匹配切换:`/model <模型ID或名称>` |
144
145
 
145
- 在飞书私聊中也可直接说“帮我拉个 XX 群”或“创建飞书群 XX”。机器人会创建群聊、邀请已绑定的 Owner 并发送欢迎消息;之后 Owner 在新群里 @机器人即可继续使用当前 Pi 会话。已创建群聊的授权列表会保存在本地凭据文件中,Pi 重启后仍可继续使用。
146
+ 在飞书私聊中也可直接说“帮我拉个 XX 群”或“创建飞书群 XX”。机器人会创建群聊、邀请已绑定的 Owner 并发送欢迎消息;之后 Owner 在新群里 @机器人即可协作。该群首次收到消息时会创建一个独立 Pi session 并绑定到群,之后该群消息都会自动切回这个 session。已创建群聊的授权列表和 session 绑定会保存在本地凭据文件中,Pi 重启后仍可继续使用。
146
147
 
147
148
  未知命令会返回提示,不会发给 Pi。`/model` 的候选来自本地 Pi 的 scoped models(未配置时为全部已授权模型);`/model` 无参数时展示当前模型。注意:任何以 `/` 开头的消息都会先被当作命令解析,想发给 Pi 的提问请勿以 `/` 开头。
148
149
 
@@ -174,6 +175,10 @@ pi -e D:\ai_study\pi-feishu
174
175
 
175
176
  确认已申请并发布表情回复相关权限。该权限缺失时扩展自动降级:消息照常收发,只是原消息上不会出现“思考中”表情。
176
177
 
178
+ ### 群里必须 @ 才有响应
179
+
180
+ 飞书默认只向 Bot 推送群内 @bot 消息。若希望扩展创建的项目群里普通消息也触发,需要额外申请并发布敏感权限 `im:message.group_msg`。本扩展已关闭 SDK 侧的强制 @ 过滤;权限生效后,已授权项目群内普通消息会直接进入 Pi。
181
+
177
182
  ### 远程 `/new` 提示暂不可用
178
183
 
179
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.6.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/contracts.ts CHANGED
@@ -3,6 +3,8 @@ export interface FeishuCredentials {
3
3
  appSecret: string;
4
4
  ownerOpenId?: string;
5
5
  managedGroupIds?: string[];
6
+ /** Per managed group Pi session file. Missing value is backfilled on the group's first message. */
7
+ groupSessions?: Record<string, string>;
6
8
  }
7
9
 
8
10
  export interface CredentialStore {
@@ -47,6 +49,8 @@ export interface FeishuGateway {
47
49
  connect(handler: FeishuMessageHandler): Promise<void>;
48
50
  disconnect(): Promise<void>;
49
51
  createGroupChat(name: string, ownerOpenId: string): Promise<string>;
52
+ /** Best-effort application scope audit. Undefined grantedScopes means the app cannot read its scope list yet. */
53
+ probeGrantedScopes?(): Promise<{ grantedScopes?: string[] }>;
50
54
  /** Sends text and resolves with the sent Feishu message id (for later recall). */
51
55
  sendText(chatId: string, text: string, replyTo?: string): Promise<string | undefined>;
52
56
  beginReply(chatId: string, replyTo: string): Promise<FeishuReply>;
@@ -109,8 +113,13 @@ export interface AgentProgressObserver {
109
113
  onActivity?: (activity: AgentActivity) => void;
110
114
  }
111
115
 
116
+ export interface AgentRunOptions {
117
+ /** Route this turn through the Pi session bound to the Feishu chat. */
118
+ chatId?: string;
119
+ }
120
+
112
121
  export interface AgentBridge {
113
- run(text: string, observer?: AgentProgressObserver): Promise<string>;
122
+ run(text: string, observer?: AgentProgressObserver, options?: AgentRunOptions): Promise<string>;
114
123
  cancel(reason?: string): void;
115
124
  }
116
125
 
package/src/controller.ts CHANGED
@@ -17,6 +17,7 @@ import { MessageDeduplicator } from "./message-deduplicator.js";
17
17
  import { SerialMessageQueue } from "./message-queue.js";
18
18
  import { OwnerBinding } from "./owner-binding.js";
19
19
  import { executeRemoteCommand, parseRemoteCommand } from "./remote-commands.js";
20
+ import { buildGroupPermissionReminder } from "./scopes.js";
20
21
 
21
22
  // 与 easycodeclient 的飞书集成一致:THINKING 表情兼作"已读 + 处理中"回执。
22
23
  const READ_REACTION_EMOJI = "THINKING";
@@ -91,6 +92,7 @@ export class FeishuController {
91
92
  ...credentials,
92
93
  ...(existing.ownerOpenId ? { ownerOpenId: existing.ownerOpenId } : {}),
93
94
  ...(existing.managedGroupIds ? { managedGroupIds: existing.managedGroupIds } : {}),
95
+ ...(existing.groupSessions ? { groupSessions: existing.groupSessions } : {}),
94
96
  }
95
97
  : credentials;
96
98
  await this.store.save(next);
@@ -147,11 +149,49 @@ export class FeishuController {
147
149
  }
148
150
  await gateway.sendText(
149
151
  chatId,
150
- `👋 群聊「${name}」已创建,当前绑定的 Pi 飞书机器人已就绪。直接在群内 @机器人即可开始协作。`,
152
+ `👋 群聊「${name}」已创建,当前绑定的 Pi 飞书机器人已就绪。直接在群内 @机器人即可开始协作。若群内普通消息没有响应,请先 @ 机器人;开通免 @ 权限后可直接发消息。`,
151
153
  );
154
+ await this.sendGroupPermissionReminder(gateway, ownerOpenId, name);
152
155
  return chatId;
153
156
  }
154
157
 
158
+ getChatSessionFile(chatId: string): string | undefined {
159
+ return this.credentials?.groupSessions?.[chatId];
160
+ }
161
+
162
+ async setChatSessionFile(chatId: string, sessionFile: string): Promise<void> {
163
+ const credentials = this.credentials;
164
+ if (!credentials) return;
165
+ const updated = {
166
+ ...credentials,
167
+ groupSessions: { ...(credentials.groupSessions ?? {}), [chatId]: sessionFile },
168
+ };
169
+ await this.store.save(updated);
170
+ this.credentials = updated;
171
+ }
172
+
173
+ private async sendGroupPermissionReminder(
174
+ gateway: FeishuGateway,
175
+ ownerOpenId: string,
176
+ groupName: string,
177
+ ): Promise<void> {
178
+ const credentials = this.credentials;
179
+ if (!credentials) return;
180
+ let grantedScopes: string[] | undefined;
181
+ try {
182
+ grantedScopes = (await gateway.probeGrantedScopes?.())?.grantedScopes;
183
+ } catch {
184
+ grantedScopes = undefined;
185
+ }
186
+ const reminder = buildGroupPermissionReminder({
187
+ appId: credentials.appId,
188
+ groupName,
189
+ ...(grantedScopes ? { grantedScopes } : {}),
190
+ });
191
+ if (!reminder) return;
192
+ await gateway.sendText(ownerOpenId, reminder).catch(() => undefined);
193
+ }
194
+
155
195
  async stop(): Promise<boolean> {
156
196
  const gateway = this.gateway;
157
197
  if (!gateway) return false;
@@ -173,6 +213,19 @@ export class FeishuController {
173
213
  await this.store.clear();
174
214
  }
175
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
+
176
229
  async status(environment: Environment): Promise<FeishuStatus> {
177
230
  const stored = await this.store.load();
178
231
  const resolved = resolveRuntimeCredentials(environment, stored);
@@ -355,18 +408,22 @@ export class FeishuController {
355
408
  if (reply) this.activeReplies.add(reply);
356
409
  let latestText = "";
357
410
  try {
358
- const response = await this.agent.run(text, {
359
- onText: (latest) => {
360
- latestText = latest;
361
- reply?.update({ text: latest, status: "正在生成回复" });
362
- },
363
- onActivity: (activity) => {
364
- reply?.update({
365
- text: latestText,
366
- status: activity.kind === "tool" ? "正在执行工具" : "正在思考",
367
- });
411
+ const response = await this.agent.run(
412
+ text,
413
+ {
414
+ onText: (latest) => {
415
+ latestText = latest;
416
+ reply?.update({ text: latest, status: "正在生成回复" });
417
+ },
418
+ onActivity: (activity) => {
419
+ reply?.update({
420
+ text: latestText,
421
+ status: activity.kind === "tool" ? "正在执行工具" : "正在思考",
422
+ });
423
+ },
368
424
  },
369
- });
425
+ message.chatType === "group" ? { chatId: message.chatId } : undefined,
426
+ );
370
427
  if (this.gateway === gateway) {
371
428
  if (reply) {
372
429
  await reply.complete({ text: response, status: "已完成" });
@@ -67,6 +67,7 @@ export function resolveRuntimeCredentials(
67
67
  appSecret,
68
68
  ...(matching?.ownerOpenId ? { ownerOpenId: matching.ownerOpenId } : {}),
69
69
  ...(matching?.managedGroupIds ? { managedGroupIds: matching.managedGroupIds } : {}),
70
+ ...(matching?.groupSessions ? { groupSessions: matching.groupSessions } : {}),
70
71
  };
71
72
  }
72
73
  return stored;
@@ -134,6 +135,7 @@ export class FileCredentialStore implements CredentialStore {
134
135
  ...(Array.isArray(value.managedGroupIds)
135
136
  ? { managedGroupIds: value.managedGroupIds.filter((id): id is string => typeof id === "string") }
136
137
  : {}),
138
+ ...(isStringRecord(value.groupSessions) ? { groupSessions: value.groupSessions } : {}),
137
139
  };
138
140
  }
139
141
 
@@ -170,10 +172,16 @@ function isCredentialRecord(value: unknown): value is FeishuCredentials {
170
172
  typeof record.appId === "string" &&
171
173
  typeof record.appSecret === "string" &&
172
174
  (record.ownerOpenId === undefined || typeof record.ownerOpenId === "string") &&
173
- (record.managedGroupIds === undefined || Array.isArray(record.managedGroupIds))
175
+ (record.managedGroupIds === undefined || Array.isArray(record.managedGroupIds)) &&
176
+ (record.groupSessions === undefined || isStringRecord(record.groupSessions))
174
177
  );
175
178
  }
176
179
 
180
+ function isStringRecord(value: unknown): value is Record<string, string> {
181
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
182
+ return Object.values(value).every((entry) => typeof entry === "string");
183
+ }
184
+
177
185
  function isNodeError(error: unknown): error is NodeJS.ErrnoException {
178
186
  return error instanceof Error && "code" in error;
179
187
  }
package/src/extension.ts CHANGED
@@ -6,19 +6,30 @@ 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"]>;
12
21
  type PiThinkingLevel = NonNullable<ExtensionContext["thinkingLevel"]>;
13
22
 
14
23
  interface SessionLink {
15
- bridge: PiAgentBridge;
16
24
  runtime: PiRuntime;
17
25
  }
18
26
 
19
27
  interface SharedFeishuState {
20
28
  controller: FeishuController;
21
29
  current: SessionLink | undefined;
30
+ activeBridge: PiAgentBridge | undefined;
31
+ latestCommandContext: ExtensionCommandContext | undefined;
32
+ sendCurrentMessage: ((text: string) => void) | undefined;
22
33
  }
23
34
 
24
35
  const SHARED_STATE_KEY = "__piFeishuSharedState__";
@@ -33,14 +44,30 @@ function getSharedState(): SharedFeishuState {
33
44
  const existing = host[SHARED_STATE_KEY];
34
45
  if (existing) return existing;
35
46
 
36
- const state: SharedFeishuState = { current: undefined, controller: undefined as unknown as FeishuController };
47
+ const state: SharedFeishuState = {
48
+ current: undefined,
49
+ activeBridge: undefined,
50
+ latestCommandContext: undefined,
51
+ sendCurrentMessage: undefined,
52
+ controller: undefined as unknown as FeishuController,
53
+ };
37
54
  const proxyAgent: AgentBridge = {
38
- run: (text, observer) => {
39
- const link = state.current;
40
- if (!link) return Promise.reject(new Error("Pi 会话尚未就绪,请稍后再试。"));
41
- return link.bridge.run(text, observer);
55
+ run: (text, observer, options) => {
56
+ const bridge = new PiAgentBridge(async (prompt) => {
57
+ if (options?.chatId) {
58
+ await sendToChatSession(state, options.chatId, prompt);
59
+ return;
60
+ }
61
+ if (!state.current) throw new Error("Pi 会话尚未就绪,请稍后再试。");
62
+ if (!state.sendCurrentMessage) throw new Error("Pi 会话尚未就绪,请稍后再试。");
63
+ state.sendCurrentMessage(prompt);
64
+ });
65
+ state.activeBridge = bridge;
66
+ return bridge.run(text, observer).finally(() => {
67
+ if (state.activeBridge === bridge) state.activeBridge = undefined;
68
+ });
42
69
  },
43
- cancel: (reason) => state.current?.bridge.cancel(reason),
70
+ cancel: (reason) => state.activeBridge?.cancel(reason),
44
71
  };
45
72
  const proxyRuntime: PiRuntime = {
46
73
  isIdle: () => state.current?.runtime.isIdle() ?? true,
@@ -67,6 +94,35 @@ function getSharedState(): SharedFeishuState {
67
94
  return state;
68
95
  }
69
96
 
97
+ async function sendToChatSession(state: SharedFeishuState, chatId: string, prompt: string): Promise<void> {
98
+ const context = state.latestCommandContext;
99
+ if (!context) throw new Error("Pi 会话控制尚未就绪,请先在本地执行一次 /feishu status。");
100
+
101
+ const sessionFile = state.controller.getChatSessionFile(chatId);
102
+ if (sessionFile) {
103
+ const result = await context.switchSession(sessionFile, {
104
+ withSession: async (nextContext) => {
105
+ state.latestCommandContext = nextContext;
106
+ const currentFile = nextContext.sessionManager.getSessionFile();
107
+ if (currentFile && currentFile !== sessionFile) await state.controller.setChatSessionFile(chatId, currentFile);
108
+ await nextContext.sendUserMessage(prompt);
109
+ },
110
+ });
111
+ if (result.cancelled) throw new Error("切换到飞书群绑定的 Pi 会话已取消。");
112
+ return;
113
+ }
114
+
115
+ const result = await context.newSession({
116
+ withSession: async (nextContext) => {
117
+ state.latestCommandContext = nextContext;
118
+ const currentFile = nextContext.sessionManager.getSessionFile();
119
+ if (currentFile) await state.controller.setChatSessionFile(chatId, currentFile);
120
+ await nextContext.sendUserMessage(prompt);
121
+ },
122
+ });
123
+ if (result.cancelled) throw new Error("创建飞书群专属 Pi 会话已取消。");
124
+ }
125
+
70
126
  export interface ParsedFeishuCommand {
71
127
  name: FeishuCommandName;
72
128
  args: string;
@@ -107,29 +163,158 @@ export function renderFeishuHelp(): string {
107
163
  ].join("\n");
108
164
  }
109
165
 
110
- 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 {
111
284
  if (!status.configured) {
112
285
  return [
113
- "飞书状态",
114
- " 配置:未配置",
115
- ` 连接:${status.running ? "已连接" : "未连接"}`,
286
+ "📊 飞书状态:",
287
+ " 配置:未配置,请运行 /feishu setup",
288
+ ` 连接:${status.running ? "🟢 运行中" : "🔴 已停止"}`,
116
289
  ` 队列:${status.pendingMessages}`,
117
290
  ].join("\n");
118
291
  }
119
- return [
120
- "飞书状态",
121
- " 配置:已配置",
292
+ const lines = [
293
+ "📊 飞书状态:",
294
+ " 配置:✅ 已配置",
122
295
  ` App ID:${status.appId ?? "未知"}`,
123
296
  ` 来源:${status.source === "environment" ? "环境变量" : "凭据文件"}`,
124
- ` 连接:${status.running ? "已连接" : "未连接"}`,
297
+ ` 连接:${status.running ? "🟢 运行中" : "🔴 已停止"}`,
125
298
  ` Owner:${status.ownerOpenId ?? "未绑定"}`,
126
299
  ` 队列:${status.pendingMessages}`,
127
- ].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");
128
313
  }
129
314
 
130
315
  export default function feishuExtension(pi: ExtensionAPI): void {
131
316
  const state = getSharedState();
132
- const bridge = new PiAgentBridge((text) => pi.sendUserMessage(text));
317
+ state.sendCurrentMessage = (text) => pi.sendUserMessage(text);
133
318
  let latestContext: ExtensionContext | undefined;
134
319
  let latestCommandContext: ExtensionCommandContext | undefined;
135
320
 
@@ -138,7 +323,7 @@ export default function feishuExtension(pi: ExtensionAPI): void {
138
323
  abort: () => withRuntimeContext(() => latestContext?.abort()),
139
324
  compact: () => withRuntimeContext(() => latestContext?.compact()),
140
325
  newSession: async () => {
141
- const context = latestCommandContext;
326
+ const context = latestCommandContext ?? state.latestCommandContext;
142
327
  if (!context) return false;
143
328
  try {
144
329
  const result = await context.newSession();
@@ -181,37 +366,63 @@ export default function feishuExtension(pi: ExtensionAPI): void {
181
366
  return snapshot;
182
367
  },
183
368
  };
184
- state.current = { bridge, runtime };
369
+ state.current = { runtime };
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
+ };
185
391
 
186
392
  // 每个事件都会带来新的 ExtensionContext;持续刷新,保证远程命令拿到的能力不失效。
187
393
  const trackContext = (_event: unknown, context: ExtensionContext): void => {
188
394
  latestContext = context;
189
395
  };
190
- 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
+ });
191
402
  pi.on("agent_start", trackContext);
192
403
  pi.on("message_end", (event, context) => {
193
404
  trackContext(event, context);
194
- bridge.captureMessage(event.message);
405
+ state.activeBridge?.captureMessage(event.message);
195
406
  });
196
407
  pi.on("message_update", (event, context) => {
197
408
  trackContext(event, context);
198
- bridge.captureStreamingMessage(event.message);
409
+ state.activeBridge?.captureStreamingMessage(event.message);
199
410
  });
200
411
  pi.on("tool_execution_start", (event, context) => {
201
412
  trackContext(event, context);
202
- bridge.captureToolStart(event.toolName);
413
+ state.activeBridge?.captureToolStart(event.toolName);
203
414
  });
204
415
  pi.on("tool_execution_end", (event, context) => {
205
416
  trackContext(event, context);
206
- bridge.captureToolEnd();
417
+ state.activeBridge?.captureToolEnd();
207
418
  });
208
419
  pi.on("agent_settled", (event, context) => {
209
420
  trackContext(event, context);
210
- bridge.settle();
421
+ state.activeBridge?.settle();
211
422
  });
212
423
  pi.on("session_shutdown", () => {
213
- // 只解绑本会话的桥:长连接归全局 controller 保管,本地 /new 切换会话后自动延续。
214
- bridge.cancel("Pi 会话已关闭。");
424
+ // 长连接归全局 controller 保管,本地 /new 切换会话后自动延续。
425
+ state.activeBridge?.cancel("Pi 会话已关闭。");
215
426
  });
216
427
 
217
428
  pi.registerTool({
@@ -247,6 +458,7 @@ export default function feishuExtension(pi: ExtensionAPI): void {
247
458
  description: "配置和管理飞书私聊连接",
248
459
  handler: async (args, context) => {
249
460
  latestCommandContext = context;
461
+ state.latestCommandContext = context;
250
462
  try {
251
463
  await handleFeishuCommand(parseFeishuCommand(args), state.controller, context);
252
464
  } catch (error) {
@@ -289,6 +501,11 @@ function toModelInfo(model: PiModel): PiModelInfo {
289
501
  return { id: model.id, name: model.name || model.id, provider: String(model.provider) };
290
502
  }
291
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
+
292
509
  function isThinkingLevel(value: string): value is PiThinkingLevel {
293
510
  return (THINKING_LEVELS as readonly string[]).includes(value);
294
511
  }
@@ -319,31 +536,53 @@ async function handleFeishuCommand(
319
536
  throw new CredentialError("请先执行 /feishu stop,再修改飞书凭据。");
320
537
  }
321
538
  const credentials = await controller.setup(command.args, process.env);
322
- 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);
323
544
  return;
324
545
  }
325
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
+ }
326
553
  const result = await controller.start(process.env);
327
554
  if (result.alreadyRunning) {
328
- context.ui.notify("飞书长连接已经在运行。", "info");
555
+ context.ui.notify("⚠️ 飞书 Bot 已在运行中。输入 /feishu stop 停止后再启动。", "info");
329
556
  return;
330
557
  }
331
- if (result.bindingCode) {
332
- context.ui.notify(
333
- `飞书长连接已启动。\n一次性绑定码:${result.bindingCode}\n请在飞书私聊 Bot 发送:/bind ${result.bindingCode}`,
334
- "warning",
335
- );
336
- } else {
337
- 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);
338
565
  }
566
+ context.ui.notify(dashboard, result.bindingCode ? "warning" : "info");
339
567
  return;
340
568
  }
341
569
  case "stop":
342
- context.ui.notify((await controller.stop()) ? "飞书长连接已停止。" : "飞书长连接未运行。", "info");
570
+ context.ui.notify((await controller.stop()) ? "✅ 飞书长连接已停止。" : "⚠️ 飞书 Bot 未运行。", "info");
343
571
  return;
344
- case "status":
345
- 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");
346
584
  return;
585
+ }
347
586
  case "logout": {
348
587
  if (context.hasUI) {
349
588
  const confirmed = await context.ui.confirm("退出飞书", "停止连接并清除本地飞书凭据?");
package/src/gateway.ts CHANGED
@@ -7,6 +7,7 @@ import type {
7
7
  FeishuReply,
8
8
  FeishuReplySnapshot,
9
9
  } from "./contracts.js";
10
+ import { probeGrantedScopes } from "./scopes.js";
10
11
 
11
12
  export interface NormalizedChannelMessage {
12
13
  messageId: string;
@@ -123,6 +124,10 @@ export class SdkFeishuGateway implements FeishuGateway {
123
124
  return result.data.chat_id;
124
125
  }
125
126
 
127
+ async probeGrantedScopes(): Promise<{ grantedScopes?: string[] }> {
128
+ return probeGrantedScopes(this.credentials);
129
+ }
130
+
126
131
  async sendText(chatId: string, text: string, replyTo?: string): Promise<string | undefined> {
127
132
  const channel = this.channel;
128
133
  if (!channel) throw new Error("飞书长连接尚未启动。");
@@ -190,8 +195,11 @@ function createOfficialChannel(credentials: FeishuCredentials): ChannelLike {
190
195
  policy: {
191
196
  dmMode: "open",
192
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.
193
201
  groupAllowlist: [],
194
- requireMention: true,
202
+ requireMention: false,
195
203
  },
196
204
  safety: {
197
205
  chatQueue: { enabled: false },
@@ -8,10 +8,10 @@ interface PendingTurn {
8
8
  }
9
9
 
10
10
  export class PiAgentBridge implements AgentBridge {
11
- private readonly sendUserMessage: (text: string) => void;
11
+ private readonly sendUserMessage: (text: string) => void | Promise<void>;
12
12
  private pending: PendingTurn | undefined;
13
13
 
14
- constructor(sendUserMessage: (text: string) => void) {
14
+ constructor(sendUserMessage: (text: string) => void | Promise<void>) {
15
15
  this.sendUserMessage = sendUserMessage;
16
16
  }
17
17
 
@@ -23,12 +23,10 @@ export class PiAgentBridge implements AgentBridge {
23
23
  return new Promise<string>((resolve, reject) => {
24
24
  this.pending = { resolve, reject, lastAssistantText: "", observer };
25
25
  observer?.onActivity?.({ kind: "thinking" });
26
- try {
27
- this.sendUserMessage(text);
28
- } catch (error) {
26
+ Promise.resolve(this.sendUserMessage(text)).catch((error: unknown) => {
29
27
  this.pending = undefined;
30
28
  reject(error instanceof Error ? error : new Error(String(error)));
31
- }
29
+ });
32
30
  });
33
31
  }
34
32
 
package/src/scopes.ts ADDED
@@ -0,0 +1,119 @@
1
+ import type { FeishuCredentials } from "./contracts.js";
2
+
3
+ export const REQUIRED_APP_SCOPES = [
4
+ "im:message.p2p_msg:readonly",
5
+ "im:message.group_at_msg:readonly",
6
+ "im:message:send_as_bot",
7
+ "im:message:update",
8
+ "im:message.reactions:read",
9
+ "im:message.reactions:write_only",
10
+ "im:chat",
11
+ "im:chat:read",
12
+ "application:application:self_manage",
13
+ ] as const;
14
+
15
+ export const SENSITIVE_GROUP_MSG_SCOPE = "im:message.group_msg";
16
+
17
+ export interface ScopeProbeResult {
18
+ grantedScopes?: string[];
19
+ }
20
+
21
+ export function missingScopes(granted: readonly string[] | undefined, required: readonly string[]): string[] {
22
+ if (!granted) return [...required];
23
+ const set = new Set(granted);
24
+ return required.filter((scope) => !set.has(scope));
25
+ }
26
+
27
+ export function hasScope(granted: readonly string[] | undefined, scope: string): boolean {
28
+ return Boolean(granted?.includes(scope));
29
+ }
30
+
31
+ export function buildScopeApplyUrl(params: { appId: string; scopes: readonly string[] }): string {
32
+ const url = new URL(`https://open.feishu.cn/app/${params.appId}/auth`);
33
+ if (params.scopes.length > 0 && params.scopes.length < 20) url.searchParams.set("q", params.scopes.join(","));
34
+ url.searchParams.set("op_from", "pi-feishu");
35
+ url.searchParams.set("token_type", "tenant");
36
+ return url.toString();
37
+ }
38
+
39
+ export function buildPermissionPageUrl(appId: string): string {
40
+ return `https://open.feishu.cn/app/${appId}/permission`;
41
+ }
42
+
43
+ export function buildEventSubUrl(appId: string): string {
44
+ return `https://open.feishu.cn/app/${appId}/event-sub`;
45
+ }
46
+
47
+ export async function probeGrantedScopes(credentials: FeishuCredentials): Promise<ScopeProbeResult> {
48
+ const tokenResponse = await fetch("https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal", {
49
+ method: "POST",
50
+ headers: { "Content-Type": "application/json" },
51
+ body: JSON.stringify({ app_id: credentials.appId, app_secret: credentials.appSecret }),
52
+ });
53
+ const tokenResult = (await tokenResponse.json()) as { code?: number; msg?: string; tenant_access_token?: string };
54
+ if (!tokenResponse.ok || tokenResult.code !== 0 || !tokenResult.tenant_access_token) {
55
+ throw new Error(`获取飞书 tenant token 失败:${tokenResult.msg ?? tokenResponse.statusText}`);
56
+ }
57
+
58
+ const scopeResponse = await fetch("https://open.feishu.cn/open-apis/application/v6/applications/me?lang=zh_cn", {
59
+ headers: { Authorization: `Bearer ${tokenResult.tenant_access_token}` },
60
+ });
61
+ const scopeResult = (await scopeResponse.json()) as {
62
+ code?: number;
63
+ app?: ScopeApp;
64
+ data?: ScopeApp & { app?: ScopeApp };
65
+ };
66
+ if (!scopeResponse.ok || scopeResult.code !== 0) return {};
67
+ const app = scopeResult.data?.app ?? scopeResult.app ?? scopeResult.data ?? {};
68
+ const rawScopes = app.scopes ?? app.online_version?.scopes ?? [];
69
+ return {
70
+ grantedScopes: rawScopes.map((entry) => entry.scope).filter((scope): scope is string => Boolean(scope)),
71
+ };
72
+ }
73
+
74
+ interface ScopeApp {
75
+ scopes?: Array<{ scope?: string }>;
76
+ online_version?: { scopes?: Array<{ scope?: string }> };
77
+ }
78
+
79
+ export function buildGroupPermissionReminder(params: {
80
+ appId: string;
81
+ groupName: string;
82
+ grantedScopes?: readonly string[];
83
+ }): string | null {
84
+ const missingRequired = missingScopes(params.grantedScopes, REQUIRED_APP_SCOPES);
85
+ const missingGroupMsg = !hasScope(params.grantedScopes, SENSITIVE_GROUP_MSG_SCOPE);
86
+ const unknown = !params.grantedScopes;
87
+ if (!unknown && missingRequired.length === 0 && !missingGroupMsg) return null;
88
+
89
+ const requiredForLink = unknown ? REQUIRED_APP_SCOPES : missingRequired;
90
+ const lines = ["💬 **【重要体验提示 — 飞书项目群权限】**", "", `您刚才成功创建了项目群「${params.groupName}」。`, ""];
91
+ if (unknown) {
92
+ lines.push(
93
+ "ℹ️ 当前应用还不能读取自身已开通 scope 列表(通常缺少 `application:application:self_manage`),无法自动确认权限完整性。",
94
+ "",
95
+ );
96
+ }
97
+ if (missingRequired.length > 0 || unknown) {
98
+ lines.push(
99
+ "⚠️ **基础权限可能未完整开通**,缺失时会影响私聊/群聊接收、回复、更新卡片和已读表情。",
100
+ `👉 一键申请基础权限:${buildScopeApplyUrl({ appId: params.appId, scopes: requiredForLink })}`,
101
+ "",
102
+ );
103
+ }
104
+ if (missingGroupMsg || unknown) {
105
+ lines.push(
106
+ `⚠️ **免 @ 权限未确认**:如果不开通 \`${SENSITIVE_GROUP_MSG_SCOPE}\`,群里普通消息可能不会触发机器人;请在群里 @ 机器人,或申请免 @ 权限。`,
107
+ `👉 一键申请免 @ 权限:${buildScopeApplyUrl({ appId: params.appId, scopes: [SENSITIVE_GROUP_MSG_SCOPE] })}`,
108
+ "",
109
+ );
110
+ }
111
+ lines.push(
112
+ "还需要确认事件订阅与发布:",
113
+ `1️⃣ 事件订阅页确认订阅 \`im.message.receive_v1\`:${buildEventSubUrl(params.appId)}`,
114
+ `2️⃣ 权限管理页申请发布版本使权限生效:${buildPermissionPageUrl(params.appId)}`,
115
+ "",
116
+ "权限生效前,群里请先使用 `@机器人 你的问题`。",
117
+ );
118
+ return lines.join("\n");
119
+ }