chatccc 0.2.58 → 0.2.59
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/config.sample.json +8 -8
- package/im-skills/wechat-skill/receive-send-image.md +39 -0
- package/im-skills/wechat-skill/send-image.mjs +80 -0
- package/im-skills/wechat-skill/skill.md +11 -0
- package/package.json +59 -59
- package/src/__tests__/claude-adapter.test.ts +48 -48
- package/src/__tests__/config-reload.test.ts +14 -14
- package/src/__tests__/config-sample.test.ts +22 -22
- package/src/__tests__/orchestrator.test.ts +168 -168
- package/src/__tests__/web-ui.test.ts +23 -23
- package/src/__tests__/wechat-platform.test.ts +44 -2
- package/src/adapters/claude-adapter.ts +40 -40
- package/src/index.ts +17 -17
- package/src/orchestrator.ts +31 -31
- package/src/session.ts +2 -0
- package/src/web-ui.ts +9 -5
- package/src/wechat-platform.ts +141 -16
package/src/orchestrator.ts
CHANGED
|
@@ -72,21 +72,21 @@ export function cwdDisplayName(cwd: string): string {
|
|
|
72
72
|
return trimmed.split(/[\\/]/).filter(Boolean).pop() || trimmed || "cwd";
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
export function sessionChatName(left: string, cwd: string): string {
|
|
76
|
-
return `${left}-${cwdDisplayName(cwd)}`;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function isUntitledSessionChatName(name: string): boolean {
|
|
80
|
-
return name === "新会话" || name.startsWith("新会话-");
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function shouldSendWechatProcessingAck(
|
|
84
|
-
platform: PlatformAdapter,
|
|
85
|
-
textLower: string,
|
|
86
|
-
chatType: string,
|
|
87
|
-
): boolean {
|
|
88
|
-
return platform.kind === "wechat" && chatType === "p2p" && !textLower.startsWith("/");
|
|
89
|
-
}
|
|
75
|
+
export function sessionChatName(left: string, cwd: string): string {
|
|
76
|
+
return `${left}-${cwdDisplayName(cwd)}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function isUntitledSessionChatName(name: string): boolean {
|
|
80
|
+
return name === "新会话" || name.startsWith("新会话-");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function shouldSendWechatProcessingAck(
|
|
84
|
+
platform: PlatformAdapter,
|
|
85
|
+
textLower: string,
|
|
86
|
+
chatType: string,
|
|
87
|
+
): boolean {
|
|
88
|
+
return platform.kind === "wechat" && chatType === "p2p" && !textLower.startsWith("/");
|
|
89
|
+
}
|
|
90
90
|
|
|
91
91
|
// ---------------------------------------------------------------------------
|
|
92
92
|
// handleCommand — 平台无关的命令分发
|
|
@@ -979,11 +979,11 @@ export async function handleCommand(
|
|
|
979
979
|
}
|
|
980
980
|
|
|
981
981
|
// 并发检查:同一 session 只能有一个活跃 prompt
|
|
982
|
-
if (isSessionRunning(sessionId)) {
|
|
983
|
-
logTrace(tid, "BLOCKED", {
|
|
984
|
-
outcome: "session_busy",
|
|
985
|
-
sessionId,
|
|
986
|
-
});
|
|
982
|
+
if (isSessionRunning(sessionId)) {
|
|
983
|
+
logTrace(tid, "BLOCKED", {
|
|
984
|
+
outcome: "session_busy",
|
|
985
|
+
sessionId,
|
|
986
|
+
});
|
|
987
987
|
console.log(
|
|
988
988
|
`[${ts()}] [BLOCKED] Session ${sessionId} is already generating, rejecting message from chat ${chatId}`,
|
|
989
989
|
);
|
|
@@ -992,17 +992,17 @@ export async function handleCommand(
|
|
|
992
992
|
"生成中",
|
|
993
993
|
"该会话正在生成回复中,请等待完成后再发送新消息。",
|
|
994
994
|
"yellow",
|
|
995
|
-
);
|
|
996
|
-
return;
|
|
997
|
-
}
|
|
998
|
-
|
|
999
|
-
if (shouldSendWechatProcessingAck(platform, textLower, chatType)) {
|
|
1000
|
-
await platform.sendText(chatId, "生成中...").catch(() => {});
|
|
1001
|
-
}
|
|
1002
|
-
|
|
1003
|
-
try {
|
|
1004
|
-
logTrace(tid, "RESUME", { sessionId, tool: descriptionTool });
|
|
1005
|
-
await resumeAndPrompt(
|
|
995
|
+
);
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
if (shouldSendWechatProcessingAck(platform, textLower, chatType)) {
|
|
1000
|
+
await platform.sendText(chatId, "生成中...").catch(() => {});
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
try {
|
|
1004
|
+
logTrace(tid, "RESUME", { sessionId, tool: descriptionTool });
|
|
1005
|
+
await resumeAndPrompt(
|
|
1006
1006
|
sessionId,
|
|
1007
1007
|
text,
|
|
1008
1008
|
platform,
|
package/src/session.ts
CHANGED
|
@@ -698,6 +698,7 @@ export async function runAgentSession(
|
|
|
698
698
|
|
|
699
699
|
// 构建 IM skills prompt(sessionId 方式,无 token)
|
|
700
700
|
const feishuSkillDir = join(PROJECT_ROOT, "im-skills", "feishu-skill");
|
|
701
|
+
const wechatSkillDir = join(PROJECT_ROOT, "im-skills", "wechat-skill");
|
|
701
702
|
const imSkillsCacheDir = join(USER_DATA_DIR, "im-skills");
|
|
702
703
|
const skillVariables = {
|
|
703
704
|
cwd,
|
|
@@ -708,6 +709,7 @@ export async function runAgentSession(
|
|
|
708
709
|
send_image_script: join(feishuSkillDir, "send-image.mjs"),
|
|
709
710
|
send_file_script: join(feishuSkillDir, "send-file.mjs"),
|
|
710
711
|
download_video_script: join(feishuSkillDir, "download-video.mjs"),
|
|
712
|
+
wechat_send_image_script: join(wechatSkillDir, "send-image.mjs"),
|
|
711
713
|
};
|
|
712
714
|
var imSkillsPrompt = await buildImSkillsPrompt({ variables: skillVariables });
|
|
713
715
|
await exportSkillSubDocs({ variables: skillVariables }, imSkillsCacheDir);
|
package/src/web-ui.ts
CHANGED
|
@@ -270,9 +270,8 @@ async function handleApiCheck(_req: IncomingMessage, res: ServerResponse): Promi
|
|
|
270
270
|
if (hasConfig) {
|
|
271
271
|
const c = loadConfig();
|
|
272
272
|
const feishuEnabled = c.platforms?.feishu?.enabled !== false; // 默认 true(向后兼容)
|
|
273
|
-
const ilinkEnabled = c.platforms?.ilink?.enabled !== false;
|
|
274
273
|
const feishuOk = feishuEnabled && Boolean(c.feishu?.appId?.trim() && c.feishu?.appSecret?.trim());
|
|
275
|
-
hasCreds = feishuOk ||
|
|
274
|
+
hasCreds = feishuOk || !feishuEnabled;
|
|
276
275
|
}
|
|
277
276
|
jsonReply(res, 200, { hasConfig, hasCreds, configPath: CONFIG_FILE });
|
|
278
277
|
}
|
|
@@ -1198,9 +1197,14 @@ function renderStep1() {
|
|
|
1198
1197
|
var f = c.feishu || {};
|
|
1199
1198
|
prefillNested('field-CHATCCC_APP_ID', f.appId);
|
|
1200
1199
|
prefillNested('field-CHATCCC_APP_SECRET', f.appSecret);
|
|
1201
|
-
// 平台开关:按已有 config
|
|
1202
|
-
var
|
|
1203
|
-
var
|
|
1200
|
+
// 平台开关:按已有 config 回填;首次配置(无飞书凭证)时默认关闭飞书、开启微信
|
|
1201
|
+
var hasExistingCreds = Boolean(c.feishu?.appId?.trim() && c.feishu?.appSecret?.trim());
|
|
1202
|
+
var feishuEnabled = hasExistingCreds
|
|
1203
|
+
? (c.platforms?.feishu?.enabled !== false)
|
|
1204
|
+
: false;
|
|
1205
|
+
var ilinkEnabled = hasExistingCreds
|
|
1206
|
+
? (c.platforms?.ilink?.enabled !== false)
|
|
1207
|
+
: true;
|
|
1204
1208
|
state.platformsEnabled = { feishu: feishuEnabled, ilink: ilinkEnabled };
|
|
1205
1209
|
var feToggle = document.getElementById('platform-enable-feishu');
|
|
1206
1210
|
if (feToggle) feToggle.checked = feishuEnabled;
|
package/src/wechat-platform.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
12
12
|
import { createRequire } from "node:module";
|
|
13
|
-
import { dirname, join } from "node:path";
|
|
13
|
+
import { dirname, extname, join } from "node:path";
|
|
14
14
|
import { homedir } from "node:os";
|
|
15
15
|
|
|
16
16
|
import {
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
type GetUpdatesResponse,
|
|
20
20
|
type WeixinMessage,
|
|
21
21
|
} from "@openilink/openilink-sdk-node";
|
|
22
|
+
import type { CDNMedia, ImageItem } from "@openilink/openilink-sdk-node";
|
|
22
23
|
|
|
23
24
|
import type { PlatformAdapter } from "./platform-adapter.ts";
|
|
24
25
|
import { setupFileLogging } from "./shared.ts";
|
|
@@ -56,10 +57,17 @@ const { logPath: WECHAT_LOG_PATH } =
|
|
|
56
57
|
setupFileLogging(ILINK_LOG_DIR, "wechat");
|
|
57
58
|
|
|
58
59
|
let ilinkWire: OpenIlinkWire | null = null;
|
|
60
|
+
|
|
61
|
+
/** 获取当前 iLink wire 实例(供外部脚本/测试使用) */
|
|
62
|
+
export function getIlinkWire(): OpenIlinkWire | null {
|
|
63
|
+
return ilinkWire;
|
|
64
|
+
}
|
|
59
65
|
/** chatId → 最新 context_token */
|
|
60
66
|
const contextTokenMap = new Map<string, string>();
|
|
61
67
|
/** chatId → 用户未回复时已连发消息数 */
|
|
62
68
|
const consecutiveSendCount = new Map<string, number>();
|
|
69
|
+
/** chatId → claw 限制后等待用户唤醒再补发的最终消息 */
|
|
70
|
+
const pendingClawFinalText = new Map<string, string>();
|
|
63
71
|
const textCardMap = new Map<string, { chatId?: string; text: string; lastSentText: string; lastSentAt: number }>();
|
|
64
72
|
let textCardSeq = 0;
|
|
65
73
|
let platformLog: (msg: string) => void = () => {};
|
|
@@ -87,6 +95,49 @@ function compressGeneratingText(text: string): string {
|
|
|
87
95
|
return [...lines.slice(0, 5), "...", ...lines.slice(-5)].join("\n");
|
|
88
96
|
}
|
|
89
97
|
|
|
98
|
+
async function sendWechatTextRaw(wire: WechatWireSender, chatId: string, text: string): Promise<void> {
|
|
99
|
+
const contextToken = contextTokenMap.get(chatId);
|
|
100
|
+
if (contextToken) {
|
|
101
|
+
await wire.sendText(chatId, text, contextToken);
|
|
102
|
+
} else {
|
|
103
|
+
await wire.push(chatId, text);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function flushPendingClawFinalText(
|
|
108
|
+
chatId: string,
|
|
109
|
+
wire: WechatWireSender | null,
|
|
110
|
+
log: (msg: string) => void,
|
|
111
|
+
): Promise<boolean> {
|
|
112
|
+
const text = pendingClawFinalText.get(chatId);
|
|
113
|
+
if (!text || !wire) return false;
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
await sendWechatTextRaw(wire, chatId, text);
|
|
117
|
+
pendingClawFinalText.delete(chatId);
|
|
118
|
+
consecutiveSendCount.set(chatId, 1);
|
|
119
|
+
log(`[WECHAT] pending final sent after claw wake: chatId=${chatId} len=${text.length}`);
|
|
120
|
+
return true;
|
|
121
|
+
} catch (err) {
|
|
122
|
+
log(`[WECHAT] pending final send failed: ${(err as Error).message}`);
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function _resetWechatClawStateForTest(): void {
|
|
128
|
+
consecutiveSendCount.clear();
|
|
129
|
+
pendingClawFinalText.clear();
|
|
130
|
+
contextTokenMap.clear();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function _flushPendingClawFinalTextForTest(
|
|
134
|
+
chatId: string,
|
|
135
|
+
wire: WechatWireSender | null,
|
|
136
|
+
log: (msg: string) => void = () => {},
|
|
137
|
+
): Promise<boolean> {
|
|
138
|
+
return flushPendingClawFinalText(chatId, wire, log);
|
|
139
|
+
}
|
|
140
|
+
|
|
90
141
|
// ---------------------------------------------------------------------------
|
|
91
142
|
// Snapshot 持久化
|
|
92
143
|
// ---------------------------------------------------------------------------
|
|
@@ -160,19 +211,20 @@ export function createWechatAdapter(
|
|
|
160
211
|
text = text + "\n━━ 后台工作中,由于微信claw机制限制,请唤醒我才能继续发送消息";
|
|
161
212
|
}
|
|
162
213
|
|
|
163
|
-
// 超过10
|
|
164
|
-
if (count > 10
|
|
165
|
-
|
|
166
|
-
|
|
214
|
+
// 超过10条后不再直接发送。微信端 claw 可能会静默丢弃,即使 iLink 返回 OK。
|
|
215
|
+
if (count > 10) {
|
|
216
|
+
if (isFinal) {
|
|
217
|
+
pendingClawFinalText.set(chatId, text);
|
|
218
|
+
log(`[WECHAT] final queued (claw limit): chatId=${chatId} count=${count} len=${text.length}`);
|
|
219
|
+
return true;
|
|
220
|
+
} else {
|
|
221
|
+
log(`[WECHAT] sendText skipped (claw limit): chatId=${chatId} count=${count}`);
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
167
224
|
}
|
|
168
225
|
|
|
169
226
|
try {
|
|
170
|
-
|
|
171
|
-
if (contextToken) {
|
|
172
|
-
await wire.sendText(chatId, text, contextToken);
|
|
173
|
-
} else {
|
|
174
|
-
await wire.push(chatId, text);
|
|
175
|
-
}
|
|
227
|
+
await sendWechatTextRaw(wire, chatId, text);
|
|
176
228
|
const preview = text.length > 60 ? text.slice(0, 60) + "..." : text;
|
|
177
229
|
log(`[WECHAT] sendText OK: chatId=${chatId} len=${text.length} count=${count} text="${preview}"`);
|
|
178
230
|
return true;
|
|
@@ -467,6 +519,44 @@ export async function startWechatPlatform(
|
|
|
467
519
|
);
|
|
468
520
|
}
|
|
469
521
|
|
|
522
|
+
const WECHAT_IMAGE_DOWNLOAD_DIR = join(homedir(), ".chatccc", "images", "downloads");
|
|
523
|
+
|
|
524
|
+
function extFromMimeOrName(mime?: string | null, fileName?: string | null): string {
|
|
525
|
+
if (mime) {
|
|
526
|
+
const map: Record<string, string> = {
|
|
527
|
+
"image/png": ".png",
|
|
528
|
+
"image/jpeg": ".jpg",
|
|
529
|
+
"image/webp": ".webp",
|
|
530
|
+
"image/gif": ".gif",
|
|
531
|
+
"image/bmp": ".bmp",
|
|
532
|
+
"image/svg+xml": ".svg",
|
|
533
|
+
};
|
|
534
|
+
const key = mime.split(";")[0].trim().toLowerCase();
|
|
535
|
+
if (map[key]) return map[key];
|
|
536
|
+
}
|
|
537
|
+
if (fileName) {
|
|
538
|
+
const ext = extname(fileName).toLowerCase();
|
|
539
|
+
if (ext) return ext;
|
|
540
|
+
}
|
|
541
|
+
return ".png";
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
async function downloadWechatImage(imageItem: ImageItem, msgId?: number): Promise<string> {
|
|
545
|
+
const wire = ilinkWire;
|
|
546
|
+
if (!wire) throw new Error("iLink wire not available");
|
|
547
|
+
if (!imageItem.media) throw new Error("image item has no media");
|
|
548
|
+
|
|
549
|
+
const data = await wire.downloadMedia(imageItem.media);
|
|
550
|
+
const mime = (imageItem as Record<string, unknown>).mime_type as string | undefined;
|
|
551
|
+
const ext = extFromMimeOrName(mime);
|
|
552
|
+
const key = imageItem.media.aes_key?.slice(0, 16) ?? (msgId?.toString() ?? Date.now().toString());
|
|
553
|
+
await mkdirSync(WECHAT_IMAGE_DOWNLOAD_DIR, { recursive: true });
|
|
554
|
+
const localPath = join(WECHAT_IMAGE_DOWNLOAD_DIR, `wx_${key}${ext}`);
|
|
555
|
+
writeFileSync(localPath, data);
|
|
556
|
+
platformLog(`图片已下载: ${localPath}`);
|
|
557
|
+
return localPath;
|
|
558
|
+
}
|
|
559
|
+
|
|
470
560
|
async function handleWechatMessage(
|
|
471
561
|
message: WeixinMessage,
|
|
472
562
|
handler: MessageHandler,
|
|
@@ -492,17 +582,52 @@ async function handleWechatMessage(
|
|
|
492
582
|
const text = extractText(message).trim();
|
|
493
583
|
const msgTimestamp = message.create_time_ms ?? Date.now();
|
|
494
584
|
|
|
585
|
+
// 检测并下载图片
|
|
586
|
+
const imagePaths: string[] = [];
|
|
587
|
+
const items = message.item_list;
|
|
588
|
+
if (items) {
|
|
589
|
+
for (const item of items) {
|
|
590
|
+
if (item.image_item?.media) {
|
|
591
|
+
try {
|
|
592
|
+
const localPath = await downloadWechatImage(item.image_item, message.message_id);
|
|
593
|
+
imagePaths.push(localPath);
|
|
594
|
+
} catch (err) {
|
|
595
|
+
platformLog(`图片下载失败: ${(err as Error).message}`);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// 构建消息文本:文本内容 + 图片路径
|
|
602
|
+
let fullText = text;
|
|
603
|
+
if (imagePaths.length > 0) {
|
|
604
|
+
const imageLines = imagePaths.map((p) => `[图片] ${p}`).join("\n");
|
|
605
|
+
fullText = fullText ? `${fullText}\n${imageLines}` : imageLines;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// 纯图片且无文字时跳过(避免空消息触发会话)
|
|
609
|
+
if (!fullText.trim()) {
|
|
610
|
+
platformLog(`跳过纯媒体消息(无文本): chatId=${chatId}`);
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
|
|
495
614
|
platformLog(
|
|
496
|
-
`收到消息: chatId=${chatId} text="${text.slice(0, 80)}"`,
|
|
615
|
+
`收到消息: chatId=${chatId} text="${text.slice(0, 80)}" images=${imagePaths.length}`,
|
|
497
616
|
);
|
|
498
|
-
appendChatLog(chatId, chatId,
|
|
617
|
+
appendChatLog(chatId, chatId, fullText);
|
|
499
618
|
|
|
500
619
|
// 用户回复,重置 claw 连发计数
|
|
501
620
|
consecutiveSendCount.set(chatId, 0);
|
|
502
621
|
|
|
503
|
-
//
|
|
504
|
-
|
|
505
|
-
|
|
622
|
+
// 如果上一轮最终消息因 claw 被暂存,这次用户消息只作为唤醒使用。
|
|
623
|
+
if (pendingClawFinalText.has(chatId)) {
|
|
624
|
+
await flushPendingClawFinalText(chatId, ilinkWire, platformLog);
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// WeChat 中所有会话都视为 p2p,/new 复用 p2p 路径(等同飞书 /newh 效果)
|
|
629
|
+
// 不 await:避免长 prompt 阻塞后续消息处理(如 /cd、/stop 等命令)
|
|
630
|
+
handler(fullText, chatId, chatId, msgTimestamp, "p2p").catch((err) => {
|
|
506
631
|
platformLog(`消息处理失败: ${(err as Error).stack ?? (err as Error).message}`);
|
|
507
632
|
});
|
|
508
633
|
}
|