chatccc 0.1.6 → 0.2.1

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/src/feishu-api.ts CHANGED
@@ -1,244 +1,377 @@
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
- export async function getTenantAccessToken(): Promise<string> {
20
- const resp = await fetch(`${BASE_URL}/auth/v3/tenant_access_token/internal`, {
21
- method: "POST",
22
- headers: { "Content-Type": "application/json" },
23
- body: JSON.stringify({ app_id: APP_ID, app_secret: APP_SECRET }),
24
- });
25
- const data = (await resp.json()) as { code: number; msg?: string; tenant_access_token: string };
26
- if (data.code !== 0) throw new Error(`Failed to get token: ${data.msg}`);
27
- return data.tenant_access_token;
28
- }
29
-
30
- // ---------------------------------------------------------------------------
31
- // Group chat CRUD
32
- // ---------------------------------------------------------------------------
33
-
34
- export async function createGroupChat(
35
- token: string,
36
- name: string,
37
- userIds: string[]
38
- ): Promise<string> {
39
- const resp = await fetch(`${BASE_URL}/im/v1/chats`, {
40
- method: "POST",
41
- headers: {
42
- Authorization: `Bearer ${token}`,
43
- "Content-Type": "application/json",
44
- },
45
- body: JSON.stringify({ name, description: "Creating...", user_id_list: userIds }),
46
- });
47
- const data = (await resp.json()) as {
48
- code: number; msg?: string; data?: { chat_id?: string };
49
- };
50
- if (data.code !== 0) throw new Error(`[${data.code}] ${data.msg}`);
51
- return data.data!.chat_id!;
52
- }
53
-
54
- export async function updateChatInfo(
55
- token: string,
56
- chatId: string,
57
- name: string,
58
- description: string
59
- ): Promise<void> {
60
- const resp = await fetch(`${BASE_URL}/im/v1/chats/${chatId}`, {
61
- method: "PUT",
62
- headers: {
63
- Authorization: `Bearer ${token}`,
64
- "Content-Type": "application/json",
65
- },
66
- body: JSON.stringify({ name, description }),
67
- });
68
- const data = (await resp.json()) as { code: number; msg?: string };
69
- if (data.code !== 0) throw new Error(`[${data.code}] ${data.msg}`);
70
- }
71
-
72
- export async function getChatInfo(
73
- token: string,
74
- chatId: string
75
- ): Promise<{ name: string; description: string }> {
76
- const resp = await fetch(`${BASE_URL}/im/v1/chats/${chatId}`, {
77
- headers: { Authorization: `Bearer ${token}` },
78
- });
79
- const data = (await resp.json()) as {
80
- code: number; msg?: string; data?: { name?: string; description?: string };
81
- };
82
- if (data.code !== 0) throw new Error(`[${data.code}] ${data.msg}`);
83
- return {
84
- name: data.data?.name ?? "",
85
- description: data.data?.description ?? "",
86
- };
87
- }
88
-
89
- export function extractSessionId(description: string): string | null {
90
- const idx = description.indexOf(SESSION_DESC_PREFIX);
91
- if (idx === -1) return null;
92
- const after = description.slice(idx + SESSION_DESC_PREFIX.length).trim();
93
- 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);
94
- return match ? match[1] : null;
95
- }
96
-
97
- // ---------------------------------------------------------------------------
98
- // Messaging
99
- // ---------------------------------------------------------------------------
100
-
101
- export async function sendTextReply(
102
- token: string,
103
- chatId: string,
104
- text: string
105
- ): Promise<void> {
106
- const card = JSON.stringify({
107
- config: { wide_screen_mode: true },
108
- elements: [{ tag: "markdown", content: text }],
109
- });
110
- await fetch(`${BASE_URL}/im/v1/messages?receive_id_type=chat_id`, {
111
- method: "POST",
112
- headers: {
113
- Authorization: `Bearer ${token}`,
114
- "Content-Type": "application/json",
115
- },
116
- body: JSON.stringify({
117
- receive_id: chatId,
118
- msg_type: "interactive",
119
- content: card,
120
- }),
121
- });
122
- }
123
-
124
- export async function addReaction(
125
- token: string,
126
- messageId: string,
127
- emojiType = "Get"
128
- ): Promise<void> {
129
- await fetch(`${BASE_URL}/im/v1/messages/${messageId}/reactions`, {
130
- method: "POST",
131
- headers: {
132
- Authorization: `Bearer ${token}`,
133
- "Content-Type": "application/json",
134
- },
135
- body: JSON.stringify({ reaction_type: { emoji_type: emojiType } }),
136
- });
137
- }
138
-
139
- export async function sendCardReply(
140
- token: string,
141
- chatId: string,
142
- title: string,
143
- content: string,
144
- template = "green"
145
- ): Promise<void> {
146
- const card = JSON.stringify({
147
- config: { wide_screen_mode: true },
148
- header: { template, title: { content: title, tag: "plain_text" } },
149
- elements: [{ tag: "div", text: { tag: "lark_md", content } }],
150
- });
151
-
152
- await fetch(`${BASE_URL}/im/v1/messages?receive_id_type=chat_id`, {
153
- method: "POST",
154
- headers: {
155
- Authorization: `Bearer ${token}`,
156
- "Content-Type": "application/json",
157
- },
158
- body: JSON.stringify({ receive_id: chatId, msg_type: "interactive", content: card }),
159
- });
160
- }
161
-
162
- // 重启后,向最后有发言的会话发送 "已重启" 卡片(基于 chat_logs 的文件修改时间)
163
- export async function sendRestartCard(token: string): Promise<void> {
164
- try {
165
- const files = await readdir(CHAT_LOGS_DIR).catch(() => [] as string[]);
166
- if (files.length === 0) {
167
- console.log(`[${ts()}] [RESTART] No chat logs found, skipping notification`);
168
- return;
169
- }
170
-
171
- let latestChatId: string | null = null;
172
- let latestTime = 0;
173
- for (const f of files) {
174
- if (!f.endsWith(".jsonl")) continue;
175
- const filePath = join(CHAT_LOGS_DIR, f);
176
- const st = await stat(filePath).catch(() => null);
177
- if (!st) continue;
178
- if (st.mtimeMs > latestTime) {
179
- latestTime = st.mtimeMs;
180
- latestChatId = f.replace(".jsonl", "");
181
- }
182
- }
183
-
184
- if (!latestChatId) {
185
- console.log(`[${ts()}] [RESTART] Could not determine latest chat with messages`);
186
- return;
187
- }
188
-
189
- console.log(`[${ts()}] [RESTART] Latest active chat: ${latestChatId} (mtime=${new Date(latestTime).toISOString()})`);
190
-
191
- const restartCard = JSON.stringify({
192
- config: { wide_screen_mode: true },
193
- header: { template: "green", title: { content: "ChatCCC Started", tag: "plain_text" } },
194
- elements: [
195
- { tag: "div", text: { tag: "lark_md", content: "Bot 已启动完成,可以继续使用。\n\n发送 **/new** 创建新会话,或直接在已有会话群中发消息。" } },
196
- buildButtons([
197
- { text: "新建会话(/new)", value: JSON.stringify({ cmd: "new" }), type: "primary" },
198
- { text: "重启Chat CCC(/restart)", value: JSON.stringify({ cmd: "restart" }), type: "danger" },
199
- { text: "查看/切换工作路径(/cd)", value: JSON.stringify({ cmd: "cd" }), type: "default" },
200
- ]),
201
- ],
202
- });
203
- await fetch(`${BASE_URL}/im/v1/messages?receive_id_type=chat_id`, {
204
- method: "POST",
205
- headers: {
206
- Authorization: `Bearer ${token}`,
207
- "Content-Type": "application/json",
208
- },
209
- body: JSON.stringify({ receive_id: latestChatId, msg_type: "interactive", content: restartCard }),
210
- });
211
- console.log(`[${ts()}] [RESTART] Notification sent to chat ${latestChatId}`);
212
- } catch (err) {
213
- console.error(`[${ts()}] [RESTART] Failed to send notification: ${(err as Error).message}`);
214
- }
215
- }
216
-
217
- // 撤回消息,成功返回 true
218
- export async function recallMessage(token: string, messageId: string): Promise<boolean> {
219
- const resp = await fetch(`${BASE_URL}/im/v1/messages/${messageId}`, {
220
- method: "DELETE",
221
- headers: { Authorization: `Bearer ${token}` },
222
- });
223
- const data = (await resp.json()) as { code: number };
224
- return data.code === 0;
225
- }
226
-
227
- // 更新卡片消息内容(PATCH 方式仅适用于 interactive 消息)
228
- // 返回 true 表示更新成功,false 表示 API 返回了错误
229
- export async function updateCardMessage(token: string, messageId: string, content: string): Promise<boolean> {
230
- const resp = await fetch(`${BASE_URL}/im/v1/messages/${messageId}`, {
231
- method: "PATCH",
232
- headers: {
233
- Authorization: `Bearer ${token}`,
234
- "Content-Type": "application/json",
235
- },
236
- body: JSON.stringify({ content }),
237
- });
238
- const data = await resp.json().catch(() => ({})) as { code: number; msg?: string };
239
- if (data.code !== 0) {
240
- console.error(`[${ts()}] [PATCH] updateCardMessage FAIL: messageId=${messageId} code=${data.code} msg="${data.msg}"`);
241
- return false;
242
- }
243
- return true;
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
+ // Permission verification (startup check)
37
+ // ---------------------------------------------------------------------------
38
+
39
+ interface PermissionDef {
40
+ scope: string;
41
+ description: string;
42
+ method: "GET" | "POST" | "PATCH" | "DELETE";
43
+ path: string;
44
+ body?: string; // JSON string, supports __TOKEN__ placeholder
45
+ }
46
+
47
+ /** 项目所需的所有飞书权限及对应的非破坏性测试请求 */
48
+ const REQUIRED_PERMISSIONS: PermissionDef[] = [
49
+ {
50
+ scope: "im:chat",
51
+ description: "读取/创建/更新群聊(创建会话群、改名、读群信息)",
52
+ method: "GET",
53
+ path: "/im/v1/chats?page_size=1",
54
+ },
55
+ {
56
+ scope: "im:message:send_as_bot",
57
+ description: "以机器人身份发送消息(文本/卡片回复)",
58
+ method: "POST",
59
+ path: "/im/v1/messages?receive_id_type=chat_id",
60
+ body: JSON.stringify({
61
+ receive_id: "oc_000000000000000000000000",
62
+ msg_type: "text",
63
+ content: JSON.stringify({ text: "permcheck" }),
64
+ }),
65
+ },
66
+ {
67
+ scope: "im:message:reaction",
68
+ description: "添加消息表情回应(Give a like)",
69
+ method: "POST",
70
+ path: "/im/v1/messages/om_000000000000000000000000/reactions",
71
+ body: JSON.stringify({ reaction_type: { emoji_type: "Get" } }),
72
+ },
73
+ {
74
+ scope: "im:message",
75
+ description: "撤回/更新消息(关闭卡片、撤回卡片消息)",
76
+ method: "PATCH",
77
+ path: "/im/v1/messages/om_000000000000000000000000",
78
+ body: JSON.stringify({ content: JSON.stringify({ elements: [{ tag: "markdown", content: " " }] }) }),
79
+ },
80
+ ];
81
+
82
+ interface PermissionResult {
83
+ scope: string;
84
+ description: string;
85
+ ok: boolean;
86
+ detail: string;
87
+ }
88
+
89
+ /** 对单个权限做非破坏性探针请求。返回 false 表示权限缺失。 */
90
+ async function probePermission(token: string, def: PermissionDef): Promise<PermissionResult> {
91
+ const url = `${BASE_URL}${def.path}`;
92
+ try {
93
+ const resp = await fetch(url, {
94
+ method: def.method,
95
+ headers: {
96
+ Authorization: `Bearer ${token}`,
97
+ "Content-Type": "application/json",
98
+ },
99
+ body: def.body ?? undefined,
100
+ });
101
+ const data = (await resp.json()) as { code: number; msg?: string };
102
+ if (data.code === 0) {
103
+ return { scope: def.scope, description: def.description, ok: true, detail: "通过" };
104
+ }
105
+ const msg = (data.msg ?? "").toLowerCase();
106
+ // 飞书权限缺失时 msg 通常含 "permission" / "scope" / "denied" / "unauthorized"
107
+ // 权限 OK 但资源不存在时 msg 含 "not found" / "chat" / "message" / "不存在"
108
+ const deniedKeywords = ["permission", "scope", "denied", "unauthorized", "无权限", "权限", "access denied"];
109
+ const isDenied = deniedKeywords.some((kw) => msg.includes(kw));
110
+ if (isDenied) {
111
+ return { scope: def.scope, description: def.description, ok: false, detail: `权限缺失 → code=${data.code} msg=${data.msg}` };
112
+ }
113
+ // 资源不存在 = 通过了权限检查,只是目标资源不存在(预期内)
114
+ return { scope: def.scope, description: def.description, ok: true, detail: `通过(资源不存在: code=${data.code} msg=${data.msg})` };
115
+ } catch (err) {
116
+ return { scope: def.scope, description: def.description, ok: false, detail: `网络异常: ${(err as Error).message}` };
117
+ }
118
+ }
119
+
120
+ /**
121
+ * 验证所有必需权限。返回失败的权限列表。
122
+ * 若全部通过返回空数组。
123
+ */
124
+ export async function verifyAllPermissions(token: string): Promise<PermissionResult[]> {
125
+ const results: PermissionResult[] = [];
126
+ for (const def of REQUIRED_PERMISSIONS) {
127
+ const r = await probePermission(token, def);
128
+ results.push(r);
129
+ }
130
+ return results;
131
+ }
132
+
133
+ /** 输出权限验证结果到控制台和文件日志,并返回是否有失败 */
134
+ export function reportPermissionResults(
135
+ results: PermissionResult[],
136
+ log: (msg: string) => void
137
+ ): boolean {
138
+ const failed = results.filter((r) => !r.ok);
139
+ const passed = results.filter((r) => r.ok);
140
+
141
+ log(`\n${"-".repeat(60)}`);
142
+ log(`[权限验证] 飞书应用权限检查完成: ${passed.length}/${results.length} 通过`);
143
+ for (const r of passed) {
144
+ log(` [通过] ${r.scope} — ${r.description}`);
145
+ }
146
+ for (const r of failed) {
147
+ log(` [失败] ${r.scope} ${r.description}`);
148
+ log(` 原因: ${r.detail}`);
149
+ }
150
+ if (failed.length > 0) {
151
+ log(`\n[权限验证] 以下权限未在飞书开放平台中配置:`);
152
+ for (const r of failed) {
153
+ log(` - ${r.scope}: ${r.description}`);
154
+ }
155
+ log(`\n请到飞书开放平台 应用详情 → 权限管理 中开通上述权限后重试。`);
156
+ log(`${"-".repeat(60)}\n`);
157
+ } else {
158
+ log(`${"-".repeat(60)}\n`);
159
+ }
160
+ return failed.length > 0;
161
+ }
162
+
163
+ // ---------------------------------------------------------------------------
164
+ // Group chat CRUD
165
+ // ---------------------------------------------------------------------------
166
+
167
+ export async function createGroupChat(
168
+ token: string,
169
+ name: string,
170
+ userIds: string[]
171
+ ): Promise<string> {
172
+ const resp = await fetch(`${BASE_URL}/im/v1/chats`, {
173
+ method: "POST",
174
+ headers: {
175
+ Authorization: `Bearer ${token}`,
176
+ "Content-Type": "application/json",
177
+ },
178
+ body: JSON.stringify({ name, description: "Creating...", user_id_list: userIds }),
179
+ });
180
+ const data = (await resp.json()) as {
181
+ code: number; msg?: string; data?: { chat_id?: string };
182
+ };
183
+ if (data.code !== 0) throw new Error(`[${data.code}] ${data.msg}`);
184
+ return data.data!.chat_id!;
185
+ }
186
+
187
+ export async function updateChatInfo(
188
+ token: string,
189
+ chatId: string,
190
+ name: string,
191
+ description: string
192
+ ): Promise<void> {
193
+ const resp = await fetch(`${BASE_URL}/im/v1/chats/${chatId}`, {
194
+ method: "PUT",
195
+ headers: {
196
+ Authorization: `Bearer ${token}`,
197
+ "Content-Type": "application/json",
198
+ },
199
+ body: JSON.stringify({ name, description }),
200
+ });
201
+ const data = (await resp.json()) as { code: number; msg?: string };
202
+ if (data.code !== 0) throw new Error(`[${data.code}] ${data.msg}`);
203
+ }
204
+
205
+ export async function getChatInfo(
206
+ token: string,
207
+ chatId: string
208
+ ): Promise<{ name: string; description: string }> {
209
+ const resp = await fetch(`${BASE_URL}/im/v1/chats/${chatId}`, {
210
+ headers: { Authorization: `Bearer ${token}` },
211
+ });
212
+ const data = (await resp.json()) as {
213
+ code: number; msg?: string; data?: { name?: string; description?: string };
214
+ };
215
+ if (data.code !== 0) throw new Error(`[${data.code}] ${data.msg}`);
216
+ return {
217
+ name: data.data?.name ?? "",
218
+ description: data.data?.description ?? "",
219
+ };
220
+ }
221
+
222
+ export function extractSessionId(description: string): string | null {
223
+ const idx = description.indexOf(SESSION_DESC_PREFIX);
224
+ if (idx === -1) return null;
225
+ const after = description.slice(idx + SESSION_DESC_PREFIX.length).trim();
226
+ 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);
227
+ return match ? match[1] : null;
228
+ }
229
+
230
+ // ---------------------------------------------------------------------------
231
+ // Messaging
232
+ // ---------------------------------------------------------------------------
233
+
234
+ export async function sendTextReply(
235
+ token: string,
236
+ chatId: string,
237
+ text: string
238
+ ): Promise<void> {
239
+ const card = JSON.stringify({
240
+ config: { wide_screen_mode: true },
241
+ elements: [{ tag: "markdown", content: text }],
242
+ });
243
+ await fetch(`${BASE_URL}/im/v1/messages?receive_id_type=chat_id`, {
244
+ method: "POST",
245
+ headers: {
246
+ Authorization: `Bearer ${token}`,
247
+ "Content-Type": "application/json",
248
+ },
249
+ body: JSON.stringify({
250
+ receive_id: chatId,
251
+ msg_type: "interactive",
252
+ content: card,
253
+ }),
254
+ });
255
+ }
256
+
257
+ export async function addReaction(
258
+ token: string,
259
+ messageId: string,
260
+ emojiType = "Get"
261
+ ): Promise<void> {
262
+ await fetch(`${BASE_URL}/im/v1/messages/${messageId}/reactions`, {
263
+ method: "POST",
264
+ headers: {
265
+ Authorization: `Bearer ${token}`,
266
+ "Content-Type": "application/json",
267
+ },
268
+ body: JSON.stringify({ reaction_type: { emoji_type: emojiType } }),
269
+ });
270
+ }
271
+
272
+ export async function sendCardReply(
273
+ token: string,
274
+ chatId: string,
275
+ title: string,
276
+ content: string,
277
+ template = "green"
278
+ ): Promise<void> {
279
+ const card = JSON.stringify({
280
+ config: { wide_screen_mode: true },
281
+ header: { template, title: { content: title, tag: "plain_text" } },
282
+ elements: [{ tag: "div", text: { tag: "lark_md", content } }],
283
+ });
284
+
285
+ await fetch(`${BASE_URL}/im/v1/messages?receive_id_type=chat_id`, {
286
+ method: "POST",
287
+ headers: {
288
+ Authorization: `Bearer ${token}`,
289
+ "Content-Type": "application/json",
290
+ },
291
+ body: JSON.stringify({ receive_id: chatId, msg_type: "interactive", content: card }),
292
+ });
293
+ }
294
+
295
+ // 重启后,向最后有发言的会话发送 "已重启" 卡片(基于 chat_logs 的文件修改时间)
296
+ export async function sendRestartCard(token: string): Promise<void> {
297
+ try {
298
+ const files = await readdir(CHAT_LOGS_DIR).catch(() => [] as string[]);
299
+ if (files.length === 0) {
300
+ console.log(`[${ts()}] [RESTART] No chat logs found, skipping notification`);
301
+ return;
302
+ }
303
+
304
+ let latestChatId: string | null = null;
305
+ let latestTime = 0;
306
+ for (const f of files) {
307
+ if (!f.endsWith(".jsonl")) continue;
308
+ const filePath = join(CHAT_LOGS_DIR, f);
309
+ const st = await stat(filePath).catch(() => null);
310
+ if (!st) continue;
311
+ if (st.mtimeMs > latestTime) {
312
+ latestTime = st.mtimeMs;
313
+ latestChatId = f.replace(".jsonl", "");
314
+ }
315
+ }
316
+
317
+ if (!latestChatId) {
318
+ console.log(`[${ts()}] [RESTART] Could not determine latest chat with messages`);
319
+ return;
320
+ }
321
+
322
+ console.log(`[${ts()}] [RESTART] Latest active chat: ${latestChatId} (mtime=${new Date(latestTime).toISOString()})`);
323
+
324
+ const restartCard = JSON.stringify({
325
+ config: { wide_screen_mode: true },
326
+ header: { template: "green", title: { content: "ChatCCC Started", tag: "plain_text" } },
327
+ elements: [
328
+ { tag: "div", text: { tag: "lark_md", content: "Bot 已启动完成,可以继续使用。\n\n发送 **/new** 创建新会话,或直接在已有会话群中发消息。" } },
329
+ buildButtons([
330
+ { text: "新建会话(/new)", value: JSON.stringify({ cmd: "new" }), type: "primary" },
331
+ { text: "重启Chat CCC(/restart)", value: JSON.stringify({ cmd: "restart" }), type: "danger" },
332
+ { text: "查看/切换工作路径(/cd)", value: JSON.stringify({ cmd: "cd" }), type: "default" },
333
+ ]),
334
+ ],
335
+ });
336
+ await fetch(`${BASE_URL}/im/v1/messages?receive_id_type=chat_id`, {
337
+ method: "POST",
338
+ headers: {
339
+ Authorization: `Bearer ${token}`,
340
+ "Content-Type": "application/json",
341
+ },
342
+ body: JSON.stringify({ receive_id: latestChatId, msg_type: "interactive", content: restartCard }),
343
+ });
344
+ console.log(`[${ts()}] [RESTART] Notification sent to chat ${latestChatId}`);
345
+ } catch (err) {
346
+ console.error(`[${ts()}] [RESTART] Failed to send notification: ${(err as Error).message}`);
347
+ }
348
+ }
349
+
350
+ // 撤回消息,成功返回 true
351
+ export async function recallMessage(token: string, messageId: string): Promise<boolean> {
352
+ const resp = await fetch(`${BASE_URL}/im/v1/messages/${messageId}`, {
353
+ method: "DELETE",
354
+ headers: { Authorization: `Bearer ${token}` },
355
+ });
356
+ const data = (await resp.json()) as { code: number };
357
+ return data.code === 0;
358
+ }
359
+
360
+ // 更新卡片消息内容(PATCH 方式仅适用于 interactive 消息)
361
+ // 返回 true 表示更新成功,false 表示 API 返回了错误
362
+ export async function updateCardMessage(token: string, messageId: string, content: string): Promise<boolean> {
363
+ const resp = await fetch(`${BASE_URL}/im/v1/messages/${messageId}`, {
364
+ method: "PATCH",
365
+ headers: {
366
+ Authorization: `Bearer ${token}`,
367
+ "Content-Type": "application/json",
368
+ },
369
+ body: JSON.stringify({ content }),
370
+ });
371
+ const data = await resp.json().catch(() => ({})) as { code: number; msg?: string };
372
+ if (data.code !== 0) {
373
+ console.error(`[${ts()}] [PATCH] updateCardMessage FAIL: messageId=${messageId} code=${data.code} msg="${data.msg}"`);
374
+ return false;
375
+ }
376
+ return true;
244
377
  }