pi-remote-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.
Files changed (102) hide show
  1. package/ARCHITECTURE.md +837 -0
  2. package/ARCHITECTURE.zh-CN.md +704 -0
  3. package/README.md +118 -0
  4. package/dist/attachments/mime.d.ts +11 -0
  5. package/dist/attachments/mime.js +27 -0
  6. package/dist/attachments/mime.js.map +1 -0
  7. package/dist/attachments/processor.d.ts +29 -0
  8. package/dist/attachments/processor.js +48 -0
  9. package/dist/attachments/processor.js.map +1 -0
  10. package/dist/attachments/temp-files.d.ts +16 -0
  11. package/dist/attachments/temp-files.js +32 -0
  12. package/dist/attachments/temp-files.js.map +1 -0
  13. package/dist/bin/pi-remote-feishu.d.ts +2 -0
  14. package/dist/bin/pi-remote-feishu.js +359 -0
  15. package/dist/bin/pi-remote-feishu.js.map +1 -0
  16. package/dist/bridge/card-actions.d.ts +13 -0
  17. package/dist/bridge/card-actions.js +156 -0
  18. package/dist/bridge/card-actions.js.map +1 -0
  19. package/dist/bridge/conversation-router.d.ts +21 -0
  20. package/dist/bridge/conversation-router.js +65 -0
  21. package/dist/bridge/conversation-router.js.map +1 -0
  22. package/dist/bridge/message-handler.d.ts +22 -0
  23. package/dist/bridge/message-handler.js +218 -0
  24. package/dist/bridge/message-handler.js.map +1 -0
  25. package/dist/bridge/message-normalizer.d.ts +17 -0
  26. package/dist/bridge/message-normalizer.js +81 -0
  27. package/dist/bridge/message-normalizer.js.map +1 -0
  28. package/dist/bridge/prompt-queue.d.ts +20 -0
  29. package/dist/bridge/prompt-queue.js +39 -0
  30. package/dist/bridge/prompt-queue.js.map +1 -0
  31. package/dist/bridge/runtime-host.d.ts +45 -0
  32. package/dist/bridge/runtime-host.js +133 -0
  33. package/dist/bridge/runtime-host.js.map +1 -0
  34. package/dist/bridge/session-host-manager.d.ts +59 -0
  35. package/dist/bridge/session-host-manager.js +143 -0
  36. package/dist/bridge/session-host-manager.js.map +1 -0
  37. package/dist/bridge/stream-renderer.d.ts +50 -0
  38. package/dist/bridge/stream-renderer.js +108 -0
  39. package/dist/bridge/stream-renderer.js.map +1 -0
  40. package/dist/bridge/ui-context.d.ts +19 -0
  41. package/dist/bridge/ui-context.js +150 -0
  42. package/dist/bridge/ui-context.js.map +1 -0
  43. package/dist/cards/common.d.ts +33 -0
  44. package/dist/cards/common.js +84 -0
  45. package/dist/cards/common.js.map +1 -0
  46. package/dist/cards/help.d.ts +11 -0
  47. package/dist/cards/help.js +31 -0
  48. package/dist/cards/help.js.map +1 -0
  49. package/dist/cards/models.d.ts +24 -0
  50. package/dist/cards/models.js +46 -0
  51. package/dist/cards/models.js.map +1 -0
  52. package/dist/cards/permission.d.ts +9 -0
  53. package/dist/cards/permission.js +17 -0
  54. package/dist/cards/permission.js.map +1 -0
  55. package/dist/cards/sessions.d.ts +11 -0
  56. package/dist/cards/sessions.js +54 -0
  57. package/dist/cards/sessions.js.map +1 -0
  58. package/dist/cards/status.d.ts +15 -0
  59. package/dist/cards/status.js +20 -0
  60. package/dist/cards/status.js.map +1 -0
  61. package/dist/cards/stop.d.ts +16 -0
  62. package/dist/cards/stop.js +24 -0
  63. package/dist/cards/stop.js.map +1 -0
  64. package/dist/config/load-config.d.ts +19 -0
  65. package/dist/config/load-config.js +51 -0
  66. package/dist/config/load-config.js.map +1 -0
  67. package/dist/config/schema.d.ts +52 -0
  68. package/dist/config/schema.js +260 -0
  69. package/dist/config/schema.js.map +1 -0
  70. package/dist/extensions/index.d.ts +7 -0
  71. package/dist/extensions/index.js +71 -0
  72. package/dist/extensions/index.js.map +1 -0
  73. package/dist/extensions/lark-cli-guard.d.ts +12 -0
  74. package/dist/extensions/lark-cli-guard.js +75 -0
  75. package/dist/extensions/lark-cli-guard.js.map +1 -0
  76. package/dist/feishu/channel.d.ts +28 -0
  77. package/dist/feishu/channel.js +191 -0
  78. package/dist/feishu/channel.js.map +1 -0
  79. package/dist/feishu/context.d.ts +5 -0
  80. package/dist/feishu/context.js +19 -0
  81. package/dist/feishu/context.js.map +1 -0
  82. package/dist/feishu/webhook.d.ts +5 -0
  83. package/dist/feishu/webhook.js +8 -0
  84. package/dist/feishu/webhook.js.map +1 -0
  85. package/dist/index.d.ts +30 -0
  86. package/dist/index.js +29 -0
  87. package/dist/index.js.map +1 -0
  88. package/dist/store/json-store.d.ts +25 -0
  89. package/dist/store/json-store.js +105 -0
  90. package/dist/store/json-store.js.map +1 -0
  91. package/dist/store/store.d.ts +7 -0
  92. package/dist/store/store.js +15 -0
  93. package/dist/store/store.js.map +1 -0
  94. package/dist/tools/send-file-to-chat.d.ts +26 -0
  95. package/dist/tools/send-file-to-chat.js +85 -0
  96. package/dist/tools/send-file-to-chat.js.map +1 -0
  97. package/dist/types.d.ts +300 -0
  98. package/dist/types.js +2 -0
  99. package/dist/types.js.map +1 -0
  100. package/package.json +67 -0
  101. package/skills/lark-doc-cli/SKILL.md +56 -0
  102. package/skills/lark-im-readonly/SKILL.md +49 -0
package/README.md ADDED
@@ -0,0 +1,118 @@
1
+ # pi-remote-feishu
2
+
3
+ Remote control Pi Agent via Feishu (飞书) chat. WebSocket bridge, interactive cards, attachments, and file sending for the Pi Agent.
4
+
5
+ This is an independent Pi extension project. It does not modify `pi-main`.
6
+
7
+ Current scope:
8
+
9
+ - Feishu WebSocket message transport
10
+ - Private chat and group chat session isolation
11
+ - One Pi runtime per Feishu session key
12
+ - Feishu card commands
13
+ - Attachment processing
14
+ - `send_file_to_chat` for returning local files to the current Feishu chat
15
+ - Optional `lark-doc-cli` and `lark-im-readonly` skills when `lark-cli` is installed
16
+ - Guardrails that block `lark-cli im` send/reply/receive commands
17
+
18
+ ## Quick Start
19
+
20
+ Install and build:
21
+
22
+ ```powershell
23
+ npm install --ignore-scripts
24
+ npm run build
25
+ ```
26
+
27
+ Create a config template:
28
+
29
+ ```powershell
30
+ node .\dist\bin\pi-remote-feishu.js init --cwd D:\develop\pi-main
31
+ ```
32
+
33
+ Edit:
34
+
35
+ ```text
36
+ D:\develop\pi-main\.pi\feishu.json
37
+ ```
38
+
39
+ Start the bridge:
40
+
41
+ ```powershell
42
+ node .\dist\bin\pi-remote-feishu.js serve --cwd D:\develop\pi-main
43
+ ```
44
+
45
+ You can also pass credentials through environment variables:
46
+
47
+ ```powershell
48
+ $env:FEISHU_APP_ID="cli_xxx"
49
+ $env:FEISHU_APP_SECRET="xxx"
50
+ node .\dist\bin\pi-remote-feishu.js serve --cwd D:\develop\pi-main
51
+ ```
52
+
53
+ ## CLI Commands
54
+
55
+ Check config and WebSocket connectivity:
56
+
57
+ ```powershell
58
+ node .\dist\bin\pi-remote-feishu.js doctor --cwd D:\develop\pi-main
59
+ ```
60
+
61
+ Send a test message when you know the chat id:
62
+
63
+ ```powershell
64
+ node .\dist\bin\pi-remote-feishu.js send-test --chat-id <chatId> --text "hello"
65
+ ```
66
+
67
+ Log incoming event summaries:
68
+
69
+ ```powershell
70
+ node .\dist\bin\pi-remote-feishu.js serve --cwd D:\develop\pi-main --debug-events
71
+ ```
72
+
73
+ or configure:
74
+
75
+ ```json
76
+ {
77
+ "debug": {
78
+ "logIncomingEvents": true
79
+ }
80
+ }
81
+ ```
82
+
83
+ ## Feishu Chat Commands
84
+
85
+ - `/help`
86
+ - `/sessions`
87
+ - `/models`
88
+ - `/new`
89
+ - `/stop`
90
+ - `/reset`
91
+ - `/status`
92
+
93
+ Group chats require mentioning the bot by default.
94
+
95
+ ## Skills And Message Output
96
+
97
+ The extension exposes two skills to Pi only when `lark-cli` is installed and available on `PATH`:
98
+
99
+ - `lark-doc-cli`: Feishu/Lark document workflows through `lark-cli`, including fetch, create, update, and media insertion.
100
+ - `lark-im-readonly`: read-only IM inspection through `lark-cli`, including chat/member lookup, message search, and resource download.
101
+
102
+ If `lark-cli` is not installed, these CLI skills are not loaded. The core Feishu transport, cards, attachments, and `send_file_to_chat` still work through the Feishu SDK.
103
+
104
+ Loading a skill must not send Feishu messages by itself. In a Feishu-triggered run, the final chat reply is sent by the `pi-remote-feishu serve` transport. Generated local files should be returned through `send_file_to_chat`.
105
+
106
+ The extension blocks these IM write or receive commands when the model tries to run them through bash:
107
+
108
+ ```bash
109
+ lark-cli im +messages-send
110
+ lark-cli im +messages-reply
111
+ lark-cli im +messages-receive
112
+ ```
113
+
114
+ ## Docs
115
+
116
+ - `ARCHITECTURE.md`
117
+ - `ARCHITECTURE.zh-CN.md`
118
+ - `docs/`
@@ -0,0 +1,11 @@
1
+ /**
2
+ * 附件 MIME 类型推断工具。
3
+ */
4
+ /** 根据文件名后缀推断 MIME 类型;未知扩展名返回通用二进制类型 */
5
+ export declare function inferMimeType(fileName: string): string;
6
+ /**
7
+ * 粗略判断一段 Buffer 是否像文本:
8
+ * 前 4KB 里只要出现字节 0(NUL),就认为不是文本。
9
+ * 用于决定小文件是内联内容还是落盘保存。
10
+ */
11
+ export declare function isLikelyText(buffer: Buffer): boolean;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * 附件 MIME 类型推断工具。
3
+ */
4
+ /** 常见图片扩展名 -> MIME 类型 */
5
+ const MIME_BY_EXTENSION = {
6
+ png: "image/png",
7
+ jpg: "image/jpeg",
8
+ jpeg: "image/jpeg",
9
+ gif: "image/gif",
10
+ webp: "image/webp",
11
+ bmp: "image/bmp",
12
+ tiff: "image/tiff",
13
+ };
14
+ /** 根据文件名后缀推断 MIME 类型;未知扩展名返回通用二进制类型 */
15
+ export function inferMimeType(fileName) {
16
+ const extension = fileName.split(".").pop()?.toLowerCase() ?? "";
17
+ return MIME_BY_EXTENSION[extension] ?? "application/octet-stream";
18
+ }
19
+ /**
20
+ * 粗略判断一段 Buffer 是否像文本:
21
+ * 前 4KB 里只要出现字节 0(NUL),就认为不是文本。
22
+ * 用于决定小文件是内联内容还是落盘保存。
23
+ */
24
+ export function isLikelyText(buffer) {
25
+ return !buffer.subarray(0, 4096).includes(0);
26
+ }
27
+ //# sourceMappingURL=mime.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mime.js","sourceRoot":"","sources":["../../src/attachments/mime.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,yBAAyB;AACzB,MAAM,iBAAiB,GAA2B;IAChD,GAAG,EAAE,WAAW;IAChB,GAAG,EAAE,YAAY;IACjB,IAAI,EAAE,YAAY;IAClB,GAAG,EAAE,WAAW;IAChB,IAAI,EAAE,YAAY;IAClB,GAAG,EAAE,WAAW;IAChB,IAAI,EAAE,YAAY;CACnB,CAAC;AAEF,uCAAuC;AACvC,MAAM,UAAU,aAAa,CAAC,QAAgB;IAC5C,MAAM,SAAS,GAAG,QAAQ,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,WAAW,EAAE,IAAI,EAAE,CAAC;IACjE,OAAO,iBAAiB,CAAC,SAAS,CAAC,IAAI,0BAA0B,CAAC;AACpE,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,YAAY,CAAC,MAAc;IACzC,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC;AAC/C,CAAC"}
@@ -0,0 +1,29 @@
1
+ import type { FeishuChannel, FeishuConfig, FeishuMessage, ImageInput } from "../types.js";
2
+ /**
3
+ * ============================================================
4
+ * 附件处理
5
+ * ------------------------------------------------------------
6
+ * 对消息携带的附件资源分类处理:
7
+ * 1. 表情包(sticker):直接跳过
8
+ * 2. 图片 + 模型支持图片输入:转 base64 图片传给 Pi
9
+ * 3. 小型文本文件(非音视频、≤32KB、内容像文本):内容内联到提示词
10
+ * 4. 其余文件:下载保存到临时目录,提示词里记录保存路径
11
+ * 5. 下载失败:提示词里记录失败原因
12
+ * ============================================================
13
+ */
14
+ /** 附件处理结果:图片列表 + 文字说明列表 */
15
+ export interface ProcessedAttachments {
16
+ images: ImageInput[];
17
+ notes: string[];
18
+ }
19
+ /**
20
+ * 处理一条消息的所有附件。
21
+ * 每个资源单独 try/catch:单个失败不影响其他资源。
22
+ */
23
+ export declare function processAttachments(options: {
24
+ channel: Pick<FeishuChannel, "downloadResource">;
25
+ config: FeishuConfig;
26
+ message: FeishuMessage;
27
+ sessionKey: string;
28
+ supportsImages: boolean;
29
+ }): Promise<ProcessedAttachments>;
@@ -0,0 +1,48 @@
1
+ import { inferMimeType, isLikelyText } from "./mime.js";
2
+ import { writeTempAttachment } from "./temp-files.js";
3
+ /** 文本文件大小上限:超过则视为不可内联 */
4
+ const TEXT_SIZE_LIMIT = 32 * 1024;
5
+ /** 附件落盘文件名:优先用原始文件名,没有则用 fileKey.bin */
6
+ function resourceFileName(fileKey, fileName) {
7
+ return fileName ?? `${fileKey}.bin`;
8
+ }
9
+ /**
10
+ * 处理一条消息的所有附件。
11
+ * 每个资源单独 try/catch:单个失败不影响其他资源。
12
+ */
13
+ export async function processAttachments(options) {
14
+ const images = [];
15
+ const notes = [];
16
+ for (const resource of options.message.resources) {
17
+ if (resource.type === "sticker")
18
+ continue;
19
+ const fileName = resourceFileName(resource.fileKey, resource.fileName);
20
+ try {
21
+ const buffer = await options.channel.downloadResource(options.message.messageId, resource.fileKey, resource.type);
22
+ // 图片且模型支持:转 base64 作为图像输入
23
+ if (resource.type === "image" && options.supportsImages) {
24
+ images.push({
25
+ type: "image",
26
+ data: buffer.toString("base64"),
27
+ mimeType: inferMimeType(fileName),
28
+ });
29
+ continue;
30
+ }
31
+ // 小型文本(非音视频):内容直接内联进提示词
32
+ if (resource.type !== "audio" && resource.type !== "video" && buffer.length <= TEXT_SIZE_LIMIT && isLikelyText(buffer)) {
33
+ notes.push(`[Attachment text: ${fileName}]\n${buffer.toString("utf-8")}`);
34
+ continue;
35
+ }
36
+ // 其他文件:下载到临时目录,提示词记录路径(模型可用它读取)
37
+ const filePath = await writeTempAttachment(options.config.files.tempDir ?? ".", options.sessionKey, fileName, buffer);
38
+ notes.push(`[Attachment saved: ${fileName} -> ${filePath}]`);
39
+ }
40
+ catch (error) {
41
+ // 下载/处理失败:提示词里记录原因
42
+ const message = error instanceof Error ? error.message : String(error);
43
+ notes.push(`[Attachment failed: ${fileName}: ${message}]`);
44
+ }
45
+ }
46
+ return { images, notes };
47
+ }
48
+ //# sourceMappingURL=processor.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"processor.js","sourceRoot":"","sources":["../../src/attachments/processor.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,WAAW,CAAC;AACxD,OAAO,EAAE,mBAAmB,EAAE,MAAM,iBAAiB,CAAC;AAqBtD,yBAAyB;AACzB,MAAM,eAAe,GAAG,EAAE,GAAG,IAAI,CAAC;AAElC,wCAAwC;AACxC,SAAS,gBAAgB,CAAC,OAAe,EAAE,QAA4B;IACrE,OAAO,QAAQ,IAAI,GAAG,OAAO,MAAM,CAAC;AACtC,CAAC;AAED;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,kBAAkB,CAAC,OAMxC;IACC,MAAM,MAAM,GAAiB,EAAE,CAAC;IAChC,MAAM,KAAK,GAAa,EAAE,CAAC;IAE3B,KAAK,MAAM,QAAQ,IAAI,OAAO,CAAC,OAAO,CAAC,SAAS,EAAE,CAAC;QACjD,IAAI,QAAQ,CAAC,IAAI,KAAK,SAAS;YAAE,SAAS;QAC1C,MAAM,QAAQ,GAAG,gBAAgB,CAAC,QAAQ,CAAC,OAAO,EAAE,QAAQ,CAAC,QAAQ,CAAC,CAAC;QAEvE,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,OAAO,CAAC,OAAO,CAAC,gBAAgB,CAAC,OAAO,CAAC,OAAO,CAAC,SAAS,EAAE,QAAQ,CAAC,OAAO,EAAE,QAAQ,CAAC,IAAI,CAAC,CAAC;YAElH,0BAA0B;YAC1B,IAAI,QAAQ,CAAC,IAAI,KAAK,OAAO,IAAI,OAAO,CAAC,cAAc,EAAE,CAAC;gBACxD,MAAM,CAAC,IAAI,CAAC;oBACV,IAAI,EAAE,OAAO;oBACb,IAAI,EAAE,MAAM,CAAC,QAAQ,CAAC,QAAQ,CAAC;oBAC/B,QAAQ,EAAE,aAAa,CAAC,QAAQ,CAAC;iBAClC,CAAC,CAAC;gBACH,SAAS;YACX,CAAC;YAED,wBAAwB;YACxB,IAAI,QAAQ,CAAC,IAAI,KAAK,OAAO,IAAI,QAAQ,CAAC,IAAI,KAAK,OAAO,IAAI,MAAM,CAAC,MAAM,IAAI,eAAe,IAAI,YAAY,CAAC,MAAM,CAAC,EAAE,CAAC;gBACvH,KAAK,CAAC,IAAI,CAAC,qBAAqB,QAAQ,MAAM,MAAM,CAAC,QAAQ,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC;gBAC1E,SAAS;YACX,CAAC;YAED,gCAAgC;YAChC,MAAM,QAAQ,GAAG,MAAM,mBAAmB,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,OAAO,IAAI,GAAG,EAAE,OAAO,CAAC,UAAU,EAAE,QAAQ,EAAE,MAAM,CAAC,CAAC;YACtH,KAAK,CAAC,IAAI,CAAC,sBAAsB,QAAQ,OAAO,QAAQ,GAAG,CAAC,CAAC;QAC/D,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,mBAAmB;YACnB,MAAM,OAAO,GAAG,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;YACvE,KAAK,CAAC,IAAI,CAAC,uBAAuB,QAAQ,KAAK,OAAO,GAAG,CAAC,CAAC;QAC7D,CAAC;IACH,CAAC;IAED,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC;AAC3B,CAAC"}
@@ -0,0 +1,16 @@
1
+ /**
2
+ * 附件临时文件管理:
3
+ * 每个会话一个子目录(目录名由 sessionKey 清洗而来),
4
+ * 附件下载后写到这里,生成结束后整体清理。
5
+ */
6
+ /**
7
+ * 把附件 Buffer 写入会话专属临时目录。
8
+ * 返回写入后的完整文件路径。
9
+ * 文件名会做安全清洗,去掉路径分隔符等危险字符。
10
+ */
11
+ export declare function writeTempAttachment(tempDir: string, sessionKey: string, fileName: string, buffer: Buffer): Promise<string>;
12
+ /**
13
+ * 清理某个会话的临时附件目录(生成结束/命令处理完后调用)。
14
+ * force: true 表示目录不存在也不报错。
15
+ */
16
+ export declare function cleanupTempAttachments(tempDir: string, sessionKey: string): Promise<void>;
@@ -0,0 +1,32 @@
1
+ import { mkdir, rm, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ /**
4
+ * 附件临时文件管理:
5
+ * 每个会话一个子目录(目录名由 sessionKey 清洗而来),
6
+ * 附件下载后写到这里,生成结束后整体清理。
7
+ */
8
+ /**
9
+ * 把附件 Buffer 写入会话专属临时目录。
10
+ * 返回写入后的完整文件路径。
11
+ * 文件名会做安全清洗,去掉路径分隔符等危险字符。
12
+ */
13
+ export async function writeTempAttachment(tempDir, sessionKey, fileName, buffer) {
14
+ // 清洗文件名:替换 Windows/Unix 路径分隔符与控制字符
15
+ const sanitizedName = fileName.replace(/[<>:"/\\|?*\u0000-\u001f]/g, "_");
16
+ const safeName = sanitizedName.length > 0 && sanitizedName !== "." && sanitizedName !== ".." ? sanitizedName : "attachment.bin";
17
+ // 会话目录名:只保留安全字符
18
+ const dir = join(tempDir, sessionKey.replace(/[^a-zA-Z0-9_.-]/g, "_"));
19
+ await mkdir(dir, { recursive: true });
20
+ const filePath = join(dir, safeName);
21
+ await writeFile(filePath, buffer);
22
+ return filePath;
23
+ }
24
+ /**
25
+ * 清理某个会话的临时附件目录(生成结束/命令处理完后调用)。
26
+ * force: true 表示目录不存在也不报错。
27
+ */
28
+ export async function cleanupTempAttachments(tempDir, sessionKey) {
29
+ const dir = join(tempDir, sessionKey.replace(/[^a-zA-Z0-9_.-]/g, "_"));
30
+ await rm(dir, { recursive: true, force: true });
31
+ }
32
+ //# sourceMappingURL=temp-files.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"temp-files.js","sourceRoot":"","sources":["../../src/attachments/temp-files.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,EAAE,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AACxD,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAEjC;;;;GAIG;AAEH;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,mBAAmB,CAAC,OAAe,EAAE,UAAkB,EAAE,QAAgB,EAAE,MAAc;IAC7G,mCAAmC;IACnC,MAAM,aAAa,GAAG,QAAQ,CAAC,OAAO,CAAC,4BAA4B,EAAE,GAAG,CAAC,CAAC;IAC1E,MAAM,QAAQ,GAAG,aAAa,CAAC,MAAM,GAAG,CAAC,IAAI,aAAa,KAAK,GAAG,IAAI,aAAa,KAAK,IAAI,CAAC,CAAC,CAAC,aAAa,CAAC,CAAC,CAAC,gBAAgB,CAAC;IAChI,gBAAgB;IAChB,MAAM,GAAG,GAAG,IAAI,CAAC,OAAO,EAAE,UAAU,CAAC,OAAO,CAAC,kBAAkB,EAAE,GAAG,CAAC,CAAC,CAAC;IACvE,MAAM,KAAK,CAAC,GAAG,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IACtC,MAAM,QAAQ,GAAG,IAAI,CAAC,GAAG,EAAE,QAAQ,CAAC,CAAC;IACrC,MAAM,SAAS,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;IAClC,OAAO,QAAQ,CAAC;AAClB,CAAC;AAED;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,sBAAsB,CAAC,OAAe,EAAE,UAAkB;IAC9E,MAAM,GAAG,GAAG,IAAI,CAAC,OAAO,EAAE,UAAU,CAAC,OAAO,CAAC,kBAAkB,EAAE,GAAG,CAAC,CAAC,CAAC;IACvE,MAAM,EAAE,CAAC,GAAG,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;AAClD,CAAC"}
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,359 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * ============================================================
4
+ * pi-remote-feishu 命令行入口(bin)
5
+ * ------------------------------------------------------------
6
+ * 支持子命令:
7
+ * serve 启动 WebSocket 桥接,把飞书消息转发给 Pi 运行时(主命令)
8
+ * init 生成一份 feishu.json 配置模板
9
+ * doctor 检查配置与飞书 WebSocket 连通性
10
+ * send-test 向指定聊天发送一条测试消息
11
+ * help 打印帮助
12
+ *
13
+ * 参数来源优先级:环境变量 < 配置文件 < CLI 参数(见 config/load-config.ts)
14
+ * ============================================================
15
+ */
16
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
17
+ import { dirname, join, resolve } from "node:path";
18
+ import { handleCardAction } from "../bridge/card-actions.js";
19
+ import { handleFeishuMessage } from "../bridge/message-handler.js";
20
+ import { SessionHostManager } from "../bridge/session-host-manager.js";
21
+ import { loadConfig, resolveConfigFilePath } from "../config/load-config.js";
22
+ import { createFeishuChannel } from "../feishu/channel.js";
23
+ import { createWebhookNotImplementedError } from "../feishu/webhook.js";
24
+ import { createStore } from "../store/store.js";
25
+ /** 飞书 WebSocket 连接/断开的超时时间(毫秒) */
26
+ const CONNECT_TIMEOUT_MS = 20_000;
27
+ const DISCONNECT_TIMEOUT_MS = 5_000;
28
+ /** 读取某个 flag 后面的参数值,缺失则报错 */
29
+ function readFlag(args, index) {
30
+ const value = args[index + 1];
31
+ if (!value) {
32
+ throw new Error(`Missing value for ${args[index]}`);
33
+ }
34
+ return value;
35
+ }
36
+ /**
37
+ * 解析命令行参数。
38
+ * 第一个参数如果是 serve/init/doctor/send-test/help 则作为子命令,
39
+ * 否则默认为 serve(方便直接运行 `pi-remote-feishu --app-id ...`)。
40
+ */
41
+ function parseArgs(args) {
42
+ const command = args[0] === "serve" || args[0] === "init" || args[0] === "doctor" || args[0] === "send-test"
43
+ ? args[0]
44
+ : args[0] === "help" || args[0] === "--help" || args[0] === "-h"
45
+ ? "help"
46
+ : "serve";
47
+ const config = {};
48
+ let logLevel;
49
+ let initPath;
50
+ let force = false;
51
+ let chatId;
52
+ let text;
53
+ for (let index = command === "serve" || command === "init" || command === "doctor" || command === "send-test" ? 1 : 0; index < args.length; index += 1) {
54
+ const arg = args[index];
55
+ if (arg === "--app-id") {
56
+ config.appId = readFlag(args, index);
57
+ index += 1;
58
+ }
59
+ else if (arg === "--app-secret") {
60
+ config.appSecret = readFlag(args, index);
61
+ index += 1;
62
+ }
63
+ else if (arg === "--config") {
64
+ config.configPath = readFlag(args, index);
65
+ initPath = resolve(config.configPath);
66
+ index += 1;
67
+ }
68
+ else if (arg === "--cwd") {
69
+ config.cwd = resolve(readFlag(args, index));
70
+ index += 1;
71
+ }
72
+ else if (arg === "--log-level") {
73
+ logLevel = readFlag(args, index);
74
+ index += 1;
75
+ }
76
+ else if (arg === "--group-scope") {
77
+ // 群聊会话隔离范围:shared-chat(共享)/ per-user(按用户隔离)
78
+ const groupScope = readFlag(args, index);
79
+ if (groupScope !== "shared-chat" && groupScope !== "per-user") {
80
+ throw new Error("--group-scope must be shared-chat or per-user");
81
+ }
82
+ config.sessions = { ...config.sessions, groupScope };
83
+ index += 1;
84
+ }
85
+ else if (arg === "--debug-events") {
86
+ // 打印收到的消息/卡片事件摘要
87
+ config.debug = { ...config.debug, logIncomingEvents: true };
88
+ }
89
+ else if (arg === "--force") {
90
+ force = true;
91
+ }
92
+ else if (arg === "--chat-id") {
93
+ chatId = readFlag(args, index);
94
+ index += 1;
95
+ }
96
+ else if (arg === "--text") {
97
+ text = readFlag(args, index);
98
+ index += 1;
99
+ }
100
+ }
101
+ return {
102
+ command,
103
+ config,
104
+ ...(logLevel ? { logLevel } : {}),
105
+ ...(initPath ? { initPath } : {}),
106
+ ...(force ? { force } : {}),
107
+ ...(chatId ? { chatId } : {}),
108
+ ...(text ? { text } : {}),
109
+ };
110
+ }
111
+ /** 打印命令行帮助 */
112
+ function printHelp() {
113
+ console.log(`Usage:
114
+ pi-remote-feishu serve [options]
115
+ pi-remote-feishu init [--config <path>] [--force]
116
+ pi-remote-feishu doctor [options]
117
+ pi-remote-feishu send-test --chat-id <chatId> --text <text> [options]
118
+
119
+ Options:
120
+ --app-id <id> Feishu app id
121
+ --app-secret <secret> Feishu app secret
122
+ --config <path> Config file path
123
+ --cwd <path> Default Pi working directory
124
+ --log-level <level> fatal|error|warn|info|debug|trace
125
+ --group-scope <scope> shared-chat|per-user
126
+ --debug-events Log incoming message/card action summaries
127
+ --force Overwrite config when used with init
128
+ --chat-id <id> Target chat id for send-test
129
+ --text <text> Text for send-test
130
+ `);
131
+ }
132
+ /** 脱敏 App Secret:只保留头尾各 4 位,中间用 **** 代替 */
133
+ function maskSecret(value) {
134
+ if (value.length <= 8)
135
+ return "****";
136
+ return `${value.slice(0, 4)}****${value.slice(-4)}`;
137
+ }
138
+ /** 给 Promise 加超时:超时后 reject,并在结束时清理定时器 */
139
+ async function withTimeout(promise, timeoutMs, label) {
140
+ let timer;
141
+ const timeout = new Promise((_resolve, reject) => {
142
+ timer = setTimeout(() => {
143
+ reject(new Error(`${label} timed out after ${timeoutMs}ms`));
144
+ }, timeoutMs);
145
+ });
146
+ try {
147
+ return await Promise.race([promise, timeout]);
148
+ }
149
+ finally {
150
+ if (timer) {
151
+ clearTimeout(timer);
152
+ }
153
+ }
154
+ }
155
+ /** 生成一份默认配置模板(pi-remote-feishu init 用),已存在且未带 --force 时拒绝覆盖 */
156
+ function writeConfigTemplate(path, force) {
157
+ if (existsSync(path) && !force) {
158
+ throw new Error(`Config already exists: ${path}. Use --force to overwrite.`);
159
+ }
160
+ mkdirSync(dirname(path), { recursive: true });
161
+ const template = {
162
+ appId: "cli_xxx",
163
+ appSecret: "replace-with-app-secret",
164
+ botName: "Pi Agent",
165
+ transport: "websocket",
166
+ policy: {
167
+ requireMention: true,
168
+ dmEnabled: true,
169
+ groupEnabled: true,
170
+ allowUsers: [],
171
+ allowChats: [],
172
+ },
173
+ sessions: {
174
+ groupScope: "shared-chat",
175
+ defaultCwd: process.cwd(),
176
+ store: "json",
177
+ idleTtlMs: 1_800_000,
178
+ },
179
+ rendering: {
180
+ mode: "stream-card",
181
+ showThinking: "quote",
182
+ showToolEvents: true,
183
+ },
184
+ files: {
185
+ allowedOutputDirs: [process.cwd()],
186
+ maxUploadBytes: 20_971_520,
187
+ },
188
+ debug: {
189
+ logIncomingEvents: false,
190
+ },
191
+ };
192
+ writeFileSync(path, `${JSON.stringify(template, null, 2)}\n`, "utf-8");
193
+ }
194
+ /** 生成收到的消息摘要(--debug-events 时打印到 stderr) */
195
+ function incomingMessageSummary(message) {
196
+ return JSON.stringify({
197
+ event: "message",
198
+ messageId: message.messageId,
199
+ chatId: message.chatId,
200
+ chatType: message.chatType,
201
+ userOpenId: message.userOpenId,
202
+ senderName: message.senderName ?? "",
203
+ mentionedBot: message.mentionedBot,
204
+ textLength: message.text.length,
205
+ resources: message.resources.length,
206
+ });
207
+ }
208
+ /** 生成收到的卡片事件摘要(--debug-events 时打印到 stderr) */
209
+ function incomingCardActionSummary(event) {
210
+ return JSON.stringify({
211
+ event: "cardAction",
212
+ messageId: event.messageId ?? "",
213
+ chatId: event.chatId,
214
+ userOpenId: event.userOpenId ?? "",
215
+ cmd: typeof event.actionValue.cmd === "string" ? event.actionValue.cmd : "",
216
+ hasToken: event.token !== undefined,
217
+ });
218
+ }
219
+ /** 根据配置创建飞书通道 */
220
+ function createChannel(config, logLevel) {
221
+ return createFeishuChannel({
222
+ appId: config.appId,
223
+ appSecret: config.appSecret,
224
+ requireMention: config.policy.requireMention,
225
+ ...(logLevel ? { logLevel } : {}),
226
+ });
227
+ }
228
+ /** doctor 子命令:打印配置摘要,并实际连一次飞书 WebSocket 验证连通性 */
229
+ async function runDoctor(cli) {
230
+ const cwd = resolve(cli.config.cwd ?? process.cwd());
231
+ const configPath = resolveConfigFilePath(cwd, cli.config.configPath);
232
+ const config = loadConfig(cli.config);
233
+ console.log("Pi Feishu Doctor");
234
+ console.log(`- cwd: ${cwd}`);
235
+ console.log(`- config: ${configPath ?? "env/cli only"}`);
236
+ console.log(`- appId: ${config.appId}`);
237
+ console.log(`- appSecret: ${maskSecret(config.appSecret)}`);
238
+ console.log(`- transport: ${config.transport}`);
239
+ console.log(`- dmEnabled: ${config.policy.dmEnabled}`);
240
+ console.log(`- groupEnabled: ${config.policy.groupEnabled}`);
241
+ console.log(`- requireMention: ${config.policy.requireMention}`);
242
+ console.log(`- groupScope: ${config.sessions.groupScope}`);
243
+ console.log(`- debug.logIncomingEvents: ${config.debug.logIncomingEvents}`);
244
+ if (config.transport === "webhook") {
245
+ console.log("- websocket: skipped, webhook transport is not implemented in v1");
246
+ return;
247
+ }
248
+ const channel = createChannel(config, cli.logLevel);
249
+ try {
250
+ await withTimeout(channel.connect(), CONNECT_TIMEOUT_MS, "Feishu websocket connect");
251
+ console.log(`- websocket: connected${channel.botName ? ` as ${channel.botName}` : ""}`);
252
+ }
253
+ finally {
254
+ await withTimeout(channel.disconnect(), DISCONNECT_TIMEOUT_MS, "Feishu websocket disconnect").catch(() => { });
255
+ }
256
+ }
257
+ /** send-test 子命令:连接飞书并发送一条测试文本 */
258
+ async function runSendTest(cli) {
259
+ if (!cli.chatId) {
260
+ throw new Error("Missing --chat-id");
261
+ }
262
+ if (!cli.text) {
263
+ throw new Error("Missing --text");
264
+ }
265
+ const config = loadConfig(cli.config);
266
+ if (config.transport === "webhook") {
267
+ throw createWebhookNotImplementedError();
268
+ }
269
+ const channel = createChannel(config, cli.logLevel);
270
+ try {
271
+ await withTimeout(channel.connect(), CONNECT_TIMEOUT_MS, "Feishu websocket connect");
272
+ const result = await channel.sendText(cli.chatId, cli.text);
273
+ console.log(`Sent test message: ${result.messageId}`);
274
+ }
275
+ finally {
276
+ await withTimeout(channel.disconnect(), DISCONNECT_TIMEOUT_MS, "Feishu websocket disconnect").catch(() => { });
277
+ }
278
+ }
279
+ /**
280
+ * serve 主流程:
281
+ * 1. 加载配置、创建通道和存储
282
+ * 2. 创建 SessionHostManager(负责按 sessionKey 管理 Pi 运行时)
283
+ * 3. 注册消息/卡片/错误三个回调
284
+ * 4. 连接飞书,阻塞直到收到 SIGINT/SIGTERM
285
+ */
286
+ async function main() {
287
+ const cli = parseArgs(process.argv.slice(2));
288
+ if (cli.command === "help") {
289
+ printHelp();
290
+ return;
291
+ }
292
+ if (cli.command === "init") {
293
+ const cwd = resolve(cli.config.cwd ?? process.cwd());
294
+ const configPath = cli.initPath ?? join(cwd, ".pi", "feishu.json");
295
+ writeConfigTemplate(configPath, cli.force ?? false);
296
+ console.log(`Created Feishu config: ${configPath}`);
297
+ return;
298
+ }
299
+ if (cli.command === "doctor") {
300
+ await runDoctor(cli);
301
+ process.exit(0);
302
+ }
303
+ if (cli.command === "send-test") {
304
+ await runSendTest(cli);
305
+ process.exit(0);
306
+ }
307
+ // ---- serve 子命令 ----
308
+ const config = loadConfig(cli.config);
309
+ if (config.transport === "webhook") {
310
+ throw createWebhookNotImplementedError();
311
+ }
312
+ const channel = createChannel(config, cli.logLevel);
313
+ const store = createStore(config);
314
+ const manager = new SessionHostManager({ config, store, channel });
315
+ // 收到飞书消息:可选的调试摘要 -> 交给 handleFeishuMessage 处理
316
+ channel.onMessage((message) => {
317
+ if (config.debug.logIncomingEvents) {
318
+ console.error(incomingMessageSummary(message));
319
+ }
320
+ handleFeishuMessage({ config, channel, store, manager, message }).catch((error) => {
321
+ console.error(error instanceof Error ? error.message : String(error));
322
+ });
323
+ });
324
+ // 收到卡片按钮事件:交给 handleCardAction 处理
325
+ channel.onCardAction((event) => {
326
+ if (config.debug.logIncomingEvents) {
327
+ console.error(incomingCardActionSummary(event));
328
+ }
329
+ handleCardAction({
330
+ config,
331
+ store,
332
+ manager,
333
+ event,
334
+ updateCardByToken: async (token, card) => {
335
+ await channel.updateCardByToken(token, card);
336
+ },
337
+ }).catch((error) => {
338
+ console.error(error instanceof Error ? error.message : String(error));
339
+ });
340
+ });
341
+ channel.onError((error) => {
342
+ console.error(error.message);
343
+ });
344
+ await channel.connect();
345
+ console.error(`Feishu bot connected${channel.botName ? ` as ${channel.botName}` : ""}.`);
346
+ // 阻塞等待退出信号,收到后断开飞书连接
347
+ await new Promise((resolvePromise) => {
348
+ const shutdown = () => {
349
+ channel.disconnect().finally(resolvePromise);
350
+ };
351
+ process.once("SIGINT", shutdown);
352
+ process.once("SIGTERM", shutdown);
353
+ });
354
+ }
355
+ main().catch((error) => {
356
+ console.error(error instanceof Error ? error.message : String(error));
357
+ process.exit(1);
358
+ });
359
+ //# sourceMappingURL=pi-remote-feishu.js.map