chatccc 0.2.29 → 0.2.31
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 +29 -29
- package/im-skills/feishu-skill/receive-send-file.md +26 -1
- package/package.json +1 -1
- package/src/__tests__/cards.test.ts +1 -1
- package/src/cards.ts +27 -27
- package/src/config.ts +21 -17
- package/src/feishu-api.ts +3 -2
- package/src/index.ts +27 -7
- package/src/session.ts +11 -3
package/README.md
CHANGED
|
@@ -12,17 +12,17 @@ ChatCCC 把 Claude Code、Cursor Agent、Codex (OpenAI) 接入了飞书群聊:
|
|
|
12
12
|
|
|
13
13
|
- **手机上也能用 Claude Code / Cursor / Codex** —— 在飞书群里发消息就等于在终端输入指令,AI 的思考和回复会流式展示在群卡片里
|
|
14
14
|
- **多会话并行** —— 一个群就是一个 AI 会话,完全隔离、互不干扰,并行工作效率更高
|
|
15
|
-
- **多工具切换** —— `/new` 使用默认 Agent 创建会话,也可用 `/new claude`、`/new cursor`、`/new codex` 指定工具,各取所长
|
|
15
|
+
- **多工具切换** —— `/new` 使用默认 Agent 创建会话,也可用 `/new claude`、`/new cursor`、`/new codex` 指定工具,各取所长
|
|
16
16
|
|
|
17
17
|
一句话:**在任何设备上打开飞书,就能让 Claude Code / Cursor / Codex 帮你写代码、排查问题、分析项目。**
|
|
18
18
|
|
|
19
|
-
<p align="center">
|
|
20
|
-
<img src="images/img_readme_messages.jpg" alt="飞书会话列表" width="220" align="top" />
|
|
21
|
-
|
|
22
|
-
<img src="images/img_readme_0.jpg" alt="飞书群聊中使用 ChatCCC" width="220" align="top" />
|
|
23
|
-
|
|
24
|
-
<img src="images/img_readme_1.jpg" alt="思考过程和工具调用" width="220" align="top" />
|
|
25
|
-
</p>
|
|
19
|
+
<p align="center">
|
|
20
|
+
<img src="images/img_readme_messages.jpg" alt="飞书会话列表" width="220" align="top" />
|
|
21
|
+
|
|
22
|
+
<img src="images/img_readme_0.jpg" alt="飞书群聊中使用 ChatCCC" width="220" align="top" />
|
|
23
|
+
|
|
24
|
+
<img src="images/img_readme_1.jpg" alt="思考过程和工具调用" width="220" align="top" />
|
|
25
|
+
</p>
|
|
26
26
|
|
|
27
27
|
---
|
|
28
28
|
|
|
@@ -120,7 +120,7 @@ ChatCCC 启动时会按以下顺序确定 Cursor Agent CLI:
|
|
|
120
120
|
|
|
121
121
|
> 旧版字段 `cursor.command` 仍可读取(启动时会打印一次 warning 提示改名),新配置请统一使用 `cursor.path`。
|
|
122
122
|
|
|
123
|
-
> **说明**:只使用 Claude Code(`/new claude`,或把 Claude Code 设为默认 Agent 后使用 `/new`)的用户无需安装 Cursor CLI。
|
|
123
|
+
> **说明**:只使用 Claude Code(`/new claude`,或把 Claude Code 设为默认 Agent 后使用 `/new`)的用户无需安装 Cursor CLI。
|
|
124
124
|
|
|
125
125
|
#### Codex CLI(使用 Codex 会话时需要)
|
|
126
126
|
|
|
@@ -217,24 +217,24 @@ ChatCCC 的所有运行参数都集中在包根目录的 `config.json`。
|
|
|
217
217
|
},
|
|
218
218
|
"port": 18080,
|
|
219
219
|
"gitTimeoutSeconds": 180,
|
|
220
|
-
"claude": {
|
|
221
|
-
"enabled": false,
|
|
222
|
-
"defaultAgent": true,
|
|
223
|
-
"model": "",
|
|
220
|
+
"claude": {
|
|
221
|
+
"enabled": false,
|
|
222
|
+
"defaultAgent": true,
|
|
223
|
+
"model": "",
|
|
224
224
|
"effort": "",
|
|
225
225
|
"apiKey": "",
|
|
226
226
|
"baseUrl": ""
|
|
227
227
|
},
|
|
228
|
-
"cursor": {
|
|
229
|
-
"enabled": false,
|
|
230
|
-
"defaultAgent": false,
|
|
231
|
-
"path": "",
|
|
228
|
+
"cursor": {
|
|
229
|
+
"enabled": false,
|
|
230
|
+
"defaultAgent": false,
|
|
231
|
+
"path": "",
|
|
232
232
|
"model": ""
|
|
233
233
|
},
|
|
234
|
-
"codex": {
|
|
235
|
-
"enabled": false,
|
|
236
|
-
"defaultAgent": false,
|
|
237
|
-
"path": "",
|
|
234
|
+
"codex": {
|
|
235
|
+
"enabled": false,
|
|
236
|
+
"defaultAgent": false,
|
|
237
|
+
"path": "",
|
|
238
238
|
"model": "",
|
|
239
239
|
"effort": ""
|
|
240
240
|
}
|
|
@@ -247,10 +247,10 @@ ChatCCC 的所有运行参数都集中在包根目录的 `config.json`。
|
|
|
247
247
|
| --- | --- | --- |
|
|
248
248
|
| `feishu.appId` / `feishu.appSecret` | 是 | 飞书应用「凭证与基础信息」中的 App ID / App Secret |
|
|
249
249
|
| `port` | 否 | 本地 WebSocket 中继 + Web 向导监听端口,默认 `18080`;同机多实例时改成不同值即可 |
|
|
250
|
-
| `gitTimeoutSeconds` | 否 | `/git` 命令在会话工作目录执行时的单次超时秒数,默认 `180`,允许范围 `1–3600`,超时会被 `SIGKILL` 强制终止 |
|
|
251
|
-
| `claude.enabled` / `cursor.enabled` / `codex.enabled` | 否 | 是否启用对应 AI Agent;Web 向导/管理页只展示 `enabled: true` 的 agent 卡片。字段缺省时按「任一配置字段非空」自动判定(向后兼容) |
|
|
252
|
-
| `claude.defaultAgent` / `cursor.defaultAgent` / `codex.defaultAgent` | 否 | `/new` 未指定具体 Agent 时使用哪个默认 Agent;同一时间应只有一个为 `true`。Web 配置页切换某个 Agent 为默认时会自动关闭其它 Agent 的默认开关 |
|
|
253
|
-
| `claude.model` | 否 | Claude Code 会话使用的模型;留空(`""` / 全空白)→ 不向 SDK 传 `model`,由 SDK / 服务商默认决定 |
|
|
250
|
+
| `gitTimeoutSeconds` | 否 | `/git` 命令在会话工作目录执行时的单次超时秒数,默认 `180`,允许范围 `1–3600`,超时会被 `SIGKILL` 强制终止 |
|
|
251
|
+
| `claude.enabled` / `cursor.enabled` / `codex.enabled` | 否 | 是否启用对应 AI Agent;Web 向导/管理页只展示 `enabled: true` 的 agent 卡片。字段缺省时按「任一配置字段非空」自动判定(向后兼容) |
|
|
252
|
+
| `claude.defaultAgent` / `cursor.defaultAgent` / `codex.defaultAgent` | 否 | `/new` 未指定具体 Agent 时使用哪个默认 Agent;同一时间应只有一个为 `true`。Web 配置页切换某个 Agent 为默认时会自动关闭其它 Agent 的默认开关 |
|
|
253
|
+
| `claude.model` | 否 | Claude Code 会话使用的模型;留空(`""` / 全空白)→ 不向 SDK 传 `model`,由 SDK / 服务商默认决定 |
|
|
254
254
|
| `claude.effort` | 否 | Claude 思考深度(如 `low` / `medium` / `high` / `max`);留空 → 不向 SDK 传 `effort` |
|
|
255
255
|
| `claude.apiKey` | 否 | 第三方 Anthropic 兼容网关的 API 密钥;**官方 Claude 用户保持 `""` 即可**,详见下文「第三方 API」一节 |
|
|
256
256
|
| `claude.baseUrl` | 否 | 第三方 Anthropic 兼容网关的 base URL(例如 `https://api.deepseek.com/anthropic`);**官方 Claude 用户保持 `""` 即可** |
|
|
@@ -295,20 +295,20 @@ ChatCCC 的所有运行参数都集中在包根目录的 `config.json`。
|
|
|
295
295
|
|
|
296
296
|
### 5. 开始使用
|
|
297
297
|
|
|
298
|
-
在飞书中找到你的机器人,发送 `/new`(使用 `config.json` 中 `defaultAgent: true` 的默认 Agent)或 `/new claude` / `/new cursor` / `/new codex`,机器人会自动创建一个群聊并把 AI 会话绑定到该群。之后直接在群里发消息就能对话。
|
|
298
|
+
在飞书中找到你的机器人,发送 `/new`(使用 `config.json` 中 `defaultAgent: true` 的默认 Agent)或 `/new claude` / `/new cursor` / `/new codex`,机器人会自动创建一个群聊并把 AI 会话绑定到该群。之后直接在群里发消息就能对话。
|
|
299
299
|
|
|
300
300
|
### 可用指令
|
|
301
301
|
|
|
302
302
|
|
|
303
303
|
| 指令 | 作用 |
|
|
304
304
|
| --------------- | ---------------------------- |
|
|
305
|
-
| `/new` | 使用默认 Agent 创建新会话 |
|
|
305
|
+
| `/new` | 使用默认 Agent 创建新会话 |
|
|
306
306
|
| `/new claude` | 创建新的 Claude Code 会话 |
|
|
307
307
|
| `/new cursor` | 创建新的 Cursor 会话 |
|
|
308
308
|
| `/new codex` | 创建新的 Codex 会话(OpenAI) |
|
|
309
309
|
| `/stop` | 停止当前正在生成的回复 |
|
|
310
310
|
| `/status` | 查看当前会话的状态(轮数、模型、上下文 token 等) |
|
|
311
|
-
| `/cd` |
|
|
311
|
+
| `/cd` | 查看/设置当前会话的默认工作目录 |
|
|
312
312
|
| `/sessions` | 查看所有会话状态 |
|
|
313
313
|
| `/forget` | 重置当前会话(创建新 Session,保留工作目录,同一群内继续) |
|
|
314
314
|
| `/git <子命令>` | 在**当前会话工作目录**执行 `git ...` 并把 stdout/stderr 回发到群里(仅会话群内可用,超时见 `config.json` 的 `gitTimeoutSeconds` 字段) |
|
|
@@ -319,4 +319,4 @@ ChatCCC 的所有运行参数都集中在包根目录的 `config.json`。
|
|
|
319
319
|
|
|
320
320
|
## 技术栈
|
|
321
321
|
|
|
322
|
-
TypeScript / Node.js >= 20 / tsx / Anthropic Claude Agent SDK / Cursor Agent CLI / Codex CLI (OpenAI) / 飞书 WebSocket API / CardKit
|
|
322
|
+
TypeScript / Node.js >= 20 / tsx / Anthropic Claude Agent SDK / Cursor Agent CLI / Codex CLI (OpenAI) / 飞书 WebSocket API / CardKit
|
|
@@ -29,10 +29,35 @@ Content-Type: application/json; charset=utf-8
|
|
|
29
29
|
|
|
30
30
|
- Save or choose a local file first.
|
|
31
31
|
- Use an absolute local path.
|
|
32
|
-
- Max file size:
|
|
32
|
+
- Max file size: 30MB.
|
|
33
33
|
- Supported formats: .mp4 .mov .avi .mkv .webm .flv .mp3 .wav .ogg .aac .m4a .pdf .doc .docx .xls .xlsx .csv .ppt .pptx .txt .zip .tar .gz.
|
|
34
34
|
- Only send a file/video when the user asked for one or when it materially helps the answer.
|
|
35
35
|
|
|
36
|
+
### Video Compression (when file > 30MB)
|
|
37
|
+
|
|
38
|
+
If the video exceeds 30MB, compress it with ffmpeg before sending.
|
|
39
|
+
|
|
40
|
+
**Ensure ffmpeg is available** (install if missing):
|
|
41
|
+
|
|
42
|
+
| OS | Install command |
|
|
43
|
+
|----|----------------|
|
|
44
|
+
| macOS | `brew install ffmpeg` |
|
|
45
|
+
| Linux (Debian/Ubuntu) | `sudo apt install ffmpeg` |
|
|
46
|
+
| Linux (RHEL/Fedora) | `sudo dnf install ffmpeg` |
|
|
47
|
+
| Windows | `winget install Gyan.FFmpeg` |
|
|
48
|
+
|
|
49
|
+
**Two-pass compression** (target ~28MB for 30s video, adjust `b:v` for other durations):
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
ffmpeg -y -i "<input>" -c:v libx264 -b:v <bitrate>k -pass 1 -f mp4 NUL
|
|
53
|
+
ffmpeg -y -i "<input>" -c:v libx264 -b:v <bitrate>k -pass 2 -c:a aac -b:a 128k "<output>"
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Bitrate formula: `bitrate = 28 × 8 × 1000 ÷ duration_seconds - 128` (target ~28MB, safe under 30MB).
|
|
57
|
+
On Windows replace `NUL` with `NUL` (same); on Linux/macOS use `/dev/null`.
|
|
58
|
+
|
|
59
|
+
If the compressed file still exceeds 30MB, explain to the user that automatic compression wasn't enough and suggest they manually trim or re-encode the source.
|
|
60
|
+
|
|
36
61
|
## Download Files or Videos
|
|
37
62
|
|
|
38
63
|
When the user sends a file or video to the bot, the message contains `message_id` and `file_key`. Download it with:
|
package/package.json
CHANGED
|
@@ -232,7 +232,7 @@ describe("buildCdCard", () => {
|
|
|
232
232
|
it("shows current working directory in markdown", () => {
|
|
233
233
|
const card = buildCdCard("/home/project", entries, []);
|
|
234
234
|
const parsed = JSON.parse(card);
|
|
235
|
-
const cwdContent = mdContents(parsed).find((c) => c.includes("
|
|
235
|
+
const cwdContent = mdContents(parsed).find((c) => c.includes("本会话默认工作路径"));
|
|
236
236
|
expect(cwdContent).toBeDefined();
|
|
237
237
|
expect(cwdContent).toContain("/home/project");
|
|
238
238
|
});
|
package/src/cards.ts
CHANGED
|
@@ -108,17 +108,17 @@ export function buildProgressCard(
|
|
|
108
108
|
});
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
export function buildHelpCard(
|
|
112
|
-
userText: string,
|
|
113
|
-
opts: { greeting?: string; defaultToolLabel?: string } = {},
|
|
114
|
-
): string {
|
|
115
|
-
const greeting = opts.greeting ?? `你发送了: ${userText}`;
|
|
116
|
-
const defaultToolLabel = opts.defaultToolLabel ?? "Claude Code";
|
|
117
|
-
const lines = [
|
|
118
|
-
`发送 **/new** 创建新会话(默认 ${defaultToolLabel})`,
|
|
119
|
-
"发送 **/new claude** 创建新 Claude 对话",
|
|
120
|
-
"发送 **/new cursor** 创建新 Cursor 会话",
|
|
121
|
-
"发送 **/new codex** 创建新 Codex 会话",
|
|
111
|
+
export function buildHelpCard(
|
|
112
|
+
userText: string,
|
|
113
|
+
opts: { greeting?: string; defaultToolLabel?: string } = {},
|
|
114
|
+
): string {
|
|
115
|
+
const greeting = opts.greeting ?? `你发送了: ${userText}`;
|
|
116
|
+
const defaultToolLabel = opts.defaultToolLabel ?? "Claude Code";
|
|
117
|
+
const lines = [
|
|
118
|
+
`发送 **/new** 创建新会话(默认 ${defaultToolLabel})`,
|
|
119
|
+
"发送 **/new claude** 创建新 Claude 对话",
|
|
120
|
+
"发送 **/new cursor** 创建新 Cursor 会话",
|
|
121
|
+
"发送 **/new codex** 创建新 Codex 会话",
|
|
122
122
|
"发送 **/forget** 重置当前会话(保留工作目录,同一群内继续)",
|
|
123
123
|
].join("\n");
|
|
124
124
|
return JSON.stringify({
|
|
@@ -128,7 +128,7 @@ export function buildHelpCard(
|
|
|
128
128
|
{ tag: "div", text: { tag: "lark_md", content: greeting } },
|
|
129
129
|
{ tag: "div", text: { tag: "lark_md", content: lines } },
|
|
130
130
|
buildButtons([
|
|
131
|
-
{ text: `新建默认会话(/new,${defaultToolLabel})`, value: JSON.stringify({ cmd: "new" }), type: "primary" },
|
|
131
|
+
{ text: `新建默认会话(/new,${defaultToolLabel})`, value: JSON.stringify({ cmd: "new" }), type: "primary" },
|
|
132
132
|
{ text: "新建 Cursor 会话(/new cursor)", value: JSON.stringify({ cmd: "new cursor" }), type: "primary" },
|
|
133
133
|
{ text: "新建 Codex 会话(/new codex)", value: JSON.stringify({ cmd: "new codex" }), type: "primary" },
|
|
134
134
|
{ text: "重启 ChatCCC(/restart)", value: JSON.stringify({ cmd: "restart" }), type: "danger" },
|
|
@@ -155,15 +155,15 @@ export function buildCdContent(
|
|
|
155
155
|
: "";
|
|
156
156
|
|
|
157
157
|
const statusLine = isUpdate
|
|
158
|
-
?
|
|
159
|
-
:
|
|
158
|
+
? `**本会话默认工作路径(已切换):** \`${dirPath}\``
|
|
159
|
+
: `**本会话默认工作路径:** \`${dirPath}\``;
|
|
160
160
|
|
|
161
161
|
const lines: string[] = [];
|
|
162
162
|
if (currentLine) lines.push(currentLine, "");
|
|
163
163
|
lines.push(
|
|
164
164
|
statusLine,
|
|
165
165
|
``,
|
|
166
|
-
|
|
166
|
+
`此路径仅影响**本会话中新建**的会话工作路径。`,
|
|
167
167
|
``,
|
|
168
168
|
`---`,
|
|
169
169
|
`**目录内容** (最多 ${maxFiles} 个):`,
|
|
@@ -205,7 +205,7 @@ export function buildCdCard(
|
|
|
205
205
|
}
|
|
206
206
|
elements.push({
|
|
207
207
|
tag: "div",
|
|
208
|
-
text: { tag: "lark_md", content:
|
|
208
|
+
text: { tag: "lark_md", content: `**本会话默认工作路径:** \`${dirPath}\`` },
|
|
209
209
|
});
|
|
210
210
|
|
|
211
211
|
if (recentDirs.length > 0) {
|
|
@@ -252,15 +252,15 @@ export function buildCdCard(
|
|
|
252
252
|
}
|
|
253
253
|
|
|
254
254
|
// 所有会话列表卡片(Claude Code 优先,然后 Cursor)
|
|
255
|
-
export function buildSessionsCard(sessions: Array<{
|
|
256
|
-
sessionId: string;
|
|
257
|
-
active: boolean;
|
|
258
|
-
turnCount: number;
|
|
259
|
-
elapsedSeconds: number | null;
|
|
260
|
-
model: string;
|
|
261
|
-
tool: string;
|
|
262
|
-
}>, opts: { defaultToolLabel?: string } = {}): string {
|
|
263
|
-
const defaultToolLabel = opts.defaultToolLabel ?? "Claude Code";
|
|
255
|
+
export function buildSessionsCard(sessions: Array<{
|
|
256
|
+
sessionId: string;
|
|
257
|
+
active: boolean;
|
|
258
|
+
turnCount: number;
|
|
259
|
+
elapsedSeconds: number | null;
|
|
260
|
+
model: string;
|
|
261
|
+
tool: string;
|
|
262
|
+
}>, opts: { defaultToolLabel?: string } = {}): string {
|
|
263
|
+
const defaultToolLabel = opts.defaultToolLabel ?? "Claude Code";
|
|
264
264
|
// 按 tool 分组排序:Claude Code 在前,Cursor 其次,Codex 最后
|
|
265
265
|
const claudeCodeSessions = sessions.filter(s => s.tool !== "cursor" && s.tool !== "codex");
|
|
266
266
|
const cursorSessions = sessions.filter(s => s.tool === "cursor");
|
|
@@ -274,7 +274,7 @@ export function buildSessionsCard(sessions: Array<{
|
|
|
274
274
|
config: { wide_screen_mode: true },
|
|
275
275
|
header: { template: "blue", title: { content: "所有会话", tag: "plain_text" } },
|
|
276
276
|
elements: [
|
|
277
|
-
{ tag: "div", text: { tag: "lark_md", content: `当前没有会话记录。\n\n使用 **/new**(默认 ${defaultToolLabel})、**/new claude**、**/new cursor** 或 **/new codex** 创建新会话。` } },
|
|
277
|
+
{ tag: "div", text: { tag: "lark_md", content: `当前没有会话记录。\n\n使用 **/new**(默认 ${defaultToolLabel})、**/new claude**、**/new cursor** 或 **/new codex** 创建新会话。` } },
|
|
278
278
|
{ tag: "hr" },
|
|
279
279
|
{ tag: "action", actions: [{ tag: "button", text: { tag: "plain_text", content: "收起" }, type: "default", value: { action: "close" } }] },
|
|
280
280
|
],
|
|
@@ -360,4 +360,4 @@ export function buildStatusCard(statusText: string, template = "blue"): string {
|
|
|
360
360
|
},
|
|
361
361
|
],
|
|
362
362
|
});
|
|
363
|
-
}
|
|
363
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -547,9 +547,11 @@ export function reloadConfigFromDisk(): void {
|
|
|
547
547
|
applyLoadedConfig(loadConfig());
|
|
548
548
|
}
|
|
549
549
|
|
|
550
|
-
// 新建会话的默认工作路径(/cd
|
|
550
|
+
// 新建会话的默认工作路径(/cd 命令设置,按 chatId 持久化到本地文件)
|
|
551
551
|
// 该路径仅影响通过 /new 新建的会话,不影响已有会话的 resume。
|
|
552
|
-
export
|
|
552
|
+
export function getDefaultCwdFile(chatId: string): string {
|
|
553
|
+
return join(USER_DATA_DIR, "state", `working_dir_${chatId}.txt`);
|
|
554
|
+
}
|
|
553
555
|
|
|
554
556
|
/** 会话工具类型持久化文件 */
|
|
555
557
|
export const SESSIONS_FILE = join(USER_DATA_DIR, "state", "sessions.json");
|
|
@@ -583,24 +585,26 @@ export async function addRecentDir(dir: string): Promise<void> {
|
|
|
583
585
|
}
|
|
584
586
|
}
|
|
585
587
|
|
|
586
|
-
/** 读取 /cd
|
|
587
|
-
export async function getDefaultCwd(): Promise<string> {
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
588
|
+
/** 读取 /cd 设置的默认工作路径。若 chatId 对应的文件不存在或路径已失效,回退到用户主目录。 */
|
|
589
|
+
export async function getDefaultCwd(chatId?: string): Promise<string> {
|
|
590
|
+
if (chatId) {
|
|
591
|
+
try {
|
|
592
|
+
const content = await readFile(getDefaultCwdFile(chatId), "utf-8");
|
|
593
|
+
const dir = content.trim();
|
|
594
|
+
if (dir) {
|
|
595
|
+
try {
|
|
596
|
+
const s = await stat(dir);
|
|
597
|
+
if (s.isDirectory()) return dir;
|
|
598
|
+
} catch { /* path gone, fall through */ }
|
|
599
|
+
}
|
|
600
|
+
} catch { /* file doesn't exist yet */ }
|
|
601
|
+
}
|
|
598
602
|
return homedir();
|
|
599
603
|
}
|
|
600
604
|
|
|
601
|
-
/** 设置新建会话的默认工作路径(由 /cd
|
|
602
|
-
export async function setDefaultCwd(dir: string): Promise<void> {
|
|
603
|
-
await writeFile(
|
|
605
|
+
/** 设置新建会话的默认工作路径(由 /cd 命令调用,按 chatId 持久化) */
|
|
606
|
+
export async function setDefaultCwd(dir: string, chatId: string): Promise<void> {
|
|
607
|
+
await writeFile(getDefaultCwdFile(chatId), dir, "utf-8");
|
|
604
608
|
}
|
|
605
609
|
|
|
606
610
|
// ---------------------------------------------------------------------------
|
package/src/feishu-api.ts
CHANGED
|
@@ -487,7 +487,7 @@ export async function getOrDownloadImage(token: string, messageId: string, fileK
|
|
|
487
487
|
const DELAY_NOTICE_THRESHOLD_MS = 15 * 60 * 1000; // 15 分钟
|
|
488
488
|
|
|
489
489
|
/** 消息延迟超过阈值时生成提醒文本,否则返回 null */
|
|
490
|
-
export function formatDelayNotice(createTimeMs: number, nowMs?: number): string | null {
|
|
490
|
+
export function formatDelayNotice(createTimeMs: number, messageText?: string, nowMs?: number): string | null {
|
|
491
491
|
const now = nowMs ?? Date.now();
|
|
492
492
|
const delayMs = now - createTimeMs;
|
|
493
493
|
if (delayMs < DELAY_NOTICE_THRESHOLD_MS) return null;
|
|
@@ -515,7 +515,8 @@ export function formatDelayNotice(createTimeMs: number, nowMs?: number): string
|
|
|
515
515
|
}
|
|
516
516
|
}
|
|
517
517
|
|
|
518
|
-
|
|
518
|
+
const contentLine = messageText ? `\n> 原始内容:${messageText.slice(0, 200)}` : "";
|
|
519
|
+
return `> ⚠️ 延迟送达提醒:此消息于 ${sendTimeStr} 发送,因服务离线,延迟约 ${delayStr}后送达${contentLine}`;
|
|
519
520
|
}
|
|
520
521
|
|
|
521
522
|
export async function sendTextReply(
|
package/src/index.ts
CHANGED
|
@@ -92,6 +92,7 @@ import {
|
|
|
92
92
|
getSessionStatus,
|
|
93
93
|
getAllSessionsStatus,
|
|
94
94
|
initClaudeSession,
|
|
95
|
+
lastMsgTimestamps,
|
|
95
96
|
processedMessages,
|
|
96
97
|
resetState,
|
|
97
98
|
resumeAndPrompt,
|
|
@@ -276,7 +277,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
276
277
|
if (text === "/cd" || text.startsWith("/cd ")) {
|
|
277
278
|
logTrace(tid, "BRANCH", { cmd: "/cd", arg: text.slice(3).trim() || "(none)" });
|
|
278
279
|
const cdToken = await getTenantAccessToken();
|
|
279
|
-
const currentDir = await getDefaultCwd();
|
|
280
|
+
const currentDir = await getDefaultCwd(chatId);
|
|
280
281
|
|
|
281
282
|
// 获取当前会话的实际工作路径(若在会话群内)
|
|
282
283
|
let sessionCwd: string | undefined;
|
|
@@ -319,7 +320,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
319
320
|
// Change working dir if user provided a path
|
|
320
321
|
const isUpdate = !!arg && targetDir !== currentDir;
|
|
321
322
|
if (isUpdate) {
|
|
322
|
-
await setDefaultCwd(targetDir);
|
|
323
|
+
await setDefaultCwd(targetDir, chatId);
|
|
323
324
|
await addRecentDir(targetDir);
|
|
324
325
|
}
|
|
325
326
|
|
|
@@ -396,7 +397,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
396
397
|
let sessionId: string;
|
|
397
398
|
let sessionCwd: string;
|
|
398
399
|
try {
|
|
399
|
-
const init = await initClaudeSession(tool);
|
|
400
|
+
const init = await initClaudeSession(tool, undefined, chatId);
|
|
400
401
|
sessionId = init.sessionId;
|
|
401
402
|
sessionCwd = init.cwd;
|
|
402
403
|
console.log(`[${ts()}] [STEP 1/4] ${toolLabel} session created: ${sessionId} → OK`);
|
|
@@ -477,7 +478,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
477
478
|
const prefix = text.slice(0, MAX_PREFIX);
|
|
478
479
|
const adapter = getAdapterForTool(descriptionTool);
|
|
479
480
|
const info = await adapter.getSessionInfo(sessionId).catch(() => undefined);
|
|
480
|
-
const sessionCwd = info?.cwd ?? (await getDefaultCwd());
|
|
481
|
+
const sessionCwd = info?.cwd ?? (await getDefaultCwd(chatId));
|
|
481
482
|
const newName = sessionChatName(prefix, sessionCwd);
|
|
482
483
|
try {
|
|
483
484
|
await updateChatInfo(freshToken, chatId, newName, description);
|
|
@@ -494,6 +495,10 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
494
495
|
cEntry.stopped = true;
|
|
495
496
|
if (cEntry.spinnerTimer) { clearInterval(cEntry.spinnerTimer); cEntry.spinnerTimer = null; }
|
|
496
497
|
cEntry.close();
|
|
498
|
+
const prevTs = lastMsgTimestamps.get(chatId);
|
|
499
|
+
if (prevTs === undefined || cEntry.msgTimestamp > prevTs) {
|
|
500
|
+
lastMsgTimestamps.set(chatId, cEntry.msgTimestamp);
|
|
501
|
+
}
|
|
497
502
|
console.log(`[${ts()}] [STOP] User sent /stop, session=${sessionId}`);
|
|
498
503
|
await sendTextReply(freshToken, chatId, "会话已停止。").catch(() => {});
|
|
499
504
|
logTrace(tid, "DONE", { outcome: "stopped" });
|
|
@@ -579,9 +584,9 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
579
584
|
let cwd: string;
|
|
580
585
|
try {
|
|
581
586
|
const info = await adapter.getSessionInfo(sessionId);
|
|
582
|
-
cwd = info?.cwd ?? (await getDefaultCwd());
|
|
587
|
+
cwd = info?.cwd ?? (await getDefaultCwd(chatId));
|
|
583
588
|
} catch {
|
|
584
|
-
cwd = await getDefaultCwd();
|
|
589
|
+
cwd = await getDefaultCwd(chatId);
|
|
585
590
|
}
|
|
586
591
|
|
|
587
592
|
const existing = chatSessionMap.get(chatId);
|
|
@@ -589,6 +594,10 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
589
594
|
existing.stopped = true;
|
|
590
595
|
if (existing.spinnerTimer) { clearInterval(existing.spinnerTimer); existing.spinnerTimer = null; }
|
|
591
596
|
existing.close();
|
|
597
|
+
const prevTs = lastMsgTimestamps.get(chatId);
|
|
598
|
+
if (prevTs === undefined || existing.msgTimestamp > prevTs) {
|
|
599
|
+
lastMsgTimestamps.set(chatId, existing.msgTimestamp);
|
|
600
|
+
}
|
|
592
601
|
chatSessionMap.delete(chatId);
|
|
593
602
|
}
|
|
594
603
|
|
|
@@ -678,6 +687,13 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
678
687
|
return;
|
|
679
688
|
}
|
|
680
689
|
|
|
690
|
+
const lastTs = lastMsgTimestamps.get(chatId);
|
|
691
|
+
if (lastTs !== undefined && msgTimestamp <= lastTs) {
|
|
692
|
+
logTrace(tid, "DONE", { outcome: "skip_old_message_no_session", msgTimestamp, lastTimestamp: lastTs });
|
|
693
|
+
console.log(`[${ts()}] [SKIP] Older message (${msgTimestamp} <= ${lastTs}), no active session, ignoring`);
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
|
|
681
697
|
const existing = chatSessionMap.get(chatId);
|
|
682
698
|
if (existing && !existing.stopped) {
|
|
683
699
|
if (msgTimestamp <= existing.msgTimestamp) {
|
|
@@ -688,6 +704,10 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
688
704
|
existing.stopped = true;
|
|
689
705
|
if (existing.spinnerTimer) { clearInterval(existing.spinnerTimer); existing.spinnerTimer = null; }
|
|
690
706
|
existing.close();
|
|
707
|
+
const prevTs = lastMsgTimestamps.get(chatId);
|
|
708
|
+
if (prevTs === undefined || existing.msgTimestamp > prevTs) {
|
|
709
|
+
lastMsgTimestamps.set(chatId, existing.msgTimestamp);
|
|
710
|
+
}
|
|
691
711
|
chatSessionMap.delete(chatId);
|
|
692
712
|
console.log(`[${ts()}] [INTERRUPT] New message arrived, cancelled previous session ${sessionId}`);
|
|
693
713
|
logTrace(tid, "INTERRUPT", { oldSessionId: sessionId });
|
|
@@ -886,7 +906,7 @@ async function startBotServiceCore(): Promise<void> {
|
|
|
886
906
|
if (!text) return;
|
|
887
907
|
const msgTimestamp = parseInt(message.create_time ?? "0", 10) || Date.now();
|
|
888
908
|
logTrace(traceId, "RECV", { chatId, chatType, text: text.slice(0, 100) });
|
|
889
|
-
const delayNotice = formatDelayNotice(msgTimestamp);
|
|
909
|
+
const delayNotice = formatDelayNotice(msgTimestamp, text);
|
|
890
910
|
if (delayNotice) {
|
|
891
911
|
const delayToken = await getTenantAccessToken();
|
|
892
912
|
await sendCardReply(delayToken, chatId, "延迟送达", delayNotice, "yellow").catch(() => {});
|
package/src/session.ts
CHANGED
|
@@ -50,6 +50,9 @@ import { buildImSkillsPrompt, exportSkillSubDocs } from "./im-skills.ts";
|
|
|
50
50
|
export const processedMessages = new Set<string>();
|
|
51
51
|
export const MAX_PROCESSED = 5000;
|
|
52
52
|
|
|
53
|
+
/** 每个 chatId 上一次已处理消息的时间戳,用于拦截延迟送达的旧消息 */
|
|
54
|
+
export const lastMsgTimestamps = new Map<string, number>();
|
|
55
|
+
|
|
53
56
|
export let sessionGen = 0;
|
|
54
57
|
export const chatSessionMap = new Map<string, {
|
|
55
58
|
gen: number;
|
|
@@ -93,6 +96,7 @@ export function resetState(): void {
|
|
|
93
96
|
chatSessionMap.clear();
|
|
94
97
|
sessionInfoMap.clear();
|
|
95
98
|
processedMessages.clear();
|
|
99
|
+
lastMsgTimestamps.clear();
|
|
96
100
|
console.log(`[${ts()}] [RESET] State cleared (dedup + active sessions)`);
|
|
97
101
|
}
|
|
98
102
|
|
|
@@ -289,8 +293,8 @@ function formatToolConfigForLog(tool: string, sessionModel?: string): string {
|
|
|
289
293
|
return `model=${anthropicConfigDisplay(CLAUDE_MODEL)}, effort=${anthropicConfigDisplay(CLAUDE_EFFORT)}`;
|
|
290
294
|
}
|
|
291
295
|
|
|
292
|
-
export async function initClaudeSession(tool: string, overrideCwd?: string): Promise<{ sessionId: string; cwd: string }> {
|
|
293
|
-
const cwd = overrideCwd ?? (await getDefaultCwd());
|
|
296
|
+
export async function initClaudeSession(tool: string, overrideCwd?: string, chatId?: string): Promise<{ sessionId: string; cwd: string }> {
|
|
297
|
+
const cwd = overrideCwd ?? (await getDefaultCwd(chatId));
|
|
294
298
|
const adapter = getAdapterForTool(tool);
|
|
295
299
|
console.log(
|
|
296
300
|
`[${ts()}] [STEP 1/5] Creating ${adapter.displayName} session (${formatToolConfigForLog(tool)}, cwd=${cwd})`
|
|
@@ -319,7 +323,7 @@ export async function resumeAndPrompt(
|
|
|
319
323
|
const tid = traceId ?? "";
|
|
320
324
|
const adapter = getAdapterForTool(tool);
|
|
321
325
|
const info = await adapter.getSessionInfo(sessionId);
|
|
322
|
-
const cwd = info?.cwd ?? (await getDefaultCwd());
|
|
326
|
+
const cwd = info?.cwd ?? (await getDefaultCwd(chatId));
|
|
323
327
|
if (tid) logTrace(tid, "SESSION_START", { sessionId, tool, cwd, turn: (sessionInfoMap.get(chatId)?.turnCount ?? 0) + 1 });
|
|
324
328
|
console.log(
|
|
325
329
|
`[${ts()}] Resuming ${adapter.displayName} session: ${sessionId} (${formatToolConfigForLog(tool, info?.model)}, cwd=${cwd})`
|
|
@@ -523,6 +527,10 @@ export async function resumeAndPrompt(
|
|
|
523
527
|
const cEntry = chatSessionMap.get(chatId);
|
|
524
528
|
if (!cEntry || cEntry.gen !== myGen) return;
|
|
525
529
|
const wasStopped = cEntry.stopped;
|
|
530
|
+
const prevTs = lastMsgTimestamps.get(chatId);
|
|
531
|
+
if (prevTs === undefined || cEntry.msgTimestamp > prevTs) {
|
|
532
|
+
lastMsgTimestamps.set(chatId, cEntry.msgTimestamp);
|
|
533
|
+
}
|
|
526
534
|
chatSessionMap.delete(chatId);
|
|
527
535
|
setChatAvatar(token, chatId, tool, "idle").catch(() => {});
|
|
528
536
|
|