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.
@@ -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 || ilinkEnabled;
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 feishuEnabled = c.platforms && c.platforms.feishu ? c.platforms.feishu.enabled !== false : true;
1203
- var ilinkEnabled = c.platforms && c.platforms.ilink ? c.platforms.ilink.enabled !== false : true;
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;
@@ -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条后非最终消息不再发送(claw 限制)
164
- if (count > 10 && !isFinal) {
165
- log(`[WECHAT] sendText skipped (claw limit): chatId=${chatId} count=${count}`);
166
- return false;
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
- const contextToken = contextTokenMap.get(chatId);
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, text);
617
+ appendChatLog(chatId, chatId, fullText);
499
618
 
500
619
  // 用户回复,重置 claw 连发计数
501
620
  consecutiveSendCount.set(chatId, 0);
502
621
 
503
- // WeChat 中所有会话都视为 p2p,/new 复用 p2p 路径(等同飞书 /newh 效果)
504
- // await:避免长 prompt 阻塞后续消息处理(如 /cd、/stop 等命令)
505
- handler(text, chatId, chatId, msgTimestamp, "p2p").catch((err) => {
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
  }