@hwj123weijian/pi-feishu 0.6.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 +5 -4
- package/package.json +1 -1
- package/src/contracts.ts +10 -1
- package/src/controller.ts +56 -12
- package/src/credentials.ts +9 -1
- package/src/extension.ts +65 -17
- package/src/gateway.ts +5 -0
- package/src/pi-agent-bridge.ts +4 -6
- package/src/scopes.ts +119 -0
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
- 首次启动生成一次性绑定码,只允许一个 Owner
|
|
11
11
|
- 收到消息后立刻在原消息上添加“思考中”表情作为已读回执,回复送达后自动移除
|
|
12
12
|
- 以 `/` 开头的消息走远程命令快速通道,不排队、不进入 LLM 上下文
|
|
13
|
-
-
|
|
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 在该扩展创建的群里
|
|
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
|
|
|
@@ -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 在新群里
|
|
146
|
+
在飞书私聊中也可直接说“帮我拉个 XX 群”或“创建飞书群 XX”。机器人会创建群聊、邀请已绑定的 Owner 并发送欢迎消息;之后 Owner 在新群里 @机器人即可协作。该群首次收到消息时会创建一个独立 Pi session 并绑定到群,之后该群消息都会自动切回这个 session。已创建群聊的授权列表和 session 绑定会保存在本地凭据文件中,Pi 重启后仍可继续使用。
|
|
146
147
|
|
|
147
148
|
未知命令会返回提示,不会发给 Pi。`/model` 的候选来自本地 Pi 的 scoped models(未配置时为全部已授权模型);`/model` 无参数时展示当前模型。注意:任何以 `/` 开头的消息都会先被当作命令解析,想发给 Pi 的提问请勿以 `/` 开头。
|
|
148
149
|
|
package/package.json
CHANGED
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;
|
|
@@ -355,18 +395,22 @@ export class FeishuController {
|
|
|
355
395
|
if (reply) this.activeReplies.add(reply);
|
|
356
396
|
let latestText = "";
|
|
357
397
|
try {
|
|
358
|
-
const response = await this.agent.run(
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
+
},
|
|
368
411
|
},
|
|
369
|
-
|
|
412
|
+
message.chatType === "group" ? { chatId: message.chatId } : undefined,
|
|
413
|
+
);
|
|
370
414
|
if (this.gateway === gateway) {
|
|
371
415
|
if (reply) {
|
|
372
416
|
await reply.complete({ text: response, status: "已完成" });
|
package/src/credentials.ts
CHANGED
|
@@ -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
|
@@ -12,13 +12,15 @@ type PiModel = NonNullable<ExtensionContext["model"]>;
|
|
|
12
12
|
type PiThinkingLevel = NonNullable<ExtensionContext["thinkingLevel"]>;
|
|
13
13
|
|
|
14
14
|
interface SessionLink {
|
|
15
|
-
bridge: PiAgentBridge;
|
|
16
15
|
runtime: PiRuntime;
|
|
17
16
|
}
|
|
18
17
|
|
|
19
18
|
interface SharedFeishuState {
|
|
20
19
|
controller: FeishuController;
|
|
21
20
|
current: SessionLink | undefined;
|
|
21
|
+
activeBridge: PiAgentBridge | undefined;
|
|
22
|
+
latestCommandContext: ExtensionCommandContext | undefined;
|
|
23
|
+
sendCurrentMessage: ((text: string) => void) | undefined;
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
const SHARED_STATE_KEY = "__piFeishuSharedState__";
|
|
@@ -33,14 +35,30 @@ function getSharedState(): SharedFeishuState {
|
|
|
33
35
|
const existing = host[SHARED_STATE_KEY];
|
|
34
36
|
if (existing) return existing;
|
|
35
37
|
|
|
36
|
-
const state: SharedFeishuState = {
|
|
38
|
+
const state: SharedFeishuState = {
|
|
39
|
+
current: undefined,
|
|
40
|
+
activeBridge: undefined,
|
|
41
|
+
latestCommandContext: undefined,
|
|
42
|
+
sendCurrentMessage: undefined,
|
|
43
|
+
controller: undefined as unknown as FeishuController,
|
|
44
|
+
};
|
|
37
45
|
const proxyAgent: AgentBridge = {
|
|
38
|
-
run: (text, observer) => {
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
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
|
+
});
|
|
42
60
|
},
|
|
43
|
-
cancel: (reason) => state.
|
|
61
|
+
cancel: (reason) => state.activeBridge?.cancel(reason),
|
|
44
62
|
};
|
|
45
63
|
const proxyRuntime: PiRuntime = {
|
|
46
64
|
isIdle: () => state.current?.runtime.isIdle() ?? true,
|
|
@@ -67,6 +85,35 @@ function getSharedState(): SharedFeishuState {
|
|
|
67
85
|
return state;
|
|
68
86
|
}
|
|
69
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
|
+
|
|
70
117
|
export interface ParsedFeishuCommand {
|
|
71
118
|
name: FeishuCommandName;
|
|
72
119
|
args: string;
|
|
@@ -129,7 +176,7 @@ export function renderFeishuStatus(status: FeishuStatus): string {
|
|
|
129
176
|
|
|
130
177
|
export default function feishuExtension(pi: ExtensionAPI): void {
|
|
131
178
|
const state = getSharedState();
|
|
132
|
-
|
|
179
|
+
state.sendCurrentMessage = (text) => pi.sendUserMessage(text);
|
|
133
180
|
let latestContext: ExtensionContext | undefined;
|
|
134
181
|
let latestCommandContext: ExtensionCommandContext | undefined;
|
|
135
182
|
|
|
@@ -138,7 +185,7 @@ export default function feishuExtension(pi: ExtensionAPI): void {
|
|
|
138
185
|
abort: () => withRuntimeContext(() => latestContext?.abort()),
|
|
139
186
|
compact: () => withRuntimeContext(() => latestContext?.compact()),
|
|
140
187
|
newSession: async () => {
|
|
141
|
-
const context = latestCommandContext;
|
|
188
|
+
const context = latestCommandContext ?? state.latestCommandContext;
|
|
142
189
|
if (!context) return false;
|
|
143
190
|
try {
|
|
144
191
|
const result = await context.newSession();
|
|
@@ -181,7 +228,7 @@ export default function feishuExtension(pi: ExtensionAPI): void {
|
|
|
181
228
|
return snapshot;
|
|
182
229
|
},
|
|
183
230
|
};
|
|
184
|
-
state.current = {
|
|
231
|
+
state.current = { runtime };
|
|
185
232
|
|
|
186
233
|
// 每个事件都会带来新的 ExtensionContext;持续刷新,保证远程命令拿到的能力不失效。
|
|
187
234
|
const trackContext = (_event: unknown, context: ExtensionContext): void => {
|
|
@@ -191,27 +238,27 @@ export default function feishuExtension(pi: ExtensionAPI): void {
|
|
|
191
238
|
pi.on("agent_start", trackContext);
|
|
192
239
|
pi.on("message_end", (event, context) => {
|
|
193
240
|
trackContext(event, context);
|
|
194
|
-
|
|
241
|
+
state.activeBridge?.captureMessage(event.message);
|
|
195
242
|
});
|
|
196
243
|
pi.on("message_update", (event, context) => {
|
|
197
244
|
trackContext(event, context);
|
|
198
|
-
|
|
245
|
+
state.activeBridge?.captureStreamingMessage(event.message);
|
|
199
246
|
});
|
|
200
247
|
pi.on("tool_execution_start", (event, context) => {
|
|
201
248
|
trackContext(event, context);
|
|
202
|
-
|
|
249
|
+
state.activeBridge?.captureToolStart(event.toolName);
|
|
203
250
|
});
|
|
204
251
|
pi.on("tool_execution_end", (event, context) => {
|
|
205
252
|
trackContext(event, context);
|
|
206
|
-
|
|
253
|
+
state.activeBridge?.captureToolEnd();
|
|
207
254
|
});
|
|
208
255
|
pi.on("agent_settled", (event, context) => {
|
|
209
256
|
trackContext(event, context);
|
|
210
|
-
|
|
257
|
+
state.activeBridge?.settle();
|
|
211
258
|
});
|
|
212
259
|
pi.on("session_shutdown", () => {
|
|
213
|
-
//
|
|
214
|
-
|
|
260
|
+
// 长连接归全局 controller 保管,本地 /new 切换会话后自动延续。
|
|
261
|
+
state.activeBridge?.cancel("Pi 会话已关闭。");
|
|
215
262
|
});
|
|
216
263
|
|
|
217
264
|
pi.registerTool({
|
|
@@ -247,6 +294,7 @@ export default function feishuExtension(pi: ExtensionAPI): void {
|
|
|
247
294
|
description: "配置和管理飞书私聊连接",
|
|
248
295
|
handler: async (args, context) => {
|
|
249
296
|
latestCommandContext = context;
|
|
297
|
+
state.latestCommandContext = context;
|
|
250
298
|
try {
|
|
251
299
|
await handleFeishuCommand(parseFeishuCommand(args), state.controller, context);
|
|
252
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;
|
|
@@ -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("飞书长连接尚未启动。");
|
package/src/pi-agent-bridge.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|