@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 +4 -1
- package/package.json +3 -2
- package/src/contracts.ts +2 -0
- package/src/controller.ts +32 -4
- package/src/credentials.ts +17 -6
- package/src/extension.ts +30 -0
- package/src/gateway.ts +26 -0
package/README.md
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
- 长连接由跨会话的全局控制器保管:本地 `/new`、远程 `/new` 等会话切换不会断开;`/feishu stop`、`/feishu logout` 或 Pi 进程退出时释放
|
|
23
23
|
- 凭据优先从环境变量读取,也可保存到本地凭据文件
|
|
24
24
|
|
|
25
|
-
|
|
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.
|
|
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
|
|
89
|
-
? {
|
|
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.
|
|
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);
|
package/src/credentials.ts
CHANGED
|
@@ -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
|
|
65
|
-
return
|
|
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
|
|
126
|
-
|
|
127
|
-
|
|
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
|
},
|