chatccc 0.1.5 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -12
- package/bin/chatccc.mjs +26 -9
- package/package.json +2 -1
- package/src/cardkit.ts +157 -157
- package/src/cards.ts +214 -169
- package/src/config.ts +223 -80
- package/src/exit-banner.ts +23 -0
- package/src/feishu-api.ts +248 -243
- package/src/index.ts +708 -599
- package/src/session.ts +455 -409
- package/src/shared.ts +285 -185
package/src/feishu-api.ts
CHANGED
|
@@ -1,244 +1,249 @@
|
|
|
1
|
-
import { readdir, stat } from "node:fs/promises";
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
|
|
4
|
-
import {
|
|
5
|
-
APP_ID,
|
|
6
|
-
APP_SECRET,
|
|
7
|
-
BASE_URL,
|
|
8
|
-
CHAT_LOGS_DIR,
|
|
9
|
-
PROJECT_ROOT,
|
|
10
|
-
SESSION_DESC_PREFIX,
|
|
11
|
-
ts,
|
|
12
|
-
} from "./config.ts";
|
|
13
|
-
import { buildButtons } from "./cards.ts";
|
|
14
|
-
|
|
15
|
-
// ---------------------------------------------------------------------------
|
|
16
|
-
// Auth
|
|
17
|
-
// ---------------------------------------------------------------------------
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
};
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
}
|
|
243
|
-
|
|
1
|
+
import { readdir, stat } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
APP_ID,
|
|
6
|
+
APP_SECRET,
|
|
7
|
+
BASE_URL,
|
|
8
|
+
CHAT_LOGS_DIR,
|
|
9
|
+
PROJECT_ROOT,
|
|
10
|
+
SESSION_DESC_PREFIX,
|
|
11
|
+
ts,
|
|
12
|
+
} from "./config.ts";
|
|
13
|
+
import { buildButtons } from "./cards.ts";
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Auth
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
// 不缓存 token:飞书会在某些情况下(多实例同 APP_ID 反复签发、控制台重置 secret、限流回收等)
|
|
20
|
+
// 让旧 token 在标称有效期内被服务端提前失效。一旦缓存命中失效 token,进程在 TTL 内
|
|
21
|
+
// 所有飞书调用会持续返回 99991663。每次现签可让"被服务端干掉"的 token 自然被新 token 覆盖。
|
|
22
|
+
export async function getTenantAccessToken(): Promise<string> {
|
|
23
|
+
const resp = await fetch(`${BASE_URL}/auth/v3/tenant_access_token/internal`, {
|
|
24
|
+
method: "POST",
|
|
25
|
+
headers: { "Content-Type": "application/json" },
|
|
26
|
+
body: JSON.stringify({ app_id: APP_ID, app_secret: APP_SECRET }),
|
|
27
|
+
});
|
|
28
|
+
const data = (await resp.json()) as { code: number; msg?: string; tenant_access_token: string };
|
|
29
|
+
if (data.code !== 0) {
|
|
30
|
+
throw new Error(`飞书返回 code=${data.code},msg=${data.msg ?? "(无)"}(请核对 App ID / Secret 与应用发布状态)`);
|
|
31
|
+
}
|
|
32
|
+
return data.tenant_access_token;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Group chat CRUD
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
export async function createGroupChat(
|
|
40
|
+
token: string,
|
|
41
|
+
name: string,
|
|
42
|
+
userIds: string[]
|
|
43
|
+
): Promise<string> {
|
|
44
|
+
const resp = await fetch(`${BASE_URL}/im/v1/chats`, {
|
|
45
|
+
method: "POST",
|
|
46
|
+
headers: {
|
|
47
|
+
Authorization: `Bearer ${token}`,
|
|
48
|
+
"Content-Type": "application/json",
|
|
49
|
+
},
|
|
50
|
+
body: JSON.stringify({ name, description: "Creating...", user_id_list: userIds }),
|
|
51
|
+
});
|
|
52
|
+
const data = (await resp.json()) as {
|
|
53
|
+
code: number; msg?: string; data?: { chat_id?: string };
|
|
54
|
+
};
|
|
55
|
+
if (data.code !== 0) throw new Error(`[${data.code}] ${data.msg}`);
|
|
56
|
+
return data.data!.chat_id!;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function updateChatInfo(
|
|
60
|
+
token: string,
|
|
61
|
+
chatId: string,
|
|
62
|
+
name: string,
|
|
63
|
+
description: string
|
|
64
|
+
): Promise<void> {
|
|
65
|
+
const resp = await fetch(`${BASE_URL}/im/v1/chats/${chatId}`, {
|
|
66
|
+
method: "PUT",
|
|
67
|
+
headers: {
|
|
68
|
+
Authorization: `Bearer ${token}`,
|
|
69
|
+
"Content-Type": "application/json",
|
|
70
|
+
},
|
|
71
|
+
body: JSON.stringify({ name, description }),
|
|
72
|
+
});
|
|
73
|
+
const data = (await resp.json()) as { code: number; msg?: string };
|
|
74
|
+
if (data.code !== 0) throw new Error(`[${data.code}] ${data.msg}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function getChatInfo(
|
|
78
|
+
token: string,
|
|
79
|
+
chatId: string
|
|
80
|
+
): Promise<{ name: string; description: string }> {
|
|
81
|
+
const resp = await fetch(`${BASE_URL}/im/v1/chats/${chatId}`, {
|
|
82
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
83
|
+
});
|
|
84
|
+
const data = (await resp.json()) as {
|
|
85
|
+
code: number; msg?: string; data?: { name?: string; description?: string };
|
|
86
|
+
};
|
|
87
|
+
if (data.code !== 0) throw new Error(`[${data.code}] ${data.msg}`);
|
|
88
|
+
return {
|
|
89
|
+
name: data.data?.name ?? "",
|
|
90
|
+
description: data.data?.description ?? "",
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function extractSessionId(description: string): string | null {
|
|
95
|
+
const idx = description.indexOf(SESSION_DESC_PREFIX);
|
|
96
|
+
if (idx === -1) return null;
|
|
97
|
+
const after = description.slice(idx + SESSION_DESC_PREFIX.length).trim();
|
|
98
|
+
const match = after.match(/^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i);
|
|
99
|
+
return match ? match[1] : null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// Messaging
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
export async function sendTextReply(
|
|
107
|
+
token: string,
|
|
108
|
+
chatId: string,
|
|
109
|
+
text: string
|
|
110
|
+
): Promise<void> {
|
|
111
|
+
const card = JSON.stringify({
|
|
112
|
+
config: { wide_screen_mode: true },
|
|
113
|
+
elements: [{ tag: "markdown", content: text }],
|
|
114
|
+
});
|
|
115
|
+
await fetch(`${BASE_URL}/im/v1/messages?receive_id_type=chat_id`, {
|
|
116
|
+
method: "POST",
|
|
117
|
+
headers: {
|
|
118
|
+
Authorization: `Bearer ${token}`,
|
|
119
|
+
"Content-Type": "application/json",
|
|
120
|
+
},
|
|
121
|
+
body: JSON.stringify({
|
|
122
|
+
receive_id: chatId,
|
|
123
|
+
msg_type: "interactive",
|
|
124
|
+
content: card,
|
|
125
|
+
}),
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function addReaction(
|
|
130
|
+
token: string,
|
|
131
|
+
messageId: string,
|
|
132
|
+
emojiType = "Get"
|
|
133
|
+
): Promise<void> {
|
|
134
|
+
await fetch(`${BASE_URL}/im/v1/messages/${messageId}/reactions`, {
|
|
135
|
+
method: "POST",
|
|
136
|
+
headers: {
|
|
137
|
+
Authorization: `Bearer ${token}`,
|
|
138
|
+
"Content-Type": "application/json",
|
|
139
|
+
},
|
|
140
|
+
body: JSON.stringify({ reaction_type: { emoji_type: emojiType } }),
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function sendCardReply(
|
|
145
|
+
token: string,
|
|
146
|
+
chatId: string,
|
|
147
|
+
title: string,
|
|
148
|
+
content: string,
|
|
149
|
+
template = "green"
|
|
150
|
+
): Promise<void> {
|
|
151
|
+
const card = JSON.stringify({
|
|
152
|
+
config: { wide_screen_mode: true },
|
|
153
|
+
header: { template, title: { content: title, tag: "plain_text" } },
|
|
154
|
+
elements: [{ tag: "div", text: { tag: "lark_md", content } }],
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
await fetch(`${BASE_URL}/im/v1/messages?receive_id_type=chat_id`, {
|
|
158
|
+
method: "POST",
|
|
159
|
+
headers: {
|
|
160
|
+
Authorization: `Bearer ${token}`,
|
|
161
|
+
"Content-Type": "application/json",
|
|
162
|
+
},
|
|
163
|
+
body: JSON.stringify({ receive_id: chatId, msg_type: "interactive", content: card }),
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// 重启后,向最后有发言的会话发送 "已重启" 卡片(基于 chat_logs 的文件修改时间)
|
|
168
|
+
export async function sendRestartCard(token: string): Promise<void> {
|
|
169
|
+
try {
|
|
170
|
+
const files = await readdir(CHAT_LOGS_DIR).catch(() => [] as string[]);
|
|
171
|
+
if (files.length === 0) {
|
|
172
|
+
console.log(`[${ts()}] [RESTART] No chat logs found, skipping notification`);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
let latestChatId: string | null = null;
|
|
177
|
+
let latestTime = 0;
|
|
178
|
+
for (const f of files) {
|
|
179
|
+
if (!f.endsWith(".jsonl")) continue;
|
|
180
|
+
const filePath = join(CHAT_LOGS_DIR, f);
|
|
181
|
+
const st = await stat(filePath).catch(() => null);
|
|
182
|
+
if (!st) continue;
|
|
183
|
+
if (st.mtimeMs > latestTime) {
|
|
184
|
+
latestTime = st.mtimeMs;
|
|
185
|
+
latestChatId = f.replace(".jsonl", "");
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (!latestChatId) {
|
|
190
|
+
console.log(`[${ts()}] [RESTART] Could not determine latest chat with messages`);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
console.log(`[${ts()}] [RESTART] Latest active chat: ${latestChatId} (mtime=${new Date(latestTime).toISOString()})`);
|
|
195
|
+
|
|
196
|
+
const restartCard = JSON.stringify({
|
|
197
|
+
config: { wide_screen_mode: true },
|
|
198
|
+
header: { template: "green", title: { content: "ChatCCC Started", tag: "plain_text" } },
|
|
199
|
+
elements: [
|
|
200
|
+
{ tag: "div", text: { tag: "lark_md", content: "Bot 已启动完成,可以继续使用。\n\n发送 **/new** 创建新会话,或直接在已有会话群中发消息。" } },
|
|
201
|
+
buildButtons([
|
|
202
|
+
{ text: "新建会话(/new)", value: JSON.stringify({ cmd: "new" }), type: "primary" },
|
|
203
|
+
{ text: "重启Chat CCC(/restart)", value: JSON.stringify({ cmd: "restart" }), type: "danger" },
|
|
204
|
+
{ text: "查看/切换工作路径(/cd)", value: JSON.stringify({ cmd: "cd" }), type: "default" },
|
|
205
|
+
]),
|
|
206
|
+
],
|
|
207
|
+
});
|
|
208
|
+
await fetch(`${BASE_URL}/im/v1/messages?receive_id_type=chat_id`, {
|
|
209
|
+
method: "POST",
|
|
210
|
+
headers: {
|
|
211
|
+
Authorization: `Bearer ${token}`,
|
|
212
|
+
"Content-Type": "application/json",
|
|
213
|
+
},
|
|
214
|
+
body: JSON.stringify({ receive_id: latestChatId, msg_type: "interactive", content: restartCard }),
|
|
215
|
+
});
|
|
216
|
+
console.log(`[${ts()}] [RESTART] Notification sent to chat ${latestChatId}`);
|
|
217
|
+
} catch (err) {
|
|
218
|
+
console.error(`[${ts()}] [RESTART] Failed to send notification: ${(err as Error).message}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// 撤回消息,成功返回 true
|
|
223
|
+
export async function recallMessage(token: string, messageId: string): Promise<boolean> {
|
|
224
|
+
const resp = await fetch(`${BASE_URL}/im/v1/messages/${messageId}`, {
|
|
225
|
+
method: "DELETE",
|
|
226
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
227
|
+
});
|
|
228
|
+
const data = (await resp.json()) as { code: number };
|
|
229
|
+
return data.code === 0;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// 更新卡片消息内容(PATCH 方式仅适用于 interactive 消息)
|
|
233
|
+
// 返回 true 表示更新成功,false 表示 API 返回了错误
|
|
234
|
+
export async function updateCardMessage(token: string, messageId: string, content: string): Promise<boolean> {
|
|
235
|
+
const resp = await fetch(`${BASE_URL}/im/v1/messages/${messageId}`, {
|
|
236
|
+
method: "PATCH",
|
|
237
|
+
headers: {
|
|
238
|
+
Authorization: `Bearer ${token}`,
|
|
239
|
+
"Content-Type": "application/json",
|
|
240
|
+
},
|
|
241
|
+
body: JSON.stringify({ content }),
|
|
242
|
+
});
|
|
243
|
+
const data = await resp.json().catch(() => ({})) as { code: number; msg?: string };
|
|
244
|
+
if (data.code !== 0) {
|
|
245
|
+
console.error(`[${ts()}] [PATCH] updateCardMessage FAIL: messageId=${messageId} code=${data.code} msg="${data.msg}"`);
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
return true;
|
|
244
249
|
}
|