@hwj123weijian/pi-feishu 0.5.0 → 0.6.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
@@ -22,7 +22,7 @@
22
22
  - 长连接由跨会话的全局控制器保管:本地 `/new`、远程 `/new` 等会话切换不会断开;`/feishu stop`、`/feishu logout` 或 Pi 进程退出时释放
23
23
  - 凭据优先从环境变量读取,也可保存到本地凭据文件
24
24
 
25
- 当前不支持群聊、图片、文件、卡片、语音、多用户、多会话、进程级沙箱或远程命令权限管理。这里的安全边界是“私聊 + 单 Owner”,Pi 本身仍拥有当前本地进程的权限。
25
+ 支持 Owner 在飞书私聊中请求创建群聊;扩展会把 Owner 拉入新群、发送欢迎消息,并允许 Owner 在该扩展创建的群里 @机器人继续协作。其他群聊不会被处理。图片、文件、卡片、语音、多用户、多会话、进程级沙箱或远程命令权限管理仍不支持。Pi 本身仍拥有当前本地进程的权限。
26
26
 
27
27
  ## 环境要求
28
28
 
@@ -38,6 +38,7 @@
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`:持续更新机器人发出的交互卡片
42
43
  - 表情回复相关权限:在原消息上添加/移除“思考中”已读回执
43
44
  3. 在“事件与回调”中选择“使用长连接接收事件”。
@@ -141,6 +142,8 @@ pi -e D:\ai_study\pi-feishu
141
142
  | `/thinking` | 查看思考档位,或切换:`/thinking off|minimal|low|medium|high|xhigh|max` |
142
143
  | `/model` | 查看当前模型,或模糊匹配切换:`/model <模型ID或名称>` |
143
144
 
145
+ 在飞书私聊中也可直接说“帮我拉个 XX 群”或“创建飞书群 XX”。机器人会创建群聊、邀请已绑定的 Owner 并发送欢迎消息;之后 Owner 在新群里 @机器人即可继续使用当前 Pi 会话。已创建群聊的授权列表会保存在本地凭据文件中,Pi 重启后仍可继续使用。
146
+
144
147
  未知命令会返回提示,不会发给 Pi。`/model` 的候选来自本地 Pi 的 scoped models(未配置时为全部已授权模型);`/model` 无参数时展示当前模型。注意:任何以 `/` 开头的消息都会先被当作命令解析,想发给 Pi 的提问请勿以 `/` 开头。
145
148
 
146
149
  ### 取消排队中的消息
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hwj123weijian/pi-feishu",
3
- "version": "0.5.0",
3
+ "version": "0.6.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,7 @@ export interface FeishuCredentials {
2
2
  appId: string;
3
3
  appSecret: string;
4
4
  ownerOpenId?: string;
5
+ managedGroupIds?: string[];
5
6
  }
6
7
 
7
8
  export interface CredentialStore {
@@ -45,6 +46,7 @@ export interface FeishuReply {
45
46
  export interface FeishuGateway {
46
47
  connect(handler: FeishuMessageHandler): Promise<void>;
47
48
  disconnect(): Promise<void>;
49
+ createGroupChat(name: string, ownerOpenId: string): Promise<string>;
48
50
  /** Sends text and resolves with the sent Feishu message id (for later recall). */
49
51
  sendText(chatId: string, text: string, replyTo?: string): Promise<string | undefined>;
50
52
  beginReply(chatId: string, replyTo: string): Promise<FeishuReply>;
package/src/controller.ts CHANGED
@@ -64,6 +64,7 @@ export class FeishuController {
64
64
  private environment: Environment = {};
65
65
  private readonly pendingTasks = new Map<string, PendingTask>();
66
66
  private readonly activeReplies = new Set<FeishuReply>();
67
+ private readonly managedGroupIds = new Set<string>();
67
68
 
68
69
  constructor(options: FeishuControllerOptions) {
69
70
  this.store = options.store;
@@ -84,9 +85,13 @@ export class FeishuController {
84
85
  }
85
86
 
86
87
  const existing = await this.store.load();
87
- const next =
88
- existing?.appId === credentials.appId && existing.ownerOpenId
89
- ? { ...credentials, ownerOpenId: existing.ownerOpenId }
88
+ const next: FeishuCredentials =
89
+ existing?.appId === credentials.appId
90
+ ? {
91
+ ...credentials,
92
+ ...(existing.ownerOpenId ? { ownerOpenId: existing.ownerOpenId } : {}),
93
+ ...(existing.managedGroupIds ? { managedGroupIds: existing.managedGroupIds } : {}),
94
+ }
90
95
  : credentials;
91
96
  await this.store.save(next);
92
97
  this.credentials = next;
@@ -106,6 +111,8 @@ export class FeishuController {
106
111
  const binding = new OwnerBinding(credentials.ownerOpenId, this.generateBindingCode);
107
112
  const gateway = this.gatewayFactory(credentials);
108
113
  this.credentials = credentials;
114
+ this.managedGroupIds.clear();
115
+ for (const chatId of credentials.managedGroupIds ?? []) this.managedGroupIds.add(chatId);
109
116
  this.binding = binding;
110
117
  this.gateway = gateway;
111
118
  this.environment = environment;
@@ -125,6 +132,26 @@ export class FeishuController {
125
132
  return bindingCode ? { alreadyRunning: false, bindingCode } : { alreadyRunning: false };
126
133
  }
127
134
 
135
+ async createGroupChat(name: string): Promise<string> {
136
+ const gateway = this.gateway;
137
+ const ownerOpenId = this.credentials?.ownerOpenId;
138
+ if (!gateway) throw new CredentialError("飞书尚未连接,请先执行 /feishu start。");
139
+ if (!ownerOpenId) throw new CredentialError("飞书尚未绑定 Owner,无法创建群聊。");
140
+ const chatId = await gateway.createGroupChat(name, ownerOpenId);
141
+ this.managedGroupIds.add(chatId);
142
+ const credentials = this.credentials;
143
+ if (credentials) {
144
+ const updated = { ...credentials, managedGroupIds: [...this.managedGroupIds] };
145
+ await this.store.save(updated);
146
+ this.credentials = updated;
147
+ }
148
+ await gateway.sendText(
149
+ chatId,
150
+ `👋 群聊「${name}」已创建,当前绑定的 Pi 飞书机器人已就绪。直接在群内 @机器人即可开始协作。`,
151
+ );
152
+ return chatId;
153
+ }
154
+
128
155
  async stop(): Promise<boolean> {
129
156
  const gateway = this.gateway;
130
157
  if (!gateway) return false;
@@ -167,7 +194,8 @@ export class FeishuController {
167
194
  async handleIncoming(message: FeishuIncomingMessage): Promise<void> {
168
195
  const gateway = this.gateway;
169
196
  const binding = this.binding;
170
- if (!gateway || !binding || message.chatType !== "p2p" || message.contentType !== "text") return;
197
+ if (!gateway || !binding || message.contentType !== "text") return;
198
+ if (message.chatType === "group" && !this.managedGroupIds.has(message.chatId)) return;
171
199
  if (!message.messageId || !this.deduplicator.accept(message.messageId)) return;
172
200
 
173
201
  const reactionId = await this.ackRead(gateway, message.messageId);
@@ -61,8 +61,13 @@ 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
+ };
66
71
  }
67
72
  return stored;
68
73
  }
@@ -122,9 +127,14 @@ export class FileCredentialStore implements CredentialStore {
122
127
  throw new CredentialError("飞书凭据文件格式无效,请执行 /feishu logout 后重新配置。");
123
128
  }
124
129
  validateCredentialShape(value);
125
- return value.ownerOpenId
126
- ? { appId: value.appId, appSecret: value.appSecret, ownerOpenId: value.ownerOpenId }
127
- : { appId: value.appId, appSecret: value.appSecret };
130
+ return {
131
+ appId: value.appId,
132
+ appSecret: value.appSecret,
133
+ ...(value.ownerOpenId ? { ownerOpenId: value.ownerOpenId } : {}),
134
+ ...(Array.isArray(value.managedGroupIds)
135
+ ? { managedGroupIds: value.managedGroupIds.filter((id): id is string => typeof id === "string") }
136
+ : {}),
137
+ };
128
138
  }
129
139
 
130
140
  async save(credentials: FeishuCredentials): Promise<void> {
@@ -159,7 +169,8 @@ function isCredentialRecord(value: unknown): value is FeishuCredentials {
159
169
  return (
160
170
  typeof record.appId === "string" &&
161
171
  typeof record.appSecret === "string" &&
162
- (record.ownerOpenId === undefined || typeof record.ownerOpenId === "string")
172
+ (record.ownerOpenId === undefined || typeof record.ownerOpenId === "string") &&
173
+ (record.managedGroupIds === undefined || Array.isArray(record.managedGroupIds))
163
174
  );
164
175
  }
165
176
 
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";
@@ -213,6 +214,35 @@ export default function feishuExtension(pi: ExtensionAPI): void {
213
214
  bridge.cancel("Pi 会话已关闭。");
214
215
  });
215
216
 
217
+ pi.registerTool({
218
+ name: "feishu_create_group",
219
+ label: "Create Feishu Group",
220
+ description:
221
+ "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).",
222
+ parameters: Type.Object({
223
+ name: Type.String({ description: "Name for the new Feishu group chat" }),
224
+ }),
225
+ async execute(_toolCallId, params) {
226
+ try {
227
+ const chatId = await state.controller.createGroupChat(params.name.trim());
228
+ return {
229
+ content: [
230
+ {
231
+ type: "text",
232
+ text: `群聊「${params.name.trim()}」已创建,群聊 ID:${chatId}。已邀请当前绑定的飞书用户,并发送群欢迎消息。`,
233
+ },
234
+ ],
235
+ details: { chatId, name: params.name.trim() },
236
+ };
237
+ } catch (error) {
238
+ return {
239
+ content: [{ type: "text", text: `创建飞书群失败:${state.controller.sanitizeError(error)}` }],
240
+ details: undefined,
241
+ };
242
+ }
243
+ },
244
+ });
245
+
216
246
  pi.registerCommand("feishu", {
217
247
  description: "配置和管理飞书私聊连接",
218
248
  handler: async (args, context) => {
package/src/gateway.ts CHANGED
@@ -98,6 +98,31 @@ export class SdkFeishuGateway implements FeishuGateway {
98
98
  return () => this.reactionHandlers.delete(handler);
99
99
  }
100
100
 
101
+ async createGroupChat(name: string, ownerOpenId: string): Promise<string> {
102
+ const response = await fetch("https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal", {
103
+ method: "POST",
104
+ headers: { "Content-Type": "application/json" },
105
+ body: JSON.stringify({ app_id: this.credentials.appId, app_secret: this.credentials.appSecret }),
106
+ });
107
+ const tokenResult = (await response.json()) as { code?: number; msg?: string; tenant_access_token?: string };
108
+ if (!response.ok || tokenResult.code !== 0 || !tokenResult.tenant_access_token) {
109
+ throw new Error(`获取飞书 tenant token 失败:${tokenResult.msg ?? response.statusText}`);
110
+ }
111
+ const createResponse = await fetch(`https://open.feishu.cn/open-apis/im/v1/chats?uuid=${crypto.randomUUID()}`, {
112
+ method: "POST",
113
+ headers: {
114
+ Authorization: `Bearer ${tokenResult.tenant_access_token}`,
115
+ "Content-Type": "application/json",
116
+ },
117
+ body: JSON.stringify({ name, description: "Pi Feishu 创建的协作群", user_id_list: [ownerOpenId] }),
118
+ });
119
+ const result = (await createResponse.json()) as { code?: number; msg?: string; data?: { chat_id?: string } };
120
+ if (!createResponse.ok || result.code !== 0 || !result.data?.chat_id) {
121
+ throw new Error(`飞书创建群聊失败:${result.msg ?? createResponse.statusText}`);
122
+ }
123
+ return result.data.chat_id;
124
+ }
125
+
101
126
  async sendText(chatId: string, text: string, replyTo?: string): Promise<string | undefined> {
102
127
  const channel = this.channel;
103
128
  if (!channel) throw new Error("飞书长连接尚未启动。");
@@ -164,6 +189,7 @@ function createOfficialChannel(credentials: FeishuCredentials): ChannelLike {
164
189
  },
165
190
  policy: {
166
191
  dmMode: "open",
192
+ // Controller enforces owner identity and only accepts groups it created.
167
193
  groupAllowlist: [],
168
194
  requireMention: true,
169
195
  },