@hwj123weijian/pi-feishu 0.10.2 → 0.11.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 +2 -1
- package/package.json +1 -1
- package/src/contracts.ts +7 -0
- package/src/controller.ts +108 -7
- package/src/credentials.ts +4 -1
- package/src/extension.ts +113 -16
package/README.md
CHANGED
|
@@ -155,12 +155,13 @@ pi -e D:\ai_study\pi-feishu
|
|
|
155
155
|
### 群聊使用
|
|
156
156
|
|
|
157
157
|
- **绑定已有群**:把机器人加进现有项目群后,Owner 在群里发送 `/bind` 即可绑定;之后该群获得独立 Pi session,与其他群和主对话互不串上下文。机器人被拉进新群时也会私聊 Owner 提醒绑定。
|
|
158
|
+
- **绑定项目目录**(推荐):发送 `/bind /path/to/project` 把群锚定到项目目录——群会话的工作目录会切换到该目录,bot 直接读写这个项目;Owner 显式绑定的目录会自动加入 Pi 的项目信任。私聊里说“帮我拉个群 /path/to/project”也可以在建群时一并锚定。
|
|
158
159
|
- **触发方式**:默认在群里 `@机器人 你的问题`。飞书只向 bot 推送 @bot 的群消息;普通消息直接触发需要敏感权限 `im:message.group_msg`。
|
|
159
160
|
- **授权成员**:默认只有 Owner 能使用。Owner 私聊发送 `/allow @张三` 后,张三即可在私聊和已绑定群里与机器人协作;`/deny @张三` 撤销。
|
|
160
161
|
- **分寸感**:未绑定的群里被 @ 时回复绑定引导;非授权成员被 @ 时回复一次“未授权”;其余群内消息完全静默,不会刷屏。
|
|
161
162
|
- **说话人上下文**:群消息发给 Pi 前会加上 `[飞书群聊] 说话人:` 前缀,@ 其他人会被改写成 `@名字`,模型知道在和谁对话。
|
|
162
163
|
|
|
163
|
-
已知限制:Pi
|
|
164
|
+
已知限制:Pi 是单会话进程,群与群、群与私聊之间共享一个执行引擎,**跨聊天**的任务仍全局串行(排队提示会如实显示位数)。同一聊天内任务运行中继续发消息,会直接并入当前对话(steer),无需排队。远程命令作用于最近活跃的会话,`/model`、`/thinking`、`/compact` 建议在对应聊天的上一条消息之后紧接着发送。
|
|
164
165
|
|
|
165
166
|
未知命令会返回提示,不会发给 Pi。`/model` 的候选来自本地 Pi 的 scoped models(未配置时为全部已授权模型);`/model` 无参数时展示当前模型。注意:任何以 `/` 开头的消息都会先被当作命令解析,想发给 Pi 的提问请勿以 `/` 开头。
|
|
166
167
|
|
package/package.json
CHANGED
package/src/contracts.ts
CHANGED
|
@@ -9,6 +9,8 @@ export interface FeishuCredentials {
|
|
|
9
9
|
allowlist?: string[];
|
|
10
10
|
/** Display names for allowlisted open IDs, captured from @mentions when authorizing. */
|
|
11
11
|
allowlistNames?: Record<string, string>;
|
|
12
|
+
/** Per managed group working directory (easycodeclient-style project binding). */
|
|
13
|
+
groupDirs?: Record<string, string>;
|
|
12
14
|
}
|
|
13
15
|
|
|
14
16
|
export interface CredentialStore {
|
|
@@ -153,6 +155,11 @@ export interface AgentRunOptions {
|
|
|
153
155
|
export interface AgentBridge {
|
|
154
156
|
run(text: string, observer?: AgentProgressObserver, options?: AgentRunOptions): Promise<string>;
|
|
155
157
|
cancel(reason?: string): void;
|
|
158
|
+
/**
|
|
159
|
+
* 并入当前正在运行的轮次(同聊天追加消息时使用)。
|
|
160
|
+
* 仅当确有轮次在跑时返回 true;false 表示当前空闲,调用方应回退到正常排队。
|
|
161
|
+
*/
|
|
162
|
+
steer?(text: string): boolean;
|
|
156
163
|
}
|
|
157
164
|
|
|
158
165
|
export type FeishuGatewayFactory = (credentials: FeishuCredentials) => FeishuGateway;
|
package/src/controller.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { stat } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
1
3
|
import type {
|
|
2
4
|
AgentBridge,
|
|
3
5
|
CredentialStore,
|
|
@@ -202,17 +204,36 @@ export class FeishuController {
|
|
|
202
204
|
await gateway.sendText(ownerOpenId, welcome).catch(() => undefined);
|
|
203
205
|
}
|
|
204
206
|
|
|
205
|
-
async createGroupChat(name: string): Promise<string> {
|
|
207
|
+
async createGroupChat(name: string, directory?: string): Promise<string> {
|
|
206
208
|
const gateway = this.gateway;
|
|
207
209
|
const ownerOpenId = this.credentials?.ownerOpenId;
|
|
208
210
|
if (!gateway) throw new CredentialError("飞书尚未连接,请先执行 /feishu start。");
|
|
209
211
|
if (!ownerOpenId) throw new CredentialError("飞书尚未绑定 Owner,无法创建群聊。");
|
|
210
212
|
const chatId = await gateway.createGroupChat(name, ownerOpenId);
|
|
211
213
|
this.managedGroupIds.add(chatId);
|
|
212
|
-
|
|
214
|
+
let anchoredLine = "";
|
|
215
|
+
if (directory) {
|
|
216
|
+
const resolved = resolve(directory);
|
|
217
|
+
let isDirectory = false;
|
|
218
|
+
try {
|
|
219
|
+
isDirectory = (await stat(resolved)).isDirectory();
|
|
220
|
+
} catch {
|
|
221
|
+
isDirectory = false;
|
|
222
|
+
}
|
|
223
|
+
if (isDirectory) {
|
|
224
|
+
await this.saveCredentials({
|
|
225
|
+
managedGroupIds: [...this.managedGroupIds],
|
|
226
|
+
groupDirs: { ...(this.credentials?.groupDirs ?? {}), [chatId]: resolved },
|
|
227
|
+
});
|
|
228
|
+
anchoredLine = `\n📂 本群已锚定到目录:${resolved}`;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (!this.credentials?.groupDirs?.[chatId]) {
|
|
232
|
+
await this.saveCredentials({ managedGroupIds: [...this.managedGroupIds] });
|
|
233
|
+
}
|
|
213
234
|
await gateway.sendText(
|
|
214
235
|
chatId,
|
|
215
|
-
`👋 群聊「${name}」已创建,当前绑定的 Pi 飞书机器人已就绪。直接在群内 @机器人即可开始协作。若群内普通消息没有响应,请先 @ 机器人;开通免 @
|
|
236
|
+
`👋 群聊「${name}」已创建,当前绑定的 Pi 飞书机器人已就绪。直接在群内 @机器人即可开始协作。若群内普通消息没有响应,请先 @ 机器人;开通免 @ 权限后可直接发消息。${anchoredLine}`,
|
|
216
237
|
);
|
|
217
238
|
await this.sendGroupPermissionReminder(gateway, ownerOpenId, name);
|
|
218
239
|
return chatId;
|
|
@@ -379,9 +400,37 @@ export class FeishuController {
|
|
|
379
400
|
await this.runRemoteCommand(gateway, message, reactionId, authorizedText);
|
|
380
401
|
return;
|
|
381
402
|
}
|
|
403
|
+
// 对齐 easycodeclient 的 mid-turn 注入:同聊天的追加消息直接并入正在运行的轮次,
|
|
404
|
+
// 不排队(跨聊天仍全局串行——Pi 是单会话进程)。
|
|
405
|
+
if (this.trySteerIntoRunningTurn(gateway, message, reactionId, authorizedText)) return;
|
|
382
406
|
void this.enqueueAgentTask(gateway, message, authorizedText, reactionId);
|
|
383
407
|
}
|
|
384
408
|
|
|
409
|
+
/** 同聊天已有任务在跑时,把新消息并入该轮次;返回是否成功并入。 */
|
|
410
|
+
private trySteerIntoRunningTurn(
|
|
411
|
+
gateway: FeishuGateway,
|
|
412
|
+
message: FeishuIncomingMessage,
|
|
413
|
+
reactionId: string,
|
|
414
|
+
text: string,
|
|
415
|
+
): boolean {
|
|
416
|
+
const runningTask = [...this.pendingTasks.values()].find(
|
|
417
|
+
(entry) => entry.started && !entry.cancelled && entry.chatId === message.chatId,
|
|
418
|
+
);
|
|
419
|
+
if (!runningTask) return false;
|
|
420
|
+
const steered = this.agent.steer?.(buildAgentPrompt(message, text)) ?? false;
|
|
421
|
+
if (!steered) return false;
|
|
422
|
+
// 并入后由运行中的任务统一回复;清掉本条消息的已读表情,改用文字确认。
|
|
423
|
+
void this.clearRead(gateway, message.messageId, reactionId);
|
|
424
|
+
void gateway
|
|
425
|
+
.sendText(
|
|
426
|
+
message.chatId,
|
|
427
|
+
"✅ 已收到,已并入当前正在处理的对话;回复会更新在上面的回复卡片里。",
|
|
428
|
+
message.messageId,
|
|
429
|
+
)
|
|
430
|
+
.catch(() => undefined);
|
|
431
|
+
return true;
|
|
432
|
+
}
|
|
433
|
+
|
|
385
434
|
private isAllowlisted(senderOpenId: string): boolean {
|
|
386
435
|
return this.credentials?.allowlist?.includes(senderOpenId) ?? false;
|
|
387
436
|
}
|
|
@@ -423,19 +472,28 @@ export class FeishuController {
|
|
|
423
472
|
await gateway
|
|
424
473
|
.sendText(
|
|
425
474
|
message.chatId,
|
|
426
|
-
"本群还没有绑定到 Pi。Owner 在群里发送 /bind
|
|
475
|
+
"本群还没有绑定到 Pi。Owner 在群里发送 /bind 即可绑定本群;也可带目录把群锚定到项目:/bind /path/to/project。",
|
|
427
476
|
message.messageId,
|
|
428
477
|
)
|
|
429
478
|
.catch(() => undefined);
|
|
430
479
|
}
|
|
431
480
|
|
|
432
|
-
/**
|
|
481
|
+
/** 把当前群登记为受管群并持久化;可同时绑定工作目录(对齐 easycodeclient 的 /bind <路径>)。 */
|
|
433
482
|
private async bindManagedGroup(gateway: FeishuGateway, message: FeishuIncomingMessage): Promise<void> {
|
|
483
|
+
const command = parseRemoteCommand(message.text);
|
|
484
|
+
const dirArg = command?.args.trim();
|
|
485
|
+
if (dirArg) {
|
|
486
|
+
await this.bindGroupDirectory(gateway, message, dirArg);
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
434
489
|
if (this.managedGroupIds.has(message.chatId)) {
|
|
490
|
+
const dir = this.getChatDirectory(message.chatId);
|
|
435
491
|
await gateway
|
|
436
492
|
.sendText(
|
|
437
493
|
message.chatId,
|
|
438
|
-
|
|
494
|
+
dir
|
|
495
|
+
? `本群已绑定到 Pi(工作目录:${dir})。直接 @机器人 即可提问(开通免 @ 权限后无需 @)。`
|
|
496
|
+
: "本群已绑定到 Pi,直接 @机器人 即可提问(开通免 @ 权限后无需 @)。\n如需把本群锚定到项目目录,发送:/bind /path/to/project",
|
|
439
497
|
message.messageId,
|
|
440
498
|
)
|
|
441
499
|
.catch(() => undefined);
|
|
@@ -446,12 +504,55 @@ export class FeishuController {
|
|
|
446
504
|
await gateway
|
|
447
505
|
.sendText(
|
|
448
506
|
message.chatId,
|
|
449
|
-
"✅ 本群已绑定到 Pi。下一条群消息会自动创建该群专属的独立 Pi 会话;默认需要 @机器人
|
|
507
|
+
"✅ 本群已绑定到 Pi。下一条群消息会自动创建该群专属的独立 Pi 会话;默认需要 @机器人 触发。\n💡 如需让本群在指定项目目录下工作,发送:/bind /path/to/project",
|
|
508
|
+
message.messageId,
|
|
509
|
+
)
|
|
510
|
+
.catch(() => undefined);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** /bind <目录>:校验目录存在后,把群锚定到该目录(工作目录在会话切换时生效)。 */
|
|
514
|
+
private async bindGroupDirectory(
|
|
515
|
+
gateway: FeishuGateway,
|
|
516
|
+
message: FeishuIncomingMessage,
|
|
517
|
+
dirArg: string,
|
|
518
|
+
): Promise<void> {
|
|
519
|
+
const resolved = resolve(dirArg);
|
|
520
|
+
let isDirectory = false;
|
|
521
|
+
try {
|
|
522
|
+
isDirectory = (await stat(resolved)).isDirectory();
|
|
523
|
+
} catch {
|
|
524
|
+
isDirectory = false;
|
|
525
|
+
}
|
|
526
|
+
if (!isDirectory) {
|
|
527
|
+
await gateway
|
|
528
|
+
.sendText(message.chatId, `❌ 目录不存在或不是文件夹:${resolved}\n用法:/bind /path/to/project`, message.messageId)
|
|
529
|
+
.catch(() => undefined);
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
this.managedGroupIds.add(message.chatId);
|
|
533
|
+
await this.saveCredentials({
|
|
534
|
+
managedGroupIds: [...this.managedGroupIds],
|
|
535
|
+
groupDirs: { ...(this.credentials?.groupDirs ?? {}), [message.chatId]: resolved },
|
|
536
|
+
});
|
|
537
|
+
await gateway
|
|
538
|
+
.sendText(
|
|
539
|
+
message.chatId,
|
|
540
|
+
`✅ 本群已锚定到目录:${resolved}\n下一条群消息会在该目录下的独立 Pi 会话中处理(已有会话也会切换工作目录)。`,
|
|
450
541
|
message.messageId,
|
|
451
542
|
)
|
|
452
543
|
.catch(() => undefined);
|
|
453
544
|
}
|
|
454
545
|
|
|
546
|
+
/** 群绑定的项目目录;未绑定的群返回 undefined(沿用当前 cwd)。 */
|
|
547
|
+
getChatDirectory(chatId: string): string | undefined {
|
|
548
|
+
return this.credentials?.groupDirs?.[chatId];
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** 所有已绑定的群目录(用于 project trust 自动放行)。 */
|
|
552
|
+
getBoundDirectories(): string[] {
|
|
553
|
+
return Object.values(this.credentials?.groupDirs ?? {});
|
|
554
|
+
}
|
|
555
|
+
|
|
455
556
|
/** Controller 自己处理的命令;返回 false 时回落到通用远程命令通道。 */
|
|
456
557
|
private async runManagedChatCommand(
|
|
457
558
|
gateway: FeishuGateway,
|
package/src/credentials.ts
CHANGED
|
@@ -70,6 +70,7 @@ export function resolveRuntimeCredentials(
|
|
|
70
70
|
...(matching?.groupSessions ? { groupSessions: matching.groupSessions } : {}),
|
|
71
71
|
...(matching?.allowlist ? { allowlist: matching.allowlist } : {}),
|
|
72
72
|
...(matching?.allowlistNames ? { allowlistNames: matching.allowlistNames } : {}),
|
|
73
|
+
...(matching?.groupDirs ? { groupDirs: matching.groupDirs } : {}),
|
|
73
74
|
};
|
|
74
75
|
}
|
|
75
76
|
return stored;
|
|
@@ -146,6 +147,7 @@ export class FileCredentialStore implements CredentialStore {
|
|
|
146
147
|
? { allowlist: value.allowlist.filter((id): id is string => typeof id === "string") }
|
|
147
148
|
: {}),
|
|
148
149
|
...(isStringRecord(value.allowlistNames) ? { allowlistNames: value.allowlistNames } : {}),
|
|
150
|
+
...(isStringRecord(value.groupDirs) ? { groupDirs: value.groupDirs } : {}),
|
|
149
151
|
};
|
|
150
152
|
}
|
|
151
153
|
|
|
@@ -185,7 +187,8 @@ function isCredentialRecord(value: unknown): value is FeishuCredentials {
|
|
|
185
187
|
(record.managedGroupIds === undefined || Array.isArray(record.managedGroupIds)) &&
|
|
186
188
|
(record.groupSessions === undefined || isStringRecord(record.groupSessions)) &&
|
|
187
189
|
(record.allowlist === undefined || Array.isArray(record.allowlist)) &&
|
|
188
|
-
(record.allowlistNames === undefined || isStringRecord(record.allowlistNames))
|
|
190
|
+
(record.allowlistNames === undefined || isStringRecord(record.allowlistNames)) &&
|
|
191
|
+
(record.groupDirs === undefined || isStringRecord(record.groupDirs))
|
|
189
192
|
);
|
|
190
193
|
}
|
|
191
194
|
|
package/src/extension.ts
CHANGED
|
@@ -1,4 +1,20 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionCommandContext,
|
|
4
|
+
ExtensionContext,
|
|
5
|
+
ProjectTrustEventResult,
|
|
6
|
+
ProjectTrustHandler,
|
|
7
|
+
} from "@earendil-works/pi-coding-agent";
|
|
8
|
+
|
|
9
|
+
/** switchSession/withSession 回调里的新会话上下文;Pi 未从包根导出该类型名,从签名提取。 */
|
|
10
|
+
type SwitchCallback = NonNullable<
|
|
11
|
+
Parameters<ExtensionCommandContext["switchSession"]>[1] extends infer O | undefined
|
|
12
|
+
? O extends { withSession?: (ctx: infer C) => unknown }
|
|
13
|
+
? C
|
|
14
|
+
: never
|
|
15
|
+
: never
|
|
16
|
+
>;
|
|
17
|
+
type WithSessionCallback = (ctx: SwitchCallback) => Promise<void>;
|
|
2
18
|
import { Type } from "typebox";
|
|
3
19
|
import type { AgentBridge, FeishuStatus, PiModelInfo, PiRuntime, PiRuntimeSnapshot } from "./contracts.js";
|
|
4
20
|
import { FeishuController } from "./controller.js";
|
|
@@ -30,7 +46,7 @@ interface SharedFeishuState {
|
|
|
30
46
|
current: SessionLink | undefined;
|
|
31
47
|
activeBridge: PiAgentBridge | undefined;
|
|
32
48
|
latestCommandContext: ExtensionCommandContext | undefined;
|
|
33
|
-
sendCurrentMessage: ((text: string) => void) | undefined;
|
|
49
|
+
sendCurrentMessage: ((text: string, options?: { deliverAs?: "steer" | "followUp" }) => void) | undefined;
|
|
34
50
|
/** Pi 会话文件:p2p 私聊消息归属的主会话(绝不会是群绑定会话)。 */
|
|
35
51
|
mainSessionFile: string | undefined;
|
|
36
52
|
/** Pi 进程当前所在的会话文件,随每个事件刷新。 */
|
|
@@ -81,6 +97,12 @@ function getSharedState(): SharedFeishuState {
|
|
|
81
97
|
});
|
|
82
98
|
},
|
|
83
99
|
cancel: (reason) => state.activeBridge?.cancel(reason),
|
|
100
|
+
steer: (text) => {
|
|
101
|
+
// 仅当确有飞书轮次在跑时并入:发到当前活跃会话(轮次就在那里),绝不触发会话切换。
|
|
102
|
+
if (!state.activeBridge || !state.sendCurrentMessage) return false;
|
|
103
|
+
state.sendCurrentMessage(text, { deliverAs: "steer" });
|
|
104
|
+
return true;
|
|
105
|
+
},
|
|
84
106
|
};
|
|
85
107
|
const proxyRuntime: PiRuntime = {
|
|
86
108
|
isIdle: () => state.current?.runtime.isIdle() ?? true,
|
|
@@ -180,19 +202,14 @@ async function sendToChatSession(state: SharedFeishuState, chatId: string, promp
|
|
|
180
202
|
const context = state.latestCommandContext;
|
|
181
203
|
if (!context) throw new Error("Pi 会话控制尚未就绪,请先在本地执行一次 /feishu status。");
|
|
182
204
|
|
|
205
|
+
const directory = state.controller.getChatDirectory(chatId);
|
|
183
206
|
state.switchingSession = true;
|
|
184
207
|
try {
|
|
185
208
|
const sessionFile = state.controller.getChatSessionFile(chatId);
|
|
186
209
|
if (sessionFile) {
|
|
187
|
-
const result = await context
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
const currentFile = nextContext.sessionManager.getSessionFile();
|
|
191
|
-
if (currentFile && currentFile !== sessionFile)
|
|
192
|
-
await state.controller.setChatSessionFile(chatId, currentFile);
|
|
193
|
-
await nextContext.sendUserMessage(prompt);
|
|
194
|
-
},
|
|
195
|
-
});
|
|
210
|
+
const result = await switchWithDirectory(context, sessionFile, directory, (nextContext) =>
|
|
211
|
+
sendAndTrackGroupSession(state, chatId, sessionFile, nextContext, prompt),
|
|
212
|
+
);
|
|
196
213
|
if (result.cancelled) throw new Error("切换到飞书群绑定的 Pi 会话已取消。");
|
|
197
214
|
return;
|
|
198
215
|
}
|
|
@@ -202,6 +219,15 @@ async function sendToChatSession(state: SharedFeishuState, chatId: string, promp
|
|
|
202
219
|
state.latestCommandContext = nextContext;
|
|
203
220
|
const currentFile = nextContext.sessionManager.getSessionFile();
|
|
204
221
|
if (currentFile) await state.controller.setChatSessionFile(chatId, currentFile);
|
|
222
|
+
if (directory) {
|
|
223
|
+
// 新会话默认继承当前 cwd:显式重新锚定到绑定目录,再投递消息。
|
|
224
|
+
const anchored = await switchWithDirectory(nextContext, currentFile ?? "", directory, (finalContext) => {
|
|
225
|
+
state.latestCommandContext = finalContext;
|
|
226
|
+
return finalContext.sendUserMessage(prompt);
|
|
227
|
+
});
|
|
228
|
+
if (anchored.cancelled) throw new Error("锚定群会话工作目录已取消。");
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
205
231
|
await nextContext.sendUserMessage(prompt);
|
|
206
232
|
},
|
|
207
233
|
});
|
|
@@ -211,6 +237,60 @@ async function sendToChatSession(state: SharedFeishuState, chatId: string, promp
|
|
|
211
237
|
}
|
|
212
238
|
}
|
|
213
239
|
|
|
240
|
+
/**
|
|
241
|
+
* 切换会话并在切换完成后(含目录锚定)执行回调。
|
|
242
|
+
* Pi 的命令上下文 d.ts 未声明 cwdOverride,但三种运行模式的 switchSession handler
|
|
243
|
+
* 都会把 options 原样转发给 runtime,而 runtime 完整支持 override(切换后
|
|
244
|
+
* createRuntime 直接使用 override 后的 cwd)。这里做一次窄化并在切换后校验
|
|
245
|
+
* cwd 生效——若未来 Pi 改为丢弃该参数,会得到可操作的错误而不是静默串目录。
|
|
246
|
+
*/
|
|
247
|
+
async function switchWithDirectory(
|
|
248
|
+
context: ExtensionCommandContext,
|
|
249
|
+
sessionFile: string,
|
|
250
|
+
directory: string | undefined,
|
|
251
|
+
withSession: WithSessionCallback,
|
|
252
|
+
): Promise<{ cancelled: boolean }> {
|
|
253
|
+
if (!directory) {
|
|
254
|
+
return context.switchSession(sessionFile, { withSession });
|
|
255
|
+
}
|
|
256
|
+
const options = { cwdOverride: directory, withSession };
|
|
257
|
+
const switcher = context.switchSession as unknown as (
|
|
258
|
+
path: string,
|
|
259
|
+
options: { cwdOverride: string; withSession: WithSessionCallback },
|
|
260
|
+
) => Promise<{ cancelled: boolean }>;
|
|
261
|
+
const result = await switcher.call(context, sessionFile, options);
|
|
262
|
+
if (!result.cancelled) {
|
|
263
|
+
const effectiveCwd = safeCwd(context);
|
|
264
|
+
if (effectiveCwd && effectiveCwd !== directory) {
|
|
265
|
+
throw new Error(
|
|
266
|
+
`群会话未能切换到绑定目录(当前:${effectiveCwd})。请重启本地 Pi 后重试,或在本地执行 /feishu status 刷新会话控制。`,
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return result;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function safeCwd(context: ExtensionContext): string | undefined {
|
|
274
|
+
try {
|
|
275
|
+
return context.cwd;
|
|
276
|
+
} catch {
|
|
277
|
+
return undefined;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function sendAndTrackGroupSession(
|
|
282
|
+
state: SharedFeishuState,
|
|
283
|
+
chatId: string,
|
|
284
|
+
sessionFile: string,
|
|
285
|
+
nextContext: SwitchCallback,
|
|
286
|
+
prompt: string,
|
|
287
|
+
): Promise<void> {
|
|
288
|
+
state.latestCommandContext = nextContext;
|
|
289
|
+
const currentFile = nextContext.sessionManager.getSessionFile();
|
|
290
|
+
if (currentFile && currentFile !== sessionFile) await state.controller.setChatSessionFile(chatId, currentFile);
|
|
291
|
+
await nextContext.sendUserMessage(prompt);
|
|
292
|
+
}
|
|
293
|
+
|
|
214
294
|
export interface ParsedFeishuCommand {
|
|
215
295
|
name: FeishuCommandName;
|
|
216
296
|
args: string;
|
|
@@ -386,7 +466,7 @@ export function renderFeishuStatus(status: FeishuStatus, scopeHealth?: string[])
|
|
|
386
466
|
|
|
387
467
|
export default function feishuExtension(pi: ExtensionAPI): void {
|
|
388
468
|
const state = getSharedState();
|
|
389
|
-
state.sendCurrentMessage = (text) => pi.sendUserMessage(text);
|
|
469
|
+
state.sendCurrentMessage = (text, options) => pi.sendUserMessage(text, options);
|
|
390
470
|
let latestContext: ExtensionContext | undefined;
|
|
391
471
|
let latestCommandContext: ExtensionCommandContext | undefined;
|
|
392
472
|
|
|
@@ -505,25 +585,42 @@ export default function feishuExtension(pi: ExtensionAPI): void {
|
|
|
505
585
|
state.activeBridge?.cancel("Pi 会话已关闭。");
|
|
506
586
|
});
|
|
507
587
|
|
|
588
|
+
// 对齐 easycodeclient:Owner 通过 /bind 或建群显式绑定的目录视为受信项目,
|
|
589
|
+
// 免去切换群会话时的本地信任弹窗。undecided = 不表态,Pi 落回默认流程
|
|
590
|
+
//(runner 对 undecided 的 handler 会跳过);handler 用带注解的变量传入,
|
|
591
|
+
// 内联箭头会让 on() 的重载推断失败。
|
|
592
|
+
const onProjectTrust: ProjectTrustHandler = (event) => {
|
|
593
|
+
if (state.controller.getBoundDirectories().includes(event.cwd)) {
|
|
594
|
+
return { trusted: "yes", remember: true };
|
|
595
|
+
}
|
|
596
|
+
return { trusted: "undecided" };
|
|
597
|
+
};
|
|
598
|
+
pi.on("project_trust", onProjectTrust);
|
|
599
|
+
|
|
508
600
|
pi.registerTool({
|
|
509
601
|
name: "feishu_create_group",
|
|
510
602
|
label: "Create Feishu Group",
|
|
511
603
|
description:
|
|
512
|
-
"Create a Feishu group chat and invite the currently bound owner. Use when asked to 拉群、建群、创建飞书群 or create a group.
|
|
604
|
+
"Create a Feishu group chat and invite the currently bound owner. Use when asked to 拉群、建群、创建飞书群 or create a group. Pass `path` when the user mentions a directory (e.g. 拉个群 /path/to/project) to anchor the group's sessions to that working directory; the directory must already exist.",
|
|
513
605
|
parameters: Type.Object({
|
|
514
606
|
name: Type.String({ description: "Name for the new Feishu group chat" }),
|
|
607
|
+
path: Type.Optional(
|
|
608
|
+
Type.String({ description: "Absolute path of an existing local directory to anchor the group to" }),
|
|
609
|
+
),
|
|
515
610
|
}),
|
|
516
611
|
async execute(_toolCallId, params) {
|
|
612
|
+
const name = params.name.trim();
|
|
517
613
|
try {
|
|
518
|
-
const chatId = await state.controller.createGroupChat(params.
|
|
614
|
+
const chatId = await state.controller.createGroupChat(name, params.path?.trim() || undefined);
|
|
615
|
+
const anchored = params.path?.trim() ? `,并锚定到目录 ${params.path.trim()}` : "";
|
|
519
616
|
return {
|
|
520
617
|
content: [
|
|
521
618
|
{
|
|
522
619
|
type: "text",
|
|
523
|
-
text: `群聊「${
|
|
620
|
+
text: `群聊「${name}」已创建,群聊 ID:${chatId}${anchored}。已邀请当前绑定的飞书用户,并发送群欢迎消息。`,
|
|
524
621
|
},
|
|
525
622
|
],
|
|
526
|
-
details: { chatId, name
|
|
623
|
+
details: { chatId, name },
|
|
527
624
|
};
|
|
528
625
|
} catch (error) {
|
|
529
626
|
return {
|