@hwj123weijian/pi-feishu 0.1.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/.env.example ADDED
@@ -0,0 +1,2 @@
1
+ FEISHU_APP_ID=cli_your_app_id
2
+ FEISHU_APP_SECRET=your_app_secret
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 pi-feishu contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,154 @@
1
+ # pi-feishu
2
+
3
+ 一个独立、极简的 [Pi](https://github.com/earendil-works/pi-mono) 飞书扩展。它通过飞书官方 Node SDK 的 WebSocket 长连接,把飞书私聊文本转发到当前 Pi 会话,并把最终回复发回原私聊。
4
+
5
+ ## 设计边界
6
+
7
+ 当前版本只做一条可靠的最小链路:
8
+
9
+ - 仅处理飞书私聊(`p2p`)文本消息
10
+ - 首次启动生成一次性绑定码,只允许一个 Owner
11
+ - 所有消息进入当前 Pi 会话,不创建额外 Agent 或会话
12
+ - 串行处理消息,避免多条飞书消息同时驱动 Pi
13
+ - 按飞书 `message_id` 去重
14
+ - `/feishu stop`、`/feishu logout` 和 Pi 会话关闭时释放长连接
15
+ - 凭据优先从环境变量读取,也可保存到本地凭据文件
16
+
17
+ 当前不支持群聊、图片、文件、卡片、语音、多用户、多会话、进程级沙箱或远程命令权限管理。这里的安全边界是“私聊 + 单 Owner”,Pi 本身仍拥有当前本地进程的权限。
18
+
19
+ ## 环境要求
20
+
21
+ - Node.js 22.19 或更高版本
22
+ - Pi 0.83.0 或兼容版本
23
+ - 一个启用了机器人能力的飞书企业自建应用
24
+
25
+ ## 飞书后台配置
26
+
27
+ 在[飞书开放平台](https://open.feishu.cn/app)创建企业自建应用,然后完成以下配置:
28
+
29
+ 1. 在“添加应用能力”中启用机器人。
30
+ 2. 在“权限管理”中申请:
31
+ - `im:message.p2p_msg:readonly`:接收私聊消息
32
+ - `im:message:send_as_bot`:以机器人身份回复
33
+ 3. 在“事件与回调”中选择“使用长连接接收事件”。
34
+ 4. 添加事件 `im.message.receive_v1`。
35
+ 5. 创建并发布一个应用版本,使权限和事件订阅在企业内生效。
36
+
37
+ 这个扩展不需要公网回调地址,也不需要加密密钥或 Verification Token。
38
+
39
+ ## 安装
40
+
41
+ 在项目目录安装依赖:
42
+
43
+ ```powershell
44
+ cd D:\ai_study\pi-feishu
45
+ npm install --ignore-scripts
46
+ ```
47
+
48
+ 开发时可以仅为本次启动加载:
49
+
50
+ ```powershell
51
+ pi -e D:\ai_study\pi-feishu
52
+ ```
53
+
54
+ 也可以把本地包注册到 Pi:
55
+
56
+ ```powershell
57
+ pi install D:\ai_study\pi-feishu
58
+ pi
59
+ ```
60
+
61
+ 发布版本可直接从 npm 安装:
62
+
63
+ ```powershell
64
+ pi install npm:@hwj123weijian/pi-feishu
65
+ pi
66
+ ```
67
+
68
+ ## 配置与首次绑定
69
+
70
+ 推荐用环境变量提供敏感凭据:
71
+
72
+ ```powershell
73
+ $env:FEISHU_APP_ID = "cli_xxxxxxxxxxxxx"
74
+ $env:FEISHU_APP_SECRET = "xxxxxxxxxxxxxxxx"
75
+ pi -e D:\ai_study\pi-feishu
76
+ ```
77
+
78
+ 进入 Pi 后依次执行:
79
+
80
+ ```text
81
+ /feishu setup
82
+ /feishu start
83
+ ```
84
+
85
+ `start` 会在本地 Pi 中显示六位一次性绑定码。使用计划作为 Owner 的飞书账号私聊机器人:
86
+
87
+ ```text
88
+ /bind 123456
89
+ ```
90
+
91
+ 绑定成功后,直接私聊机器人即可驱动当前 Pi 会话。
92
+
93
+ 也支持手动参数:
94
+
95
+ ```text
96
+ /feishu setup cli_xxxxxxxxxxxxx xxxxxxxxxxxxxxxx
97
+ ```
98
+
99
+ 这种方式可能让 App Secret 出现在终端历史中,因此环境变量更合适。验证成功后,凭据默认保存到:
100
+
101
+ ```text
102
+ ~/.pi/agent/feishu/credentials.json
103
+ ```
104
+
105
+ 文件通过临时文件原子替换写入;在支持 POSIX 权限的平台上会限制为 `0600`。
106
+
107
+ ## 命令
108
+
109
+ | 命令 | 作用 |
110
+ | --- | --- |
111
+ | `/feishu` | 显示帮助和当前状态 |
112
+ | `/feishu setup [appId appSecret]` | 验证并保存飞书应用凭据 |
113
+ | `/feishu start` | 建立 WebSocket 长连接并显示绑定码 |
114
+ | `/feishu stop` | 停止长连接,保留凭据与 Owner |
115
+ | `/feishu status` | 查看配置、连接、Owner 和队列状态 |
116
+ | `/feishu logout` | 停止连接并清除本地凭据和 Owner |
117
+
118
+ 环境变量优先于凭据文件。如果环境变量中的 App ID 与已保存的 App ID 不同,旧 Owner 绑定不会被继承。
119
+
120
+ ## 常见问题
121
+
122
+ ### `setup` 或 `start` 连接失败
123
+
124
+ 检查 App ID 和 App Secret 是否来自同一个应用,并确认应用版本已经发布。`setup` 会实际建立一次临时 WebSocket 连接来验证凭据,而不只是检查字符串格式。
125
+
126
+ ### 机器人收不到私聊消息
127
+
128
+ 确认已启用机器人能力、订阅 `im.message.receive_v1`、接收方式为长连接,并已发布包含这些配置的应用版本。
129
+
130
+ ### 能收到消息但不能回复
131
+
132
+ 确认已申请并发布 `im:message:send_as_bot` 权限。
133
+
134
+ ### Bot 提示“未授权”
135
+
136
+ 该 Bot 已绑定其他 Owner。若要重新绑定,先在本地 Pi 执行 `/feishu logout`,再重新 `setup`、`start` 和 `/bind`。
137
+
138
+ ### 重复消息
139
+
140
+ 飞书事件可能重投。扩展在当前进程内缓存最近 1000 个 `message_id`,重复事件不会再次驱动 Pi。重启 Pi 后缓存会清空。
141
+
142
+ ## 开发
143
+
144
+ ```powershell
145
+ npm run check
146
+ npm test
147
+ npm run build
148
+ ```
149
+
150
+ 测试使用 Fake Gateway 和 Fake Agent,不需要真实飞书凭据,覆盖凭据处理、Owner 绑定、私聊过滤、串行队列、去重、完整消息链路、错误脱敏和清理行为。
151
+
152
+ ## 许可证
153
+
154
+ MIT
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@hwj123weijian/pi-feishu",
3
+ "version": "0.1.0",
4
+ "description": "Minimal Feishu private-chat bridge for Pi",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "publishConfig": {
8
+ "access": "public",
9
+ "registry": "https://registry.npmjs.org/"
10
+ },
11
+ "keywords": [
12
+ "pi-package",
13
+ "feishu",
14
+ "coding-agent"
15
+ ],
16
+ "pi": {
17
+ "extensions": [
18
+ "./src/extension.ts"
19
+ ]
20
+ },
21
+ "files": [
22
+ "src",
23
+ "README.md",
24
+ "LICENSE",
25
+ ".env.example"
26
+ ],
27
+ "scripts": {
28
+ "build": "tsc -p tsconfig.build.json",
29
+ "check": "biome check . && tsc --noEmit",
30
+ "test": "vitest run",
31
+ "typecheck": "tsc --noEmit"
32
+ },
33
+ "dependencies": {
34
+ "@larksuiteoapi/node-sdk": "1.72.0"
35
+ },
36
+ "peerDependencies": {
37
+ "@earendil-works/pi-coding-agent": "0.83.0"
38
+ },
39
+ "devDependencies": {
40
+ "@biomejs/biome": "2.3.5",
41
+ "@earendil-works/pi-coding-agent": "0.83.0",
42
+ "@types/node": "24.12.4",
43
+ "typescript": "5.9.3",
44
+ "vitest": "4.1.9"
45
+ },
46
+ "engines": {
47
+ "node": ">=22.19.0"
48
+ }
49
+ }
@@ -0,0 +1,37 @@
1
+ export interface FeishuCredentials {
2
+ appId: string;
3
+ appSecret: string;
4
+ ownerOpenId?: string;
5
+ }
6
+
7
+ export interface CredentialStore {
8
+ load(): Promise<FeishuCredentials | null>;
9
+ save(credentials: FeishuCredentials): Promise<void>;
10
+ clear(): Promise<void>;
11
+ }
12
+
13
+ export interface FeishuIncomingMessage {
14
+ messageId: string;
15
+ chatId: string;
16
+ chatType: "p2p" | "group";
17
+ senderOpenId: string;
18
+ contentType: string;
19
+ text: string;
20
+ }
21
+
22
+ export type FeishuMessageHandler = (message: FeishuIncomingMessage) => Promise<void> | void;
23
+
24
+ export interface FeishuGateway {
25
+ connect(handler: FeishuMessageHandler): Promise<void>;
26
+ disconnect(): Promise<void>;
27
+ sendText(chatId: string, text: string, replyTo?: string): Promise<void>;
28
+ }
29
+
30
+ export interface AgentBridge {
31
+ run(text: string): Promise<string>;
32
+ cancel(reason?: string): void;
33
+ }
34
+
35
+ export type FeishuGatewayFactory = (credentials: FeishuCredentials) => FeishuGateway;
36
+ export type CredentialValidator = (credentials: FeishuCredentials) => Promise<void>;
37
+ export type Environment = Readonly<Record<string, string | undefined>>;
@@ -0,0 +1,203 @@
1
+ import type {
2
+ AgentBridge,
3
+ CredentialStore,
4
+ CredentialValidator,
5
+ Environment,
6
+ FeishuCredentials,
7
+ FeishuGateway,
8
+ FeishuGatewayFactory,
9
+ FeishuIncomingMessage,
10
+ } from "./contracts.js";
11
+ import { CredentialError, errorMessage, resolveCredentialInput, resolveRuntimeCredentials } from "./credentials.js";
12
+ import { MessageDeduplicator } from "./message-deduplicator.js";
13
+ import { SerialMessageQueue } from "./message-queue.js";
14
+ import { OwnerBinding } from "./owner-binding.js";
15
+
16
+ export interface FeishuControllerOptions {
17
+ store: CredentialStore;
18
+ gatewayFactory: FeishuGatewayFactory;
19
+ validateCredentials: CredentialValidator;
20
+ agent: AgentBridge;
21
+ generateBindingCode?: () => string;
22
+ }
23
+
24
+ export interface FeishuStartResult {
25
+ alreadyRunning: boolean;
26
+ bindingCode?: string;
27
+ }
28
+
29
+ export interface FeishuStatus {
30
+ configured: boolean;
31
+ running: boolean;
32
+ ownerOpenId?: string;
33
+ appId?: string;
34
+ source?: "environment" | "file";
35
+ pendingMessages: number;
36
+ }
37
+
38
+ export class FeishuController {
39
+ private readonly store: CredentialStore;
40
+ private readonly gatewayFactory: FeishuGatewayFactory;
41
+ private readonly validateCredentials: CredentialValidator;
42
+ private readonly agent: AgentBridge;
43
+ private readonly generateBindingCode: (() => string) | undefined;
44
+ private readonly queue = new SerialMessageQueue();
45
+ private readonly deduplicator = new MessageDeduplicator();
46
+ private gateway: FeishuGateway | undefined;
47
+ private credentials: FeishuCredentials | undefined;
48
+ private binding: OwnerBinding | undefined;
49
+
50
+ constructor(options: FeishuControllerOptions) {
51
+ this.store = options.store;
52
+ this.gatewayFactory = options.gatewayFactory;
53
+ this.validateCredentials = options.validateCredentials;
54
+ this.agent = options.agent;
55
+ this.generateBindingCode = options.generateBindingCode;
56
+ }
57
+
58
+ async setup(args: string, environment: Environment): Promise<FeishuCredentials> {
59
+ const input = resolveCredentialInput(args, environment);
60
+ const credentials: FeishuCredentials = { appId: input.appId, appSecret: input.appSecret };
61
+ try {
62
+ await this.validateCredentials(credentials);
63
+ } catch (error) {
64
+ throw new CredentialError(`飞书凭据验证失败:${errorMessage(error, credentials)}`);
65
+ }
66
+
67
+ const existing = await this.store.load();
68
+ const next =
69
+ existing?.appId === credentials.appId && existing.ownerOpenId
70
+ ? { ...credentials, ownerOpenId: existing.ownerOpenId }
71
+ : credentials;
72
+ await this.store.save(next);
73
+ this.credentials = next;
74
+ this.binding = new OwnerBinding(next.ownerOpenId, this.generateBindingCode);
75
+ return next;
76
+ }
77
+
78
+ async start(environment: Environment): Promise<FeishuStartResult> {
79
+ if (this.gateway) return { alreadyRunning: true };
80
+
81
+ const stored = await this.store.load();
82
+ const credentials = resolveRuntimeCredentials(environment, stored);
83
+ if (!credentials) {
84
+ throw new CredentialError("尚未配置飞书凭据,请先执行 /feishu setup。");
85
+ }
86
+
87
+ const binding = new OwnerBinding(credentials.ownerOpenId, this.generateBindingCode);
88
+ const gateway = this.gatewayFactory(credentials);
89
+ this.credentials = credentials;
90
+ this.binding = binding;
91
+ this.gateway = gateway;
92
+ this.deduplicator.clear();
93
+
94
+ try {
95
+ await gateway.connect((message) => this.handleIncoming(message));
96
+ } catch (error) {
97
+ this.gateway = undefined;
98
+ this.binding = undefined;
99
+ await gateway.disconnect().catch(() => undefined);
100
+ throw new CredentialError(`启动飞书长连接失败:${errorMessage(error, credentials)}`);
101
+ }
102
+
103
+ const bindingCode = binding.getOrCreateCode();
104
+ return bindingCode ? { alreadyRunning: false, bindingCode } : { alreadyRunning: false };
105
+ }
106
+
107
+ async stop(): Promise<boolean> {
108
+ const gateway = this.gateway;
109
+ if (!gateway) return false;
110
+ this.gateway = undefined;
111
+ this.binding = undefined;
112
+ this.agent.cancel("飞书连接已停止。");
113
+ await gateway.disconnect();
114
+ return true;
115
+ }
116
+
117
+ async logout(): Promise<void> {
118
+ const stopped = await this.stop();
119
+ if (!stopped) this.agent.cancel("飞书已退出。");
120
+ this.credentials = undefined;
121
+ await this.store.clear();
122
+ }
123
+
124
+ async status(environment: Environment): Promise<FeishuStatus> {
125
+ const stored = await this.store.load();
126
+ const resolved = resolveRuntimeCredentials(environment, stored);
127
+ if (!resolved) {
128
+ return { configured: false, running: Boolean(this.gateway), pendingMessages: this.queue.pendingCount };
129
+ }
130
+ const source = environment.FEISHU_APP_ID?.trim() && environment.FEISHU_APP_SECRET?.trim() ? "environment" : "file";
131
+ const result: FeishuStatus = {
132
+ configured: true,
133
+ running: Boolean(this.gateway),
134
+ appId: resolved.appId,
135
+ source,
136
+ pendingMessages: this.queue.pendingCount,
137
+ };
138
+ if (resolved.ownerOpenId) result.ownerOpenId = resolved.ownerOpenId;
139
+ return result;
140
+ }
141
+
142
+ async handleIncoming(message: FeishuIncomingMessage): Promise<void> {
143
+ const gateway = this.gateway;
144
+ const binding = this.binding;
145
+ if (!gateway || !binding || message.chatType !== "p2p" || message.contentType !== "text") return;
146
+ if (!message.messageId || !this.deduplicator.accept(message.messageId)) return;
147
+
148
+ const authorization = binding.authorize(message.senderOpenId, message.text);
149
+ switch (authorization.kind) {
150
+ case "binding-required":
151
+ await this.safeSend(gateway, message, "Bot 尚未绑定,请在本地 Pi 查看一次性绑定码。");
152
+ return;
153
+ case "invalid-binding-code":
154
+ await this.safeSend(gateway, message, "绑定码无效,请检查本地 Pi 显示的一次性绑定码。");
155
+ return;
156
+ case "unauthorized":
157
+ await this.safeSend(gateway, message, "未授权:此 Bot 仅响应已绑定的 Owner。");
158
+ return;
159
+ case "bound":
160
+ await this.persistOwner(authorization.ownerOpenId);
161
+ await this.safeSend(gateway, message, "绑定成功,现在可以直接发送问题。");
162
+ return;
163
+ case "authorized":
164
+ void this.queue.enqueue(async () => {
165
+ if (this.gateway !== gateway) return;
166
+ try {
167
+ const response = await this.agent.run(authorization.text);
168
+ if (this.gateway === gateway) {
169
+ await gateway.sendText(message.chatId, response, message.messageId);
170
+ }
171
+ } catch {
172
+ if (this.gateway === gateway) {
173
+ await this.safeSend(gateway, message, "处理消息失败,请稍后再试。");
174
+ }
175
+ }
176
+ });
177
+ }
178
+ }
179
+
180
+ async waitForIdle(): Promise<void> {
181
+ await this.queue.waitForIdle();
182
+ }
183
+
184
+ sanitizeError(error: unknown): string {
185
+ return errorMessage(error, this.credentials);
186
+ }
187
+
188
+ private async persistOwner(ownerOpenId: string): Promise<void> {
189
+ const credentials = this.credentials;
190
+ if (!credentials) return;
191
+ const next = { ...credentials, ownerOpenId };
192
+ await this.store.save(next);
193
+ this.credentials = next;
194
+ }
195
+
196
+ private async safeSend(gateway: FeishuGateway, message: FeishuIncomingMessage, text: string): Promise<void> {
197
+ try {
198
+ await gateway.sendText(message.chatId, text, message.messageId);
199
+ } catch {
200
+ // The inbound event has already been acknowledged; outbound failures are non-fatal.
201
+ }
202
+ }
203
+ }
@@ -0,0 +1,176 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import type { CredentialStore, Environment, FeishuCredentials } from "./contracts.js";
6
+
7
+ const APP_ID_PATTERN = /^cli_[A-Za-z0-9_-]+$/;
8
+
9
+ export class CredentialError extends Error {
10
+ constructor(message: string) {
11
+ super(message);
12
+ this.name = "CredentialError";
13
+ }
14
+ }
15
+
16
+ export interface CredentialInput extends FeishuCredentials {
17
+ source: "arguments" | "environment";
18
+ }
19
+
20
+ export function parseSetupArguments(args: string): FeishuCredentials | null {
21
+ const trimmed = args.trim();
22
+ if (!trimmed) return null;
23
+
24
+ const parts = trimmed.split(/\s+/);
25
+ if (parts.length !== 2) {
26
+ throw new CredentialError("用法:/feishu setup <appId> <appSecret>,或设置 FEISHU_APP_ID/FEISHU_APP_SECRET。");
27
+ }
28
+
29
+ const appId = parts[0] ?? "";
30
+ const appSecret = parts[1] ?? "";
31
+ validateCredentialShape({ appId, appSecret });
32
+ return { appId, appSecret };
33
+ }
34
+
35
+ export function resolveCredentialInput(args: string, environment: Environment): CredentialInput {
36
+ const explicit = parseSetupArguments(args);
37
+ if (explicit) return { ...explicit, source: "arguments" };
38
+
39
+ const appId = environment.FEISHU_APP_ID?.trim() ?? "";
40
+ const appSecret = environment.FEISHU_APP_SECRET?.trim() ?? "";
41
+ if (!appId && !appSecret) {
42
+ throw new CredentialError(
43
+ "未提供飞书凭据。请设置 FEISHU_APP_ID、FEISHU_APP_SECRET,或使用 /feishu setup <appId> <appSecret>。",
44
+ );
45
+ }
46
+ if (!appId || !appSecret) {
47
+ throw new CredentialError("FEISHU_APP_ID 和 FEISHU_APP_SECRET 必须同时设置。");
48
+ }
49
+ validateCredentialShape({ appId, appSecret });
50
+ return { appId, appSecret, source: "environment" };
51
+ }
52
+
53
+ export function resolveRuntimeCredentials(
54
+ environment: Environment,
55
+ stored: FeishuCredentials | null,
56
+ ): FeishuCredentials | null {
57
+ const appId = environment.FEISHU_APP_ID?.trim() ?? "";
58
+ const appSecret = environment.FEISHU_APP_SECRET?.trim() ?? "";
59
+ if (appId || appSecret) {
60
+ if (!appId || !appSecret) {
61
+ throw new CredentialError("FEISHU_APP_ID 和 FEISHU_APP_SECRET 必须同时设置。");
62
+ }
63
+ validateCredentialShape({ appId, appSecret });
64
+ const ownerOpenId = stored?.appId === appId ? stored.ownerOpenId : undefined;
65
+ return ownerOpenId ? { appId, appSecret, ownerOpenId } : { appId, appSecret };
66
+ }
67
+ return stored;
68
+ }
69
+
70
+ export function validateCredentialShape(credentials: FeishuCredentials): void {
71
+ if (!APP_ID_PATTERN.test(credentials.appId)) {
72
+ throw new CredentialError("飞书 App ID 格式无效,通常应以 cli_ 开头。");
73
+ }
74
+ if (!credentials.appSecret.trim()) {
75
+ throw new CredentialError("飞书 App Secret 不能为空。");
76
+ }
77
+ }
78
+
79
+ export function redactSensitiveText(
80
+ text: string,
81
+ credentials?: Pick<FeishuCredentials, "appId" | "appSecret">,
82
+ ): string {
83
+ let redacted = text.replace(/(Bearer\s+)[A-Za-z0-9._~+/-]+/gi, "$1[REDACTED]");
84
+ if (credentials?.appSecret) {
85
+ redacted = redacted.split(credentials.appSecret).join("[REDACTED]");
86
+ }
87
+ return redacted;
88
+ }
89
+
90
+ export function errorMessage(error: unknown, credentials?: Pick<FeishuCredentials, "appId" | "appSecret">): string {
91
+ const message = error instanceof Error ? error.message : String(error);
92
+ return redactSensitiveText(message, credentials);
93
+ }
94
+
95
+ export function defaultCredentialsPath(): string {
96
+ return join(homedir(), ".pi", "agent", "feishu", "credentials.json");
97
+ }
98
+
99
+ export class FileCredentialStore implements CredentialStore {
100
+ private readonly path: string;
101
+
102
+ constructor(path = defaultCredentialsPath()) {
103
+ this.path = path;
104
+ }
105
+
106
+ async load(): Promise<FeishuCredentials | null> {
107
+ let raw: string;
108
+ try {
109
+ raw = await readFile(this.path, "utf8");
110
+ } catch (error) {
111
+ if (isNodeError(error) && error.code === "ENOENT") return null;
112
+ throw new CredentialError(`读取飞书凭据失败:${errorMessage(error)}`);
113
+ }
114
+
115
+ let value: unknown;
116
+ try {
117
+ value = JSON.parse(raw);
118
+ } catch {
119
+ throw new CredentialError("飞书凭据文件不是有效 JSON,请执行 /feishu logout 后重新配置。");
120
+ }
121
+ if (!isCredentialRecord(value)) {
122
+ throw new CredentialError("飞书凭据文件格式无效,请执行 /feishu logout 后重新配置。");
123
+ }
124
+ validateCredentialShape(value);
125
+ return value.ownerOpenId
126
+ ? { appId: value.appId, appSecret: value.appSecret, ownerOpenId: value.ownerOpenId }
127
+ : { appId: value.appId, appSecret: value.appSecret };
128
+ }
129
+
130
+ async save(credentials: FeishuCredentials): Promise<void> {
131
+ validateCredentialShape(credentials);
132
+ const directory = dirname(this.path);
133
+ await mkdir(directory, { recursive: true, mode: 0o700 });
134
+ const temporaryPath = `${this.path}.${process.pid}.${randomUUID()}.tmp`;
135
+ const payload = `${JSON.stringify(credentials, null, 2)}\n`;
136
+
137
+ try {
138
+ await writeFile(temporaryPath, payload, { encoding: "utf8", mode: 0o600, flag: "wx" });
139
+ await rename(temporaryPath, this.path);
140
+ await restrictFilePermissions(this.path);
141
+ } catch (error) {
142
+ await rm(temporaryPath, { force: true }).catch(() => undefined);
143
+ throw new CredentialError(`保存飞书凭据失败:${errorMessage(error, credentials)}`);
144
+ }
145
+ }
146
+
147
+ async clear(): Promise<void> {
148
+ try {
149
+ await rm(this.path, { force: true });
150
+ } catch (error) {
151
+ throw new CredentialError(`清除飞书凭据失败:${errorMessage(error)}`);
152
+ }
153
+ }
154
+ }
155
+
156
+ function isCredentialRecord(value: unknown): value is FeishuCredentials {
157
+ if (!value || typeof value !== "object") return false;
158
+ const record = value as Record<string, unknown>;
159
+ return (
160
+ typeof record.appId === "string" &&
161
+ typeof record.appSecret === "string" &&
162
+ (record.ownerOpenId === undefined || typeof record.ownerOpenId === "string")
163
+ );
164
+ }
165
+
166
+ function isNodeError(error: unknown): error is NodeJS.ErrnoException {
167
+ return error instanceof Error && "code" in error;
168
+ }
169
+
170
+ async function restrictFilePermissions(path: string): Promise<void> {
171
+ try {
172
+ await chmod(path, 0o600);
173
+ } catch (error) {
174
+ if (process.platform !== "win32") throw error;
175
+ }
176
+ }
@@ -0,0 +1,156 @@
1
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { FeishuController, type FeishuStatus } from "./controller.js";
3
+ import { CredentialError, FileCredentialStore } from "./credentials.js";
4
+ import { SdkFeishuGateway, validateSdkCredentials } from "./gateway.js";
5
+ import { PiAgentBridge } from "./pi-agent-bridge.js";
6
+
7
+ type FeishuCommandName = "help" | "setup" | "start" | "stop" | "status" | "logout";
8
+
9
+ export interface ParsedFeishuCommand {
10
+ name: FeishuCommandName;
11
+ args: string;
12
+ }
13
+
14
+ export function parseFeishuCommand(input: string): ParsedFeishuCommand {
15
+ const trimmed = input.trim();
16
+ if (!trimmed || trimmed === "help") return { name: "help", args: "" };
17
+
18
+ const separator = trimmed.search(/\s/);
19
+ const rawName = separator === -1 ? trimmed : trimmed.slice(0, separator);
20
+ let args = separator === -1 ? "" : trimmed.slice(separator).trim();
21
+ if (!isCommandName(rawName)) {
22
+ throw new CredentialError(`未知子命令:${rawName}。请执行 /feishu 查看帮助。`);
23
+ }
24
+ if (rawName !== "setup" && args) {
25
+ throw new CredentialError(`/feishu ${rawName} 不接受参数。`);
26
+ }
27
+ if (rawName === "setup" && args.startsWith("--manual")) {
28
+ args = args.slice("--manual".length).trim();
29
+ }
30
+ return { name: rawName, args };
31
+ }
32
+
33
+ export function renderFeishuHelp(): string {
34
+ return [
35
+ "Pi Feishu:通过飞书私聊使用当前 Pi 会话",
36
+ "",
37
+ "命令:",
38
+ " /feishu",
39
+ " /feishu setup [<appId> <appSecret>]",
40
+ " /feishu start",
41
+ " /feishu stop",
42
+ " /feishu status",
43
+ " /feishu logout",
44
+ "",
45
+ "推荐通过 FEISHU_APP_ID、FEISHU_APP_SECRET 提供凭据,然后执行 /feishu setup。",
46
+ ].join("\n");
47
+ }
48
+
49
+ export function renderFeishuStatus(status: FeishuStatus): string {
50
+ if (!status.configured) {
51
+ return [
52
+ "飞书状态",
53
+ " 配置:未配置",
54
+ ` 连接:${status.running ? "已连接" : "未连接"}`,
55
+ ` 队列:${status.pendingMessages}`,
56
+ ].join("\n");
57
+ }
58
+ return [
59
+ "飞书状态",
60
+ " 配置:已配置",
61
+ ` App ID:${status.appId ?? "未知"}`,
62
+ ` 来源:${status.source === "environment" ? "环境变量" : "凭据文件"}`,
63
+ ` 连接:${status.running ? "已连接" : "未连接"}`,
64
+ ` Owner:${status.ownerOpenId ?? "未绑定"}`,
65
+ ` 队列:${status.pendingMessages}`,
66
+ ].join("\n");
67
+ }
68
+
69
+ export default function feishuExtension(pi: ExtensionAPI): void {
70
+ const agent = new PiAgentBridge((text) => pi.sendUserMessage(text));
71
+ const controller = new FeishuController({
72
+ store: new FileCredentialStore(),
73
+ gatewayFactory: (credentials) => new SdkFeishuGateway(credentials),
74
+ validateCredentials: validateSdkCredentials,
75
+ agent,
76
+ });
77
+
78
+ pi.on("message_end", (event) => {
79
+ agent.captureMessage(event.message);
80
+ });
81
+ pi.on("agent_settled", () => {
82
+ agent.settle();
83
+ });
84
+ pi.on("session_shutdown", async () => {
85
+ await controller.stop();
86
+ agent.cancel("Pi 会话已关闭。");
87
+ });
88
+
89
+ pi.registerCommand("feishu", {
90
+ description: "配置和管理飞书私聊连接",
91
+ handler: async (args, context) => {
92
+ try {
93
+ await handleFeishuCommand(parseFeishuCommand(args), controller, context);
94
+ } catch (error) {
95
+ context.ui.notify(`飞书操作失败:${controller.sanitizeError(error)}`, "error");
96
+ }
97
+ },
98
+ });
99
+ }
100
+
101
+ async function handleFeishuCommand(
102
+ command: ParsedFeishuCommand,
103
+ controller: FeishuController,
104
+ context: ExtensionCommandContext,
105
+ ): Promise<void> {
106
+ switch (command.name) {
107
+ case "help": {
108
+ const status = await controller.status(process.env);
109
+ context.ui.notify(`${renderFeishuHelp()}\n\n${renderFeishuStatus(status)}`, "info");
110
+ return;
111
+ }
112
+ case "setup": {
113
+ const status = await controller.status(process.env);
114
+ if (status.running) {
115
+ throw new CredentialError("请先执行 /feishu stop,再修改飞书凭据。");
116
+ }
117
+ const credentials = await controller.setup(command.args, process.env);
118
+ context.ui.notify(`飞书凭据验证成功并已保存:${credentials.appId}`, "info");
119
+ return;
120
+ }
121
+ case "start": {
122
+ const result = await controller.start(process.env);
123
+ if (result.alreadyRunning) {
124
+ context.ui.notify("飞书长连接已经在运行。", "info");
125
+ return;
126
+ }
127
+ if (result.bindingCode) {
128
+ context.ui.notify(
129
+ `飞书长连接已启动。\n一次性绑定码:${result.bindingCode}\n请在飞书私聊 Bot 发送:/bind ${result.bindingCode}`,
130
+ "warning",
131
+ );
132
+ } else {
133
+ context.ui.notify("飞书长连接已启动,Owner 已绑定。", "info");
134
+ }
135
+ return;
136
+ }
137
+ case "stop":
138
+ context.ui.notify((await controller.stop()) ? "飞书长连接已停止。" : "飞书长连接未运行。", "info");
139
+ return;
140
+ case "status":
141
+ context.ui.notify(renderFeishuStatus(await controller.status(process.env)), "info");
142
+ return;
143
+ case "logout": {
144
+ if (context.hasUI) {
145
+ const confirmed = await context.ui.confirm("退出飞书", "停止连接并清除本地飞书凭据?");
146
+ if (!confirmed) return;
147
+ }
148
+ await controller.logout();
149
+ context.ui.notify("飞书连接已停止,本地凭据和 Owner 绑定已清除。", "info");
150
+ }
151
+ }
152
+ }
153
+
154
+ function isCommandName(value: string): value is FeishuCommandName {
155
+ return value === "setup" || value === "start" || value === "stop" || value === "status" || value === "logout";
156
+ }
package/src/gateway.ts ADDED
@@ -0,0 +1,140 @@
1
+ import { createLarkChannel, type LarkChannel, LoggerLevel, type NormalizedMessage } from "@larksuiteoapi/node-sdk";
2
+ import type { FeishuCredentials, FeishuGateway, FeishuMessageHandler } from "./contracts.js";
3
+
4
+ export interface NormalizedChannelMessage {
5
+ messageId: string;
6
+ chatId: string;
7
+ chatType: "p2p" | "group";
8
+ senderId: string;
9
+ content: string;
10
+ rawContentType: string;
11
+ }
12
+
13
+ export interface ChannelLike {
14
+ onMessage(handler: (message: NormalizedChannelMessage) => Promise<void> | void): () => void;
15
+ connect(): Promise<void>;
16
+ disconnect(): Promise<void>;
17
+ sendText(to: string, text: string, replyTo?: string): Promise<void>;
18
+ }
19
+
20
+ export type ChannelFactory = (credentials: FeishuCredentials) => ChannelLike;
21
+
22
+ export class SdkFeishuGateway implements FeishuGateway {
23
+ private readonly credentials: FeishuCredentials;
24
+ private readonly channelFactory: ChannelFactory;
25
+ private channel: ChannelLike | undefined;
26
+ private unsubscribe: (() => void) | undefined;
27
+
28
+ constructor(credentials: FeishuCredentials, channelFactory: ChannelFactory = createOfficialChannel) {
29
+ this.credentials = credentials;
30
+ this.channelFactory = channelFactory;
31
+ }
32
+
33
+ async connect(handler: FeishuMessageHandler): Promise<void> {
34
+ if (this.channel) return;
35
+ const channel = this.channelFactory(this.credentials);
36
+ const unsubscribe = channel.onMessage((message) =>
37
+ handler({
38
+ messageId: message.messageId,
39
+ chatId: message.chatId,
40
+ chatType: message.chatType,
41
+ senderOpenId: message.senderId,
42
+ contentType: message.rawContentType,
43
+ text: message.content,
44
+ }),
45
+ );
46
+ this.channel = channel;
47
+ this.unsubscribe = unsubscribe;
48
+ try {
49
+ await channel.connect();
50
+ } catch (error) {
51
+ this.channel = undefined;
52
+ this.unsubscribe = undefined;
53
+ unsubscribe();
54
+ await channel.disconnect().catch(() => undefined);
55
+ throw error;
56
+ }
57
+ }
58
+
59
+ async disconnect(): Promise<void> {
60
+ const channel = this.channel;
61
+ this.channel = undefined;
62
+ this.unsubscribe?.();
63
+ this.unsubscribe = undefined;
64
+ if (channel) await channel.disconnect();
65
+ }
66
+
67
+ async sendText(chatId: string, text: string, replyTo?: string): Promise<void> {
68
+ const channel = this.channel;
69
+ if (!channel) throw new Error("飞书长连接尚未启动。");
70
+ await channel.sendText(chatId, text, replyTo);
71
+ }
72
+ }
73
+
74
+ export async function validateSdkCredentials(
75
+ credentials: FeishuCredentials,
76
+ channelFactory: ChannelFactory = createOfficialChannel,
77
+ ): Promise<void> {
78
+ const channel = channelFactory(credentials);
79
+ try {
80
+ await channel.connect();
81
+ } finally {
82
+ await channel.disconnect().catch(() => undefined);
83
+ }
84
+ }
85
+
86
+ function createOfficialChannel(credentials: FeishuCredentials): ChannelLike {
87
+ return new OfficialChannelAdapter(
88
+ createLarkChannel({
89
+ appId: credentials.appId,
90
+ appSecret: credentials.appSecret,
91
+ transport: "websocket",
92
+ handshakeTimeoutMs: 10_000,
93
+ loggerLevel: LoggerLevel.error,
94
+ source: "pi-feishu",
95
+ policy: {
96
+ dmMode: "open",
97
+ groupAllowlist: [],
98
+ requireMention: true,
99
+ },
100
+ safety: {
101
+ chatQueue: { enabled: false },
102
+ },
103
+ }),
104
+ );
105
+ }
106
+
107
+ class OfficialChannelAdapter implements ChannelLike {
108
+ private readonly channel: LarkChannel;
109
+
110
+ constructor(channel: LarkChannel) {
111
+ this.channel = channel;
112
+ }
113
+
114
+ onMessage(handler: (message: NormalizedChannelMessage) => Promise<void> | void): () => void {
115
+ return this.channel.on("message", (message) => handler(toChannelMessage(message)));
116
+ }
117
+
118
+ async connect(): Promise<void> {
119
+ await this.channel.connect();
120
+ }
121
+
122
+ async disconnect(): Promise<void> {
123
+ await this.channel.disconnect();
124
+ }
125
+
126
+ async sendText(to: string, text: string, replyTo?: string): Promise<void> {
127
+ await this.channel.send(to, { text }, replyTo ? { replyTo } : undefined);
128
+ }
129
+ }
130
+
131
+ function toChannelMessage(message: NormalizedMessage): NormalizedChannelMessage {
132
+ return {
133
+ messageId: message.messageId,
134
+ chatId: message.chatId,
135
+ chatType: message.chatType,
136
+ senderId: message.senderId,
137
+ content: message.content,
138
+ rawContentType: message.rawContentType,
139
+ };
140
+ }
@@ -0,0 +1,26 @@
1
+ export class MessageDeduplicator {
2
+ private readonly seen = new Set<string>();
3
+ private readonly order: string[] = [];
4
+ private readonly maxEntries: number;
5
+
6
+ constructor(maxEntries = 1000) {
7
+ this.maxEntries = maxEntries;
8
+ }
9
+
10
+ accept(messageId: string): boolean {
11
+ if (this.seen.has(messageId)) return false;
12
+ this.seen.add(messageId);
13
+ this.order.push(messageId);
14
+
15
+ while (this.order.length > this.maxEntries) {
16
+ const oldest = this.order.shift();
17
+ if (oldest) this.seen.delete(oldest);
18
+ }
19
+ return true;
20
+ }
21
+
22
+ clear(): void {
23
+ this.seen.clear();
24
+ this.order.length = 0;
25
+ }
26
+ }
@@ -0,0 +1,26 @@
1
+ export class SerialMessageQueue {
2
+ private tail: Promise<void> = Promise.resolve();
3
+ private pending = 0;
4
+
5
+ get pendingCount(): number {
6
+ return this.pending;
7
+ }
8
+
9
+ enqueue<T>(task: () => Promise<T>): Promise<T> {
10
+ this.pending += 1;
11
+ const execution = this.tail.then(task);
12
+ this.tail = execution
13
+ .then(
14
+ () => undefined,
15
+ () => undefined,
16
+ )
17
+ .finally(() => {
18
+ this.pending -= 1;
19
+ });
20
+ return execution;
21
+ }
22
+
23
+ async waitForIdle(): Promise<void> {
24
+ await this.tail;
25
+ }
26
+ }
@@ -0,0 +1,61 @@
1
+ import { randomInt, timingSafeEqual } from "node:crypto";
2
+
3
+ export type AuthorizationResult =
4
+ | { kind: "authorized"; text: string }
5
+ | { kind: "bound"; ownerOpenId: string }
6
+ | { kind: "binding-required" }
7
+ | { kind: "invalid-binding-code" }
8
+ | { kind: "unauthorized" };
9
+
10
+ export class OwnerBinding {
11
+ private owner: string | undefined;
12
+ private code: string | undefined;
13
+ private readonly generateCode: () => string;
14
+
15
+ constructor(ownerOpenId: string | undefined, generateCode: () => string = defaultBindingCode) {
16
+ this.owner = ownerOpenId;
17
+ this.generateCode = generateCode;
18
+ }
19
+
20
+ get ownerOpenId(): string | undefined {
21
+ return this.owner;
22
+ }
23
+
24
+ getOrCreateCode(): string | undefined {
25
+ if (this.owner) return undefined;
26
+ if (!this.code) {
27
+ const generated = this.generateCode();
28
+ if (!/^\d{6}$/.test(generated)) {
29
+ throw new Error("绑定码生成器必须返回六位数字。");
30
+ }
31
+ this.code = generated;
32
+ }
33
+ return this.code;
34
+ }
35
+
36
+ authorize(senderOpenId: string, text: string): AuthorizationResult {
37
+ if (this.owner) {
38
+ return senderOpenId === this.owner ? { kind: "authorized", text } : { kind: "unauthorized" };
39
+ }
40
+
41
+ const match = text.trim().match(/^\/bind\s+(\d{6})$/);
42
+ if (!match) return { kind: "binding-required" };
43
+ const candidate = match[1] ?? "";
44
+ const expected = this.getOrCreateCode();
45
+ if (!expected || !safeEqual(candidate, expected)) return { kind: "invalid-binding-code" };
46
+
47
+ this.owner = senderOpenId;
48
+ this.code = undefined;
49
+ return { kind: "bound", ownerOpenId: senderOpenId };
50
+ }
51
+ }
52
+
53
+ function defaultBindingCode(): string {
54
+ return randomInt(100000, 1000000).toString();
55
+ }
56
+
57
+ function safeEqual(left: string, right: string): boolean {
58
+ const leftBuffer = Buffer.from(left);
59
+ const rightBuffer = Buffer.from(right);
60
+ return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
61
+ }
@@ -0,0 +1,77 @@
1
+ import type { AgentBridge } from "./contracts.js";
2
+
3
+ interface PendingTurn {
4
+ resolve: (text: string) => void;
5
+ reject: (error: Error) => void;
6
+ lastAssistantText: string;
7
+ }
8
+
9
+ export class PiAgentBridge implements AgentBridge {
10
+ private readonly sendUserMessage: (text: string) => void;
11
+ private pending: PendingTurn | undefined;
12
+
13
+ constructor(sendUserMessage: (text: string) => void) {
14
+ this.sendUserMessage = sendUserMessage;
15
+ }
16
+
17
+ run(text: string): Promise<string> {
18
+ if (this.pending) {
19
+ return Promise.reject(new Error("已有飞书消息正在等待 Pi 回复。"));
20
+ }
21
+
22
+ return new Promise<string>((resolve, reject) => {
23
+ this.pending = { resolve, reject, lastAssistantText: "" };
24
+ try {
25
+ this.sendUserMessage(text);
26
+ } catch (error) {
27
+ this.pending = undefined;
28
+ reject(error instanceof Error ? error : new Error(String(error)));
29
+ }
30
+ });
31
+ }
32
+
33
+ captureMessage(message: unknown): void {
34
+ if (!this.pending || !isAssistantMessage(message)) return;
35
+ const text = message.content
36
+ .filter(isTextContent)
37
+ .map((item) => item.text)
38
+ .join("");
39
+ if (text.trim()) this.pending.lastAssistantText = text.trim();
40
+ }
41
+
42
+ settle(): void {
43
+ const pending = this.pending;
44
+ if (!pending) return;
45
+ this.pending = undefined;
46
+ pending.resolve(pending.lastAssistantText || "Pi 已完成处理,但没有返回文本内容。");
47
+ }
48
+
49
+ cancel(reason = "Pi 会话已关闭。"): void {
50
+ const pending = this.pending;
51
+ if (!pending) return;
52
+ this.pending = undefined;
53
+ pending.reject(new Error(reason));
54
+ }
55
+ }
56
+
57
+ interface AssistantMessageLike {
58
+ role: "assistant";
59
+ content: unknown[];
60
+ }
61
+
62
+ interface TextContentLike {
63
+ type: "text";
64
+ text: string;
65
+ }
66
+
67
+ function isAssistantMessage(value: unknown): value is AssistantMessageLike {
68
+ if (!value || typeof value !== "object") return false;
69
+ const record = value as Record<string, unknown>;
70
+ return record.role === "assistant" && Array.isArray(record.content);
71
+ }
72
+
73
+ function isTextContent(value: unknown): value is TextContentLike {
74
+ if (!value || typeof value !== "object") return false;
75
+ const record = value as Record<string, unknown>;
76
+ return record.type === "text" && typeof record.text === "string";
77
+ }