@hwj123weijian/pi-feishu 0.5.0 → 0.7.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”,Pi 本身仍拥有当前本地进程的权限。
25
+ 支持 Owner 在飞书私聊中请求创建群聊;扩展会把 Owner 拉入新群、发送欢迎消息,并允许 Owner 在该扩展创建的群里 @机器人继续协作。建群后会私聊 Owner 做一次权限体检,提示缺失的基础 scope、事件订阅页和 `im:message.group_msg` 免 @ 权限申请链接。每个扩展创建的群聊会绑定自己的 Pi session,群聊之间、群聊与本地主对话之间不会共享上下文。其他群聊不会被处理。图片、文件、卡片、语音、多用户、进程级沙箱或远程命令权限管理仍不支持。Pi 本身仍拥有当前本地进程的权限。
26
26
 
27
27
  ## 环境要求
28
28
 
@@ -38,13 +38,15 @@
38
38
  2. 在“权限管理”中申请:
39
39
  - `im:message.p2p_msg:readonly`:接收私聊消息
40
40
  - `im:message:send_as_bot`:以机器人身份回复
41
+ - `im:chat`:创建群聊并邀请 Owner
41
42
  - `im:message:update`:持续更新机器人发出的交互卡片
43
+ - `application:application:self_manage`:启动/建群后读取应用已授权 scope,用于自动提示缺失权限
42
44
  - 表情回复相关权限:在原消息上添加/移除“思考中”已读回执
43
45
  3. 在“事件与回调”中选择“使用长连接接收事件”。
44
46
  4. 添加事件 `im.message.receive_v1`。
45
47
  5. 创建并发布一个应用版本,使权限和事件订阅在企业内生效。
46
48
 
47
- 这个扩展不需要公网回调地址,也不需要加密密钥或 Verification Token。若未授予 `im:message:update`,扩展仍可工作,但会降级为最终文本一次性回复。若未授予表情回复权限,扩展同样可工作,只是原消息上不会出现已读表情。
49
+ 这个扩展不需要公网回调地址,也不需要加密密钥或 Verification Token。若未授予 `im:message:update`,扩展仍可工作,但会降级为最终文本一次性回复。若未授予表情回复权限,扩展同样可工作,只是原消息上不会出现已读表情。若希望已创建项目群里无需 @ 机器人即可触发,需要额外申请敏感权限 `im:message.group_msg`;未开通时请在群里使用 `@机器人 你的问题`。
48
50
 
49
51
  ## 安装
50
52
 
@@ -141,6 +143,8 @@ pi -e D:\ai_study\pi-feishu
141
143
  | `/thinking` | 查看思考档位,或切换:`/thinking off|minimal|low|medium|high|xhigh|max` |
142
144
  | `/model` | 查看当前模型,或模糊匹配切换:`/model <模型ID或名称>` |
143
145
 
146
+ 在飞书私聊中也可直接说“帮我拉个 XX 群”或“创建飞书群 XX”。机器人会创建群聊、邀请已绑定的 Owner 并发送欢迎消息;之后 Owner 在新群里 @机器人即可协作。该群首次收到消息时会创建一个独立 Pi session 并绑定到群,之后该群消息都会自动切回这个 session。已创建群聊的授权列表和 session 绑定会保存在本地凭据文件中,Pi 重启后仍可继续使用。
147
+
144
148
  未知命令会返回提示,不会发给 Pi。`/model` 的候选来自本地 Pi 的 scoped models(未配置时为全部已授权模型);`/model` 无参数时展示当前模型。注意:任何以 `/` 开头的消息都会先被当作命令解析,想发给 Pi 的提问请勿以 `/` 开头。
145
149
 
146
150
  ### 取消排队中的消息
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hwj123weijian/pi-feishu",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Minimal Feishu private-chat bridge for Pi",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -31,7 +31,8 @@
31
31
  "typecheck": "tsc --noEmit"
32
32
  },
33
33
  "dependencies": {
34
- "@larksuiteoapi/node-sdk": "1.72.0"
34
+ "@larksuiteoapi/node-sdk": "1.72.0",
35
+ "typebox": "1.3.7"
35
36
  },
36
37
  "peerDependencies": {
37
38
  "@earendil-works/pi-coding-agent": "0.83.0"
package/src/contracts.ts CHANGED
@@ -2,6 +2,9 @@ export interface FeishuCredentials {
2
2
  appId: string;
3
3
  appSecret: string;
4
4
  ownerOpenId?: string;
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>;
5
8
  }
6
9
 
7
10
  export interface CredentialStore {
@@ -45,6 +48,9 @@ export interface FeishuReply {
45
48
  export interface FeishuGateway {
46
49
  connect(handler: FeishuMessageHandler): Promise<void>;
47
50
  disconnect(): Promise<void>;
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[] }>;
48
54
  /** Sends text and resolves with the sent Feishu message id (for later recall). */
49
55
  sendText(chatId: string, text: string, replyTo?: string): Promise<string | undefined>;
50
56
  beginReply(chatId: string, replyTo: string): Promise<FeishuReply>;
@@ -107,8 +113,13 @@ export interface AgentProgressObserver {
107
113
  onActivity?: (activity: AgentActivity) => void;
108
114
  }
109
115
 
116
+ export interface AgentRunOptions {
117
+ /** Route this turn through the Pi session bound to the Feishu chat. */
118
+ chatId?: string;
119
+ }
120
+
110
121
  export interface AgentBridge {
111
- run(text: string, observer?: AgentProgressObserver): Promise<string>;
122
+ run(text: string, observer?: AgentProgressObserver, options?: AgentRunOptions): Promise<string>;
112
123
  cancel(reason?: string): void;
113
124
  }
114
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";
@@ -64,6 +65,7 @@ export class FeishuController {
64
65
  private environment: Environment = {};
65
66
  private readonly pendingTasks = new Map<string, PendingTask>();
66
67
  private readonly activeReplies = new Set<FeishuReply>();
68
+ private readonly managedGroupIds = new Set<string>();
67
69
 
68
70
  constructor(options: FeishuControllerOptions) {
69
71
  this.store = options.store;
@@ -84,9 +86,14 @@ export class FeishuController {
84
86
  }
85
87
 
86
88
  const existing = await this.store.load();
87
- const next =
88
- existing?.appId === credentials.appId && existing.ownerOpenId
89
- ? { ...credentials, ownerOpenId: existing.ownerOpenId }
89
+ const next: FeishuCredentials =
90
+ existing?.appId === credentials.appId
91
+ ? {
92
+ ...credentials,
93
+ ...(existing.ownerOpenId ? { ownerOpenId: existing.ownerOpenId } : {}),
94
+ ...(existing.managedGroupIds ? { managedGroupIds: existing.managedGroupIds } : {}),
95
+ ...(existing.groupSessions ? { groupSessions: existing.groupSessions } : {}),
96
+ }
90
97
  : credentials;
91
98
  await this.store.save(next);
92
99
  this.credentials = next;
@@ -106,6 +113,8 @@ export class FeishuController {
106
113
  const binding = new OwnerBinding(credentials.ownerOpenId, this.generateBindingCode);
107
114
  const gateway = this.gatewayFactory(credentials);
108
115
  this.credentials = credentials;
116
+ this.managedGroupIds.clear();
117
+ for (const chatId of credentials.managedGroupIds ?? []) this.managedGroupIds.add(chatId);
109
118
  this.binding = binding;
110
119
  this.gateway = gateway;
111
120
  this.environment = environment;
@@ -125,6 +134,64 @@ export class FeishuController {
125
134
  return bindingCode ? { alreadyRunning: false, bindingCode } : { alreadyRunning: false };
126
135
  }
127
136
 
137
+ async createGroupChat(name: string): Promise<string> {
138
+ const gateway = this.gateway;
139
+ const ownerOpenId = this.credentials?.ownerOpenId;
140
+ if (!gateway) throw new CredentialError("飞书尚未连接,请先执行 /feishu start。");
141
+ if (!ownerOpenId) throw new CredentialError("飞书尚未绑定 Owner,无法创建群聊。");
142
+ const chatId = await gateway.createGroupChat(name, ownerOpenId);
143
+ this.managedGroupIds.add(chatId);
144
+ const credentials = this.credentials;
145
+ if (credentials) {
146
+ const updated = { ...credentials, managedGroupIds: [...this.managedGroupIds] };
147
+ await this.store.save(updated);
148
+ this.credentials = updated;
149
+ }
150
+ await gateway.sendText(
151
+ chatId,
152
+ `👋 群聊「${name}」已创建,当前绑定的 Pi 飞书机器人已就绪。直接在群内 @机器人即可开始协作。若群内普通消息没有响应,请先 @ 机器人;开通免 @ 权限后可直接发消息。`,
153
+ );
154
+ await this.sendGroupPermissionReminder(gateway, ownerOpenId, name);
155
+ return chatId;
156
+ }
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
+
128
195
  async stop(): Promise<boolean> {
129
196
  const gateway = this.gateway;
130
197
  if (!gateway) return false;
@@ -167,7 +234,8 @@ export class FeishuController {
167
234
  async handleIncoming(message: FeishuIncomingMessage): Promise<void> {
168
235
  const gateway = this.gateway;
169
236
  const binding = this.binding;
170
- if (!gateway || !binding || message.chatType !== "p2p" || message.contentType !== "text") return;
237
+ if (!gateway || !binding || message.contentType !== "text") return;
238
+ if (message.chatType === "group" && !this.managedGroupIds.has(message.chatId)) return;
171
239
  if (!message.messageId || !this.deduplicator.accept(message.messageId)) return;
172
240
 
173
241
  const reactionId = await this.ackRead(gateway, message.messageId);
@@ -327,18 +395,22 @@ export class FeishuController {
327
395
  if (reply) this.activeReplies.add(reply);
328
396
  let latestText = "";
329
397
  try {
330
- const response = await this.agent.run(text, {
331
- onText: (latest) => {
332
- latestText = latest;
333
- reply?.update({ text: latest, status: "正在生成回复" });
334
- },
335
- onActivity: (activity) => {
336
- reply?.update({
337
- text: latestText,
338
- status: activity.kind === "tool" ? "正在执行工具" : "正在思考",
339
- });
398
+ const response = await this.agent.run(
399
+ text,
400
+ {
401
+ onText: (latest) => {
402
+ latestText = latest;
403
+ reply?.update({ text: latest, status: "正在生成回复" });
404
+ },
405
+ onActivity: (activity) => {
406
+ reply?.update({
407
+ text: latestText,
408
+ status: activity.kind === "tool" ? "正在执行工具" : "正在思考",
409
+ });
410
+ },
340
411
  },
341
- });
412
+ message.chatType === "group" ? { chatId: message.chatId } : undefined,
413
+ );
342
414
  if (this.gateway === gateway) {
343
415
  if (reply) {
344
416
  await reply.complete({ text: response, status: "已完成" });
@@ -61,8 +61,14 @@ export function resolveRuntimeCredentials(
61
61
  throw new CredentialError("FEISHU_APP_ID 和 FEISHU_APP_SECRET 必须同时设置。");
62
62
  }
63
63
  validateCredentialShape({ appId, appSecret });
64
- const ownerOpenId = stored?.appId === appId ? stored.ownerOpenId : undefined;
65
- return ownerOpenId ? { appId, appSecret, ownerOpenId } : { appId, appSecret };
64
+ const matching = stored?.appId === appId ? stored : undefined;
65
+ return {
66
+ appId,
67
+ appSecret,
68
+ ...(matching?.ownerOpenId ? { ownerOpenId: matching.ownerOpenId } : {}),
69
+ ...(matching?.managedGroupIds ? { managedGroupIds: matching.managedGroupIds } : {}),
70
+ ...(matching?.groupSessions ? { groupSessions: matching.groupSessions } : {}),
71
+ };
66
72
  }
67
73
  return stored;
68
74
  }
@@ -122,9 +128,15 @@ export class FileCredentialStore implements CredentialStore {
122
128
  throw new CredentialError("飞书凭据文件格式无效,请执行 /feishu logout 后重新配置。");
123
129
  }
124
130
  validateCredentialShape(value);
125
- return value.ownerOpenId
126
- ? { appId: value.appId, appSecret: value.appSecret, ownerOpenId: value.ownerOpenId }
127
- : { appId: value.appId, appSecret: value.appSecret };
131
+ return {
132
+ appId: value.appId,
133
+ appSecret: value.appSecret,
134
+ ...(value.ownerOpenId ? { ownerOpenId: value.ownerOpenId } : {}),
135
+ ...(Array.isArray(value.managedGroupIds)
136
+ ? { managedGroupIds: value.managedGroupIds.filter((id): id is string => typeof id === "string") }
137
+ : {}),
138
+ ...(isStringRecord(value.groupSessions) ? { groupSessions: value.groupSessions } : {}),
139
+ };
128
140
  }
129
141
 
130
142
  async save(credentials: FeishuCredentials): Promise<void> {
@@ -159,10 +171,17 @@ function isCredentialRecord(value: unknown): value is FeishuCredentials {
159
171
  return (
160
172
  typeof record.appId === "string" &&
161
173
  typeof record.appSecret === "string" &&
162
- (record.ownerOpenId === undefined || typeof record.ownerOpenId === "string")
174
+ (record.ownerOpenId === undefined || typeof record.ownerOpenId === "string") &&
175
+ (record.managedGroupIds === undefined || Array.isArray(record.managedGroupIds)) &&
176
+ (record.groupSessions === undefined || isStringRecord(record.groupSessions))
163
177
  );
164
178
  }
165
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
+
166
185
  function isNodeError(error: unknown): error is NodeJS.ErrnoException {
167
186
  return error instanceof Error && "code" in error;
168
187
  }
package/src/extension.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "typebox";
2
3
  import type { AgentBridge, FeishuStatus, PiModelInfo, PiRuntime, PiRuntimeSnapshot } from "./contracts.js";
3
4
  import { FeishuController } from "./controller.js";
4
5
  import { CredentialError, FileCredentialStore } from "./credentials.js";
@@ -11,13 +12,15 @@ type PiModel = NonNullable<ExtensionContext["model"]>;
11
12
  type PiThinkingLevel = NonNullable<ExtensionContext["thinkingLevel"]>;
12
13
 
13
14
  interface SessionLink {
14
- bridge: PiAgentBridge;
15
15
  runtime: PiRuntime;
16
16
  }
17
17
 
18
18
  interface SharedFeishuState {
19
19
  controller: FeishuController;
20
20
  current: SessionLink | undefined;
21
+ activeBridge: PiAgentBridge | undefined;
22
+ latestCommandContext: ExtensionCommandContext | undefined;
23
+ sendCurrentMessage: ((text: string) => void) | undefined;
21
24
  }
22
25
 
23
26
  const SHARED_STATE_KEY = "__piFeishuSharedState__";
@@ -32,14 +35,30 @@ function getSharedState(): SharedFeishuState {
32
35
  const existing = host[SHARED_STATE_KEY];
33
36
  if (existing) return existing;
34
37
 
35
- const state: SharedFeishuState = { current: undefined, controller: undefined as unknown as FeishuController };
38
+ const state: SharedFeishuState = {
39
+ current: undefined,
40
+ activeBridge: undefined,
41
+ latestCommandContext: undefined,
42
+ sendCurrentMessage: undefined,
43
+ controller: undefined as unknown as FeishuController,
44
+ };
36
45
  const proxyAgent: AgentBridge = {
37
- run: (text, observer) => {
38
- const link = state.current;
39
- if (!link) return Promise.reject(new Error("Pi 会话尚未就绪,请稍后再试。"));
40
- return link.bridge.run(text, observer);
46
+ run: (text, observer, options) => {
47
+ const bridge = new PiAgentBridge(async (prompt) => {
48
+ if (options?.chatId) {
49
+ await sendToChatSession(state, options.chatId, prompt);
50
+ return;
51
+ }
52
+ if (!state.current) throw new Error("Pi 会话尚未就绪,请稍后再试。");
53
+ if (!state.sendCurrentMessage) throw new Error("Pi 会话尚未就绪,请稍后再试。");
54
+ state.sendCurrentMessage(prompt);
55
+ });
56
+ state.activeBridge = bridge;
57
+ return bridge.run(text, observer).finally(() => {
58
+ if (state.activeBridge === bridge) state.activeBridge = undefined;
59
+ });
41
60
  },
42
- cancel: (reason) => state.current?.bridge.cancel(reason),
61
+ cancel: (reason) => state.activeBridge?.cancel(reason),
43
62
  };
44
63
  const proxyRuntime: PiRuntime = {
45
64
  isIdle: () => state.current?.runtime.isIdle() ?? true,
@@ -66,6 +85,35 @@ function getSharedState(): SharedFeishuState {
66
85
  return state;
67
86
  }
68
87
 
88
+ async function sendToChatSession(state: SharedFeishuState, chatId: string, prompt: string): Promise<void> {
89
+ const context = state.latestCommandContext;
90
+ if (!context) throw new Error("Pi 会话控制尚未就绪,请先在本地执行一次 /feishu status。");
91
+
92
+ const sessionFile = state.controller.getChatSessionFile(chatId);
93
+ if (sessionFile) {
94
+ const result = await context.switchSession(sessionFile, {
95
+ withSession: async (nextContext) => {
96
+ state.latestCommandContext = nextContext;
97
+ const currentFile = nextContext.sessionManager.getSessionFile();
98
+ if (currentFile && currentFile !== sessionFile) await state.controller.setChatSessionFile(chatId, currentFile);
99
+ await nextContext.sendUserMessage(prompt);
100
+ },
101
+ });
102
+ if (result.cancelled) throw new Error("切换到飞书群绑定的 Pi 会话已取消。");
103
+ return;
104
+ }
105
+
106
+ const result = await context.newSession({
107
+ withSession: async (nextContext) => {
108
+ state.latestCommandContext = nextContext;
109
+ const currentFile = nextContext.sessionManager.getSessionFile();
110
+ if (currentFile) await state.controller.setChatSessionFile(chatId, currentFile);
111
+ await nextContext.sendUserMessage(prompt);
112
+ },
113
+ });
114
+ if (result.cancelled) throw new Error("创建飞书群专属 Pi 会话已取消。");
115
+ }
116
+
69
117
  export interface ParsedFeishuCommand {
70
118
  name: FeishuCommandName;
71
119
  args: string;
@@ -128,7 +176,7 @@ export function renderFeishuStatus(status: FeishuStatus): string {
128
176
 
129
177
  export default function feishuExtension(pi: ExtensionAPI): void {
130
178
  const state = getSharedState();
131
- const bridge = new PiAgentBridge((text) => pi.sendUserMessage(text));
179
+ state.sendCurrentMessage = (text) => pi.sendUserMessage(text);
132
180
  let latestContext: ExtensionContext | undefined;
133
181
  let latestCommandContext: ExtensionCommandContext | undefined;
134
182
 
@@ -137,7 +185,7 @@ export default function feishuExtension(pi: ExtensionAPI): void {
137
185
  abort: () => withRuntimeContext(() => latestContext?.abort()),
138
186
  compact: () => withRuntimeContext(() => latestContext?.compact()),
139
187
  newSession: async () => {
140
- const context = latestCommandContext;
188
+ const context = latestCommandContext ?? state.latestCommandContext;
141
189
  if (!context) return false;
142
190
  try {
143
191
  const result = await context.newSession();
@@ -180,7 +228,7 @@ export default function feishuExtension(pi: ExtensionAPI): void {
180
228
  return snapshot;
181
229
  },
182
230
  };
183
- state.current = { bridge, runtime };
231
+ state.current = { runtime };
184
232
 
185
233
  // 每个事件都会带来新的 ExtensionContext;持续刷新,保证远程命令拿到的能力不失效。
186
234
  const trackContext = (_event: unknown, context: ExtensionContext): void => {
@@ -190,33 +238,63 @@ export default function feishuExtension(pi: ExtensionAPI): void {
190
238
  pi.on("agent_start", trackContext);
191
239
  pi.on("message_end", (event, context) => {
192
240
  trackContext(event, context);
193
- bridge.captureMessage(event.message);
241
+ state.activeBridge?.captureMessage(event.message);
194
242
  });
195
243
  pi.on("message_update", (event, context) => {
196
244
  trackContext(event, context);
197
- bridge.captureStreamingMessage(event.message);
245
+ state.activeBridge?.captureStreamingMessage(event.message);
198
246
  });
199
247
  pi.on("tool_execution_start", (event, context) => {
200
248
  trackContext(event, context);
201
- bridge.captureToolStart(event.toolName);
249
+ state.activeBridge?.captureToolStart(event.toolName);
202
250
  });
203
251
  pi.on("tool_execution_end", (event, context) => {
204
252
  trackContext(event, context);
205
- bridge.captureToolEnd();
253
+ state.activeBridge?.captureToolEnd();
206
254
  });
207
255
  pi.on("agent_settled", (event, context) => {
208
256
  trackContext(event, context);
209
- bridge.settle();
257
+ state.activeBridge?.settle();
210
258
  });
211
259
  pi.on("session_shutdown", () => {
212
- // 只解绑本会话的桥:长连接归全局 controller 保管,本地 /new 切换会话后自动延续。
213
- bridge.cancel("Pi 会话已关闭。");
260
+ // 长连接归全局 controller 保管,本地 /new 切换会话后自动延续。
261
+ state.activeBridge?.cancel("Pi 会话已关闭。");
262
+ });
263
+
264
+ pi.registerTool({
265
+ name: "feishu_create_group",
266
+ label: "Create Feishu Group",
267
+ description:
268
+ "Create a Feishu group chat and invite the currently bound owner. Use when asked to 拉群、建群、创建飞书群 or create a group. This creates only the group (not a local project directory).",
269
+ parameters: Type.Object({
270
+ name: Type.String({ description: "Name for the new Feishu group chat" }),
271
+ }),
272
+ async execute(_toolCallId, params) {
273
+ try {
274
+ const chatId = await state.controller.createGroupChat(params.name.trim());
275
+ return {
276
+ content: [
277
+ {
278
+ type: "text",
279
+ text: `群聊「${params.name.trim()}」已创建,群聊 ID:${chatId}。已邀请当前绑定的飞书用户,并发送群欢迎消息。`,
280
+ },
281
+ ],
282
+ details: { chatId, name: params.name.trim() },
283
+ };
284
+ } catch (error) {
285
+ return {
286
+ content: [{ type: "text", text: `创建飞书群失败:${state.controller.sanitizeError(error)}` }],
287
+ details: undefined,
288
+ };
289
+ }
290
+ },
214
291
  });
215
292
 
216
293
  pi.registerCommand("feishu", {
217
294
  description: "配置和管理飞书私聊连接",
218
295
  handler: async (args, context) => {
219
296
  latestCommandContext = context;
297
+ state.latestCommandContext = context;
220
298
  try {
221
299
  await handleFeishuCommand(parseFeishuCommand(args), state.controller, context);
222
300
  } catch (error) {
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;
@@ -98,6 +99,35 @@ export class SdkFeishuGateway implements FeishuGateway {
98
99
  return () => this.reactionHandlers.delete(handler);
99
100
  }
100
101
 
102
+ async createGroupChat(name: string, ownerOpenId: string): Promise<string> {
103
+ const response = await fetch("https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal", {
104
+ method: "POST",
105
+ headers: { "Content-Type": "application/json" },
106
+ body: JSON.stringify({ app_id: this.credentials.appId, app_secret: this.credentials.appSecret }),
107
+ });
108
+ const tokenResult = (await response.json()) as { code?: number; msg?: string; tenant_access_token?: string };
109
+ if (!response.ok || tokenResult.code !== 0 || !tokenResult.tenant_access_token) {
110
+ throw new Error(`获取飞书 tenant token 失败:${tokenResult.msg ?? response.statusText}`);
111
+ }
112
+ const createResponse = await fetch(`https://open.feishu.cn/open-apis/im/v1/chats?uuid=${crypto.randomUUID()}`, {
113
+ method: "POST",
114
+ headers: {
115
+ Authorization: `Bearer ${tokenResult.tenant_access_token}`,
116
+ "Content-Type": "application/json",
117
+ },
118
+ body: JSON.stringify({ name, description: "Pi Feishu 创建的协作群", user_id_list: [ownerOpenId] }),
119
+ });
120
+ const result = (await createResponse.json()) as { code?: number; msg?: string; data?: { chat_id?: string } };
121
+ if (!createResponse.ok || result.code !== 0 || !result.data?.chat_id) {
122
+ throw new Error(`飞书创建群聊失败:${result.msg ?? createResponse.statusText}`);
123
+ }
124
+ return result.data.chat_id;
125
+ }
126
+
127
+ async probeGrantedScopes(): Promise<{ grantedScopes?: string[] }> {
128
+ return probeGrantedScopes(this.credentials);
129
+ }
130
+
101
131
  async sendText(chatId: string, text: string, replyTo?: string): Promise<string | undefined> {
102
132
  const channel = this.channel;
103
133
  if (!channel) throw new Error("飞书长连接尚未启动。");
@@ -164,6 +194,7 @@ function createOfficialChannel(credentials: FeishuCredentials): ChannelLike {
164
194
  },
165
195
  policy: {
166
196
  dmMode: "open",
197
+ // Controller enforces owner identity and only accepts groups it created.
167
198
  groupAllowlist: [],
168
199
  requireMention: true,
169
200
  },
@@ -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
+ }